Share media playback devices and await cleanup before calls

This commit is contained in:
evgeny committed 2026-10-22 15:07:25 +02:00
1 parent a893f6b4f2
commit bebc579180
3 files changed
+21 -3

No files matched your search

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