From ded0d401c7ba963ff3ecabbcef7e37290be2df19 Mon Sep 17 00:00:00 2001 From: evgeny Date: Wed, 14 Oct 2026 17:19:04 +0200 Subject: [PATCH] Match stream audio queue to device buffer and document playback ownership --- doc/stream.txt | 9 +++++++++ .../main/java/com/utun/chat/data/StreamAudioOutput.kt | 8 ++++++-- .../java/com/utun/chat/data/StreamAudioOutputTest.kt | 9 +++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/doc/stream.txt b/doc/stream.txt index fa3263bc..f64db09d 100644 --- a/doc/stream.txt +++ b/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 начинает измерение заново. Отдельного контроллера задержки декодера нет. diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamAudioOutput.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamAudioOutput.kt index fc8f50e0..c15fba1d 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamAudioOutput.kt +++ b/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}" } diff --git a/tools/chatgui-android/app/src/test/java/com/utun/chat/data/StreamAudioOutputTest.kt b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/StreamAudioOutputTest.kt index b027bb39..ce2f6f17 100644 --- a/tools/chatgui-android/app/src/test/java/com/utun/chat/data/StreamAudioOutputTest.kt +++ b/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() @@ -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)