Browse Source

Match stream audio queue to device buffer and document playback ownership

stream-multiview
evgeny 1 week ago
parent
commit
ded0d401c7
  1. 9
      doc/stream.txt
  2. 8
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamAudioOutput.kt
  3. 9
      tools/chatgui-android/app/src/test/java/com/utun/chat/data/StreamAudioOutputTest.kt

9
doc/stream.txt

@ -838,6 +838,15 @@ Android:
- Фиксированного prebuffer нет. Начало clock/audio output разрешено после первого декодированного кадра.
AudioTrack задаёт время показа видео по реально услышанному PCM;
без аудио используются монотонные часы с остановкой на границе готовых кадров.
Android StreamAudioOutput принадлежит viewer worker и хранит один недописанный PCM (до 480 stereo frames).
Одна WRITE_NON_BLOCKING за проход: частичный/нулевой результат сохраняет остаток, не задерживая video pump.
Выдано native = принято устройством + остаток; часы используют playbackHeadPosition без экстраполяции timestamp.
Заполнение поддерживается до фактического bufferSizeInFrames устройства; API>=31 задаёт start threshold=1.
При EOF недописанный PCM передаётся полностью; короткий хвост дополняется не более одного start threshold тишины.
Эта тишина не входит в native PTS; завершение ждёт воспроизведения всего принятого PCM, не окончания тишины.
Пауза сохраняет остаток; GAP освобождает output вместе с native timeline. Новый output начинает счётчики с нуля.
Watchdog 2s проверяет отсутствие движения hardware head только при достаточном заполнении либо заблокированной записи;
намеренная пауза и ожидание данных до порога запуска не считаются зависанием. Ошибка содержит состояние и счётчики устройства.
Защитный guard минутного контроллера учитывает audio device lead и наблюдённое опережение decoder
(максимум last_input_PTS-output_PTS текущей revision плюс период кадра). Drain не обнуляет этот guard;
новый decoder начинает измерение заново. Отдельного контроллера задержки декодера нет.

8
tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamAudioOutput.kt

@ -12,6 +12,7 @@ internal class StreamAudioOutput(
private val log: (String) -> Unit = {}
) {
internal interface Sink {
val bufferFrames: Int
val threshold: Int
val head: Long
val description: String
@ -36,6 +37,7 @@ internal class StreamAudioOutput(
if (Build.VERSION.SDK_INT >= 31) track.setStartThresholdInFrames(1)
} catch (e: Exception) { track.release(); throw e }
}
override val bufferFrames: Int get() = track.bufferSizeInFrames
override val threshold: Int get() = if (Build.VERSION.SDK_INT >= 31)
minOf(track.startThresholdInFrames, track.bufferSizeInFrames) else track.bufferSizeInFrames
override val head: Long get() = track.playbackHeadPosition.toLong() and 0xffffffffL
@ -79,12 +81,13 @@ internal class StreamAudioOutput(
}
previousHead = raw
val head = headEpoch + raw
val first = deviceHead == 0L && head > 0
if (head > deviceHead) {
if (deviceHead == 0L) log("audio started delayMs=${(now - openedAt) / 1_000_000} $description")
stalledSince = -1
}
deviceHead = head
played = head.coerceAtMost(written)
if (first) log("audio started delayMs=${(now - openedAt) / 1_000_000} $description")
}
/** Ровно одна неблокирующая запись за итерацию; ожидание устройства не задерживает video pump. */
@ -96,7 +99,8 @@ internal class StreamAudioOutput(
log("audio ${if (hold) "paused" else "resumed"} $description")
}
if (hold) return
if (pending == null && written - played < maxOf(960, sink?.threshold ?: 1)) {
// Размер устройства уже учитывает его период обслуживания; 20ms могут быть меньше одного периода.
if (pending == null && written - played < (sink?.bufferFrames ?: 1)) {
val pcm = pull()
if (pcm != null) {
check(pcm.isNotEmpty() && pcm.size <= 960 && pcm.size % 2 == 0) { "Invalid stream PCM size=${pcm.size}" }

9
tools/chatgui-android/app/src/test/java/com/utun/chat/data/StreamAudioOutputTest.kt

@ -6,6 +6,7 @@ import java.util.ArrayDeque
class StreamAudioOutputTest {
private class Device(override val threshold: Int = 1) : StreamAudioOutput.Sink {
override val bufferFrames = maxOf(960, threshold)
override var head = 0L
override val description: String get() = "fake threshold=$threshold"
val samples = ArrayList<Short>()
@ -59,6 +60,14 @@ class StreamAudioOutputTest {
assertEquals(100L, p.output.written); assertEquals(0L, p.output.played)
}
@Test fun feedsHardwareBufferEvenWhenStartThresholdIsSmall() {
val p = Player(); repeat(4) { p.input.add(ShortArray(960)) }
p.step(0); p.step(5); p.step(10)
assertEquals(960L, p.output.written); assertEquals(2, p.pulls)
p.device.head = 480; p.step(15)
assertEquals(1440L, p.output.written); assertEquals(3, p.pulls)
}
@Test fun clockDoesNotAdvanceWithWallTimeAndStalledDeviceFails() {
val p = Player(); p.input.add(ShortArray(960)); p.step(0); p.step(1999)
assertEquals(0L, p.output.played)

Loading…
Cancel
Save