diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioEngine.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioEngine.kt index bebfa117..66238a2d 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioEngine.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioEngine.kt @@ -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 diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioOutput.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioOutput.kt index 70aa2558..66fa3f9e 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioOutput.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioOutput.kt @@ -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) }