Bound Android call AudioTrack working buffer and pace PCM by device

This commit is contained in:
evgeny committed 2026-10-15 03:04:43 +02:00
1 parent a82c2ede6a
commit a439b222da
3 files changed
+47 -28

No files matched your search

@@ -6,6 +6,7 @@ import android.media.AudioDeviceInfo
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioRecord
import android.media.AudioRouting
import android.media.AudioTimestamp
import android.media.AudioTrack
import android.media.MediaRecorder
@@ -71,6 +72,12 @@ class CallAudioEngine {
@Volatile private var startElapsedMs = 0L
@Volatile private var captureElapsedMs = 0L
private val mainHandler = Handler(Looper.getMainLooper())
private val trackRouteListener = AudioRouting.OnRoutingChangedListener { output ->
synchronized(trackLock) {
val track = audioTrack
if (active && track != null && output === track) configurePlaybackBuffer(track, "route")
}
}
private val aecDelayRunnable = Runnable { measureAecDelay() }
private val failed = AtomicBoolean(false)
var onFailure: ((Long, String) -> Unit)? = null
@@ -176,14 +183,8 @@ class CallAudioEngine {
return@runWorker
}
applyTrackOutputDevice(router.currentOutputDevice)
// Ёмкость устройства не должна становиться постоянной задержкой звонка.
// Два блока оставляют запас на пробуждение playback-потока каждые 20 мс.
val startFrames = FRAME_SAMPLES * 2
val actualStart = if (Build.VERSION.SDK_INT >= 31) track.setStartThresholdInFrames(startFrames)
else track.setBufferSizeInFrames(startFrames)
LogManager.addLog(if (actualStart > 0) "INFO" else "WARN", "CallAudio",
"playback buffering id=$id requestedStart=$startFrames actualStart=$actualStart " +
"bufferFrames=${track.bufferSizeInFrames} capacityFrames=${track.bufferCapacityInFrames}")
configurePlaybackBuffer(track, "start")
track.addOnRoutingChangedListener(trackRouteListener, mainHandler)
val trackCreateMs = SystemClock.elapsedRealtime() - createdAt
val playedAt = SystemClock.elapsedRealtime()
track.play()
@@ -275,6 +276,8 @@ class CallAudioEngine {
}
synchronized(trackLock) {
audioTrack?.let { track ->
try { track.removeOnRoutingChangedListener(trackRouteListener) }
catch (e: Exception) { LogManager.addLog("WARN", "CallAudio", "track listener removal failed id=$id: ${e.message}") }
try { if (track.playState == AudioTrack.PLAYSTATE_PLAYING) track.pause() }
catch (e: Exception) { LogManager.addLog("WARN", "CallAudio", "track pause failed id=$id: ${e.message}") }
}
@@ -480,7 +483,7 @@ class CallAudioEngine {
val buf = ShortArray(FRAME_SAMPLES)
val silence = ShortArray(FRAME_SAMPLES)
val frameNs = FRAME_MS * 1_000_000L
var nextNs = System.nanoTime()
var previousPullNs = System.nanoTime()
var firstWrite = true
val snapshot = LongArray(8)
if (!NativeLib.callAudioClock(callId, System.nanoTime() / 1000, snapshot)) {
@@ -495,6 +498,8 @@ class CallAudioEngine {
while (active) {
val track = audioTrack ?: return
val pullAtNs = System.nanoTime()
val pullGapExcessUs = ((pullAtNs - previousPullNs - frameNs) / 1000).coerceAtLeast(0)
previousPullNs = pullAtNs
val n = NativeLib.callAudioPull(callId, buf)
val pullEndNs = System.nanoTime()
val pullUs = (pullEndNs - pullAtNs) / 1000
@@ -561,7 +566,7 @@ class CallAudioEngine {
device.copyTo(row)
row[0] = nowUs
row[3] = playFramesWritten; row[4] = observedPlayed
row[5] = pullUs; row[6] = writeCallNs / 1000; row[7] = ((pullAtNs - nextNs) / 1000).coerceAtLeast(0)
row[5] = pullUs; row[6] = writeCallNs / 1000; row[7] = pullGapExcessUs
row[8] = zeroWrites
diagnostic.copyInto(row, 16); row[24] = offset.toLong()
row[25] = headCallNs / 1000; row[26] = progressCallNs / 1000; row[27] = writeSpanUs
@@ -575,16 +580,22 @@ class CallAudioEngine {
"first playback write id=$callId totalMs=${SystemClock.elapsedRealtime() - startElapsedMs} captureReady=$captureReady")
}
/* абсолютный deadline: не даём Thread.sleep накопить систематический дрейф
* (важно для стабильного выравнивания рендер↔захват в AEC). */
nextNs += frameNs
val waitNs = nextNs - System.nanoTime()
if (waitNs > 0) {
try { Thread.sleep(waitNs / 1_000_000L, (waitNs % 1_000_000L).toInt()) }
catch (_: InterruptedException) { return }
} else {
nextNs = System.nanoTime() /* отстали — сбрасываем, не копим долг */
}
// Темп задаёт освобождение AudioTrack. До полной записи блока следующий PCM не извлекаем:
// запас остаётся в адаптивном JB, а вне устройства ожидает не более одного блока (20 мс).
}
}
/** Вызывать под trackLock при создании и после фактической смены выхода, вне playback-loop. */
private fun configurePlaybackBuffer(track: AudioTrack, reason: String) {
try {
val requested = 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",
"playback buffering id=$callId reason=$reason requestedFrames=$requested actualFrames=$actual " +
"startFrames=$start capacityFrames=${track.bufferCapacityInFrames} pacing=device")
} catch (e: Exception) {
LogManager.addLog("WARN", "CallAudio", "playback buffer configuration failed id=$callId reason=$reason: ${e.message}")
}
}
@@ -5,7 +5,7 @@ internal class CallAudioTrace(private val id: Long, private val emit: (String, S
companion object {
const val WIDTH = 35
const val CAPACITY = 512
// monoUs, routeId, routeType, written, played, pullUs, writeCallUs, wakeLateUs, zeroWrites,
// monoUs, routeId, routeType, written, played, pullUs, writeCallUs, pullGapExcessUs, zeroWrites,
// deviceUnderruns, bufferFrames, startFrames, stampFrame, stampNs, stampReadUs, stampOk,
// encodedMs, processingMs, reserveMs, spreadMs, pcmSamples, tempo100, jbUnderruns, waiting, accepted
// headCallUs, progressCallUs, writeSpanUs, jbSnapshotUs, blockUs,
@@ -132,7 +132,7 @@ internal class CallAudioTrace(private val id: Long, private val emit: (String, S
"jbMs=${range { it[16] + it[17] }} reserveMs=${range { it[18] }} spreadMs=${range { it[19] }} " +
"pullUs=${range { it[5] }} writeCallUs=${range { it[6] }} headCallUs=${range { it[25] }} " +
"progressCallUs=${range { it[26] }} writeSpanUs=${range { it[27] }} jbSnapshotUs=${range { it[28] }} " +
"blockUs=${range { it[29] }} wakeLateUs=${range { it[7] }} " +
"blockUs=${range { it[29] }} pullGapExcessUs=${range { it[7] }} " +
"tempo=${last[21]} deviceUnderruns=${last[9]} deviceUnderDelta=${last[9] - reportedDeviceUnderruns} " +
"jbUnderruns=${last[22]} jbUnderDelta=${last[22] - reportedJbUnderruns} " +
"zeroWrites=${summary.sumOf { it[8] }} missingPcm=${summary.sumOf { (960 - it[20]).coerceAtLeast(0) }} " +
@@ -147,7 +147,7 @@ internal class CallAudioTrace(private val id: Long, private val emit: (String, S
private fun dump(partial: Boolean) {
emit("DEBUG", "audio detail call=$id triggerUs=$eventAt reasons=$eventBits events=$eventCount partial=$partial " +
"bits=start:1,route:2,queue:4,stalled:8,underrun:16 " +
"columns=monoUs,routeId,routeType,written,played,pullUs,writeCallUs,wakeLateUs,zeroWrites,deviceUnderruns," +
"columns=monoUs,routeId,routeType,written,played,pullUs,writeCallUs,pullGapExcessUs,zeroWrites,deviceUnderruns," +
"bufferFrames,startFrames,stampFrame,stampNs,stampReadUs,stampOk," +
"encodedMs,processingMs,reserveMs,spreadMs,pcmSamples,tempo100,jbUnderruns,waiting,accepted," +
"headCallUs,progressCallUs,writeSpanUs,jbSnapshotUs,blockUs," +