From 50bcf5539aa8cbef66022320c5b1bc9134258355 Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 15 Oct 2026 11:10:08 +0200 Subject: [PATCH] Adapt Android call output buffer to device underruns --- .../com/utun/chat/data/CallAudioEngine.kt | 71 ++++++++++++++++--- .../com/utun/chat/data/CallAudioRouter.kt | 4 ++ .../com/utun/chat/data/CallPlaybackBuffer.kt | 3 +- 3 files changed, 68 insertions(+), 10 deletions(-) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioEngine.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioEngine.kt index e444e1bc..c3fb2e13 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioEngine.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioEngine.kt @@ -68,7 +68,9 @@ class CallAudioEngine { /* Для замера задержки AEC (getTimestamp) — счётчики кадров play/capture. */ @Volatile private var playFramesWritten = 0L + @Volatile private var playFramesPlayed = 0L @Volatile private var capFramesRead = 0L + private var playbackBuffer: CallPlaybackBuffer? = null // под trackLock @Volatile private var startElapsedMs = 0L @Volatile private var captureElapsedMs = 0L private val mainHandler = Handler(Looper.getMainLooper()) @@ -154,6 +156,7 @@ class CallAudioEngine { /* AEC: включён только на громкой связи (спикерфон); на гарнитуре/разговорном * динамике эхо незначимо. Задержка рендер→захват замеряется чуть позже. */ playFramesWritten = 0L + playFramesPlayed = 0L capFramesRead = 0L syncAecForRoute(router.route) @@ -163,6 +166,9 @@ class CallAudioEngine { wantedWired = router.telecom == null && router.route == AudioRoute.HEADSET && router.wiredInputDevice() != null } router.onOutputDevice = { dev -> applyTrackOutputDevice(dev) } + router.onRouteRequested = { + synchronized(trackLock) { playbackBuffer?.settle(SystemClock.elapsedRealtime()) } + } /* Замер AEC — через 300мс после готовности обоих направлений; RX этого не ждёт. */ val ready = Runnable { @@ -313,6 +319,8 @@ class CallAudioEngine { NativeLib.callAudioStop() router.onOutputDevice = null router.onInputDevice = null + router.onRouteRequested = null + playbackBuffer = null val restoreMode = router.telecom == null router.stop() if (restoreMode) restoreAudioMode() @@ -452,6 +460,7 @@ class CallAudioEngine { try { val stamp = AudioTimestamp() while (!Thread.currentThread().isInterrupted) { + if (active) adjustPlaybackBuffer(track) if (trace.enabled && active) trace.device = samplePlaybackDevice(track, stamp) trace.flush() Thread.sleep(200) @@ -459,7 +468,7 @@ class CallAudioEngine { } catch (_: InterruptedException) { // Playback уже закончен; ниже сохраняем неполное окно события. } catch (e: Exception) { - LogManager.addLog("ERROR", "CallAudio", "diagnostic reporter failed: ${e.message}") + LogManager.addLog("ERROR", "CallAudio", "playback buffer monitor/diagnostics failed: ${e.message}") } finally { try { trace.flush(true) } catch (e: Exception) { LogManager.addLog("ERROR", "CallAudio", "diagnostic flush failed: ${e.message}") } @@ -565,11 +574,13 @@ class CallAudioEngine { val played = head.update(position, playFramesWritten) if (played != null) { observedPlayed = played + playFramesPlayed = played operationNs = System.nanoTime() NativeLib.callAudioProgress(callId, epoch, played, nowUs) progressCallNs += System.nanoTime() - operationNs } else if (!clockFailed) { clockFailed = true + playFramesPlayed = -1 // Отрицательная позиция немедленно инвалидирует снимок в native. NativeLib.callAudioProgress(callId, epoch, -1, nowUs) LogManager.addLog("ERROR", "CallAudio", @@ -602,17 +613,59 @@ class CallAudioEngine { } } - /** Вызывать под trackLock при создании и после фактической смены выхода, вне playback-loop. */ - private fun configurePlaybackBuffer(track: AudioTrack, reason: String) { + /** Reporter управляет устройством независимо от уровня debug; playback не берёт trackLock. */ + private fun adjustPlaybackBuffer(track: AudioTrack) { + synchronized(trackLock) { + if (!active || audioTrack !== track) return + val policy = playbackBuffer ?: return + try { + val change = policy.sample(SystemClock.elapsedRealtime(), track.underrunCount, playFramesPlayed) ?: return + if (change.frames != track.bufferSizeInFrames) { + configurePlaybackBuffer(track, change.reason, change) + } else { + LogManager.addLog("WARN", "CallAudio", + "playback buffering id=$callId reason=${change.reason} actualFrames=${change.frames} underruns=${change.underruns}") + } + } catch (e: Exception) { + playbackBuffer = null + LogManager.addLog("ERROR", "CallAudio", "playback buffer monitor disabled id=$callId: ${e.message}") + } + } + } + + /** Под trackLock, вне playback-loop; уменьшение дренирует очередь без flush и потери PCM. */ + private fun configurePlaybackBuffer(track: AudioTrack, reason: String, change: CallPlaybackBuffer.Change? = null) { try { - val requested = FRAME_SAMPLES * 2 + val previous = track.bufferSizeInFrames + val requested = change?.frames ?: FRAME_SAMPLES * 2 val actual = track.setBufferSizeInFrames(requested) - val start = if (Build.VERSION.SDK_INT >= 31) track.setStartThresholdInFrames(requested) else actual - LogManager.addLog(if (actual > 0 && actual <= requested && start > 0) "INFO" else "WARN", "CallAudio", + check(actual > 0) { "setBufferSizeInFrames($requested)=$actual" } + val start = if (Build.VERSION.SDK_INT >= 31) track.setStartThresholdInFrames(actual) else actual + check(start > 0) { "setStartThresholdInFrames($actual)=$start" } + val capacity = track.bufferCapacityInFrames + val now = SystemClock.elapsedRealtime() + if (change == null) { + playbackBuffer = CallPlaybackBuffer(FRAME_SAMPLES).also { it.reset(now, actual, capacity, track.underrunCount) } + } else { + playbackBuffer?.applied(now, change, actual, capacity) + } + val level = if (actual == requested && (change == null || change.underruns == 0)) "INFO" else "WARN" + LogManager.addLog(level, "CallAudio", "playback buffering id=$callId reason=$reason requestedFrames=$requested actualFrames=$actual " + - "startFrames=$start capacityFrames=${track.bufferCapacityInFrames} pacing=device") + "previousFrames=$previous startFrames=$start capacityFrames=$capacity underruns=${change?.underruns ?: 0} pacing=device") + if (playReady && (previous != actual || reason == "route")) { + // Очередь сначала принимает новый размер; устаревшая задача не затронет следующий звонок. + mainHandler.post { + if (active && audioTrack === track) { + mainHandler.removeCallbacks(aecDelayRunnable) + mainHandler.postDelayed(aecDelayRunnable, 1_000) + } + } + } } catch (e: Exception) { - LogManager.addLog("WARN", "CallAudio", "playback buffer configuration failed id=$callId reason=$reason: ${e.message}") + playbackBuffer = null + LogManager.addLog("WARN", "CallAudio", + "playback buffer adaptation disabled id=$callId reason=$reason: ${e.message}") } } @@ -623,7 +676,7 @@ class CallAudioEngine { NativeLib.callAudioSetAec(on) } - /** Замер реальной задержки рендер→захват и применение к AEC (один раз после старта). */ + /** Замер задержки рендер→захват после старта, смены выхода или размера его очереди. */ private fun measureAecDelay() { synchronized(recordLock) { if (!active || !captureReady || !playReady) return diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioRouter.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioRouter.kt index 74384d42..77a22e36 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioRouter.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioRouter.kt @@ -60,6 +60,9 @@ class CallAudioRouter { /** Уведомление об изменении маршрута или доступности гарнитуры. */ var onChanged: ((AudioRoute, Boolean) -> Unit)? = null + /** Начало перестройки тракта: кратковременные underrun не характеризуют размер буфера. */ + var onRouteRequested: (() -> Unit)? = null + /** Уведомление об изменении доступности входных устройств (проводная, BT). */ var onInputDevicesChanged: ((Boolean, Boolean) -> Unit)? = null @@ -210,6 +213,7 @@ class CallAudioRouter { /* ── применение маршрута ── */ private fun applyRoute(r: AudioRoute) { + onRouteRequested?.invoke() telecom?.let { connection -> connection.routeTo(r) LogManager.addLog("INFO", "CallAudioRouter", "route requested=$r confirmed=${connection.confirmedRoute} owner=telecom") diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallPlaybackBuffer.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallPlaybackBuffer.kt index 9a01c3a5..fd5de870 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallPlaybackBuffer.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallPlaybackBuffer.kt @@ -63,6 +63,7 @@ internal class CallPlaybackBuffer(val blockFrames: Int = 960) { if (change.frames < actual && frames >= actual) floor = frames actual = frames capacity = capacityFrames - settle(nowMs) + // Уменьшение не останавливает устройство: первый underrun сразу опровергает пробу. + stableSinceMs = maxOf(nowMs, settleUntilMs) } }