Browse Source

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

master
evgeny 4 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 preferWiredHeadset = false
private var started = false private var started = false
private var globalRouting = true
private var desiredRoute = AudioRoute.SPEAKER private var desiredRoute = AudioRoute.SPEAKER
var telecom: UtunConnection? = null var telecom: UtunConnection? = null
private set private set
@ -150,7 +151,7 @@ class CallAudioRouter {
"devices=${devices?.size} hasEarpiece=$hasEarpiece autoHeadset=$autoHeadset fallback=$fallbackRoute") "devices=${devices?.size} hasEarpiece=$hasEarpiece autoHeadset=$autoHeadset fallback=$fallbackRoute")
} }
fun stop() { @Synchronized fun stop() {
if (!started) { bindTelecom(null); return } if (!started) { bindTelecom(null); return }
started = false started = false
@ -159,7 +160,7 @@ class CallAudioRouter {
unbindBtHeadsetProfile() unbindBtHeadsetProfile()
try { try {
if (telecom == null) { if (telecom == null && globalRouting) {
stopBluetoothSco() stopBluetoothSco()
am.isBluetoothScoOn = false am.isBluetoothScoOn = false
clearCommunicationDevice() 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() onRouteRequested?.invoke()
telecom?.let { connection -> telecom?.let { connection ->
connection.routeTo(r) connection.routeTo(r)
@ -221,6 +235,21 @@ class CallAudioRouter {
route = r route = r
val startedAt = SystemClock.elapsedRealtime() val startedAt = SystemClock.elapsedRealtime()
try { 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 dev: AudioDeviceInfo? = null
var inDev: AudioDeviceInfo? = null var inDev: AudioDeviceInfo? = null
when (r) { 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicLong
/** /**
* Полудуплексный аудио-движок рации (аналог CallAudioEngine, но с PTT). * Полудуплексный аудио-движок рации (аналог CallAudioEngine, но с PTT).
@ -58,13 +59,17 @@ class RadioAudioEngine {
const val INPUT_BT = 3 const val INPUT_BT = 3
} }
@Volatile private var audioRecord: AudioRecord? = null @Volatile private var captureSubscription: MicrophoneCapture.Subscription? = null
private var captureThread: Thread? = null private var captureThread: Thread? = null
@Volatile private var audioTrack: AudioTrack? = null @Volatile private var audioTrack: AudioTrack? = null
private var playThread: Thread? = null private var playThread: Thread? = null
private var prevMode: Int = AudioManager.MODE_NORMAL private var prevMode: Int = AudioManager.MODE_NORMAL
private var prevSpeaker: Boolean = false private var prevSpeaker: Boolean = false
private var audioModeOwned = 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 active = false
@Volatile private var groupId = 0L @Volatile private var groupId = 0L
@ -119,6 +124,42 @@ class RadioAudioEngine {
fun isActive(): Boolean = active 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 fun isTalking(): Boolean = _talkingFlow.value
/** Текущий маршрут выхода рации (SPEAKER/HEADSET). */ /** Текущий маршрут выхода рации (SPEAKER/HEADSET). */
@ -201,15 +242,8 @@ class RadioAudioEngine {
loadCaptureSettings() loadCaptureSettings()
val group = audioGroup val group = audioGroup
val ctx = ChatApplication.instance val ctx = ChatApplication.instance
val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager if (streamPriority == 0L) acquireAudioMode()
prevMode = am.mode router.setGlobalRouting(streamPriority == 0L)
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}")
}
/* Маршрут выхода: автоследование за гарнитурой (воткнули → HEADSET, вынули → SPEAKER). */ /* Маршрут выхода: автоследование за гарнитурой (воткнули → HEADSET, вынули → SPEAKER). */
router.fallbackRoute = AudioRoute.SPEAKER router.fallbackRoute = AudioRoute.SPEAKER
@ -302,7 +336,7 @@ class RadioAudioEngine {
/* Создать AudioRecord с маршрутизацией входного устройства: встроенный микрофон /* Создать AudioRecord с маршрутизацией входного устройства: встроенный микрофон
* (стерео + предобработка из настроек), проводная/BT-гарнитура (моно, MIC), * (стерео + предобработка из настроек), проводная/BT-гарнитура (моно, MIC),
* авто — системный дефолт. Возвращает (record, каналы). */ * авто — системный дефолт. Возвращает (record, каналы). */
private fun createAudioRecord(): Pair<AudioRecord?, Int> { private fun createAudioRecord(preferredDev: AudioDeviceInfo?): Pair<AudioRecord?, Int> {
val inputSource = configuredInput val inputSource = configuredInput
val pref = configuredMicSource val pref = configuredMicSource
val wantStereo = configuredStereo val wantStereo = configuredStereo
@ -313,12 +347,6 @@ class RadioAudioEngine {
val autoDev = router.currentInputDevice val autoDev = router.currentInputDevice
val headsetInput = inputSource == INPUT_WIRED || inputSource == INPUT_BT || val headsetInput = inputSource == INPUT_WIRED || inputSource == INPUT_BT ||
(inputSource == INPUT_AUTO && autoDev != null) (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) { val primary = when (pref) {
1 -> MediaRecorder.AudioSource.MIC 1 -> MediaRecorder.AudioSource.MIC
@ -356,7 +384,7 @@ class RadioAudioEngine {
if (rec != null) { if (rec != null) {
if (wantStereo && channels == 1) if (wantStereo && channels == 1)
LogManager.addLog("WARN", "RadioAudio", "stereo capture unavailable — fallback to mono") 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", LogManager.addLog("INFO", "RadioAudio",
"AudioRecord source=$src ch=$channels input=$inputSource dev=${preferredDev?.type ?: "default"}") "AudioRecord source=$src ch=$channels input=$inputSource dev=${preferredDev?.type ?: "default"}")
return rec to channels return rec to channels
@ -368,8 +396,8 @@ class RadioAudioEngine {
/* Прямое роутирование захвата на выбранное входное устройство (обход глючной /* Прямое роутирование захвата на выбранное входное устройство (обход глючной
* глобальной маршрутизации); null — системный дефолт. */ * глобальной маршрутизации); null — системный дефолт. */
private fun applyRecordPreferredDevice(rec: AudioRecord, dev: AudioDeviceInfo?) { private fun applyRecordPreferredDevice(rec: AudioRecord, dev: AudioDeviceInfo?): Boolean {
if (dev == null) return if (dev == null) return true
val ok = try { val ok = try {
rec.setPreferredDevice(dev) rec.setPreferredDevice(dev)
} catch (e: Exception) { } catch (e: Exception) {
@ -377,6 +405,7 @@ class RadioAudioEngine {
false false
} }
LogManager.addLog(if (ok) "DEBUG" else "WARN", "RadioAudio", "record input -> dev=${dev.type} setPreferredDevice=$ok") 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? { private fun tryCreateRecord(source: Int, bufferSize: Int, channelMask: Int): AudioRecord? {
@ -432,6 +461,7 @@ class RadioAudioEngine {
private fun updateCapture() { private fun updateCapture() {
mainHandler.removeCallbacks(vadTimeout) mainHandler.removeCallbacks(vadTimeout)
if (!settingsReady) return if (!settingsReady) return
if (streamPriority != 0L && !streamCaptureReady) return
val now = SystemClock.elapsedRealtime() val now = SystemClock.elapsedRealtime()
vadWindow.setEnabled(autoSettings.enabled, now) vadWindow.setEnabled(autoSettings.enabled, now)
val mode = radioCaptureMode(active, screenInteractive, vadWindow.isAllowed(now), autoSettings, requestedTalk, levelMonitoring) 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)) mainHandler.postDelayed(vadTimeout, vadWindow.timeLeftMs(now).coerceAtLeast(1L))
if (mode == null) { stopCapture(); return } if (mode == null) { stopCapture(); return }
val detectorFailed = mode != RadioAutoSettings.OFF && autoMode == RadioAutoSettings.OFF 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 (audioTrack == null) return
if (audioRecord == null && !startCapture(mode)) { if (captureSubscription == null && !startCapture(mode)) {
requestedTalk = false requestedTalk = false
_talkingFlow.value = false _talkingFlow.value = false
return return
} }
if (requestedTalk && !talking) { if (requestedTalk != capturePttRequested) {
NativeLib.radioTalkBegin(audioGroup) if (captureSubscription?.ptt(requestedTalk) != true) {
talking = true LogManager.addLog("ERROR", "radio", "PTT command rejected; stopping capture group=$audioGroup")
_talkingFlow.value = true stopCapture(); return
} else if (!requestedTalk && talking) { }
NativeLib.radioTalkEnd(audioGroup) capturePttRequested = requestedTalk
talking = false
_talkingFlow.value = false
} }
} }
@ -465,34 +493,40 @@ class RadioAudioEngine {
LogManager.addLog("WARN", "debug", "Auto/PTT capture blocked: RECORD_AUDIO not granted group=$audioGroup mode=$mode") LogManager.addLog("WARN", "debug", "Auto/PTT capture blocked: RECORD_AUDIO not granted group=$audioGroup mode=$mode")
return false return false
} }
val startNs = SystemClock.elapsedRealtimeNanos() val preferred = when (configuredInput) {
val (rec, channels) = createAudioRecord() INPUT_BUILTIN -> router.builtinMicDevice()
if (rec == null) return false INPUT_WIRED -> router.wiredInputDevice()
val createdNs = SystemClock.elapsedRealtimeNanos() 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)) { if (!NativeLib.radioCaptureStart(audioGroup, channels, mode)) {
LogManager.addLog("ERROR", "RadioAudio", "native capture start failed group=$audioGroup ch=$channels mode=$mode") LogManager.addLog("ERROR", "RadioAudio", "native capture start failed group=$audioGroup ch=$channels mode=$mode")
rec.release() subscription.close()
return false return false
} }
autoMode = NativeLib.radioAutoMode() autoMode = NativeLib.radioAutoMode()
captureRequestedMode = mode captureRequestedMode = mode
if (mode != RadioAutoSettings.OFF && autoMode == RadioAutoSettings.OFF && !requestedTalk && !levelMonitoring) { if (mode != RadioAutoSettings.OFF && autoMode == RadioAutoSettings.OFF && !requestedTalk && !levelMonitoring) {
LogManager.addLog("ERROR", "debug", "Automatic transmission unavailable: capture cancelled group=$audioGroup") LogManager.addLog("ERROR", "debug", "Automatic transmission unavailable: capture cancelled group=$audioGroup")
NativeLib.radioCaptureStop(); rec.release() NativeLib.radioCaptureStop(); subscription.close()
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
return false return false
} }
val recordingNs = SystemClock.elapsedRealtimeNanos() captureSubscription = subscription
audioRecord = rec
captureRunning = true captureRunning = true
_autoActiveFlow.value = autoMode != RadioAutoSettings.OFF _autoActiveFlow.value = autoMode != RadioAutoSettings.OFF
// BEGIN должен попасть в uasync раньше первого кадра ручной передачи. // BEGIN должен попасть в uasync раньше первого кадра ручной передачи.
@ -501,13 +535,14 @@ class RadioAudioEngine {
talking = true talking = true
_talkingFlow.value = true _talkingFlow.value = true
} }
capturePttRequested = requestedTalk
val captureGroup = audioGroup val captureGroup = audioGroup
captureThread = Thread { captureThread = Thread {
try { captureLoop(rec, captureGroup, channels) } try { captureLoop(subscription, captureGroup, channels) }
catch (e: Exception) { LogManager.addLog("ERROR", "RadioAudio", "capture failed: ${e.message}") } catch (e: Exception) { LogManager.addLog("ERROR", "RadioAudio", "capture failed: ${e.message}") }
finally { finally {
if (captureRunning) io.execute { if (captureRunning) io.execute {
if (audioRecord === rec) { if (captureSubscription === subscription) {
requestedTalk = false requestedTalk = false
stopCapture() stopCapture()
LogManager.addLog("WARN", "RadioAudio", "capture lost: resources released") LogManager.addLog("WARN", "RadioAudio", "capture lost: resources released")
@ -517,29 +552,23 @@ class RadioAudioEngine {
}.apply { }.apply {
name = "radio-capture"; priority = Thread.MAX_PRIORITY; start() name = "radio-capture"; priority = Thread.MAX_PRIORITY; start()
} }
LogManager.addLog("INFO", "debug", LogManager.addLog("INFO", "radio", "capture started group=$audioGroup ch=$channels mode=$autoMode input=${preferred?.id}")
"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}")
return true return true
} }
private fun stopCapture() { private fun stopCapture() {
captureRunning = false captureRunning = false
val rec = audioRecord val subscription = captureSubscription
if (rec != null) { if (subscription != null) {
try { rec.stop() } catch (e: Exception) { subscription.close()
LogManager.addLog("WARN", "RadioAudio", "capture stop failed: ${e.message}")
}
// stop разблокирует read; release только после выхода потока.
captureThread?.join() captureThread?.join()
captureThread = null captureThread = null
rec.release() captureSubscription = null
audioRecord = null
NativeLib.radioCaptureStop() NativeLib.radioCaptureStop()
LogManager.addLog("INFO", "debug", "Auto/PTT capture released group=$audioGroup") LogManager.addLog("INFO", "debug", "Auto/PTT capture released group=$audioGroup")
} }
autoMode = RadioAutoSettings.OFF autoMode = RadioAutoSettings.OFF
capturePttRequested = false
talking = false talking = false
if (_transmittingGroup.value != null) { if (_transmittingGroup.value != null) {
LogManager.addLog("DEBUG", "radio", "TX cleared after capture stop group=${_transmittingGroup.value}") 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() } io.execute { if (active && generation == version) updateCapture() }
} }
private fun captureLoop(rec: AudioRecord, group: Long, ch: Int) { private fun captureLoop(subscription: MicrophoneCapture.Subscription, group: Long, ch: Int) {
val frame = FRAME_SAMPLES * ch
val buf = ShortArray(frame)
var frameN = 0L var frameN = 0L
var reportedRequestNs = 0L var reportedRequestNs = 0L
var statsMs = SystemClock.elapsedRealtime() var statsMs = SystemClock.elapsedRealtime()
@ -642,25 +669,16 @@ class RadioAudioEngine {
var maxFeedNs = 0L var maxFeedNs = 0L
var previousFeedAllowed: Boolean? = null var previousFeedAllowed: Boolean? = null
while (captureRunning) { 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() val readStartedNs = SystemClock.elapsedRealtimeNanos()
var read = 0 val event = subscription.poll() ?: continue
while (read < frame) { if (!captureRunning) break
if (!captureRunning) return if (event is MicrophoneCapture.Ptt) {
val n = rec.read(buf, read, frame - read) if (event.pressed) NativeLib.radioTalkBegin(group) else NativeLib.radioTalkEnd(group)
if (n <= 0) { talking = event.pressed
if (captureRunning) { continue
LogManager.addLog("WARN", "RadioAudio", "capture loop: read()=$n, mic lost")
}
return
}
read += n
} }
val captured = event as MicrophoneCapture.Frame
val buf = captured.pcm
maxReadNs = maxOf(maxReadNs, SystemClock.elapsedRealtimeNanos() - readStartedNs) maxReadNs = maxOf(maxReadNs, SystemClock.elapsedRealtimeNanos() - readStartedNs)
val inputDb = rmsDb(buf) val inputDb = rmsDb(buf)
minRmsDb = minOf(minRmsDb, inputDb) minRmsDb = minOf(minRmsDb, inputDb)
@ -669,7 +687,7 @@ class RadioAudioEngine {
frameN++ frameN++
val autoAllowed = autoMode != RadioAutoSettings.OFF && screenInteractive && vadWindow.isAllowed(SystemClock.elapsedRealtime()) val autoAllowed = autoMode != RadioAutoSettings.OFF && screenInteractive && vadWindow.isAllowed(SystemClock.elapsedRealtime())
val feedAllowed = active && groupId == group && captureRunning && val feedAllowed = active && groupId == group && captureRunning &&
((talking && requestedTalk) || autoAllowed || (levelMonitoring && screenInteractive)) (talking || autoAllowed || (levelMonitoring && screenInteractive))
if (previousFeedAllowed != feedAllowed) { if (previousFeedAllowed != feedAllowed) {
LogManager.addLog("DEBUG", "debug", "Auto PCM gate group=$group frame=$frameN allowed=$feedAllowed " + LogManager.addLog("DEBUG", "debug", "Auto PCM gate group=$group frame=$frameN allowed=$feedAllowed " +
"active=$active currentGroup=$groupId capture=$captureRunning manual=$requestedTalk talking=$talking " + "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} " + LogManager.addLog("DEBUG", "debug", "Auto capture summary group=$group spanMs=${nowMs - statsMs} " +
"frames=$statsFrames audioMs=${statsFrames * FRAME_MS} fed=$fedFrames skipped=$skippedFrames rejected=$failedFrames " + "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} " + "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}") "route=${route()} mode=$autoMode screen=$screenInteractive manual=$requestedTalk tx=${_talkingFlow.value}")
statsMs = nowMs statsMs = nowMs
statsFrames = 0; fedFrames = 0; skippedFrames = 0; failedFrames = 0 statsFrames = 0; fedFrames = 0; skippedFrames = 0; failedFrames = 0
@ -762,6 +780,14 @@ class RadioAudioEngine {
LogManager.addLog("DEBUG", "RadioAudio", "play loop done") 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() { private fun restoreAudioMode() {
if (!audioModeOwned) return if (!audioModeOwned) return
audioModeOwned = false 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.CaptureRequest
import android.hardware.camera2.CaptureResult import android.hardware.camera2.CaptureResult
import android.hardware.camera2.TotalCaptureResult import android.hardware.camera2.TotalCaptureResult
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.AudioTimestamp
import android.media.MediaCodec import android.media.MediaCodec
import android.media.MediaCodecInfo import android.media.MediaCodecInfo
import android.media.MediaFormat import android.media.MediaFormat
import android.os.Build import android.os.Build
import android.media.MediaRecorder
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.HandlerThread import android.os.HandlerThread
@ -45,7 +41,7 @@ class StreamCaptureEngine(
private var previewTarget: StreamPreviewTarget? = null private var previewTarget: StreamPreviewTarget? = null
private var sessionGeneration = 0L private var sessionGeneration = 0L
private val cameraSessions = mutableMapOf<Long, Pair<CameraDevice, (String) -> Unit>>() 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 private var audioThread: Thread? = null
@Volatile private var audioRunning = false @Volatile private var audioRunning = false
@Volatile var audioCompressorSettings = initialAudioCompressor @Volatile var audioCompressorSettings = initialAudioCompressor
@ -440,39 +436,35 @@ class StreamCaptureEngine(
} }
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
private fun startAudio() { private fun startAudio() {
val min = AudioRecord.getMinBufferSize(48000, AudioFormat.CHANNEL_IN_STEREO, AudioFormat.ENCODING_PCM_16BIT) val radio = RadioAudioEngine.instance()
check(min > 0) val priority = radio.prepareStreamCapture()
val rec = AudioRecord(MediaRecorder.AudioSource.CAMCORDER, 48000, AudioFormat.CHANNEL_IN_STEREO, val subscription = try { AndroidMicrophoneCapture.openStream { radio.releaseStreamCapture(priority) } }
AudioFormat.ENCODING_PCM_16BIT, (min * 2).coerceAtLeast(1920 * 8)) catch (e: Exception) { radio.releaseStreamCapture(priority); throw e }
record = rec; check(rec.state == AudioRecord.STATE_INITIALIZED) audioCapture = subscription
check(rec.channelCount == 2) { "CAMCORDER stereo capture unavailable: channels=${rec.channelCount}" }
rec.startRecording(); check(rec.recordingState == AudioRecord.RECORDSTATE_RECORDING) { "CAMCORDER capture did not start" }
audioRunning = true audioRunning = true
LogManager.addLog("INFO", "stream", "audio capture started id=$id source=CAMCORDER(${rec.audioSource}) " + LogManager.addLog("INFO", "stream", "audio capture started id=$id source=CAMCORDER rate=48000 channels=2 compressor=$audioCompressorSettings")
"rate=${rec.sampleRate} channels=${rec.channelCount} compressor=$audioCompressorSettings")
audioThread = Thread({ audioThread = Thread({
var opus = 0L var opus = 0L
try { try {
opus = NativeLib.streamOpusCreate(true); check(opus != 0L) opus = NativeLib.streamOpusCreate(true); check(opus != 0L)
val pcm = ShortArray(1920); val stamp = AudioTimestamp() var waitingStamp = true; var last = -1L; var expected = -1L
var total = 0L; var waitingStamp = true; var last = -1L
while (audioRunning) { while (audioRunning) {
var filled = 0 val frame = subscription.poll() as? MicrophoneCapture.Frame ?: continue
while (audioRunning && filled < pcm.size) { if (!audioRunning) break
val n = rec.read(pcm, filled, pcm.size - filled, AudioRecord.READ_BLOCKING) if (frame.timeNs == 0L) {
check(n > 0 || !audioRunning) { "AudioRecord read=$n" } if (waitingStamp) LogManager.addLog("DEBUG", "stream", "audio timestamp pending id=$id samples=${frame.sequence}")
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")
continue continue
} }
if (waitingStamp) radio.resumeAfterStreamCapture(priority)
waitingStamp = false 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 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" } 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}") } } } catch (e: Exception) { if (audioRunning) handler.post { failed("audio capture: ${e.message}") } }
@ -496,10 +488,8 @@ class StreamCaptureEngine(
if (stopping) return@post if (stopping) return@post
stopping = true; audioRunning = false stopping = true; audioRunning = false
preview.close(); previewTarget?.release(); previewTarget = null 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 audioThread?.join(); audioThread = null
try { record?.release() } catch (e: Exception) { LogManager.addLog("WARN", "stream", "audio release id=$id: ${e.message}") }
record = null
releaseVideo { releaseVideo {
stopped = true stopped = true
LogManager.addLog("INFO", "stream", "capture released id=$id frames=$frames dropped=$dropped bytes=$bytesSent") LogManager.addLog("INFO", "stream", "capture released id=$id frames=$frames dropped=$dropped bytes=$bytesSent")

Loading…
Cancel
Save