|
|
|
|
@ -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 |
|
|
|
|
|