From a893f6b4f2823cd2e39eea0fbd84e3d37ce47332 Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 22 Oct 2026 15:07:25 +0200 Subject: [PATCH] Route radio and stream capture through the shared controller --- .../com/utun/chat/data/CallAudioRouter.kt | 27 +- .../com/utun/chat/data/RadioAudioEngine.kt | 256 ++---------------- .../com/utun/chat/data/StreamCaptureEngine.kt | 9 +- 3 files changed, 40 insertions(+), 252 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 8a79dad1..89fe4d49 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 @@ -212,7 +212,8 @@ class CallAudioRouter { } /* ── применение маршрута ── */ - /** Трансляция владеет глобальным трактом; рация меняет только preferredDevice своих входа/выхода. */ + /** Выбрать глобальный разговорный тракт либо только preferredDevice (media/CAMCORDER). + * Вызовы звонка сохраняют глобальный режим; общий media-контроллер выбирает режим по захвату. */ @Synchronized fun setGlobalRouting(enabled: Boolean) { if (globalRouting == enabled) return if (!enabled && started && telecom == null) { @@ -221,7 +222,7 @@ class CallAudioRouter { clearCommunicationDevice() } globalRouting = enabled - LogManager.addLog("INFO", "CallAudioRouter", "global routing=$enabled owner=${if (enabled) "radio" else "stream"}") + LogManager.addLog("INFO", "CallAudioRouter", "global routing=$enabled") if (started) applyRoute(desiredRoute) } @@ -237,17 +238,17 @@ class CallAudioRouter { try { if (!globalRouting) { val wired = findWiredDevice() - val headset = if (preferWiredHeadset && wired != null) wired else findBtScoDevice() ?: wired + val headset = if (preferWiredHeadset && wired != null) wired else findBtMediaDevice() ?: 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() + else if (headset != null && isBtType(headset.type)) 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}") + LogManager.addLog("INFO", "CallAudioRouter", "per-track route=$r output=${output?.id} input=${input?.id}") return } var dev: AudioDeviceInfo? = null @@ -340,7 +341,7 @@ class CallAudioRouter { fun btInputDevice(): AudioDeviceInfo? = (devices ?: am.getDevices(AudioManager.GET_DEVICES_ALL)).firstOrNull { - it.isSource && it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO + it.isSource && isBtType(it.type) } private fun refreshInputDevices() { @@ -410,11 +411,21 @@ class CallAudioRouter { private fun findWiredDevice(): AudioDeviceInfo? = (devices ?: am.getDevices(AudioManager.GET_DEVICES_ALL)).firstOrNull { it.isSink && isWiredType(it.type) } - private fun findBtScoDevice(): AudioDeviceInfo? = + private fun isBtType(type: Int): Boolean = type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO || + type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP || (Build.VERSION.SDK_INT >= 31 && type == AudioDeviceInfo.TYPE_BLE_HEADSET) + + private fun findBtMediaDevice(): AudioDeviceInfo? = (devices ?: am.getDevices(AudioManager.GET_DEVICES_ALL)).firstOrNull { - it.isSink && it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO + it.isSink && (it.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP || + (Build.VERSION.SDK_INT >= 31 && it.type == AudioDeviceInfo.TYPE_BLE_HEADSET)) } + private fun findBtScoDevice(): AudioDeviceInfo? { + val available = if (Build.VERSION.SDK_INT >= 31) am.availableCommunicationDevices + else (devices ?: am.getDevices(AudioManager.GET_DEVICES_ALL)).toList() + return available.firstOrNull { it.isSink && isBtType(it.type) && it.type != AudioDeviceInfo.TYPE_BLUETOOTH_A2DP } + } + private fun isWiredType(type: Int): Boolean = when (type) { AudioDeviceInfo.TYPE_WIRED_HEADSET, AudioDeviceInfo.TYPE_WIRED_HEADPHONES, 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 1bdd953c..b6abdf3b 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 @@ -6,12 +6,9 @@ import android.content.Intent import android.content.IntentFilter import android.content.Context import android.media.AudioAttributes -import android.media.AudioDeviceInfo import android.media.AudioFormat import android.media.AudioManager -import android.media.AudioRecord import android.media.AudioTrack -import android.media.MediaRecorder import android.os.Build import android.os.Handler import android.os.Looper @@ -22,7 +19,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import java.util.concurrent.Executors import java.util.concurrent.Future -import java.util.concurrent.atomic.AtomicLong /** * Полудуплексный аудио-движок рации (аналог CallAudioEngine, но с PTT). @@ -53,22 +49,13 @@ class RadioAudioEngine { inst ?: RadioAudioEngine().also { inst = it } } - /* Входное устройство захвата (значение radio_input_source). */ - const val INPUT_AUTO = 0 - const val INPUT_BUILTIN = 1 - const val INPUT_WIRED = 2 - const val INPUT_BT = 3 } @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 prevSpeaker: Boolean = false - private var audioModeOwned = false - private val streamPriorities = AtomicLong() - private var streamPriority = 0L // Только io. - private var streamCaptureReady = false + private var outputLease: AutoCloseable? = null private var capturePttRequested = false @Volatile private var active = false @@ -86,9 +73,6 @@ class RadioAudioEngine { private val vadWindow = RadioVadWindow(VAD_BACKGROUND_MS) private var screenReceiver: BroadcastReceiver? = null @Volatile private var screenInteractive = false - private var configuredInput = INPUT_AUTO - private var configuredMicSource = 0 - private var configuredStereo = false private var autoSettings = RadioAutoSettings() private var settingsReady = false private var settingsRequest = 0L @@ -96,9 +80,6 @@ class RadioAudioEngine { @Volatile private var autoMode = RadioAutoSettings.OFF @Volatile private var levelMonitoring = false - /** Маршрутизация аудио-выхода рации (динамик ↔ гарнитура), как в звонке. */ - val router = CallAudioRouter() - private val _talkingFlow = MutableStateFlow(false) val talkingFlow: StateFlow = _talkingFlow /** Фактический TX (PTT/автоматика), привязанный к каналу. Capture публикует, io сбрасывает после join. */ @@ -126,58 +107,10 @@ 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). */ - fun route(): AudioRoute = router.route - - /** Текущее входное устройство захвата (INPUT_AUTO/BUILTIN/WIRED/BT). */ - fun inputSource(): Int = ChatApplication.instance.configProvider.getRadioInputSource() - - /** Доступна ли гарнитура (проводная/USB/BT). */ - fun headsetAvailable(): Boolean = router.headsetAvailable - - /** Ручной выбор выхода (отключает автоследование за гарнитурой). */ - fun setRoute(r: AudioRoute, preferWired: Boolean = false) { - router.setAutoHeadset(false) - router.setRoute(r, preferWired) - } + fun route(): AudioRoute = if (MediaAudioController.state.value.active == MediaAudioProfile.PHONE) AudioRoute.SPEAKER else AudioRoute.HEADSET /** Начать прослушивание канала (переключатель рации вкл). * Тяжёлая инициализация аудио выполняется на io-потоке, не блокируя main. */ @@ -205,7 +138,6 @@ class RadioAudioEngine { releaseLocked() audioGroup = group vadWindow.restart(SystemClock.elapsedRealtime()) - router.setAutoHeadset(true) if (active && generation == version) { try { setupAudio() } catch (e: Exception) { LogManager.addLog("ERROR", "RadioAudio", "audio setup failed: ${e.message}") @@ -296,15 +228,8 @@ class RadioAudioEngine { private fun setupAudio() { loadCaptureSettings() val group = audioGroup - if (streamPriority == 0L) acquireAudioMode() - router.setGlobalRouting(streamPriority == 0L) - - /* Маршрут выхода: автоследование за гарнитурой (воткнули → HEADSET, вынули → SPEAKER). */ - router.fallbackRoute = AudioRoute.SPEAKER - router.start() - if (!NativeLib.radioAudioStart(group, 0)) { - router.stop(); restoreAudioMode(); active = false + active = false LogManager.addLog("ERROR", "RadioAudio", "native radioAudioStart failed group=$group") return } @@ -316,7 +241,7 @@ class RadioAudioEngine { val track = try { AudioTrack( AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) + .setUsage(AudioAttributes.USAGE_MEDIA) .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH).build(), AudioFormat.Builder().setSampleRate(SAMPLE_RATE) .setEncoding(AudioFormat.ENCODING_PCM_16BIT) @@ -324,21 +249,12 @@ class RadioAudioEngine { trackBuf, AudioTrack.MODE_STREAM, AudioManager.AUDIO_SESSION_ID_GENERATE) } catch (e: Exception) { LogManager.addLog("ERROR", "RadioAudio", "AudioTrack create failed: ${e.message}") - NativeLib.radioAudioStop(); router.stop(); restoreAudioMode(); active = false; return + NativeLib.radioAudioStop(); active = false; return } audioTrack = track - /* Прямое роутирование трека на выбранное устройство (обход глючной глобальной - * маршрутизации), как в CallAudioEngine. */ - router.onOutputDevice = { dev -> applyTrackOutputDevice(dev) } - applyTrackOutputDevice(router.currentOutputDevice) - - /* AUTO-вход: смена маршрута выхода меняет и входное устройство — пересоздаём захват. */ - router.onInputDevice = { _ -> - if (ChatApplication.instance.configProvider.getRadioInputSource() == INPUT_AUTO) reconfigure() - } - + outputLease = MediaAudioController.attachOutput(track) track.play() playbackRunning = true playThread = Thread { playLoop() }.apply { @@ -353,9 +269,7 @@ class RadioAudioEngine { /* Настройки загружаются при включении/перенастройке, без DataStore в пути PTT. */ private fun loadCaptureSettings() { val provider = ChatApplication.instance.configProvider - configuredInput = provider.getRadioInputSource() - configuredMicSource = provider.getRadioMicSource() - configuredStereo = provider.getRadioCaptureStereo() + MediaAudioController.refresh() autoSettings = provider.getRadioAutoSettings() settingsReady = false val request = ++settingsRequest @@ -387,96 +301,6 @@ class RadioAudioEngine { } } - /* Создать AudioRecord с маршрутизацией входного устройства: встроенный микрофон - * (стерео + предобработка из настроек), проводная/BT-гарнитура (моно, MIC), - * авто — системный дефолт. Возвращает (record, каналы). */ - private fun createAudioRecord(preferredDev: AudioDeviceInfo?): Pair { - val inputSource = configuredInput - val pref = configuredMicSource - val wantStereo = configuredStereo - - /* гарнитурный вход: моно + VOICE_COMMUNICATION (звонковый тракт «запитывает» - * микрофон гарнитуры; AudioSource.MIC на ней даёт тишину). AUTO: следуем за - * маршрутом выхода (currentInputDevice из applyRoute). */ - val autoDev = router.currentInputDevice - val headsetInput = inputSource == INPUT_WIRED || inputSource == INPUT_BT || - (inputSource == INPUT_AUTO && autoDev != null) - - val primary = when (pref) { - 1 -> MediaRecorder.AudioSource.MIC - 2 -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) - MediaRecorder.AudioSource.UNPROCESSED else MediaRecorder.AudioSource.MIC - 3 -> MediaRecorder.AudioSource.CAMCORDER - else -> MediaRecorder.AudioSource.VOICE_COMMUNICATION - } - val sources = if (headsetInput) { - intArrayOf(MediaRecorder.AudioSource.VOICE_COMMUNICATION) - } else if (wantStereo && pref == 0) { - /* voice-источник (AEC/NS/beamforming) коллапсирует в моно/один луч — - * для стерео используем CAMCORDER (тот же тракт, что у видеозаписи). */ - LogManager.addLog("WARN", "RadioAudio", "voice source не даёт стерео — использую CAMCORDER") - intArrayOf(MediaRecorder.AudioSource.CAMCORDER) - } else if (pref == 2 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - intArrayOf(primary, MediaRecorder.AudioSource.MIC) - } else { - intArrayOf(primary) - } - val masks = if (!headsetInput && wantStereo) - intArrayOf(AudioFormat.CHANNEL_IN_STEREO, AudioFormat.CHANNEL_IN_MONO) - else intArrayOf(AudioFormat.CHANNEL_IN_MONO) - - for (mask in masks) { - val channels = if (mask == AudioFormat.CHANNEL_IN_STEREO) 2 else 1 - val recMin = AudioRecord.getMinBufferSize(SAMPLE_RATE, mask, AudioFormat.ENCODING_PCM_16BIT) - if (recMin <= 0) { - LogManager.addLog("WARN", "RadioAudio", "unsupported capture format ch=$channels minBuffer=$recMin") - continue - } - val bufSize = (recMin * 2).coerceAtLeast(FRAME_SAMPLES * channels * 4) - for (src in sources) { - val rec = tryCreateRecord(src, bufSize, mask) - if (rec != null) { - if (wantStereo && channels == 1) - LogManager.addLog("WARN", "RadioAudio", "stereo capture unavailable — fallback to mono") - 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 - } - } - } - return null to 1 - } - - /* Прямое роутирование захвата на выбранное входное устройство (обход глючной - * глобальной маршрутизации); null — системный дефолт. */ - private fun applyRecordPreferredDevice(rec: AudioRecord, dev: AudioDeviceInfo?): Boolean { - if (dev == null) return true - val ok = try { - rec.setPreferredDevice(dev) - } catch (e: Exception) { - LogManager.addLog("ERROR", "RadioAudio", "setPreferredDevice(record) threw: ${e.message}") - 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? { - val rec = try { - AudioRecord(source, SAMPLE_RATE, channelMask, AudioFormat.ENCODING_PCM_16BIT, bufferSize) - } catch (e: Exception) { - LogManager.addLog("ERROR", "RadioAudio", "AudioRecord create failed (source=$source chMask=$channelMask): ${e.message}") - return null - } - if (rec.state != AudioRecord.STATE_INITIALIZED) { - LogManager.addLog("ERROR", "RadioAudio", "AudioRecord not initialized (source=$source chMask=$channelMask)") - rec.release() - return null - } - return rec - } - private fun screenOn(): Boolean = (ChatApplication.instance.getSystemService(Context.POWER_SERVICE) as PowerManager).isInteractive @@ -515,7 +339,6 @@ 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) @@ -547,24 +370,10 @@ class RadioAudioEngine { LogManager.addLog("WARN", "radio", "Auto/PTT capture blocked: RECORD_AUDIO not granted group=$audioGroup mode=$mode") return false } - 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") - } + MediaAudioController.subscribe("radio") } catch (e: Exception) { - LogManager.addLog("ERROR", "radio", "capture open failed input=${preferred?.id}: ${e.message}") + LogManager.addLog("ERROR", "radio", "shared capture subscription failed: ${e.message}") return false } val channels = subscription.channels @@ -606,7 +415,7 @@ class RadioAudioEngine { }.apply { name = "radio-capture"; priority = Thread.MAX_PRIORITY; start() } - LogManager.addLog("INFO", "radio", "capture started group=$audioGroup ch=$channels mode=$autoMode input=${preferred?.id}") + LogManager.addLog("INFO", "radio", "capture started group=$audioGroup ch=$channels mode=$autoMode shared=true") return true } @@ -647,14 +456,11 @@ class RadioAudioEngine { playThread?.interrupt() playThread?.join() playThread = null + outputLease?.close(); outputLease = null track.release() } audioTrack = null NativeLib.radioAudioStop() - router.onOutputDevice = null - router.onInputDevice = null - router.stop() - restoreAudioMode() } internal fun talkBegin(source: RadioPttSource = RadioPttSource.BUTTON): Boolean { @@ -711,6 +517,7 @@ class RadioAudioEngine { private fun captureLoop(subscription: MicrophoneCapture.Subscription, group: Long, ch: Int) { var expected = -1L + var captureEpoch = -1L var discontinuity = false var frameN = 0L var reportedRequestNs = 0L @@ -740,7 +547,11 @@ class RadioAudioEngine { discontinuity = true continue } - if (discontinuity || (expected >= 0 && captured.sequence != expected)) NativeLib.radioCaptureDiscontinuity(group) + if (discontinuity || (expected >= 0 && (captured.sequence != expected || captured.generation != captureEpoch))) { + LogManager.addLog("INFO", "radio", "shared capture boundary group=$group epoch=$captureEpoch->${captured.generation}") + NativeLib.radioCaptureDiscontinuity(group) + } + captureEpoch = captured.generation discontinuity = false; expected = captured.sequence + FRAME_SAMPLES val buf = captured.pcm maxReadNs = maxOf(maxReadNs, SystemClock.elapsedRealtimeNanos() - readStartedNs) @@ -783,7 +594,7 @@ class RadioAudioEngine { LogManager.addLog("DEBUG", "radio", "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} " + - "channels=$ch input=$configuredInput " + + "channels=$ch input=${MediaAudioController.state.value.inputName} " + "route=${route()} mode=$autoMode screen=$screenInteractive manual=$requestedTalk tx=${_talkingFlow.value}") statsMs = nowMs statsFrames = 0; fedFrames = 0; skippedFrames = 0; failedFrames = 0 @@ -844,35 +655,4 @@ 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 - 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 - try { - val am = ChatApplication.instance.getSystemService(Context.AUDIO_SERVICE) as AudioManager - am.isSpeakerphoneOn = prevSpeaker - // MODE_NORMAL снимает нашу заявку; прежний глобальный mode мог принадлежать завершаемому Telecom-звонку. - am.mode = AudioManager.MODE_NORMAL - } catch (e: Exception) { - LogManager.addLog("WARN", "RadioAudio", "restore audio mode failed: ${e.message}") - } - } - - private fun applyTrackOutputDevice(dev: AudioDeviceInfo?) { - val track = audioTrack ?: return - if (dev == null) return - try { - track.setPreferredDevice(dev) - LogManager.addLog("DEBUG", "RadioAudio", "track preferred device -> type=${dev.type}") - } catch (e: Exception) { - LogManager.addLog("WARN", "RadioAudio", "setPreferredDevice failed: ${e.message}") - } - } } 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 14d87524..ebb85958 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 @@ -491,13 +491,10 @@ class StreamCaptureEngine( @SuppressLint("MissingPermission") private fun startAudio() { - val radio = RadioAudioEngine.instance() - val priority = radio.prepareStreamCapture() - val subscription = try { AndroidMicrophoneCapture.openStream { radio.releaseStreamCapture(priority) } } - catch (e: Exception) { radio.releaseStreamCapture(priority); throw e } + val subscription = MediaAudioController.subscribe("stream") audioCapture = subscription audioRunning = true - LogManager.addLog("INFO", "stream", "audio capture started id=$id source=CAMCORDER rate=48000 channels=2 compressor=$audioCompressorSettings") + LogManager.addLog("INFO", "stream", "shared audio subscribed id=$id rate=48000 channels=2 compressor=$audioCompressorSettings") audioThread = Thread({ var opus = 0L try { @@ -510,7 +507,6 @@ class StreamCaptureEngine( if (waitingStamp) LogManager.addLog("DEBUG", "stream", "audio timestamp pending id=$id samples=${frame.sequence}") continue } - if (waitingStamp) radio.resumeAfterStreamCapture(priority) waitingStamp = false if (expected >= 0 && frame.sequence != expected) { LogManager.addLog("WARN", "stream", "audio queue discontinuity id=$id expected=$expected sequence=${frame.sequence}") @@ -518,6 +514,7 @@ class StreamCaptureEngine( } expected = frame.sequence + 960 val pts = (frame.timeNs - originNs) / 1000 + if (pts < 0) continue // Общий захват мог начаться раньше эфира; его запас времени ещё дренируется. check(pts >= 0 && pts > last) { "Audio timestamp discontinuity pts=$pts last=$last" }; last = pts if (audioMuted) frame.pcm.fill(0) val packet = NativeLib.streamOpusEncode(opus, frame.pcm, audioCompressorSettings) ?: error("Stream compressor/Opus encode failed")