аудио: убрать «просадки» — запас буферов + PLC на пропусках

- glasses: очередь capture→Opus 4→12 кадров (джиттер планировщика больше не роняет кадры
  до присвоения seq, отчего телефон не видел потерь); буфер AudioRecord 2→4 кадра
- glasses: диагностика captured/encoded/dropped в логе (видно потери на источнике)
- phone: AudioTrack-буфер 1→4 кадра (редкие underrun'ы давали щелчки); на пропуске seq
  вставляем PLC-кадры (было — сразу склейка); в лог добавлен underruns
This commit is contained in:
2026-10-01 05:58:50 +03:00
parent d4e1b66924
commit de6c648c35
3 changed files with 48 additions and 11 deletions
@@ -88,14 +88,22 @@ class VmService : LifecycleService() {
/**
* Очередь кадров PCM (interleaved shorts 48 кГц stereo) между потоком чтения
* [AudioRecord] и Opus-кодером. Крошечная ёмкость + DROP_OLDEST: для live-аудио
* важнее свежесть, чем полнота — иначе задержка растёт безгранично.
* [AudioRecord] и Opus-кодером.
*
* Ёмкость — запас на джиттер планировщика/GC/кодера; в норме очередь почти пуста, так что
* на задержку это не влияет. DROP_OLDEST остаётся аварийным клапаном, но при ёмкости 12
* (120 мс) срабатывает куда реже, чем при 4 (40 мс), где любой чих кодировщика рождал
* слышимый «провал» (кадр выкидывался до присвоения seq — телефон этого даже не замечал).
*/
private val audioFrames = Channel<ShortArray>(
capacity = 4,
capacity = 12,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
/** Диагностика потерь на очках: сколько кадров сняли с AudioRecord и сколько закодировали. */
private val audioCaptured = java.util.concurrent.atomic.AtomicInteger()
private val audioEncoded = java.util.concurrent.atomic.AtomicInteger()
override fun onCreate() {
super.onCreate()
instance = this
@@ -314,6 +322,11 @@ class VmService : LifecycleService() {
Log.w(TAG, "audio send failed: ${t.message}")
}
}
val encoded = audioEncoded.incrementAndGet()
if (encoded % 200 == 0) {
val captured = audioCaptured.get()
Log.i(TAG, "audio: captured=$captured encoded=$encoded dropped=${captured - encoded} sessions=${audioSessions.size}")
}
}
}
}
@@ -374,7 +387,10 @@ class VmService : LifecycleService() {
if (audioCapture != null) return true
val capture = GlassesAudioCapture(
projection = projection,
sink = { bytes -> audioFrames.trySend(bytes) },
sink = { bytes ->
audioCaptured.incrementAndGet()
audioFrames.trySend(bytes)
},
onStopped = { lifecycleScope.launch { onCaptureDied() } },
)
return if (capture.start(lifecycleScope)) {
@@ -48,9 +48,9 @@ class GlassesAudioCapture(
Log.w(TAG, "getMinBufferSize=$minBuf")
return false
}
// Небольшой буфер = меньшая задержка. Берём минимум HAL, но не меньше двух кадров,
// чтобы мгновенный джиттер планировщика не приводил к overrun/потере сэмплов.
val bufferSize = maxOf(minBuf, AudioConfig.FRAME_BYTES * 2)
// Небольшой буфер = меньшая задержка. Берём минимум HAL, но не меньше четырёх кадров
// (40 мс): при двух кадрах любой сбой планировщика/GC приводил к overrun и «просадке».
val bufferSize = maxOf(minBuf, AudioConfig.FRAME_BYTES * 4)
val rec = try {
val config = AudioPlaybackCaptureConfiguration.Builder(projection)