Own Android media devices in one shared audio controller
This commit is contained in:
1 parent
b72e51accb
commit
ecada765a2
3 files changed
+420
-84
No files matched your search
+100
-66
@@ -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() {}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user