Browse Source

Adapt Android call output buffer to device underruns

master
evgeny 4 days ago
parent
commit
50bcf5539a
  1. 71
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioEngine.kt
  2. 4
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioRouter.kt
  3. 3
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallPlaybackBuffer.kt

71
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

4
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")

3
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)
}
}

Loading…
Cancel
Save