Retain call audio owners until worker and device cleanup succeeds

This commit is contained in:
evgeny committed 2026-10-29 13:34:23 +01:00
1 parent a1c77268b8
commit e857b9de75
2 files changed
+69 -40

No files matched your search

@@ -34,6 +34,8 @@ import com.utun.chat.ChatApplication
* оценка по playback head допустима лишь до первого принятого timestamp за этот запуск.
* При смене выхода сохраняются native JB и общий счётчик PCM, его старый аппаратный якорь снимается.
* Stop ждёт capture/playback и вложенный reporter до освобождения устройств и native-стека.
* При незавершённой очистке сохраняет владельцев и бросает ошибку; CallService повторяет stop,
* а новый start сначала обязан завершить прежний stop.
*/
class CallAudioEngine {
@@ -56,6 +58,7 @@ class CallAudioEngine {
private var playThread: Thread? = null
private var prevMode: Int = AudioManager.MODE_NORMAL
private var prevSpeaker: Boolean = false
private var modeOwned = false // Main: прежний AudioManager mode/speaker ещё надо восстановить.
/** Маршрутизация аудио-выхода (динамик/громкая/гарнитура). */
val router = CallAudioRouter()
@@ -168,15 +171,16 @@ class CallAudioEngine {
callId = id
routeGeneration = 0; outputSample = null
outputRecovery = CallOutputRecovery()
router.bindTelecom(TelecomCallManager.connectionFor(id))
muted = false
failed.set(false)
captureReady = false; playReady = false; duplexReady = false
try {
router.bindTelecom(TelecomCallManager.connectionFor(id))
val ctx = ChatApplication.instance
val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager
prevMode = am.mode
prevSpeaker = am.isSpeakerphoneOn
modeOwned = router.telecom == null
var stageAt = SystemClock.elapsedRealtime()
try {
if (router.telecom == null) am.mode = AudioManager.MODE_IN_COMMUNICATION
@@ -256,7 +260,6 @@ class CallAudioEngine {
val createdAt = SystemClock.elapsedRealtime()
wiredActive = wantedWired
val rec = createAudioRecord(wiredActive)
audioRecord = rec
if (!active) return@runWorker
if (rec == null) {
reportFailure(id, "AudioRecord create failed wired=$wiredActive")
@@ -309,54 +312,90 @@ class CallAudioEngine {
mainHandler.post { if (callId == id && active) onFailure?.invoke(id, detail) }
}
/** Main: снять active, разблокировать AudioRecord.read через stop и дождаться workers/reporter.
* AudioTrack пишется без блокировки; AAudio.write ограничен native timeout. Release выполняется после join. */
/** Main: отменить работу, разблокировать read и подтвердить join/release каждой независимой части.
* Join ограничен 250 мс на worker; живые потоки и устройства остаются до следующей попытки.
* При неполной очистке бросает ошибку владельцу; native останавливается лишь после устройств. */
fun stop() {
if (callId == 0L) return
val id = callId
active = false
router.onOutputDevice = null
router.onInputDevice = null
router.onRouteRequested = null
mainHandler.removeCallbacks(aecDelayRunnable)
if (Build.VERSION.SDK_INT >= 31) {
communicationListener?.let { listener ->
val am = ChatApplication.instance.getSystemService(Context.AUDIO_SERVICE) as AudioManager
am.removeOnCommunicationDeviceChangedListener(listener)
try {
val am = ChatApplication.instance.getSystemService(Context.AUDIO_SERVICE) as AudioManager
am.removeOnCommunicationDeviceChangedListener(listener)
communicationListener = null
} catch (e: Exception) {
LogManager.addLog("ERROR", "CallAudio", "communication listener removal failed id=$id: $e")
}
}
communicationListener = null
}
synchronized(recordLock) {
audioRecord?.let { rec ->
try { if (rec.recordingState == AudioRecord.RECORDSTATE_RECORDING) rec.stop() }
catch (e: Exception) { LogManager.addLog("WARN", "CallAudio", "record stop failed id=$id: ${e.message}") }
catch (e: Exception) { LogManager.addLog("ERROR", "CallAudio", "record stop failed id=$id: $e") }
}
}
synchronized(trackLock) {
audioTrack?.let { track ->
try { track.removeOnRoutingChangedListener(trackRouteListener) }
catch (e: Exception) { LogManager.addLog("WARN", "CallAudio", "track listener removal failed id=$id: ${e.message}") }
catch (e: Exception) { LogManager.addLog("WARN", "CallAudio", "track listener removal failed id=$id: $e") }
try { if (track.playState == AudioTrack.PLAYSTATE_PLAYING) track.pause() }
catch (e: Exception) { LogManager.addLog("WARN", "CallAudio", "track pause failed id=$id: ${e.message}") }
catch (e: Exception) { LogManager.addLog("WARN", "CallAudio", "track pause failed id=$id: $e") }
}
}
playThread?.interrupt()
captureThread?.join(); captureThread = null
playThread?.join(); playThread = null
synchronized(recordLock) {
try { audioRecord?.release() }
catch (e: Exception) { LogManager.addLog("WARN", "CallAudio", "record release failed id=$id: ${e.message}") }
audioRecord = null
var interrupted = false
for (worker in listOfNotNull(captureThread, playThread)) {
try { worker.join(250) }
catch (e: InterruptedException) {
interrupted = true
LogManager.addLog("WARN", "CallAudio", "worker join interrupted id=$id worker=${worker.name}: $e")
}
if (worker.isAlive) LogManager.addLog("ERROR", "CallAudio", "worker join pending id=$id worker=${worker.name} state=${worker.state}")
}
if (captureThread?.isAlive != true) captureThread = null
if (playThread?.isAlive != true) playThread = null
if (captureThread == null && playThread == null) {
synchronized(recordLock) {
try { audioRecord?.release(); audioRecord = null }
catch (e: Exception) { LogManager.addLog("ERROR", "CallAudio", "record release failed id=$id: $e") }
}
synchronized(trackLock) {
try { audioTrack?.release(); audioTrack = null }
catch (e: Exception) { LogManager.addLog("ERROR", "CallAudio", "track release failed id=$id: $e") }
}
}
try { audioTrack?.release() }
catch (e: Exception) { LogManager.addLog("WARN", "CallAudio", "track release failed id=$id: ${e.message}") }
audioTrack = null
captureReady = false; playReady = false; duplexReady = false
NativeLib.callAudioStop()
router.onOutputDevice = null
router.onInputDevice = null
router.onRouteRequested = null
playbackBuffer = null
val restoreMode = router.telecom == null
router.stop()
if (restoreMode) restoreAudioMode()
if (modeOwned) {
try {
val am = ChatApplication.instance.getSystemService(Context.AUDIO_SERVICE) as AudioManager
am.isSpeakerphoneOn = prevSpeaker
am.mode = prevMode
modeOwned = false
} catch (e: Exception) {
LogManager.addLog("ERROR", "CallAudio", "restore audio mode failed id=$id: $e")
}
}
if (interrupted) Thread.currentThread().interrupt()
check(captureThread == null && playThread == null && audioRecord == null && audioTrack == null &&
communicationListener == null && !modeOwned) {
val detail = "cleanup pending id=$id workers=${captureThread?.name}/${playThread?.name} " +
"record=${audioRecord != null} track=${audioTrack != null} listener=${communicationListener != null} mode=$modeOwned"
LogManager.addLog("ERROR", "CallAudio", detail)
detail
}
try { NativeLib.callAudioStop() }
catch (e: Exception) {
LogManager.addLog("ERROR", "CallAudio", "native stop failed id=$id: $e")
throw e
}
callId = 0L
muted = false
LogManager.addLog("INFO", "CallAudio", "stopped id=$id capture=$capFramesRead play=$playFramesWritten; workers joined")
@@ -385,7 +424,6 @@ class CallAudioEngine {
audioRecord = null
wiredActive = wantedWired
rec = createAudioRecord(wiredActive)
audioRecord = rec
if (rec == null) return
rec?.startRecording()
}
@@ -483,9 +521,9 @@ class CallAudioEngine {
return null
}
val constructorMs = SystemClock.elapsedRealtime() - constructorAt
audioRecord = rec // Под recordLock: владение до любых запросов состояния/регистрации listeners.
if (rec.state != AudioRecord.STATE_INITIALIZED) {
LogManager.addLog("ERROR", "CallAudio", "AudioRecord not initialized (wired=$wired)")
rec.release()
return null
}
val routeAt = SystemClock.elapsedRealtime()
@@ -514,6 +552,7 @@ class CallAudioEngine {
val begin = SystemClock.elapsedRealtime()
val track = CallAudioOutput.open(am, outputRecovery.deviceId, outputRecovery.tryFast)
audioTrack = track
check(track.track == null || track.track.state == AudioTrack.STATE_INITIALIZED) { "AudioTrack not initialized" }
applyTrackOutputDevice(router.currentOutputDevice)
configurePlaybackBuffer(track, reason)
track.addOnRoutingChangedListener(trackRouteListener, mainHandler)
@@ -841,16 +880,6 @@ class CallAudioEngine {
}
}
private fun restoreAudioMode() {
try {
val am = ChatApplication.instance.getSystemService(Context.AUDIO_SERVICE) as AudioManager
am.isSpeakerphoneOn = prevSpeaker
am.mode = prevMode
} catch (e: Exception) {
LogManager.addLog("WARN", "CallAudio", "restore audio mode failed: ${e.message}")
}
}
private fun applyTrackOutputDevice(dev: AudioDeviceInfo?) {
synchronized(trackLock) {
val track = audioTrack ?: return
@@ -79,12 +79,13 @@ internal class CallAudioOutput private constructor(
fun removeOnRoutingChangedListener(listener: AudioRouting.OnRoutingChangedListener) {
track?.removeOnRoutingChangedListener(listener)
}
/** Идемпотентно закрыть выход; после этого write/play/настройка буфера недопустимы. */
/** Идемпотентно закрыть выход; ошибка release сохраняет владельца для повторной попытки.
* После подтверждённого освобождения write/play/настройка буфера недопустимы. */
@Synchronized fun release() {
if (closed) return
closed = true
if (handle != 0L) { CallOutputNative.close(handle); handle = 0 }
track?.release()
closed = true
}
companion object {
@@ -109,7 +110,6 @@ internal class CallAudioOutput private constructor(
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO).build())
.setBufferSizeInBytes((min * 2).coerceAtLeast(3840)).setTransferMode(AudioTrack.MODE_STREAM)
.setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY).build()
if (track.state != AudioTrack.STATE_INITIALIZED) { track.release(); error("AudioTrack not initialized") }
LogManager.addLog("INFO", "CallAudio", "output fallback=AudioTrack device=$deviceId type=$type tryFast=$tryFast fastRoute=$fastRoute")
return CallAudioOutput(track, 0, manager)
}