Connect Android stream and radio to shared capture and preserve stream audio mode
This commit is contained in:
1 parent
4a49c830d1
commit
9d69cce8e8
3 files changed
+163
-118
No files matched your search
@@ -83,6 +83,7 @@ class CallAudioRouter {
|
||||
|
||||
private var preferWiredHeadset = false
|
||||
private var started = false
|
||||
private var globalRouting = true
|
||||
private var desiredRoute = AudioRoute.SPEAKER
|
||||
var telecom: UtunConnection? = null
|
||||
private set
|
||||
@@ -150,7 +151,7 @@ class CallAudioRouter {
|
||||
"devices=${devices?.size} hasEarpiece=$hasEarpiece autoHeadset=$autoHeadset fallback=$fallbackRoute")
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
@Synchronized fun stop() {
|
||||
if (!started) { bindTelecom(null); return }
|
||||
started = false
|
||||
|
||||
@@ -159,7 +160,7 @@ class CallAudioRouter {
|
||||
unbindBtHeadsetProfile()
|
||||
|
||||
try {
|
||||
if (telecom == null) {
|
||||
if (telecom == null && globalRouting) {
|
||||
stopBluetoothSco()
|
||||
am.isBluetoothScoOn = false
|
||||
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()
|
||||
telecom?.let { connection ->
|
||||
connection.routeTo(r)
|
||||
@@ -221,6 +235,21 @@ class CallAudioRouter {
|
||||
route = r
|
||||
val startedAt = SystemClock.elapsedRealtime()
|
||||
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 inDev: AudioDeviceInfo? = null
|
||||
when (r) {
|
||||
|
||||
@@ -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()
|
||||
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
|
||||
}
|
||||
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
|
||||
|
||||
@@ -11,14 +11,10 @@ import android.hardware.camera2.CameraManager
|
||||
import android.hardware.camera2.CaptureRequest
|
||||
import android.hardware.camera2.CaptureResult
|
||||
import android.hardware.camera2.TotalCaptureResult
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.AudioTimestamp
|
||||
import android.media.MediaCodec
|
||||
import android.media.MediaCodecInfo
|
||||
import android.media.MediaFormat
|
||||
import android.os.Build
|
||||
import android.media.MediaRecorder
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
@@ -45,7 +41,7 @@ class StreamCaptureEngine(
|
||||
private var previewTarget: StreamPreviewTarget? = null
|
||||
private var sessionGeneration = 0L
|
||||
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
|
||||
@Volatile private var audioRunning = false
|
||||
@Volatile var audioCompressorSettings = initialAudioCompressor
|
||||
@@ -440,39 +436,35 @@ class StreamCaptureEngine(
|
||||
}
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun startAudio() {
|
||||
val min = AudioRecord.getMinBufferSize(48000, AudioFormat.CHANNEL_IN_STEREO, AudioFormat.ENCODING_PCM_16BIT)
|
||||
check(min > 0)
|
||||
val rec = AudioRecord(MediaRecorder.AudioSource.CAMCORDER, 48000, AudioFormat.CHANNEL_IN_STEREO,
|
||||
AudioFormat.ENCODING_PCM_16BIT, (min * 2).coerceAtLeast(1920 * 8))
|
||||
record = rec; check(rec.state == AudioRecord.STATE_INITIALIZED)
|
||||
check(rec.channelCount == 2) { "CAMCORDER stereo capture unavailable: channels=${rec.channelCount}" }
|
||||
rec.startRecording(); check(rec.recordingState == AudioRecord.RECORDSTATE_RECORDING) { "CAMCORDER capture did not start" }
|
||||
val radio = RadioAudioEngine.instance()
|
||||
val priority = radio.prepareStreamCapture()
|
||||
val subscription = try { AndroidMicrophoneCapture.openStream { radio.releaseStreamCapture(priority) } }
|
||||
catch (e: Exception) { radio.releaseStreamCapture(priority); throw e }
|
||||
audioCapture = subscription
|
||||
audioRunning = true
|
||||
LogManager.addLog("INFO", "stream", "audio capture started id=$id source=CAMCORDER(${rec.audioSource}) " +
|
||||
"rate=${rec.sampleRate} channels=${rec.channelCount} compressor=$audioCompressorSettings")
|
||||
LogManager.addLog("INFO", "stream", "audio capture started id=$id source=CAMCORDER rate=48000 channels=2 compressor=$audioCompressorSettings")
|
||||
audioThread = Thread({
|
||||
var opus = 0L
|
||||
try {
|
||||
opus = NativeLib.streamOpusCreate(true); check(opus != 0L)
|
||||
val pcm = ShortArray(1920); val stamp = AudioTimestamp()
|
||||
var total = 0L; var waitingStamp = true; var last = -1L
|
||||
var waitingStamp = true; var last = -1L; var expected = -1L
|
||||
while (audioRunning) {
|
||||
var filled = 0
|
||||
while (audioRunning && filled < pcm.size) {
|
||||
val n = rec.read(pcm, filled, pcm.size - filled, AudioRecord.READ_BLOCKING)
|
||||
check(n > 0 || !audioRunning) { "AudioRecord read=$n" }
|
||||
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")
|
||||
val frame = subscription.poll() as? MicrophoneCapture.Frame ?: continue
|
||||
if (!audioRunning) break
|
||||
if (frame.timeNs == 0L) {
|
||||
if (waitingStamp) LogManager.addLog("DEBUG", "stream", "audio timestamp pending id=$id samples=${frame.sequence}")
|
||||
continue
|
||||
}
|
||||
if (waitingStamp) radio.resumeAfterStreamCapture(priority)
|
||||
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
|
||||
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" }
|
||||
}
|
||||
} catch (e: Exception) { if (audioRunning) handler.post { failed("audio capture: ${e.message}") } }
|
||||
@@ -496,10 +488,8 @@ class StreamCaptureEngine(
|
||||
if (stopping) return@post
|
||||
stopping = true; audioRunning = false
|
||||
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
|
||||
try { record?.release() } catch (e: Exception) { LogManager.addLog("WARN", "stream", "audio release id=$id: ${e.message}") }
|
||||
record = null
|
||||
releaseVideo {
|
||||
stopped = true
|
||||
LogManager.addLog("INFO", "stream", "capture released id=$id frames=$frames dropped=$dropped bytes=$bytesSent")
|
||||
|
||||
Reference in new issue
Block a user