Browse Source

Connect Android stream and radio to shared capture and preserve stream audio mode

master
evgeny 3 days ago
parent
commit
9d69cce8e8
  1. 35
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioRouter.kt
  2. 194
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt
  3. 52
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamCaptureEngine.kt

35
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) {

194
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<Long> {
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<AudioRecord?, Int> {
private fun createAudioRecord(preferredDev: AudioDeviceInfo?): Pair<AudioRecord?, Int> {
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

52
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<Long, Pair<CameraDevice, (String) -> 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")

Loading…
Cancel
Save