Route radio and stream capture through the shared controller
This commit is contained in:
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")
|
||||
|
||||
Reference in new issue
Block a user