From ecada765a29ad525511cc3442df0e83600b26471 Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 22 Oct 2026 15:07:25 +0200 Subject: [PATCH] Own Android media devices in one shared audio controller --- .../chat/data/AndroidMicrophoneCapture.kt | 166 ++++++---- .../java/com/utun/chat/data/ConfigProvider.kt | 29 +- .../utun/chat/data/MediaAudioController.kt | 309 ++++++++++++++++++ 3 files changed, 420 insertions(+), 84 deletions(-) create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/data/MediaAudioController.kt diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AndroidMicrophoneCapture.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AndroidMicrophoneCapture.kt index 14bd969f..266a061a 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AndroidMicrophoneCapture.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AndroidMicrophoneCapture.kt @@ -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}") } } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt index 1ac03faf..75575dce 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt @@ -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 diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/MediaAudioController.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/MediaAudioController.kt new file mode 100644 index 00000000..e64b13ba --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/MediaAudioController.kt @@ -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 = 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? = null + private var systemRoute: Pair? = 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() + private var wantedOutput: AudioDeviceInfo? = null + private var wantedInputName: String? = null + private var routeAvailable = false + private var availability: Pair? = 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): 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() {} + } +}