Route radio and stream capture through the shared controller

This commit is contained in:
evgeny committed 2026-10-22 15:07:25 +02:00
1 parent ecada765a2
commit a893f6b4f2
3 files changed
+40 -252

No files matched your search

@@ -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,
@@ -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<Boolean> = _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<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). */
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<AudioRecord?, Int> {
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}")
}
}
}
@@ -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")