From bebc579180b712d327de546083abfce95b0c4580 Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 22 Oct 2026 15:07:25 +0200 Subject: [PATCH] Share media playback devices and await cleanup before calls --- .../main/java/com/utun/chat/data/CallController.kt | 2 +- .../java/com/utun/chat/data/StreamAudioOutput.kt | 8 ++++++-- .../com/utun/chat/data/StreamAudioOutputTest.kt | 14 ++++++++++++++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt index 6dfa889b..64c709af 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt @@ -207,7 +207,7 @@ object CallController : TelecomCallManager.Listener { val radioStopped = RadioAudioEngine.instance().stopForCall() StreamController.stopForCall() StreamPlayerEngine.stopForCall() - withContext(Dispatchers.IO) { radioStopped.get() } + withContext(Dispatchers.IO) { radioStopped.get(); MediaAudioController.awaitIdle() } val current = _state.value if (current?.callId != id || !current.ongoing) { LogManager.addLog("INFO", "Call", "media released after call cancellation id=$id") diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamAudioOutput.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamAudioOutput.kt index d06c57f8..f22d3b4c 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamAudioOutput.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamAudioOutput.kt @@ -12,6 +12,7 @@ internal class StreamAudioOutput( private val log: (String) -> Unit = {} ) { internal interface Sink { + val routeChanging: Boolean get() = false val bufferFrames: Int val threshold: Int val head: Long @@ -23,6 +24,8 @@ internal class StreamAudioOutput( } private class AndroidSink : Sink { + override val routeChanging: Boolean get() = MediaAudioController.state.value.switching + private var route: AutoCloseable? = null private val track = AudioTrack.Builder() .setAudioAttributes(AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA) .setContentType(AudioAttributes.CONTENT_TYPE_MOVIE).build()) @@ -35,6 +38,7 @@ internal class StreamAudioOutput( check(track.state == AudioTrack.STATE_INITIALIZED) { "AudioTrack initialization failed" } // API 26..30 не умеет уменьшать порог отдельно от аппаратного буфера. if (Build.VERSION.SDK_INT >= 31) track.setStartThresholdInFrames(1) + route = MediaAudioController.attachOutput(track) } catch (e: Exception) { track.release(); throw e } } override val bufferFrames: Int get() = track.bufferSizeInFrames @@ -46,7 +50,7 @@ internal class StreamAudioOutput( override fun write(pcm: ShortArray, offset: Int, count: Int) = track.write(pcm, offset, count, AudioTrack.WRITE_NON_BLOCKING) override fun pause() = track.pause() override fun play() = track.play() - override fun close() = track.release() + override fun close() { route?.close(); route = null; track.release() } } private var sink: Sink? = null @@ -132,7 +136,7 @@ internal class StreamAudioOutput( poll(now) // До достаточного заполнения ожидание сети не является зависанием устройства. val expectedProgress = written + padded - deviceHead >= s.threshold || blocked - if (!expectedProgress) stalledSince = -1 + if (!expectedProgress || s.routeChanging) stalledSince = -1 else if (stalledSince < 0) stalledSince = now else check(now - stalledSince < 2_000_000_000) { "AudioTrack made no playback progress for ${(now - stalledSince) / 1_000_000}ms; $description" diff --git a/tools/chatgui-android/app/src/test/java/com/utun/chat/data/StreamAudioOutputTest.kt b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/StreamAudioOutputTest.kt index ce2f6f17..733c8de5 100644 --- a/tools/chatgui-android/app/src/test/java/com/utun/chat/data/StreamAudioOutputTest.kt +++ b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/StreamAudioOutputTest.kt @@ -6,6 +6,7 @@ import java.util.ArrayDeque class StreamAudioOutputTest { private class Device(override val threshold: Int = 1) : StreamAudioOutput.Sink { + override var routeChanging = false override val bufferFrames = maxOf(960, threshold) override var head = 0L override val description: String get() = "fake threshold=$threshold" @@ -84,6 +85,19 @@ class StreamAudioOutputTest { assertTrue(p.output.drained) } + @Test fun routeSwitchPreservesPlayerAndClockUntilOutputResumes() { + val p = Player(); p.input.add(ShortArray(960) { 7 }); p.step(0) + p.device.routeChanging = true + var videoSteps = 0 + for (ms in 5L..30000L step 5) { p.step(ms); videoSteps++ } + assertEquals(6000, videoSteps) + assertFalse(p.device.closed); assertEquals(0L, p.output.played) + assertEquals(List(960) { 7.toShort() }, p.device.samples) + p.device.routeChanging = false + p.device.head = 480; p.step(30005) + assertTrue(p.output.drained) + } + @Test fun intentionalPausePreservesPartialPcmAndSuspendsWatchdog() { val p = Player(); val pcm = ShortArray(960) { it.toShort() }; p.input.add(pcm); p.device.limit = 120 p.step(0); p.step(5, true); p.step(10000, true)