diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt index 781fa8b5..01cefed9 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt @@ -62,6 +62,7 @@ class RadioAudioEngine { private var playThread: Thread? = null private var outputLease: AutoCloseable? = null private var capturePttRequested = false + private var nativeCaptureOwned = false // Только io: capture start мог иметь эффект; сбрасывается после stop API. @Volatile private var active = false @Volatile private var groupId = 0L @@ -312,9 +313,10 @@ class RadioAudioEngine { outputLease = MediaAudioController.attachOutput(track) track.play() playbackRunning = true - playThread = Thread { playLoop() }.apply { - priority = Thread.MAX_PRIORITY; start() - } + val worker = Thread({ playLoop(track, group, version) }, "radio-playback") + playThread = worker + worker.priority = Thread.MAX_PRIORITY + worker.start() registerScreenReceiver(version) updateCapture() @@ -412,8 +414,10 @@ class RadioAudioEngine { /** Оба детектора используют одно окно по экрану/фону; измеритель не продлевает это окно. */ private fun updateCapture() { + try { mainHandler.removeCallbacks(vadTimeout) if (!settingsReady) return + if (!active || groupId != audioGroup) { stopCapture(); return } val now = SystemClock.elapsedRealtime() vadWindow.setEnabled(autoSettings.enabled, now) var mode = radioCaptureMode(active, screenInteractive, vadWindow.isAllowed(now), autoSettings, requestedTalk, levelMonitoring) @@ -431,6 +435,7 @@ class RadioAudioEngine { val detectorFailed = mode != RadioAutoSettings.OFF && autoMode == RadioAutoSettings.OFF if (captureSubscription != null && (captureRequestedMode != mode || (detectorFailed && !requestedTalk && !levelMonitoring))) stopCapture() if (audioTrack == null) return + if (captureSubscription == null && nativeCaptureOwned) stopCapture() if (captureSubscription == null && !startCapture(mode)) { requestedTalk = false _talkingFlow.value = false @@ -439,10 +444,17 @@ class RadioAudioEngine { if (requestedTalk != capturePttRequested) { if (captureSubscription?.ptt(requestedTalk) != true) { LogManager.addLog("ERROR", "radio", "PTT command rejected; stopping capture group=$audioGroup") + requestedTalk = false stopCapture(); return } capturePttRequested = requestedTalk } + } catch (e: Exception) { + requestedTalk = false + _talkingFlow.value = nativeCaptureOwned && _transmittingGroup.value != null + LogManager.addLog("ERROR", "RadioAudio", "capture update failed group=$audioGroup generation=$generation " + + "nativeOwned=$nativeCaptureOwned; retained for retry: $e") + } } private fun startCapture(mode: Int): Boolean { @@ -450,53 +462,64 @@ class RadioAudioEngine { LogManager.addLog("WARN", "radio", "Auto/PTT capture blocked: RECORD_AUDIO not granted group=$audioGroup mode=$mode") return false } - val subscription = try { - MediaAudioController.subscribe("radio") - } catch (e: Exception) { - LogManager.addLog("ERROR", "radio", "shared capture subscription failed: ${e.message}") - return false - } - val channels = subscription.channels - if (!NativeLib.radioCaptureStart(audioGroup, channels, mode)) { - LogManager.addLog("ERROR", "RadioAudio", "native capture start failed group=$audioGroup ch=$channels mode=$mode") - subscription.close() - return false - } - autoMode = NativeLib.radioAutoMode() - captureRequestedMode = mode - if (mode != RadioAutoSettings.OFF && autoMode == RadioAutoSettings.OFF && !requestedTalk && !levelMonitoring) { - LogManager.addLog("ERROR", "radio", "Automatic transmission unavailable: capture cancelled group=$audioGroup") - NativeLib.radioCaptureStop(); subscription.close() - return false - } - captureSubscription = subscription - captureRunning = true - _autoActiveFlow.value = autoMode != RadioAutoSettings.OFF - // BEGIN должен попасть в uasync раньше первого кадра ручной передачи. - if (requestedTalk) { - NativeLib.radioTalkBegin(audioGroup) - talking = true - _talkingFlow.value = true - } - capturePttRequested = requestedTalk val captureGroup = audioGroup - captureThread = Thread { - try { captureLoop(subscription, captureGroup, channels) } - catch (e: Exception) { LogManager.addLog("ERROR", "RadioAudio", "capture failed: ${e.message}") } - finally { - if (captureRunning) io.execute { - if (captureSubscription === subscription) { + val version = generation + var operation = "subscribe" + try { + val subscription = MediaAudioController.subscribe("radio") + captureSubscription = subscription // Сохраняется до любых native-вызовов с возможным частичным эффектом. + val channels = subscription.channels + check(channels in 1..2) { "invalid capture channels=$channels" } + operation = "native start" + nativeCaptureOwned = true + check(NativeLib.radioCaptureStart(captureGroup, channels, mode)) { "native capture start rejected" } + operation = "mode" + autoMode = NativeLib.radioAutoMode() + check(autoMode == mode || autoMode == RadioAutoSettings.OFF) { "native capture mode mismatch requested=$mode actual=$autoMode" } + captureRequestedMode = mode + if (mode != RadioAutoSettings.OFF && autoMode == RadioAutoSettings.OFF) { + check(requestedTalk || levelMonitoring) { "Automatic transmission unavailable: capture cancelled" } + LogManager.addLog("WARN", "radio", "Automatic transmission unavailable: keeping manual/monitor capture group=$captureGroup") + } + captureRunning = true + _autoActiveFlow.value = autoMode != RadioAutoSettings.OFF + // BEGIN должен попасть в uasync раньше первого кадра ручной передачи. + operation = "begin" + if (requestedTalk) { + NativeLib.radioTalkBegin(captureGroup) + talking = true + _talkingFlow.value = true + } + capturePttRequested = requestedTalk + operation = "worker start" + val worker = Thread({ + try { captureLoop(subscription, captureGroup, channels) } finally { + if (captureRunning && active && generation == version && groupId == captureGroup) io.execute { + if (captureSubscription !== subscription || generation != version) return@execute requestedTalk = false - stopCapture() - LogManager.addLog("WARN", "RadioAudio", "capture lost: resources released") + try { + stopCapture() + LogManager.addLog("WARN", "RadioAudio", "capture lost: resources released group=$captureGroup generation=$version") + } catch (e: Exception) { + LogManager.addLog("ERROR", "RadioAudio", "capture loss cleanup failed group=$captureGroup; retained for retry: $e") + } } } + }, "radio-capture") + captureThread = worker + worker.priority = Thread.MAX_PRIORITY + worker.start() + LogManager.addLog("INFO", "radio", "capture started group=$captureGroup ch=$channels mode=$autoMode shared=true") + return true + } catch (e: Exception) { + requestedTalk = false + LogManager.addLog("ERROR", "RadioAudio", "capture startup failed operation=$operation group=$captureGroup " + + "generation=$version mode=$mode: $e") + try { stopCapture() } catch (cleanup: Exception) { + LogManager.addLog("ERROR", "RadioAudio", "capture startup rollback failed group=$captureGroup; retained for retry: $cleanup") } - }.apply { - name = "radio-capture"; priority = Thread.MAX_PRIORITY; start() + return false } - LogManager.addLog("INFO", "radio", "capture started group=$audioGroup ch=$channels mode=$autoMode shared=true") - return true } private fun stopCapture() { @@ -514,28 +537,39 @@ class RadioAudioEngine { } interrupted = joinWorker(captureThread, "capture") captureThread = null - if (subscription != null) try { + if (nativeCaptureOwned) try { NativeLib.radioCaptureStop() + nativeCaptureOwned = false LogManager.addLog("INFO", "radio", "Auto/PTT capture released group=$audioGroup") } catch (e: Exception) { LogManager.addLog("ERROR", "RadioAudio", "native capture stop failed group=$audioGroup: $e") if (failure == null) failure = e } - autoMode = RadioAutoSettings.OFF capturePttRequested = false talking = false - if (_transmittingGroup.value != null) { - LogManager.addLog("DEBUG", "radio", "TX cleared after capture stop group=${_transmittingGroup.value}") - _transmittingGroup.value = null - } - _talkingFlow.value = requestedTalk _autoActiveFlow.value = false + if (!nativeCaptureOwned) clearCaptureState() + else _talkingFlow.value = requestedTalk || _transmittingGroup.value != null failure?.let { throw it } } finally { if (interrupted) Thread.currentThread().interrupt() } } + /** Только после подтверждённого native capture/audio stop: снять наблюдаемый TX и режим детектора. */ + private fun clearCaptureState() { + nativeCaptureOwned = false + autoMode = RadioAutoSettings.OFF + capturePttRequested = false + talking = false + if (_transmittingGroup.value != null) { + LogManager.addLog("DEBUG", "radio", "TX cleared after capture stop group=${_transmittingGroup.value}") + _transmittingGroup.value = null + } + _talkingFlow.value = requestedTalk + _autoActiveFlow.value = false + } + /** Дождаться своего worker до остановки DSP; interruption не разрешает освободить используемые им ресурсы. */ private fun joinWorker(worker: Thread?, kind: String): Boolean { var interrupted = Thread.interrupted() @@ -597,7 +631,7 @@ class RadioAudioEngine { "track=${System.identityHashCode(track)}; retained for retry: $e") if (failure == null) failure = e } - try { NativeLib.radioAudioStop() } catch (e: Exception) { + try { NativeLib.radioAudioStop(); clearCaptureState() } catch (e: Exception) { LogManager.addLog("ERROR", "RadioAudio", "native audio stop failed group=$audioGroup: $e") if (failure == null) failure = e }