From de6c648c354f126c226819fe521bea5b98491ffb Mon Sep 17 00:00:00 2001 From: subochev Date: Thu, 1 Oct 2026 05:58:50 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B0=D1=83=D0=B4=D0=B8=D0=BE:=20=D1=83=D0=B1?= =?UTF-8?q?=D1=80=D0=B0=D1=82=D1=8C=20=C2=AB=D0=BF=D1=80=D0=BE=D1=81=D0=B0?= =?UTF-8?q?=D0=B4=D0=BA=D0=B8=C2=BB=20=E2=80=94=20=D0=B7=D0=B0=D0=BF=D0=B0?= =?UTF-8?q?=D1=81=20=D0=B1=D1=83=D1=84=D0=B5=D1=80=D0=BE=D0=B2=20+=20PLC?= =?UTF-8?q?=20=D0=BD=D0=B0=20=D0=BF=D1=80=D0=BE=D0=BF=D1=83=D1=81=D0=BA?= =?UTF-8?q?=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - glasses: очередь capture→Opus 4→12 кадров (джиттер планировщика больше не роняет кадры до присвоения seq, отчего телефон не видел потерь); буфер AudioRecord 2→4 кадра - glasses: диагностика captured/encoded/dropped в логе (видно потери на источнике) - phone: AudioTrack-буфер 1→4 кадра (редкие underrun'ы давали щелчки); на пропуске seq вставляем PLC-кадры (было — сразу склейка); в лог добавлен underruns --- .../pw/binom/rayneovm/glasses/VmService.kt | 24 ++++++++++++--- .../glasses/audio/GlassesAudioCapture.kt | 6 ++-- .../phone/audio/AudioPlaybackManager.kt | 29 ++++++++++++++++--- 3 files changed, 48 insertions(+), 11 deletions(-) diff --git a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmService.kt b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmService.kt index 0b3c0ab..c3f286a 100644 --- a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmService.kt +++ b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmService.kt @@ -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( - 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)) { diff --git a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/audio/GlassesAudioCapture.kt b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/audio/GlassesAudioCapture.kt index 315ef63..172982d 100644 --- a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/audio/GlassesAudioCapture.kt +++ b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/audio/GlassesAudioCapture.kt @@ -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) diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/audio/AudioPlaybackManager.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/audio/AudioPlaybackManager.kt index 3f41638..aa2e3e6 100644 --- a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/audio/AudioPlaybackManager.kt +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/audio/AudioPlaybackManager.kt @@ -60,7 +60,9 @@ class AudioPlaybackManager(private val context: Context) { val encoding = AudioFormat.ENCODING_PCM_16BIT val minBuffer = AudioTrack.getMinBufferSize(sampleRate, channelMask, encoding) - val bufferSize = maxOf(minBuffer, AudioConfig.FRAME_BYTES) + // Четыре кадра (40 мс) запаса: при буфере меньше этого редкие подвисания потока + // приёма/GC опустошали очередь и давали слышимый «провал» (underrun). + val bufferSize = maxOf(minBuffer, AudioConfig.FRAME_BYTES * 4) track = AudioTrack.Builder() .setAudioAttributes( @@ -104,12 +106,25 @@ class AudioPlaybackManager(private val context: Context) { onMessage = { datagram -> if (!AudioDatagram.hasPayload(datagram)) return@connect val seq = AudioDatagram.seq(datagram) + val t = track ?: return@connect if (lastSeq >= 0) { + // Оборачивание Int через переполнение даёт «огромный» gap — отсекаем. val gap = seq - lastSeq - 1 - if (gap in 1 until 1000) lost += gap + if (gap in 1 until MAX_GAP) { + lost += gap + // PLC: синтезируем заменители потерянных кадров, чтобы на месте + // пропуска не было резкого «щелчка» на стыке. + repeat(minOf(gap, MAX_CONCEAL)) { + val n = try { + decoder.decodeLost(pcmOut) + } catch (e: Throwable) { + -1 + } + if (n > 0) t.write(pcmOut, 0, n * AudioConfig.CHANNELS) + } + } } lastSeq = seq - val t = track ?: return@connect val decoded = try { decoder.decode( datagram, @@ -126,7 +141,7 @@ class AudioPlaybackManager(private val context: Context) { } frames++ if (frames % 200 == 0) { - Log.i(TAG, "playing: frames=$frames lost=$lost lastSeq=$seq") + Log.i(TAG, "playing: frames=$frames lost=$lost underruns=${t.underrunCount} lastSeq=$seq") } }, onConnected = { @@ -167,5 +182,11 @@ class AudioPlaybackManager(private val context: Context) { private companion object { const val TAG = "AudioPlaybackMgr" + + /** Пропуск seq больше этого — оборачивание Int или сбой, а не реальные потери. */ + const val MAX_GAP = 100 + + /** Максимум кадров подряд «дотягиваем» PLC, чтобы не залипнуть на длинной потере. */ + const val MAX_CONCEAL = 5 } }