From 9d69cce8e830ca157ef00f1276307c90c87e2ac1 Mon Sep 17 00:00:00 2001 From: evgeny Date: Fri, 16 Oct 2026 01:18:26 +0200 Subject: [PATCH] Connect Android stream and radio to shared capture and preserve stream audio mode --- .../com/utun/chat/data/CallAudioRouter.kt | 35 +++- .../com/utun/chat/data/RadioAudioEngine.kt | 194 ++++++++++-------- .../com/utun/chat/data/StreamCaptureEngine.kt | 52 ++--- 3 files changed, 163 insertions(+), 118 deletions(-) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioRouter.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioRouter.kt index f7f01d23..8a79dad1 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioRouter.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioRouter.kt @@ -83,6 +83,7 @@ class CallAudioRouter { private var preferWiredHeadset = false private var started = false + private var globalRouting = true private var desiredRoute = AudioRoute.SPEAKER var telecom: UtunConnection? = null private set @@ -150,7 +151,7 @@ class CallAudioRouter { "devices=${devices?.size} hasEarpiece=$hasEarpiece autoHeadset=$autoHeadset fallback=$fallbackRoute") } - fun stop() { + @Synchronized fun stop() { if (!started) { bindTelecom(null); return } started = false @@ -159,7 +160,7 @@ class CallAudioRouter { unbindBtHeadsetProfile() try { - if (telecom == null) { + if (telecom == null && globalRouting) { stopBluetoothSco() am.isBluetoothScoOn = false clearCommunicationDevice() @@ -211,7 +212,20 @@ class CallAudioRouter { } /* ── применение маршрута ── */ - private fun applyRoute(r: AudioRoute) { + /** Трансляция владеет глобальным трактом; рация меняет только preferredDevice своих входа/выхода. */ + @Synchronized fun setGlobalRouting(enabled: Boolean) { + if (globalRouting == enabled) return + if (!enabled && started && telecom == null) { + stopBluetoothSco() + am.isBluetoothScoOn = false + clearCommunicationDevice() + } + globalRouting = enabled + LogManager.addLog("INFO", "CallAudioRouter", "global routing=$enabled owner=${if (enabled) "radio" else "stream"}") + if (started) applyRoute(desiredRoute) + } + + @Synchronized private fun applyRoute(r: AudioRoute) { onRouteRequested?.invoke() telecom?.let { connection -> connection.routeTo(r) @@ -221,6 +235,21 @@ class CallAudioRouter { route = r val startedAt = SystemClock.elapsedRealtime() try { + if (!globalRouting) { + val wired = findWiredDevice() + val headset = if (preferWiredHeadset && wired != null) wired else findBtScoDevice() ?: wired + val output = when (r) { + AudioRoute.SPEAKER -> findBuiltinSpeaker() + AudioRoute.EARPIECE -> findBuiltinEarpiece() ?: findBuiltinSpeaker() + AudioRoute.HEADSET -> headset + } + val input = if (r != AudioRoute.HEADSET) null + else if (headset?.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO) btInputDevice() else wiredInputDevice() + currentOutputDevice = output; currentInputDevice = input + onOutputDevice?.invoke(output); onInputDevice?.invoke(input) + LogManager.addLog("INFO", "CallAudioRouter", "stream priority route=$r output=${output?.id} input=${input?.id}") + return + } var dev: AudioDeviceInfo? = null var inDev: AudioDeviceInfo? = null when (r) { 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 f7f1119e..1148bda8 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 @@ -21,6 +21,7 @@ import com.utun.chat.ChatApplication import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicLong /** * Полудуплексный аудио-движок рации (аналог CallAudioEngine, но с PTT). @@ -58,13 +59,17 @@ class RadioAudioEngine { const val INPUT_BT = 3 } - @Volatile private var audioRecord: AudioRecord? = null + @Volatile private var captureSubscription: MicrophoneCapture.Subscription? = null private var captureThread: Thread? = null @Volatile private var audioTrack: AudioTrack? = null private var playThread: Thread? = null private var prevMode: Int = AudioManager.MODE_NORMAL private var prevSpeaker: Boolean = false private var audioModeOwned = false + private val streamPriorities = AtomicLong() + private var streamPriority = 0L // Только io. + private var streamCaptureReady = false + private var capturePttRequested = false @Volatile private var active = false @Volatile private var groupId = 0L @@ -119,6 +124,42 @@ class RadioAudioEngine { fun isActive(): Boolean = active + /** Camera worker ждёт освобождения разговорного тракта до открытия CAMCORDER. + * Старое уведомление release не отменяет приоритет следующей трансляции. */ + fun prepareStreamCapture(): Long { + val token = streamPriorities.incrementAndGet() + return io.submit { + streamPriority = token + streamCaptureReady = false + stopCapture() + router.setGlobalRouting(false) + restoreAudioMode() + LogManager.addLog("INFO", "radio", "stream capture priority acquired token=$token") + token + }.get() + } + + fun resumeAfterStreamCapture(token: Long) { + io.execute { + if (streamPriority != token) return@execute + streamCaptureReady = true + if (active) updateCapture() + } + } + + fun releaseStreamCapture(token: Long) { + io.execute { + if (streamPriority != token) return@execute + streamPriority = 0 + streamCaptureReady = false + stopCapture() + if (active) acquireAudioMode() + router.setGlobalRouting(true) + if (active) updateCapture() + LogManager.addLog("INFO", "radio", "stream capture priority released token=$token") + } + } + fun isTalking(): Boolean = _talkingFlow.value /** Текущий маршрут выхода рации (SPEAKER/HEADSET). */ @@ -201,15 +242,8 @@ class RadioAudioEngine { loadCaptureSettings() val group = audioGroup val ctx = ChatApplication.instance - val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager - prevMode = am.mode - prevSpeaker = am.isSpeakerphoneOn - audioModeOwned = true - try { - am.mode = AudioManager.MODE_IN_COMMUNICATION - } catch (e: Exception) { - LogManager.addLog("WARN", "RadioAudio", "set audio mode failed: ${e.message}") - } + if (streamPriority == 0L) acquireAudioMode() + router.setGlobalRouting(streamPriority == 0L) /* Маршрут выхода: автоследование за гарнитурой (воткнули → HEADSET, вынули → SPEAKER). */ router.fallbackRoute = AudioRoute.SPEAKER @@ -302,7 +336,7 @@ class RadioAudioEngine { /* Создать AudioRecord с маршрутизацией входного устройства: встроенный микрофон * (стерео + предобработка из настроек), проводная/BT-гарнитура (моно, MIC), * авто — системный дефолт. Возвращает (record, каналы). */ - private fun createAudioRecord(): Pair { + private fun createAudioRecord(preferredDev: AudioDeviceInfo?): Pair { val inputSource = configuredInput val pref = configuredMicSource val wantStereo = configuredStereo @@ -313,12 +347,6 @@ class RadioAudioEngine { val autoDev = router.currentInputDevice val headsetInput = inputSource == INPUT_WIRED || inputSource == INPUT_BT || (inputSource == INPUT_AUTO && autoDev != null) - val preferredDev = when (inputSource) { - INPUT_BUILTIN -> router.builtinMicDevice() - INPUT_WIRED -> router.wiredInputDevice() - INPUT_BT -> router.btInputDevice() - else -> autoDev - } val primary = when (pref) { 1 -> MediaRecorder.AudioSource.MIC @@ -356,7 +384,7 @@ class RadioAudioEngine { if (rec != null) { if (wantStereo && channels == 1) LogManager.addLog("WARN", "RadioAudio", "stereo capture unavailable — fallback to mono") - applyRecordPreferredDevice(rec, preferredDev) + if (!applyRecordPreferredDevice(rec, preferredDev)) { rec.release(); return null to channels } LogManager.addLog("INFO", "RadioAudio", "AudioRecord source=$src ch=$channels input=$inputSource dev=${preferredDev?.type ?: "default"}") return rec to channels @@ -368,8 +396,8 @@ class RadioAudioEngine { /* Прямое роутирование захвата на выбранное входное устройство (обход глючной * глобальной маршрутизации); null — системный дефолт. */ - private fun applyRecordPreferredDevice(rec: AudioRecord, dev: AudioDeviceInfo?) { - if (dev == null) return + private fun applyRecordPreferredDevice(rec: AudioRecord, dev: AudioDeviceInfo?): Boolean { + if (dev == null) return true val ok = try { rec.setPreferredDevice(dev) } catch (e: Exception) { @@ -377,6 +405,7 @@ class RadioAudioEngine { false } LogManager.addLog(if (ok) "DEBUG" else "WARN", "RadioAudio", "record input -> dev=${dev.type} setPreferredDevice=$ok") + return ok } private fun tryCreateRecord(source: Int, bufferSize: Int, channelMask: Int): AudioRecord? { @@ -432,6 +461,7 @@ class RadioAudioEngine { private fun updateCapture() { mainHandler.removeCallbacks(vadTimeout) if (!settingsReady) return + if (streamPriority != 0L && !streamCaptureReady) return val now = SystemClock.elapsedRealtime() vadWindow.setEnabled(autoSettings.enabled, now) val mode = radioCaptureMode(active, screenInteractive, vadWindow.isAllowed(now), autoSettings, requestedTalk, levelMonitoring) @@ -442,21 +472,19 @@ class RadioAudioEngine { mainHandler.postDelayed(vadTimeout, vadWindow.timeLeftMs(now).coerceAtLeast(1L)) if (mode == null) { stopCapture(); return } val detectorFailed = mode != RadioAutoSettings.OFF && autoMode == RadioAutoSettings.OFF - if (audioRecord != null && (captureRequestedMode != mode || (detectorFailed && !requestedTalk && !levelMonitoring))) stopCapture() + if (captureSubscription != null && (captureRequestedMode != mode || (detectorFailed && !requestedTalk && !levelMonitoring))) stopCapture() if (audioTrack == null) return - if (audioRecord == null && !startCapture(mode)) { + if (captureSubscription == null && !startCapture(mode)) { requestedTalk = false _talkingFlow.value = false return } - if (requestedTalk && !talking) { - NativeLib.radioTalkBegin(audioGroup) - talking = true - _talkingFlow.value = true - } else if (!requestedTalk && talking) { - NativeLib.radioTalkEnd(audioGroup) - talking = false - _talkingFlow.value = false + if (requestedTalk != capturePttRequested) { + if (captureSubscription?.ptt(requestedTalk) != true) { + LogManager.addLog("ERROR", "radio", "PTT command rejected; stopping capture group=$audioGroup") + stopCapture(); return + } + capturePttRequested = requestedTalk } } @@ -465,34 +493,40 @@ class RadioAudioEngine { LogManager.addLog("WARN", "debug", "Auto/PTT capture blocked: RECORD_AUDIO not granted group=$audioGroup mode=$mode") return false } - val startNs = SystemClock.elapsedRealtimeNanos() - val (rec, channels) = createAudioRecord() - if (rec == null) return false - val createdNs = SystemClock.elapsedRealtimeNanos() + val preferred = when (configuredInput) { + INPUT_BUILTIN -> router.builtinMicDevice() + INPUT_WIRED -> router.wiredInputDevice() + INPUT_BT -> router.btInputDevice() + else -> router.currentInputDevice + } + if (preferred == null && (configuredInput != INPUT_AUTO || router.route == AudioRoute.HEADSET)) { + LogManager.addLog("ERROR", "radio", "selected microphone unavailable input=$configuredInput route=${router.route}") + return false + } + val subscription = try { + AndroidMicrophoneCapture.borrowRadio(preferred) ?: run { + val (rec, _) = createAudioRecord(preferred) + if (rec == null) return false + AndroidMicrophoneCapture.own(rec).subscribe("radio") + } + } catch (e: Exception) { + LogManager.addLog("ERROR", "radio", "capture open failed input=${preferred?.id}: ${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") - rec.release() + subscription.close() return false } autoMode = NativeLib.radioAutoMode() captureRequestedMode = mode if (mode != RadioAutoSettings.OFF && autoMode == RadioAutoSettings.OFF && !requestedTalk && !levelMonitoring) { LogManager.addLog("ERROR", "debug", "Automatic transmission unavailable: capture cancelled group=$audioGroup") - NativeLib.radioCaptureStop(); rec.release() - return false - } - val preparedNs = SystemClock.elapsedRealtimeNanos() - try { - rec.startRecording() - if (rec.recordingState != AudioRecord.RECORDSTATE_RECORDING) error("recording did not start") - } catch (e: Exception) { - LogManager.addLog("ERROR", "RadioAudio", "capture start failed: ${e.message}") - NativeLib.radioCaptureStop(); rec.release() - autoMode = RadioAutoSettings.OFF + NativeLib.radioCaptureStop(); subscription.close() return false } - val recordingNs = SystemClock.elapsedRealtimeNanos() - audioRecord = rec + captureSubscription = subscription captureRunning = true _autoActiveFlow.value = autoMode != RadioAutoSettings.OFF // BEGIN должен попасть в uasync раньше первого кадра ручной передачи. @@ -501,13 +535,14 @@ class RadioAudioEngine { talking = true _talkingFlow.value = true } + capturePttRequested = requestedTalk val captureGroup = audioGroup captureThread = Thread { - try { captureLoop(rec, captureGroup, channels) } + try { captureLoop(subscription, captureGroup, channels) } catch (e: Exception) { LogManager.addLog("ERROR", "RadioAudio", "capture failed: ${e.message}") } finally { if (captureRunning) io.execute { - if (audioRecord === rec) { + if (captureSubscription === subscription) { requestedTalk = false stopCapture() LogManager.addLog("WARN", "RadioAudio", "capture lost: resources released") @@ -517,29 +552,23 @@ class RadioAudioEngine { }.apply { name = "radio-capture"; priority = Thread.MAX_PRIORITY; start() } - LogManager.addLog("INFO", "debug", - "capture started group=$audioGroup ch=$channels mode=$autoMode " + - "createMs=${(createdNs - startNs) / 1_000_000} prepareMs=${(preparedNs - createdNs) / 1_000_000} " + - "startMs=${(recordingNs - preparedNs) / 1_000_000} totalMs=${(recordingNs - startNs) / 1_000_000}") + LogManager.addLog("INFO", "radio", "capture started group=$audioGroup ch=$channels mode=$autoMode input=${preferred?.id}") return true } private fun stopCapture() { captureRunning = false - val rec = audioRecord - if (rec != null) { - try { rec.stop() } catch (e: Exception) { - LogManager.addLog("WARN", "RadioAudio", "capture stop failed: ${e.message}") - } - // stop разблокирует read; release только после выхода потока. + val subscription = captureSubscription + if (subscription != null) { + subscription.close() captureThread?.join() captureThread = null - rec.release() - audioRecord = null + captureSubscription = null NativeLib.radioCaptureStop() LogManager.addLog("INFO", "debug", "Auto/PTT capture released group=$audioGroup") } autoMode = RadioAutoSettings.OFF + capturePttRequested = false talking = false if (_transmittingGroup.value != null) { LogManager.addLog("DEBUG", "radio", "TX cleared after capture stop group=${_transmittingGroup.value}") @@ -626,9 +655,7 @@ class RadioAudioEngine { io.execute { if (active && generation == version) updateCapture() } } - private fun captureLoop(rec: AudioRecord, group: Long, ch: Int) { - val frame = FRAME_SAMPLES * ch - val buf = ShortArray(frame) + private fun captureLoop(subscription: MicrophoneCapture.Subscription, group: Long, ch: Int) { var frameN = 0L var reportedRequestNs = 0L var statsMs = SystemClock.elapsedRealtime() @@ -642,25 +669,16 @@ class RadioAudioEngine { var maxFeedNs = 0L var previousFeedAllowed: Boolean? = null while (captureRunning) { - if (rec.recordingState != AudioRecord.RECORDSTATE_RECORDING) { - if (captureRunning) { - LogManager.addLog("WARN", "RadioAudio", "capture loop: recordingState=${rec.recordingState}, mic stopped") - } - break - } val readStartedNs = SystemClock.elapsedRealtimeNanos() - var read = 0 - while (read < frame) { - if (!captureRunning) return - val n = rec.read(buf, read, frame - read) - if (n <= 0) { - if (captureRunning) { - LogManager.addLog("WARN", "RadioAudio", "capture loop: read()=$n, mic lost") - } - return - } - read += n + val event = subscription.poll() ?: continue + if (!captureRunning) break + if (event is MicrophoneCapture.Ptt) { + if (event.pressed) NativeLib.radioTalkBegin(group) else NativeLib.radioTalkEnd(group) + talking = event.pressed + continue } + val captured = event as MicrophoneCapture.Frame + val buf = captured.pcm maxReadNs = maxOf(maxReadNs, SystemClock.elapsedRealtimeNanos() - readStartedNs) val inputDb = rmsDb(buf) minRmsDb = minOf(minRmsDb, inputDb) @@ -669,7 +687,7 @@ class RadioAudioEngine { frameN++ val autoAllowed = autoMode != RadioAutoSettings.OFF && screenInteractive && vadWindow.isAllowed(SystemClock.elapsedRealtime()) val feedAllowed = active && groupId == group && captureRunning && - ((talking && requestedTalk) || autoAllowed || (levelMonitoring && screenInteractive)) + (talking || autoAllowed || (levelMonitoring && screenInteractive)) if (previousFeedAllowed != feedAllowed) { LogManager.addLog("DEBUG", "debug", "Auto PCM gate group=$group frame=$frameN allowed=$feedAllowed " + "active=$active currentGroup=$groupId capture=$captureRunning manual=$requestedTalk talking=$talking " + @@ -701,7 +719,7 @@ class RadioAudioEngine { LogManager.addLog("DEBUG", "debug", "Auto capture summary group=$group spanMs=${nowMs - statsMs} " + "frames=$statsFrames audioMs=${statsFrames * FRAME_MS} fed=$fedFrames skipped=$skippedFrames rejected=$failedFrames " + "rmsDb=$minRmsDb..$maxRmsDb readMaxMs=${maxReadNs / 1_000_000.0} feedMaxMs=${maxFeedNs / 1_000_000.0} " + - "source=${rec.audioSource} channels=$ch routed=${rec.routedDevice?.type ?: "-"} input=$configuredInput " + + "channels=$ch input=$configuredInput " + "route=${route()} mode=$autoMode screen=$screenInteractive manual=$requestedTalk tx=${_talkingFlow.value}") statsMs = nowMs statsFrames = 0; fedFrames = 0; skippedFrames = 0; failedFrames = 0 @@ -762,6 +780,14 @@ class RadioAudioEngine { LogManager.addLog("DEBUG", "RadioAudio", "play loop done") } + private fun acquireAudioMode() { + if (audioModeOwned) return + val am = ChatApplication.instance.getSystemService(Context.AUDIO_SERVICE) as AudioManager + prevMode = am.mode; prevSpeaker = am.isSpeakerphoneOn; audioModeOwned = true + try { am.mode = AudioManager.MODE_IN_COMMUNICATION } + catch (e: Exception) { LogManager.addLog("WARN", "radio", "set audio mode failed: ${e.message}") } + } + private fun restoreAudioMode() { if (!audioModeOwned) return audioModeOwned = false diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamCaptureEngine.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamCaptureEngine.kt index f8cadaa0..3e788175 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamCaptureEngine.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamCaptureEngine.kt @@ -11,14 +11,10 @@ import android.hardware.camera2.CameraManager import android.hardware.camera2.CaptureRequest import android.hardware.camera2.CaptureResult import android.hardware.camera2.TotalCaptureResult -import android.media.AudioFormat -import android.media.AudioRecord -import android.media.AudioTimestamp import android.media.MediaCodec import android.media.MediaCodecInfo import android.media.MediaFormat import android.os.Build -import android.media.MediaRecorder import android.os.Bundle import android.os.Handler import android.os.HandlerThread @@ -45,7 +41,7 @@ class StreamCaptureEngine( private var previewTarget: StreamPreviewTarget? = null private var sessionGeneration = 0L private val cameraSessions = mutableMapOf Unit>>() - private var record: AudioRecord? = null + private var audioCapture: MicrophoneCapture.Subscription? = null private var audioThread: Thread? = null @Volatile private var audioRunning = false @Volatile var audioCompressorSettings = initialAudioCompressor @@ -440,39 +436,35 @@ class StreamCaptureEngine( } @SuppressLint("MissingPermission") private fun startAudio() { - val min = AudioRecord.getMinBufferSize(48000, AudioFormat.CHANNEL_IN_STEREO, AudioFormat.ENCODING_PCM_16BIT) - check(min > 0) - val rec = AudioRecord(MediaRecorder.AudioSource.CAMCORDER, 48000, AudioFormat.CHANNEL_IN_STEREO, - AudioFormat.ENCODING_PCM_16BIT, (min * 2).coerceAtLeast(1920 * 8)) - record = rec; check(rec.state == AudioRecord.STATE_INITIALIZED) - check(rec.channelCount == 2) { "CAMCORDER stereo capture unavailable: channels=${rec.channelCount}" } - rec.startRecording(); check(rec.recordingState == AudioRecord.RECORDSTATE_RECORDING) { "CAMCORDER capture did not start" } + val radio = RadioAudioEngine.instance() + val priority = radio.prepareStreamCapture() + val subscription = try { AndroidMicrophoneCapture.openStream { radio.releaseStreamCapture(priority) } } + catch (e: Exception) { radio.releaseStreamCapture(priority); throw e } + audioCapture = subscription audioRunning = true - LogManager.addLog("INFO", "stream", "audio capture started id=$id source=CAMCORDER(${rec.audioSource}) " + - "rate=${rec.sampleRate} channels=${rec.channelCount} compressor=$audioCompressorSettings") + LogManager.addLog("INFO", "stream", "audio capture started id=$id source=CAMCORDER rate=48000 channels=2 compressor=$audioCompressorSettings") audioThread = Thread({ var opus = 0L try { opus = NativeLib.streamOpusCreate(true); check(opus != 0L) - val pcm = ShortArray(1920); val stamp = AudioTimestamp() - var total = 0L; var waitingStamp = true; var last = -1L + var waitingStamp = true; var last = -1L; var expected = -1L while (audioRunning) { - var filled = 0 - while (audioRunning && filled < pcm.size) { - val n = rec.read(pcm, filled, pcm.size - filled, AudioRecord.READ_BLOCKING) - check(n > 0 || !audioRunning) { "AudioRecord read=$n" } - if (n > 0) filled += n - } - if (!audioRunning || filled != pcm.size) break - val first = total; total += filled / 2 // AudioTimestamp считает кадры на канал, а read — interleaved сэмплы. - if (rec.getTimestamp(stamp, AudioTimestamp.TIMEBASE_MONOTONIC) != AudioRecord.SUCCESS) { - if (waitingStamp) LogManager.addLog("DEBUG", "stream", "audio timestamp pending id=$id samples=$total") + val frame = subscription.poll() as? MicrophoneCapture.Frame ?: continue + if (!audioRunning) break + if (frame.timeNs == 0L) { + if (waitingStamp) LogManager.addLog("DEBUG", "stream", "audio timestamp pending id=$id samples=${frame.sequence}") continue } + if (waitingStamp) radio.resumeAfterStreamCapture(priority) waitingStamp = false - val pts = (stamp.nanoTime - originNs) / 1000 - (stamp.framePosition - first) * 1_000_000 / 48000 + if (expected >= 0 && frame.sequence != expected) { + LogManager.addLog("WARN", "stream", "audio queue discontinuity id=$id expected=$expected sequence=${frame.sequence}") + NativeLib.streamOpusClose(opus); opus = NativeLib.streamOpusCreate(true); check(opus != 0L) + } + expected = frame.sequence + 960 + val pts = (frame.timeNs - originNs) / 1000 check(pts >= 0 && pts > last) { "Audio timestamp discontinuity pts=$pts last=$last" }; last = pts - val packet = NativeLib.streamOpusEncode(opus, pcm, audioCompressorSettings) ?: error("Stream compressor/Opus encode failed") + val packet = NativeLib.streamOpusEncode(opus, frame.pcm, audioCompressorSettings) ?: error("Stream compressor/Opus encode failed") check(NativeLib.streamFeed(id, false, pts, false, packet) >= 0) { "Stream audio publisher detached" } } } catch (e: Exception) { if (audioRunning) handler.post { failed("audio capture: ${e.message}") } } @@ -496,10 +488,8 @@ class StreamCaptureEngine( if (stopping) return@post stopping = true; audioRunning = false preview.close(); previewTarget?.release(); previewTarget = null - try { record?.stop() } catch (e: Exception) { LogManager.addLog("WARN", "stream", "audio stop id=$id: ${e.message}") } + audioCapture?.close(); audioCapture = null audioThread?.join(); audioThread = null - try { record?.release() } catch (e: Exception) { LogManager.addLog("WARN", "stream", "audio release id=$id: ${e.message}") } - record = null releaseVideo { stopped = true LogManager.addLog("INFO", "stream", "capture released id=$id frames=$frames dropped=$dropped bytes=$bytesSent")