Own Android media devices in one shared audio controller

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

No files matched your search

@@ -7,82 +7,116 @@ import android.media.AudioTimestamp
import android.media.MediaRecorder
import android.os.Build
/** Реестр трансляционного захвата. Рация заимствует его только при совпадении выбранного входа.
* Системный default при отсутствии явного выбора означает тот же вход, который уже использует трансляция. */
object AndroidMicrophoneCapture {
private var stream: MicrophoneCapture? = null
private data class Status(val input: Int?, val initialInput: Int?, val silenced: Boolean)
@Volatile private var streamStatus: Status? = null
/** Один аппаратный reader. Контроллер закрывает его перед сменой маршрута;
* только подтверждённый свежий PCM попадает в общую временную шкалу. */
internal class AndroidMicrophoneCapture private constructor(
private val record: AudioRecord, private val input: AudioDeviceInfo, private val generation: Long,
private val accept: (MicrophoneCapture.Frame) -> Boolean, private val failed: (Exception) -> Unit,
private val confirmed: () -> Unit
) : AutoCloseable {
@Volatile private var running = true
private val worker = Thread(::read, "media-microphone")
private class Record(private val record: AudioRecord, private val isStream: Boolean) : MicrophoneCapture.Device {
override val channels = record.channelCount
override val inputId: Int? get() = record.routedDevice?.id
private val stamp = AudioTimestamp()
private var total = 0L
private var initialInput: Int? = null
private val preferred = record.preferredDevice?.id
@Volatile private var running = true
override fun read(): MicrophoneCapture.Frame {
val pcm = ShortArray(960 * channels)
var used = 0
while (running && used < pcm.size) {
val count = record.read(pcm, used, pcm.size - used, AudioRecord.READ_BLOCKING)
check(count > 0) { "AudioRecord read=$count input=$inputId" }
used += count
companion object {
fun open(input: AudioDeviceInfo, voice: Boolean, stereo: Boolean, micSource: Int, generation: Long,
accept: (MicrophoneCapture.Frame) -> Boolean, failed: (Exception) -> Unit,
confirmed: () -> Unit): AndroidMicrophoneCapture {
val source = if (voice) MediaRecorder.AudioSource.VOICE_COMMUNICATION else when (micSource) {
1 -> MediaRecorder.AudioSource.MIC
2 -> MediaRecorder.AudioSource.UNPROCESSED
3 -> MediaRecorder.AudioSource.CAMCORDER
else -> if (stereo) MediaRecorder.AudioSource.CAMCORDER else MediaRecorder.AudioSource.VOICE_COMMUNICATION
}
check(running) { "AudioRecord stopped" }
val actual = inputId
if (initialInput == null) {
initialInput = actual
LogManager.addLog("INFO", if (isStream) "stream" else "radio",
"capture route selected=$preferred actual=$actual source=${record.audioSource} channels=$channels")
}
val silenced = Build.VERSION.SDK_INT >= 29 && record.activeRecordingConfiguration?.isClientSilenced == true
if (isStream) streamStatus = Status(actual, initialInput, silenced)
else {
check(preferred == null || actual == preferred) { "Selected radio microphone unavailable: selected=$preferred actual=$actual" }
check(!silenced) { "Android silenced radio microphone input=$actual" }
val protected = streamStatus
check(protected == null || (!protected.silenced && protected.input == protected.initialInput)) {
"Radio input conflicts with stream: stream=$protected radio=$actual"
val masks = if (stereo && !voice) intArrayOf(AudioFormat.CHANNEL_IN_STEREO, AudioFormat.CHANNEL_IN_MONO)
else intArrayOf(AudioFormat.CHANNEL_IN_MONO)
var failure: Exception? = null
for (mask in masks) {
var record: AudioRecord? = null
try {
val min = AudioRecord.getMinBufferSize(48000, mask, AudioFormat.ENCODING_PCM_16BIT)
check(min > 0) { "Unsupported capture mask=$mask min=$min" }
record = AudioRecord(source, 48000, mask, AudioFormat.ENCODING_PCM_16BIT, maxOf(min * 2, 15360))
check(record.state == AudioRecord.STATE_INITIALIZED) { "AudioRecord not initialized mask=$mask" }
check(record.setPreferredDevice(input)) { "Input selection rejected id=${input.id}" }
record.startRecording()
check(record.recordingState == AudioRecord.RECORDSTATE_RECORDING) { "AudioRecord did not start" }
LogManager.addLog("INFO", "media_audio", "capture opened generation=$generation input=${input.id}/${input.type} " +
"name=${input.productName} source=$source channels=${record.channelCount} rate=48000")
return AndroidMicrophoneCapture(record, input, generation, accept, failed, confirmed).also { it.worker.start() }
} catch (e: Exception) {
failure = e
LogManager.addLog("WARN", "media_audio", "capture open failed input=${input.id} mask=$mask: $e")
record?.release()
}
}
val first = total; total += 960
val measured = record.getTimestamp(stamp, AudioTimestamp.TIMEBASE_MONOTONIC) == AudioRecord.SUCCESS
val time = if (measured) stamp.nanoTime - (stamp.framePosition - first) * 1_000_000_000 / 48000 else 0L
return MicrophoneCapture.Frame(pcm, time, first)
throw IllegalStateException("Cannot open microphone ${input.productName}", failure)
}
override fun stop() { running = false; record.stop() }
override fun close() { record.release(); if (isStream) streamStatus = null }
}
fun own(record: AudioRecord, isStream: Boolean = false, released: () -> Unit = {}): MicrophoneCapture {
private fun read() {
val stamp = AudioTimestamp()
val channels = record.channelCount
var total = 0L
var validSince = 0L
var ready = false
var lateSince = 0L
val deadline = System.nanoTime() + 5_000_000_000L
try {
record.startRecording()
check(record.recordingState == AudioRecord.RECORDSTATE_RECORDING) { "Microphone did not start" }
return MicrophoneCapture(Record(record, isStream), { level, message -> LogManager.addLog(level, "radio", message) }, released)
} catch (e: Exception) { record.release(); throw e }
}
@Synchronized fun openStream(released: () -> Unit): MicrophoneCapture.Subscription {
check(stream == null || stream!!.closed) { "Stream microphone already owned" }
val min = AudioRecord.getMinBufferSize(48000, AudioFormat.CHANNEL_IN_STEREO, AudioFormat.ENCODING_PCM_16BIT)
check(min > 0) { "CAMCORDER stereo format unavailable" }
val record = AudioRecord(MediaRecorder.AudioSource.CAMCORDER, 48000, AudioFormat.CHANNEL_IN_STEREO,
AudioFormat.ENCODING_PCM_16BIT, (min * 2).coerceAtLeast(1920 * 8))
if (record.state != AudioRecord.STATE_INITIALIZED || record.channelCount != 2) {
record.release(); error("CAMCORDER stereo capture unavailable")
android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_AUDIO)
while (running) {
val pcm = ShortArray(960 * channels)
var used = 0
val before = record.routedDevice?.id
while (running && used < pcm.size) {
val count = record.read(pcm, used, pcm.size - used, AudioRecord.READ_BLOCKING)
check(count > 0 || !running) { "AudioRecord read=$count input=${input.id}" }
if (count > 0) used += count
}
if (!running) break
val first = total; total += 960
val actual = record.routedDevice?.id
val silenced = Build.VERSION.SDK_INT >= 29 && record.activeRecordingConfiguration?.isClientSilenced == true
val valid = before == input.id && actual == input.id && !silenced
if (!valid) {
if (ready) error("Microphone route lost selected=${input.id} actual=$actual silenced=$silenced")
validSince = 0
} else if (validSince == 0L) validSince = System.nanoTime()
val measured = record.getTimestamp(stamp, AudioTimestamp.TIMEBASE_MONOTONIC) == AudioRecord.SUCCESS
val time = if (measured) stamp.nanoTime - (stamp.framePosition - first) * 1_000_000_000 / 48000 else 0L
if (!valid || !measured || time < validSince) {
check(System.nanoTime() < deadline) {
"Microphone confirmation timeout selected=${input.id} actual=$actual silenced=$silenced timestamp=$time"
}
continue
}
val stereo = if (channels == 2) pcm else ShortArray(1920) { pcm[it / 2] }
val accepted = accept(MicrophoneCapture.Frame(stereo, time, first, generation = generation))
if (!accepted) {
if (lateSince == 0L) lateSince = System.nanoTime()
check(System.nanoTime() - lateSince < 1_000_000_000L) { "Microphone PCM exceeds capture reserve input=${input.id}" }
continue
}
lateSince = 0
if (!ready) {
ready = true
LogManager.addLog("INFO", "media_audio", "capture confirmed generation=$generation input=$actual timeNs=$time")
confirmed()
}
}
} catch (e: Exception) {
if (running) {
LogManager.addLog("ERROR", "media_audio", "capture failed generation=$generation input=${input.id}: $e")
failed(e)
}
}
val capture = own(record, true, released)
stream = capture
LogManager.addLog("INFO", "stream", "capture opened source=CAMCORDER channels=2 rate=48000 input=${capture.inputId}")
return capture.subscribe("stream")
}
@Synchronized fun borrowRadio(input: AudioDeviceInfo?): MicrophoneCapture.Subscription? {
val capture = stream ?: return null
if (capture.closed || (input != null && input.id != capture.inputId)) return null
LogManager.addLog("INFO", "radio", "shared stream capture input=${capture.inputId} selected=${input?.id} channels=${capture.channels}")
return capture.subscribe("radio")
override fun close() {
running = false
try { record.stop() }
catch (e: Exception) { LogManager.addLog("WARN", "media_audio", "capture stop input=${input.id}: $e") }
worker.join()
record.release()
LogManager.addLog("INFO", "media_audio", "capture released generation=$generation input=${input.id}")
}
}
@@ -99,6 +99,7 @@ class ConfigProvider(private val context: Context) {
}
private val mutedChannelsKey = stringSetPreferencesKey("muted_channels")
private val callAudioRouteKey = stringPreferencesKey("call_audio_route")
private val mediaAudioProfileKey = stringPreferencesKey("media_audio_profile")
private val radioAutoEnabledKey = intPreferencesKey("radio_auto_enabled")
private val radioAutoModeKey = intPreferencesKey("radio_auto_mode")
private val radioLevelOnKey = intPreferencesKey("radio_level_on_dbfs")
@@ -109,7 +110,6 @@ class ConfigProvider(private val context: Context) {
private val radioVolumePttKey = booleanPreferencesKey("radio_volume_ptt")
private val radioOverlayKey = booleanPreferencesKey("radio_overlay")
private val radioMicSourceKey = intPreferencesKey("radio_mic_source")
private val radioInputSourceKey = intPreferencesKey("radio_input_source")
private val radioOpusModeKey = intPreferencesKey("radio_opus_mode")
private val radioOpusBitrateKey = intPreferencesKey("radio_opus_bitrate")
private val radioCaptureStereoKey = intPreferencesKey("radio_capture_stereo")
@@ -179,6 +179,16 @@ class ConfigProvider(private val context: Context) {
LogManager.addLog("INFO", "CallSettings", "Video bitrate range saved: ${range.encode()} kbps")
}
suspend fun getMediaAudioProfile(): MediaAudioProfile {
val value = context.dataStore.data.first()[mediaAudioProfileKey]
return MediaAudioProfile.entries.firstOrNull { it.name == value } ?: MediaAudioProfile.AUTO
}
suspend fun setMediaAudioProfile(profile: MediaAudioProfile) {
context.dataStore.edit { it[mediaAudioProfileKey] = profile.name }
LogManager.addLog("INFO", "media_audio", "common profile saved=$profile")
}
suspend fun saveStreamSettings(subblocks: Int, redundant: Int) {
require(subblocks in 1..255 && redundant in 0 until subblocks)
context.dataStore.edit { prefs ->
@@ -552,23 +562,6 @@ class ConfigProvider(private val context: Context) {
context.dataStore.edit { prefs -> prefs[radioMicSourceKey] = source }
}
@Volatile private var radioInputSourceCached = 0
@Volatile private var radioInputSourceLoaded = false
fun getRadioInputSource(): Int {
if (!radioInputSourceLoaded) {
radioInputSourceCached = runBlocking { context.dataStore.data.first()[radioInputSourceKey] ?: 0 }
radioInputSourceLoaded = true
}
return radioInputSourceCached
}
suspend fun setRadioInputSource(source: Int) {
radioInputSourceCached = source
radioInputSourceLoaded = true
context.dataStore.edit { prefs -> prefs[radioInputSourceKey] = source }
}
@Volatile private var radioCaptureStereoCached = 0
@Volatile private var radioCaptureStereoLoaded = false
@@ -0,0 +1,309 @@
package com.utun.chat.data
import android.content.Context
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.media.AudioRouting
import android.media.AudioTrack
import android.os.Build
import android.os.Handler
import android.os.Looper
import com.utun.chat.ChatApplication
import com.utun.chat.R
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
enum class MediaAudioProfile { AUTO, PHONE, WIRED, BLUETOOTH }
data class MediaAudioState(
val preferred: MediaAudioProfile = MediaAudioProfile.AUTO, val active: MediaAudioProfile = MediaAudioProfile.PHONE,
val wiredAvailable: Boolean = false, val bluetoothAvailable: Boolean = false,
val inputName: String? = null, val outputName: String? = null, val switching: Boolean = false, val notice: Int? = null
)
/** Общий владелец аудио рации и трансляций. Маршрутизация — тот же CallAudioRouter, что у звонка.
* UI только выбирает профиль. Аппаратный reader и регистрация AudioTrack принадлежат io.
* Постоянные stereo-подписки сохраняют PTT, mute и PTS при смене устройства; видео не затрагивается. */
object MediaAudioController {
private val app get() = ChatApplication.instance
private val am = app.getSystemService(Context.AUDIO_SERVICE) as AudioManager
private val main = Handler(Looper.getMainLooper())
private val io = Executors.newSingleThreadScheduledExecutor { Thread(it, "media-audio-control").apply { isDaemon = true } }
private val router = CallAudioRouter()
private val mutable = MutableStateFlow(MediaAudioState())
val state: StateFlow<MediaAudioState> = mutable
private val inputLock = Any()
private var shared: MicrophoneCapture? = null // Только под inputLock.
private var bridge: InputBridge? = null // Только под inputLock.
private var record: AndroidMicrophoneCapture? = null
private var camcorderCapture: MicrophoneCapture? = null
private var inputReady = false
private var generation = 0L
private var routeKey: List<Any?>? = null
private var systemRoute: Pair<MediaAudioProfile, Boolean>? = null
private var preferred = MediaAudioProfile.AUTO
private var committed = preferred
private var forcedPhone = false
private var savePending = false
private var modeOwned = false
private val outputs = linkedMapOf<AudioTrack, AudioRouting.OnRoutingChangedListener>()
private var wantedOutput: AudioDeviceInfo? = null
private var wantedInputName: String? = null
private var routeAvailable = false
private var availability: Pair<Boolean, Boolean>? = null
init {
// Наблюдение не занимает микрофон и не меняет глобальный маршрут без потребителей.
router.setGlobalRouting(false)
router.onChanged = { _, _ -> refresh() }
router.onInputDevicesChanged = { _, _ -> refresh() }
router.onInputDevice = { refresh() }
router.onOutputDevice = { refresh() }
io.execute {
try {
preferred = kotlinx.coroutines.runBlocking { app.configProvider.getMediaAudioProfile() }; committed = preferred
router.start()
reconcile()
} catch (e: Exception) { recover(e) }
}
}
fun refresh() {
io.execute {
try { reconcile() }
catch (e: Exception) { recover(e) }
}
}
fun select(profile: MediaAudioProfile) {
io.execute {
if (CallController.blocksMedia()) {
LogManager.addLog("WARN", "media_audio", "selection blocked by call profile=$profile"); return@execute
}
preferred = profile; forcedPhone = false; savePending = true
mutable.value = mutable.value.copy(notice = null)
LogManager.addLog("INFO", "media_audio", "profile requested=$profile")
try { router.reapply(); reconcile(force = true) }
catch (e: Exception) { recover(e) }
}
}
/** Не открывает микрофон из UI. Вызывается реальным владельцем захвата с RECORD_AUDIO. */
fun subscribe(name: String): MicrophoneCapture.Subscription = synchronized(inputLock) {
check(!CallController.blocksMedia()) { "Media capture blocked by call" }
if (shared?.closed != false) {
val device = InputBridge()
bridge = device
shared = MicrophoneCapture(device, { level, message -> LogManager.addLog(level, "media_audio", message) },
released = { refresh() }, changed = { refresh() })
}
shared!!.subscribe(name)
}
/** Регистрация завершается до play, удаление — до release. Не вызывается из main или io. */
fun attachOutput(track: AudioTrack): AutoCloseable {
io.submit {
check(!CallController.blocksMedia()) { "Media output blocked by call" }
val listener = AudioRouting.OnRoutingChangedListener { refresh() }
check(track !in outputs) { "AudioTrack already registered" }
outputs[track] = listener
try {
track.addOnRoutingChangedListener(listener, main)
reconcile()
check(track.setPreferredDevice(wantedOutput)) { "Output selection rejected session=${track.audioSessionId}" }
mutable.value = mutable.value.copy(switching = true)
confirm()
val epoch = generation
io.schedule({ if (generation == epoch && track in outputs && !outputsReady(listOf(track)))
recover(IllegalStateException("Output confirmation timeout session=${track.audioSessionId} " +
"wanted=${wantedOutput?.id} actual=${track.routedDevice?.id}")) }, 30, TimeUnit.SECONDS)
} catch (e: Exception) {
outputs.remove(track); track.removeOnRoutingChangedListener(listener)
LogManager.addLog("ERROR", "media_audio", "output registration failed session=${track.audioSessionId}: $e")
recover(e)
throw e
}
LogManager.addLog("INFO", "media_audio", "output attached session=${track.audioSessionId} count=${outputs.size}")
}.get()
return AutoCloseable {
io.submit {
outputs.remove(track)?.let { track.removeOnRoutingChangedListener(it) }
try { reconcile() }
catch (e: Exception) { recover(e) }
LogManager.addLog("INFO", "media_audio", "output detached session=${track.audioSessionId} count=${outputs.size}")
}.get()
}
}
/** Барьер cleanup после остановки всех движков, перед передачей ресурсов Telecom. */
fun awaitIdle() {
io.submit {
check(outputs.isEmpty() && synchronized(inputLock) { shared?.closed != false }) { "Media audio still owned" }
reconcile()
LogManager.addLog("INFO", "media_audio", "all media audio resources released for call")
}.get()
}
private fun isWired(type: Int) = type == AudioDeviceInfo.TYPE_WIRED_HEADSET || type == AudioDeviceInfo.TYPE_WIRED_HEADPHONES ||
type == AudioDeviceInfo.TYPE_USB_HEADSET || type == AudioDeviceInfo.TYPE_USB_DEVICE || type == AudioDeviceInfo.TYPE_USB_ACCESSORY
private fun isBluetooth(type: Int) = type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO || type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP ||
(Build.VERSION.SDK_INT >= 31 && type == AudioDeviceInfo.TYPE_BLE_HEADSET)
private fun reconcile(force: Boolean = false) {
val devices = am.getDevices(AudioManager.GET_DEVICES_ALL).toList()
val wired = devices.any { isWired(it.type) }
val bluetooth = router.btInputAvailable || devices.any { isBluetooth(it.type) }
val available = wired to bluetooth
if (availability?.let { (!it.first && wired) || (!it.second && bluetooth) } == true) forcedPhone = false
availability = available
val resolved = when (preferred) {
MediaAudioProfile.AUTO -> if (wired) MediaAudioProfile.WIRED else if (bluetooth) MediaAudioProfile.BLUETOOTH else MediaAudioProfile.PHONE
MediaAudioProfile.WIRED -> if (wired) preferred else MediaAudioProfile.PHONE
MediaAudioProfile.BLUETOOTH -> if (bluetooth) preferred else MediaAudioProfile.PHONE
else -> preferred
}
val active = if (forcedPhone) MediaAudioProfile.PHONE else resolved
val (capture, targetBridge) = synchronized(inputLock) {
val current = shared?.takeUnless { it.closed }
current to bridge?.takeIf { current != null }
}
// После подключения эфира сохраняем CAMCORDER до последней подписки, без разрыва оставшейся рации.
if (capture == null || capture.names.contains("stream")) camcorderCapture = capture
val publishing = capture != null && capture === camcorderCapture
val stereo = publishing || app.configProvider.getRadioCaptureStereo()
val wiredSink = devices.any { it.isSink && isWired(it.type) }
val configuredSource = app.configProvider.getRadioMicSource()
val micSource = if (publishing) 3 else if (active == MediaAudioProfile.WIRED && !wiredSink && configuredSource == 0) 1
else configuredSource
val voice = capture != null && (active == MediaAudioProfile.BLUETOOTH ||
(active == MediaAudioProfile.WIRED && wiredSink) || (active == MediaAudioProfile.PHONE && !stereo && micSource == 0))
val owned = capture != null || outputs.isNotEmpty()
val route = active to (voice && owned)
if (systemRoute != route) {
// Смена глобального тракта принадлежит только этому владельцу, до открытия нового reader.
record?.close(); record = null
router.setGlobalRouting(false)
if (modeOwned) { am.mode = AudioManager.MODE_NORMAL; modeOwned = false }
router.setAutoHeadset(false)
router.setRoute(if (active == MediaAudioProfile.PHONE) AudioRoute.SPEAKER else AudioRoute.HEADSET,
preferWired = active == MediaAudioProfile.WIRED)
if (voice && owned) {
am.mode = AudioManager.MODE_IN_COMMUNICATION; modeOwned = true
router.setGlobalRouting(true)
}
systemRoute = route
}
val output = router.currentOutputDevice
val input = when (active) {
MediaAudioProfile.PHONE -> router.builtinMicDevice()
MediaAudioProfile.WIRED -> router.wiredInputDevice() ?: router.builtinMicDevice()
else -> router.btInputDevice()
}
val key = listOf(owned, active, input?.id, output?.id, voice, stereo, micSource, capture)
mutable.value = mutable.value.copy(preferred = preferred, active = active, wiredAvailable = wired,
bluetoothAvailable = bluetooth, inputName = if (capture == null) null else mutable.value.inputName,
notice = if (preferred != MediaAudioProfile.AUTO && preferred != resolved) R.string.media_audio_fallback
else mutable.value.notice?.takeUnless { it == R.string.media_audio_fallback })
if (!force && key == routeKey) { confirm(); return }
routeKey = key
val epoch = ++generation
inputReady = capture == null
targetBridge?.reset(epoch)
record?.close(); record = null
mutable.value = mutable.value.copy(switching = owned)
if (!owned) {
wantedOutput = null
mutable.value = mutable.value.copy(switching = false, inputName = null, outputName = null)
saveSelection()
return
}
LogManager.addLog("INFO", "media_audio", "apply generation=$epoch preferred=$preferred active=$active " +
"input=${input?.id}/${input?.type} output=${output?.id}/${output?.type} voice=$voice stereo=$stereo users=${capture?.names} outputs=${outputs.size}")
wantedOutput = output
wantedInputName = if (capture != null) input?.productName?.toString() else null
routeAvailable = output != null && (active != MediaAudioProfile.BLUETOOTH || isBluetooth(output.type))
for (track in outputs.keys) check(track.setPreferredDevice(output)) { "Output rejected session=${track.audioSessionId} device=${output?.id}" }
val expectedOutputs = outputs.keys.toList()
io.schedule({
if (generation == epoch && (!inputReady || !routeAvailable || !outputsReady(expectedOutputs)))
recover(IllegalStateException("Audio route confirmation timeout generation=$epoch inputReady=$inputReady " +
"wantedOutput=${wantedOutput?.id} actualOutputs=${outputs.keys.map { it.routedDevice?.id }} state=${mutable.value}"))
}, if (active == MediaAudioProfile.BLUETOOTH) 30 else 6, TimeUnit.SECONDS)
if (capture != null && input != null) {
record = AndroidMicrophoneCapture.open(input, voice, stereo, micSource, epoch,
accept = { frame -> targetBridge?.offer(frame) == true },
failed = { error -> io.execute { if (generation == epoch) recover(error) } },
confirmed = { io.execute { if (generation == epoch) { inputReady = true; confirm() } } })
} else if (capture != null && active != MediaAudioProfile.BLUETOOTH) error("Selected microphone unavailable profile=$active")
confirm()
}
private fun confirm() {
if (!mutable.value.switching) return
if (!inputReady || !outputsReady(outputs.keys) || !routeAvailable) return
mutable.value = mutable.value.copy(switching = false, inputName = wantedInputName,
outputName = wantedOutput?.productName?.toString())
saveSelection()
LogManager.addLog("INFO", "media_audio", "route confirmed generation=$generation state=${mutable.value}")
}
private fun outputsReady(tracks: Collection<AudioTrack>): Boolean = tracks.all { track ->
val actual = if (track in outputs) track.routedDevice else null
track !in outputs || (actual != null && actual.id == wantedOutput?.id)
}
private fun saveSelection() {
if (!savePending) return
savePending = false; committed = preferred
kotlinx.coroutines.runBlocking { app.configProvider.setMediaAudioProfile(preferred) }
}
private fun recover(error: Exception) {
LogManager.addLog("ERROR", "media_audio", "route failed generation=$generation preferred=$preferred: $error")
if (savePending) { preferred = committed; savePending = false }
mutable.value = mutable.value.copy(notice = R.string.media_audio_failed, switching = false)
if (!forcedPhone) {
forcedPhone = true
try { reconcile(force = true) }
catch (e: Exception) { recover(e) }
} else {
++generation
synchronized(inputLock) { bridge }?.reset(generation)
record?.close(); record = null; inputReady = false
mutable.value = mutable.value.copy(switching = false)
}
}
/** Постоянный stereo-вход с запасом времени на аппаратный reader и смену маршрута. */
private class InputBridge : MicrophoneCapture.Device {
private val lock = Object()
private val timeline = MediaCaptureTimeline(System.nanoTime())
private var running = true
private var reportedLate = 0L
override val channels = 2
override val inputId: Int? get() = null
fun reset(epoch: Long) = synchronized(lock) { timeline.reset(epoch) }
fun offer(frame: MicrophoneCapture.Frame): Boolean = synchronized(lock) {
val accepted = timeline.offer(frame)
if (timeline.lateFrames / 50 != reportedLate / 50 || (timeline.lateFrames > 0 && reportedLate == 0L)) {
reportedLate = timeline.lateFrames
LogManager.addLog("WARN", "media_audio", "late microphone PCM blocks=$reportedLate generation=${timeline.generation}")
}
accepted
}
override fun read(): MicrophoneCapture.Frame = synchronized(lock) {
while (running) {
val now = System.nanoTime()
val wait = timeline.deadlineNs - now
if (wait <= 0) return@synchronized timeline.pull(now)
lock.wait(wait / 1_000_000, (wait % 1_000_000).toInt())
}
error("Shared microphone stopped")
}
override fun stop() = synchronized(lock) { running = false; lock.notifyAll() }
override fun close() {}
}
}