From b72e51accb153b00a05c71419d2f41a47f2e236e Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 22 Oct 2026 15:07:25 +0200 Subject: [PATCH 01/11] Keep shared microphone PCM continuous across device changes --- .../utun/chat/data/MediaCaptureTimeline.kt | 65 +++++++++++++++++ .../com/utun/chat/data/MicrophoneCapture.kt | 14 ++-- .../chat/data/MediaCaptureTimelineTest.kt | 71 +++++++++++++++++++ 3 files changed, 145 insertions(+), 5 deletions(-) create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/data/MediaCaptureTimeline.kt create mode 100644 tools/chatgui-android/app/src/test/java/com/utun/chat/data/MediaCaptureTimelineTest.kt diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/MediaCaptureTimeline.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/MediaCaptureTimeline.kt new file mode 100644 index 00000000..a8011be0 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/MediaCaptureTimeline.kt @@ -0,0 +1,65 @@ +package com.utun.chat.data + +import java.util.ArrayDeque +import kotlin.math.roundToInt + +/** Общая сетка PCM 48k stereo. Аппаратные timestamps определяют положение отсчётов; + * отсутствующие участки — тишина. Никакого переноса старого звука на новое время. + * Все методы вызываются под lock владельца. */ +internal class MediaCaptureTimeline(val originNs: Long) { + companion object { + const val FRAMES = 960 + const val PERIOD_NS = 20_000_000L + const val RESERVE_NS = 200_000_000L + } + private val pending = ArrayDeque() + private var position = 0L + private var lastInputNs = -1L + var generation = 0L + private set + var lateFrames = 0L + private set + var silentFrames = 0L + private set + val nextTimeNs: Long get() = originNs + position * 1_000_000_000 / 48000 + val deadlineNs: Long get() = nextTimeNs + PERIOD_NS + RESERVE_NS + + fun reset(epoch: Long) { + generation = epoch; pending.clear(); lastInputNs = -1 + } + + fun offer(frame: MicrophoneCapture.Frame): Boolean { + require(frame.pcm.size == FRAMES * 2) + if (frame.generation != generation || frame.timeNs <= lastInputNs) return false + lastInputNs = frame.timeNs + if (frame.timeNs + PERIOD_NS <= nextTimeNs) { lateFrames++; return false } + if (pending.size >= 24) { lateFrames++; return false } + pending.addLast(frame) + return true + } + + fun pull(nowNs: Long): MicrophoneCapture.Frame { + check(nowNs >= deadlineNs) + val start = nextTimeNs + val pcm = ShortArray(FRAMES * 2) + var filled = 0 + while (pending.isNotEmpty()) { + val frame = pending.first + val offset = ((frame.timeNs - start).toDouble() * 48000 / 1_000_000_000).roundToInt() + if (offset >= FRAMES) break + val source = maxOf(0, -offset) + val target = maxOf(0, offset, filled) + val skip = target - maxOf(0, offset) + val count = minOf(FRAMES - source - skip, FRAMES - target) + if (count > 0) { + frame.pcm.copyInto(pcm, target * 2, (source + skip) * 2, (source + skip + count) * 2) + silentFrames += target - filled + filled = target + count + } + if (offset + FRAMES > FRAMES) break + pending.removeFirst() + } + silentFrames += FRAMES - filled + return MicrophoneCapture.Frame(pcm, start, position, nowNs, generation).also { position += FRAMES } + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/MicrophoneCapture.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/MicrophoneCapture.kt index 474da5a7..05dfe314 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/MicrophoneCapture.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/MicrophoneCapture.kt @@ -6,7 +6,8 @@ import java.util.concurrent.TimeUnit /** Один аппаратный reader, независимые ограниченные очереди потребителей. PCM каждого потребителя — своя копия. * Закрытие подписки не останавливает соседей; последний владелец останавливает read, делает join и release. * PTT попадает в ту же очередь, что PCM, поэтому native TX всегда имеет одного последовательного владельца. */ -class MicrophoneCapture(private val device: Device, private val log: (String, String) -> Unit, private val released: () -> Unit = {}) { +class MicrophoneCapture(private val device: Device, private val log: (String, String) -> Unit, private val released: () -> Unit = {}, + private val changed: () -> Unit = {}) { interface Device { val channels: Int val inputId: Int? @@ -15,14 +16,15 @@ class MicrophoneCapture(private val device: Device, private val log: (String, St fun close() } sealed interface Event - data class Frame(val pcm: ShortArray, val timeNs: Long, val sequence: Long, val createdNs: Long = System.nanoTime()) : Event + data class Frame(val pcm: ShortArray, val timeNs: Long, val sequence: Long, val createdNs: Long = System.nanoTime(), + val generation: Long = 0) : Event data class Ptt(val pressed: Boolean) : Event private val consumers = mutableListOf() @Volatile private var reading = true - @Volatile private var disposed = false @Volatile private var failure: Exception? = null private var worker: Thread? = null - val closed: Boolean get() = disposed + val closed: Boolean get() = !reading + val names: Set get() = synchronized(this) { consumers.map { it.name }.toSet() } val inputId: Int? get() = device.inputId val channels: Int get() = device.channels @@ -72,6 +74,7 @@ class MicrophoneCapture(private val device: Device, private val log: (String, St } }, "microphone-capture").apply { start() } log("INFO", "capture attached consumer=$name channels=$channels consumers=${consumers.size}") + changed() return subscription } @@ -81,10 +84,11 @@ class MicrophoneCapture(private val device: Device, private val log: (String, St log("INFO", "capture detached consumer=${subscription.name} remaining=${consumers.size}") consumers.isEmpty().also { if (it) reading = false } } + changed() if (!last) return try { device.stop() } catch (e: Exception) { log("WARN", "capture stop failed: ${e.message}") } worker?.join() try { device.close() } catch (e: Exception) { log("ERROR", "capture release failed: ${e.message}") } - finally { disposed = true; released() } + finally { released() } } } diff --git a/tools/chatgui-android/app/src/test/java/com/utun/chat/data/MediaCaptureTimelineTest.kt b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/MediaCaptureTimelineTest.kt new file mode 100644 index 00000000..461d6fab --- /dev/null +++ b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/MediaCaptureTimelineTest.kt @@ -0,0 +1,71 @@ +package com.utun.chat.data + +import org.junit.Assert.* +import org.junit.Test + +class MediaCaptureTimelineTest { + private val origin = 1_000_000_000L + private fun frame(time: Long, value: Int, epoch: Long = 1) = + MicrophoneCapture.Frame(ShortArray(1920) { value.toShort() }, time, 0, generation = epoch) + private fun pull(timeline: MediaCaptureTimeline) = timeline.pull(timeline.deadlineNs) + + @Test fun switchingKeepsTimeAndSequenceAndRejectsOldDevice() { + val timeline = MediaCaptureTimeline(origin) + timeline.reset(1) + assertTrue(timeline.offer(frame(origin, 11))) + val first = pull(timeline) + timeline.reset(2) + assertFalse(timeline.offer(frame(origin + 20_000_000, 99))) + val silence = pull(timeline) + assertTrue(silence.pcm.all { it == 0.toShort() }) + assertTrue(timeline.offer(frame(origin + 40_000_000, 22, 2))) + val next = pull(timeline) + assertEquals(first.timeNs + 40_000_000, next.timeNs) + assertEquals(1920L, next.sequence) + assertTrue(next.pcm.all { it == 22.toShort() }) + } + + @Test fun differentlyAlignedMicrophoneFramesFillTheirActualTime() { + val timeline = MediaCaptureTimeline(origin) + timeline.reset(1) + assertTrue(timeline.offer(frame(origin + 10_000_000, 1))) + assertTrue(timeline.offer(frame(origin + 30_000_000, 2))) + val first = pull(timeline).pcm + val second = pull(timeline).pcm + assertTrue(first.take(960).all { it == 0.toShort() }) + assertTrue(first.drop(960).all { it == 1.toShort() }) + assertTrue(second.take(960).all { it == 1.toShort() }) + assertTrue(second.drop(960).all { it == 2.toShort() }) + } + + @Test fun lateSoundIsNeverRetimestampedOverPreviouslySentSilence() { + val timeline = MediaCaptureTimeline(origin) + timeline.reset(1) + repeat(20) { pull(timeline) } + assertFalse(timeline.offer(frame(origin, 123))) + assertEquals(1L, timeline.lateFrames) + val result = pull(timeline) + assertEquals(origin + 400_000_000, result.timeNs) + assertTrue(result.pcm.all { it == 0.toShort() }) + } + + @Test fun deviceOpeningDelayProducesContinuousTwentyMillisecondPackets() { + val timeline = MediaCaptureTimeline(origin) + timeline.reset(1) + repeat(1500) { index -> + if (index % 50 == 0) timeline.reset(index.toLong() + 1) + val result = pull(timeline) + assertEquals(origin + index * 20_000_000L, result.timeNs) + assertEquals(index * 960L, result.sequence) + assertEquals(1920, result.pcm.size) + } + } + + @Test fun stalePacketsCannotGrowTheCaptureQueueWithoutBound() { + val timeline = MediaCaptureTimeline(origin) + timeline.reset(1) + repeat(24) { assertTrue(timeline.offer(frame(origin + it * 20_000_000L, 1))) } + assertFalse(timeline.offer(frame(origin + 480_000_000L, 2))) + assertEquals(1L, timeline.lateFrames) + } +} From ecada765a29ad525511cc3442df0e83600b26471 Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 22 Oct 2026 15:07:25 +0200 Subject: [PATCH 02/11] 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() {} + } +} From a893f6b4f2823cd2e39eea0fbd84e3d37ce47332 Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 22 Oct 2026 15:07:25 +0200 Subject: [PATCH 03/11] Route radio and stream capture through the shared controller --- .../com/utun/chat/data/CallAudioRouter.kt | 27 +- .../com/utun/chat/data/RadioAudioEngine.kt | 256 ++---------------- .../com/utun/chat/data/StreamCaptureEngine.kt | 9 +- 3 files changed, 40 insertions(+), 252 deletions(-) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioRouter.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioRouter.kt index 8a79dad1..89fe4d49 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioRouter.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioRouter.kt @@ -212,7 +212,8 @@ class CallAudioRouter { } /* ── применение маршрута ── */ - /** Трансляция владеет глобальным трактом; рация меняет только preferredDevice своих входа/выхода. */ + /** Выбрать глобальный разговорный тракт либо только preferredDevice (media/CAMCORDER). + * Вызовы звонка сохраняют глобальный режим; общий media-контроллер выбирает режим по захвату. */ @Synchronized fun setGlobalRouting(enabled: Boolean) { if (globalRouting == enabled) return if (!enabled && started && telecom == null) { @@ -221,7 +222,7 @@ class CallAudioRouter { clearCommunicationDevice() } globalRouting = enabled - LogManager.addLog("INFO", "CallAudioRouter", "global routing=$enabled owner=${if (enabled) "radio" else "stream"}") + LogManager.addLog("INFO", "CallAudioRouter", "global routing=$enabled") if (started) applyRoute(desiredRoute) } @@ -237,17 +238,17 @@ class CallAudioRouter { try { if (!globalRouting) { val wired = findWiredDevice() - val headset = if (preferWiredHeadset && wired != null) wired else findBtScoDevice() ?: wired + val headset = if (preferWiredHeadset && wired != null) wired else findBtMediaDevice() ?: 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() + else if (headset != null && isBtType(headset.type)) 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}") + LogManager.addLog("INFO", "CallAudioRouter", "per-track route=$r output=${output?.id} input=${input?.id}") return } var dev: AudioDeviceInfo? = null @@ -340,7 +341,7 @@ class CallAudioRouter { fun btInputDevice(): AudioDeviceInfo? = (devices ?: am.getDevices(AudioManager.GET_DEVICES_ALL)).firstOrNull { - it.isSource && it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO + it.isSource && isBtType(it.type) } private fun refreshInputDevices() { @@ -410,11 +411,21 @@ class CallAudioRouter { private fun findWiredDevice(): AudioDeviceInfo? = (devices ?: am.getDevices(AudioManager.GET_DEVICES_ALL)).firstOrNull { it.isSink && isWiredType(it.type) } - private fun findBtScoDevice(): AudioDeviceInfo? = + private fun isBtType(type: Int): Boolean = type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO || + type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP || (Build.VERSION.SDK_INT >= 31 && type == AudioDeviceInfo.TYPE_BLE_HEADSET) + + private fun findBtMediaDevice(): AudioDeviceInfo? = (devices ?: am.getDevices(AudioManager.GET_DEVICES_ALL)).firstOrNull { - it.isSink && it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO + it.isSink && (it.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP || + (Build.VERSION.SDK_INT >= 31 && it.type == AudioDeviceInfo.TYPE_BLE_HEADSET)) } + private fun findBtScoDevice(): AudioDeviceInfo? { + val available = if (Build.VERSION.SDK_INT >= 31) am.availableCommunicationDevices + else (devices ?: am.getDevices(AudioManager.GET_DEVICES_ALL)).toList() + return available.firstOrNull { it.isSink && isBtType(it.type) && it.type != AudioDeviceInfo.TYPE_BLUETOOTH_A2DP } + } + private fun isWiredType(type: Int): Boolean = when (type) { AudioDeviceInfo.TYPE_WIRED_HEADSET, AudioDeviceInfo.TYPE_WIRED_HEADPHONES, diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt index 1bdd953c..b6abdf3b 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt @@ -6,12 +6,9 @@ import android.content.Intent import android.content.IntentFilter import android.content.Context import android.media.AudioAttributes -import android.media.AudioDeviceInfo import android.media.AudioFormat import android.media.AudioManager -import android.media.AudioRecord import android.media.AudioTrack -import android.media.MediaRecorder import android.os.Build import android.os.Handler import android.os.Looper @@ -22,7 +19,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import java.util.concurrent.Executors import java.util.concurrent.Future -import java.util.concurrent.atomic.AtomicLong /** * Полудуплексный аудио-движок рации (аналог CallAudioEngine, но с PTT). @@ -53,22 +49,13 @@ class RadioAudioEngine { inst ?: RadioAudioEngine().also { inst = it } } - /* Входное устройство захвата (значение radio_input_source). */ - const val INPUT_AUTO = 0 - const val INPUT_BUILTIN = 1 - const val INPUT_WIRED = 2 - const val INPUT_BT = 3 } @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 prevSpeaker: Boolean = false - private var audioModeOwned = false - private val streamPriorities = AtomicLong() - private var streamPriority = 0L // Только io. - private var streamCaptureReady = false + private var outputLease: AutoCloseable? = null private var capturePttRequested = false @Volatile private var active = false @@ -86,9 +73,6 @@ class RadioAudioEngine { private val vadWindow = RadioVadWindow(VAD_BACKGROUND_MS) private var screenReceiver: BroadcastReceiver? = null @Volatile private var screenInteractive = false - private var configuredInput = INPUT_AUTO - private var configuredMicSource = 0 - private var configuredStereo = false private var autoSettings = RadioAutoSettings() private var settingsReady = false private var settingsRequest = 0L @@ -96,9 +80,6 @@ class RadioAudioEngine { @Volatile private var autoMode = RadioAutoSettings.OFF @Volatile private var levelMonitoring = false - /** Маршрутизация аудио-выхода рации (динамик ↔ гарнитура), как в звонке. */ - val router = CallAudioRouter() - private val _talkingFlow = MutableStateFlow(false) val talkingFlow: StateFlow = _talkingFlow /** Фактический TX (PTT/автоматика), привязанный к каналу. Capture публикует, io сбрасывает после join. */ @@ -126,58 +107,10 @@ class RadioAudioEngine { fun isActive(): Boolean = active - /** Camera worker ждёт освобождения разговорного тракта до открытия CAMCORDER. - * Старое уведомление release не отменяет приоритет следующей трансляции. */ - fun prepareStreamCapture(): Long { - val token = streamPriorities.incrementAndGet() - return io.submit { - 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). */ - fun route(): AudioRoute = router.route - - /** Текущее входное устройство захвата (INPUT_AUTO/BUILTIN/WIRED/BT). */ - fun inputSource(): Int = ChatApplication.instance.configProvider.getRadioInputSource() - - /** Доступна ли гарнитура (проводная/USB/BT). */ - fun headsetAvailable(): Boolean = router.headsetAvailable - - /** Ручной выбор выхода (отключает автоследование за гарнитурой). */ - fun setRoute(r: AudioRoute, preferWired: Boolean = false) { - router.setAutoHeadset(false) - router.setRoute(r, preferWired) - } + fun route(): AudioRoute = if (MediaAudioController.state.value.active == MediaAudioProfile.PHONE) AudioRoute.SPEAKER else AudioRoute.HEADSET /** Начать прослушивание канала (переключатель рации вкл). * Тяжёлая инициализация аудио выполняется на io-потоке, не блокируя main. */ @@ -205,7 +138,6 @@ class RadioAudioEngine { releaseLocked() audioGroup = group vadWindow.restart(SystemClock.elapsedRealtime()) - router.setAutoHeadset(true) if (active && generation == version) { try { setupAudio() } catch (e: Exception) { LogManager.addLog("ERROR", "RadioAudio", "audio setup failed: ${e.message}") @@ -296,15 +228,8 @@ class RadioAudioEngine { private fun setupAudio() { loadCaptureSettings() val group = audioGroup - if (streamPriority == 0L) acquireAudioMode() - router.setGlobalRouting(streamPriority == 0L) - - /* Маршрут выхода: автоследование за гарнитурой (воткнули → HEADSET, вынули → SPEAKER). */ - router.fallbackRoute = AudioRoute.SPEAKER - router.start() - if (!NativeLib.radioAudioStart(group, 0)) { - router.stop(); restoreAudioMode(); active = false + active = false LogManager.addLog("ERROR", "RadioAudio", "native radioAudioStart failed group=$group") return } @@ -316,7 +241,7 @@ class RadioAudioEngine { val track = try { AudioTrack( AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) + .setUsage(AudioAttributes.USAGE_MEDIA) .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH).build(), AudioFormat.Builder().setSampleRate(SAMPLE_RATE) .setEncoding(AudioFormat.ENCODING_PCM_16BIT) @@ -324,21 +249,12 @@ class RadioAudioEngine { trackBuf, AudioTrack.MODE_STREAM, AudioManager.AUDIO_SESSION_ID_GENERATE) } catch (e: Exception) { LogManager.addLog("ERROR", "RadioAudio", "AudioTrack create failed: ${e.message}") - NativeLib.radioAudioStop(); router.stop(); restoreAudioMode(); active = false; return + NativeLib.radioAudioStop(); active = false; return } audioTrack = track - /* Прямое роутирование трека на выбранное устройство (обход глючной глобальной - * маршрутизации), как в CallAudioEngine. */ - router.onOutputDevice = { dev -> applyTrackOutputDevice(dev) } - applyTrackOutputDevice(router.currentOutputDevice) - - /* AUTO-вход: смена маршрута выхода меняет и входное устройство — пересоздаём захват. */ - router.onInputDevice = { _ -> - if (ChatApplication.instance.configProvider.getRadioInputSource() == INPUT_AUTO) reconfigure() - } - + outputLease = MediaAudioController.attachOutput(track) track.play() playbackRunning = true playThread = Thread { playLoop() }.apply { @@ -353,9 +269,7 @@ class RadioAudioEngine { /* Настройки загружаются при включении/перенастройке, без DataStore в пути PTT. */ private fun loadCaptureSettings() { val provider = ChatApplication.instance.configProvider - configuredInput = provider.getRadioInputSource() - configuredMicSource = provider.getRadioMicSource() - configuredStereo = provider.getRadioCaptureStereo() + MediaAudioController.refresh() autoSettings = provider.getRadioAutoSettings() settingsReady = false val request = ++settingsRequest @@ -387,96 +301,6 @@ class RadioAudioEngine { } } - /* Создать AudioRecord с маршрутизацией входного устройства: встроенный микрофон - * (стерео + предобработка из настроек), проводная/BT-гарнитура (моно, MIC), - * авто — системный дефолт. Возвращает (record, каналы). */ - private fun createAudioRecord(preferredDev: AudioDeviceInfo?): Pair { - val inputSource = configuredInput - val pref = configuredMicSource - val wantStereo = configuredStereo - - /* гарнитурный вход: моно + VOICE_COMMUNICATION (звонковый тракт «запитывает» - * микрофон гарнитуры; AudioSource.MIC на ней даёт тишину). AUTO: следуем за - * маршрутом выхода (currentInputDevice из applyRoute). */ - val autoDev = router.currentInputDevice - val headsetInput = inputSource == INPUT_WIRED || inputSource == INPUT_BT || - (inputSource == INPUT_AUTO && autoDev != null) - - val primary = when (pref) { - 1 -> MediaRecorder.AudioSource.MIC - 2 -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) - MediaRecorder.AudioSource.UNPROCESSED else MediaRecorder.AudioSource.MIC - 3 -> MediaRecorder.AudioSource.CAMCORDER - else -> MediaRecorder.AudioSource.VOICE_COMMUNICATION - } - val sources = if (headsetInput) { - intArrayOf(MediaRecorder.AudioSource.VOICE_COMMUNICATION) - } else if (wantStereo && pref == 0) { - /* voice-источник (AEC/NS/beamforming) коллапсирует в моно/один луч — - * для стерео используем CAMCORDER (тот же тракт, что у видеозаписи). */ - LogManager.addLog("WARN", "RadioAudio", "voice source не даёт стерео — использую CAMCORDER") - intArrayOf(MediaRecorder.AudioSource.CAMCORDER) - } else if (pref == 2 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - intArrayOf(primary, MediaRecorder.AudioSource.MIC) - } else { - intArrayOf(primary) - } - val masks = if (!headsetInput && wantStereo) - intArrayOf(AudioFormat.CHANNEL_IN_STEREO, AudioFormat.CHANNEL_IN_MONO) - else intArrayOf(AudioFormat.CHANNEL_IN_MONO) - - for (mask in masks) { - val channels = if (mask == AudioFormat.CHANNEL_IN_STEREO) 2 else 1 - val recMin = AudioRecord.getMinBufferSize(SAMPLE_RATE, mask, AudioFormat.ENCODING_PCM_16BIT) - if (recMin <= 0) { - LogManager.addLog("WARN", "RadioAudio", "unsupported capture format ch=$channels minBuffer=$recMin") - continue - } - val bufSize = (recMin * 2).coerceAtLeast(FRAME_SAMPLES * channels * 4) - for (src in sources) { - val rec = tryCreateRecord(src, bufSize, mask) - if (rec != null) { - if (wantStereo && channels == 1) - LogManager.addLog("WARN", "RadioAudio", "stereo capture unavailable — fallback to mono") - 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 - } - } - } - return null to 1 - } - - /* Прямое роутирование захвата на выбранное входное устройство (обход глючной - * глобальной маршрутизации); null — системный дефолт. */ - private fun applyRecordPreferredDevice(rec: AudioRecord, dev: AudioDeviceInfo?): Boolean { - if (dev == null) return true - val ok = try { - rec.setPreferredDevice(dev) - } catch (e: Exception) { - LogManager.addLog("ERROR", "RadioAudio", "setPreferredDevice(record) threw: ${e.message}") - 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? { - val rec = try { - AudioRecord(source, SAMPLE_RATE, channelMask, AudioFormat.ENCODING_PCM_16BIT, bufferSize) - } catch (e: Exception) { - LogManager.addLog("ERROR", "RadioAudio", "AudioRecord create failed (source=$source chMask=$channelMask): ${e.message}") - return null - } - if (rec.state != AudioRecord.STATE_INITIALIZED) { - LogManager.addLog("ERROR", "RadioAudio", "AudioRecord not initialized (source=$source chMask=$channelMask)") - rec.release() - return null - } - return rec - } - private fun screenOn(): Boolean = (ChatApplication.instance.getSystemService(Context.POWER_SERVICE) as PowerManager).isInteractive @@ -515,7 +339,6 @@ 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) @@ -547,24 +370,10 @@ class RadioAudioEngine { LogManager.addLog("WARN", "radio", "Auto/PTT capture blocked: RECORD_AUDIO not granted group=$audioGroup mode=$mode") return false } - 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") - } + MediaAudioController.subscribe("radio") } catch (e: Exception) { - LogManager.addLog("ERROR", "radio", "capture open failed input=${preferred?.id}: ${e.message}") + LogManager.addLog("ERROR", "radio", "shared capture subscription failed: ${e.message}") return false } val channels = subscription.channels @@ -606,7 +415,7 @@ class RadioAudioEngine { }.apply { name = "radio-capture"; priority = Thread.MAX_PRIORITY; start() } - LogManager.addLog("INFO", "radio", "capture started group=$audioGroup ch=$channels mode=$autoMode input=${preferred?.id}") + LogManager.addLog("INFO", "radio", "capture started group=$audioGroup ch=$channels mode=$autoMode shared=true") return true } @@ -647,14 +456,11 @@ class RadioAudioEngine { playThread?.interrupt() playThread?.join() playThread = null + outputLease?.close(); outputLease = null track.release() } audioTrack = null NativeLib.radioAudioStop() - router.onOutputDevice = null - router.onInputDevice = null - router.stop() - restoreAudioMode() } internal fun talkBegin(source: RadioPttSource = RadioPttSource.BUTTON): Boolean { @@ -711,6 +517,7 @@ class RadioAudioEngine { private fun captureLoop(subscription: MicrophoneCapture.Subscription, group: Long, ch: Int) { var expected = -1L + var captureEpoch = -1L var discontinuity = false var frameN = 0L var reportedRequestNs = 0L @@ -740,7 +547,11 @@ class RadioAudioEngine { discontinuity = true continue } - if (discontinuity || (expected >= 0 && captured.sequence != expected)) NativeLib.radioCaptureDiscontinuity(group) + if (discontinuity || (expected >= 0 && (captured.sequence != expected || captured.generation != captureEpoch))) { + LogManager.addLog("INFO", "radio", "shared capture boundary group=$group epoch=$captureEpoch->${captured.generation}") + NativeLib.radioCaptureDiscontinuity(group) + } + captureEpoch = captured.generation discontinuity = false; expected = captured.sequence + FRAME_SAMPLES val buf = captured.pcm maxReadNs = maxOf(maxReadNs, SystemClock.elapsedRealtimeNanos() - readStartedNs) @@ -783,7 +594,7 @@ class RadioAudioEngine { LogManager.addLog("DEBUG", "radio", "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} " + - "channels=$ch input=$configuredInput " + + "channels=$ch input=${MediaAudioController.state.value.inputName} " + "route=${route()} mode=$autoMode screen=$screenInteractive manual=$requestedTalk tx=${_talkingFlow.value}") statsMs = nowMs statsFrames = 0; fedFrames = 0; skippedFrames = 0; failedFrames = 0 @@ -844,35 +655,4 @@ 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 - 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 - try { - val am = ChatApplication.instance.getSystemService(Context.AUDIO_SERVICE) as AudioManager - am.isSpeakerphoneOn = prevSpeaker - // MODE_NORMAL снимает нашу заявку; прежний глобальный mode мог принадлежать завершаемому Telecom-звонку. - am.mode = AudioManager.MODE_NORMAL - } catch (e: Exception) { - LogManager.addLog("WARN", "RadioAudio", "restore audio mode failed: ${e.message}") - } - } - - private fun applyTrackOutputDevice(dev: AudioDeviceInfo?) { - val track = audioTrack ?: return - if (dev == null) return - try { - track.setPreferredDevice(dev) - LogManager.addLog("DEBUG", "RadioAudio", "track preferred device -> type=${dev.type}") - } catch (e: Exception) { - LogManager.addLog("WARN", "RadioAudio", "setPreferredDevice failed: ${e.message}") - } - } } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamCaptureEngine.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamCaptureEngine.kt index 14d87524..ebb85958 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamCaptureEngine.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamCaptureEngine.kt @@ -491,13 +491,10 @@ class StreamCaptureEngine( @SuppressLint("MissingPermission") private fun startAudio() { - val radio = RadioAudioEngine.instance() - val priority = radio.prepareStreamCapture() - val subscription = try { AndroidMicrophoneCapture.openStream { radio.releaseStreamCapture(priority) } } - catch (e: Exception) { radio.releaseStreamCapture(priority); throw e } + val subscription = MediaAudioController.subscribe("stream") audioCapture = subscription audioRunning = true - LogManager.addLog("INFO", "stream", "audio capture started id=$id source=CAMCORDER rate=48000 channels=2 compressor=$audioCompressorSettings") + LogManager.addLog("INFO", "stream", "shared audio subscribed id=$id rate=48000 channels=2 compressor=$audioCompressorSettings") audioThread = Thread({ var opus = 0L try { @@ -510,7 +507,6 @@ class StreamCaptureEngine( if (waitingStamp) LogManager.addLog("DEBUG", "stream", "audio timestamp pending id=$id samples=${frame.sequence}") continue } - if (waitingStamp) radio.resumeAfterStreamCapture(priority) waitingStamp = false if (expected >= 0 && frame.sequence != expected) { LogManager.addLog("WARN", "stream", "audio queue discontinuity id=$id expected=$expected sequence=${frame.sequence}") @@ -518,6 +514,7 @@ class StreamCaptureEngine( } expected = frame.sequence + 960 val pts = (frame.timeNs - originNs) / 1000 + if (pts < 0) continue // Общий захват мог начаться раньше эфира; его запас времени ещё дренируется. check(pts >= 0 && pts > last) { "Audio timestamp discontinuity pts=$pts last=$last" }; last = pts if (audioMuted) frame.pcm.fill(0) val packet = NativeLib.streamOpusEncode(opus, frame.pcm, audioCompressorSettings) ?: error("Stream compressor/Opus encode failed") From bebc579180b712d327de546083abfce95b0c4580 Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 22 Oct 2026 15:07:25 +0200 Subject: [PATCH 04/11] Share media playback devices and await cleanup before calls --- .../main/java/com/utun/chat/data/CallController.kt | 2 +- .../java/com/utun/chat/data/StreamAudioOutput.kt | 8 ++++++-- .../com/utun/chat/data/StreamAudioOutputTest.kt | 14 ++++++++++++++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt index 6dfa889b..64c709af 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt @@ -207,7 +207,7 @@ object CallController : TelecomCallManager.Listener { val radioStopped = RadioAudioEngine.instance().stopForCall() StreamController.stopForCall() StreamPlayerEngine.stopForCall() - withContext(Dispatchers.IO) { radioStopped.get() } + withContext(Dispatchers.IO) { radioStopped.get(); MediaAudioController.awaitIdle() } val current = _state.value if (current?.callId != id || !current.ongoing) { LogManager.addLog("INFO", "Call", "media released after call cancellation id=$id") diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamAudioOutput.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamAudioOutput.kt index d06c57f8..f22d3b4c 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamAudioOutput.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamAudioOutput.kt @@ -12,6 +12,7 @@ internal class StreamAudioOutput( private val log: (String) -> Unit = {} ) { internal interface Sink { + val routeChanging: Boolean get() = false val bufferFrames: Int val threshold: Int val head: Long @@ -23,6 +24,8 @@ internal class StreamAudioOutput( } private class AndroidSink : Sink { + override val routeChanging: Boolean get() = MediaAudioController.state.value.switching + private var route: AutoCloseable? = null private val track = AudioTrack.Builder() .setAudioAttributes(AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA) .setContentType(AudioAttributes.CONTENT_TYPE_MOVIE).build()) @@ -35,6 +38,7 @@ internal class StreamAudioOutput( check(track.state == AudioTrack.STATE_INITIALIZED) { "AudioTrack initialization failed" } // API 26..30 не умеет уменьшать порог отдельно от аппаратного буфера. if (Build.VERSION.SDK_INT >= 31) track.setStartThresholdInFrames(1) + route = MediaAudioController.attachOutput(track) } catch (e: Exception) { track.release(); throw e } } override val bufferFrames: Int get() = track.bufferSizeInFrames @@ -46,7 +50,7 @@ internal class StreamAudioOutput( override fun write(pcm: ShortArray, offset: Int, count: Int) = track.write(pcm, offset, count, AudioTrack.WRITE_NON_BLOCKING) override fun pause() = track.pause() override fun play() = track.play() - override fun close() = track.release() + override fun close() { route?.close(); route = null; track.release() } } private var sink: Sink? = null @@ -132,7 +136,7 @@ internal class StreamAudioOutput( poll(now) // До достаточного заполнения ожидание сети не является зависанием устройства. val expectedProgress = written + padded - deviceHead >= s.threshold || blocked - if (!expectedProgress) stalledSince = -1 + if (!expectedProgress || s.routeChanging) stalledSince = -1 else if (stalledSince < 0) stalledSince = now else check(now - stalledSince < 2_000_000_000) { "AudioTrack made no playback progress for ${(now - stalledSince) / 1_000_000}ms; $description" diff --git a/tools/chatgui-android/app/src/test/java/com/utun/chat/data/StreamAudioOutputTest.kt b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/StreamAudioOutputTest.kt index ce2f6f17..733c8de5 100644 --- a/tools/chatgui-android/app/src/test/java/com/utun/chat/data/StreamAudioOutputTest.kt +++ b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/StreamAudioOutputTest.kt @@ -6,6 +6,7 @@ import java.util.ArrayDeque class StreamAudioOutputTest { private class Device(override val threshold: Int = 1) : StreamAudioOutput.Sink { + override var routeChanging = false override val bufferFrames = maxOf(960, threshold) override var head = 0L override val description: String get() = "fake threshold=$threshold" @@ -84,6 +85,19 @@ class StreamAudioOutputTest { assertTrue(p.output.drained) } + @Test fun routeSwitchPreservesPlayerAndClockUntilOutputResumes() { + val p = Player(); p.input.add(ShortArray(960) { 7 }); p.step(0) + p.device.routeChanging = true + var videoSteps = 0 + for (ms in 5L..30000L step 5) { p.step(ms); videoSteps++ } + assertEquals(6000, videoSteps) + assertFalse(p.device.closed); assertEquals(0L, p.output.played) + assertEquals(List(960) { 7.toShort() }, p.device.samples) + p.device.routeChanging = false + p.device.head = 480; p.step(30005) + assertTrue(p.output.drained) + } + @Test fun intentionalPausePreservesPartialPcmAndSuspendsWatchdog() { val p = Player(); val pcm = ShortArray(960) { it.toShort() }; p.input.add(pcm); p.device.limit = 120 p.step(0); p.step(5, true); p.step(10000, true) From 33ccba1f3f197cc3380ba77c35bef4c4d88f8f47 Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 22 Oct 2026 15:07:25 +0200 Subject: [PATCH 05/11] Add a common audio device selector for radio and streams --- .../ui/components/MediaAudioDeviceButton.kt | 66 +++++++++++++++++++ .../app/src/main/res/values-ru/strings.xml | 9 +++ .../app/src/main/res/values/strings.xml | 9 +++ 3 files changed, 84 insertions(+) create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MediaAudioDeviceButton.kt diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MediaAudioDeviceButton.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MediaAudioDeviceButton.kt new file mode 100644 index 00000000..55ace16d --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MediaAudioDeviceButton.kt @@ -0,0 +1,66 @@ +package com.utun.chat.ui.components + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Box +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.VolumeUp +import androidx.compose.material.icons.filled.HeadsetMic +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.core.content.ContextCompat +import com.utun.chat.R +import com.utun.chat.data.MediaAudioController +import com.utun.chat.data.MediaAudioProfile +import com.utun.chat.data.LogManager + +/** Один выбор для панели рации, публикации и всех экранов просмотра. */ +@Composable +fun MediaAudioDeviceButton(tint: Color = LocalContentColor.current, onExpandedChange: (Boolean) -> Unit = {}) { + val state by MediaAudioController.state.collectAsState() + val context = LocalContext.current + var expanded by remember { mutableStateOf(false) } + val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) MediaAudioController.select(MediaAudioProfile.BLUETOOTH) + else LogManager.addLog("WARN", "media_audio", "Bluetooth selection cancelled: BLUETOOTH_CONNECT denied") + } + val bluetoothPermission = Build.VERSION.SDK_INT < 31 || + ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED + fun expand(value: Boolean) { expanded = value; onExpandedChange(value) } + val labels = mapOf(MediaAudioProfile.AUTO to R.string.media_audio_auto, MediaAudioProfile.PHONE to R.string.media_audio_phone, + MediaAudioProfile.WIRED to R.string.media_audio_wired, MediaAudioProfile.BLUETOOTH to R.string.media_audio_bluetooth) + Box { + IconButton(onClick = { expand(true) }) { + Icon(if (state.active == MediaAudioProfile.PHONE) Icons.AutoMirrored.Filled.VolumeUp else Icons.Filled.HeadsetMic, + stringResource(R.string.media_audio_devices, stringResource(labels.getValue(state.preferred))), + tint = if (state.notice != null) MaterialTheme.colorScheme.error else tint) + } + DropdownMenu(expanded, onDismissRequest = { expand(false) }) { + for ((profile, label) in labels) { + val available = when (profile) { + MediaAudioProfile.WIRED -> state.wiredAvailable + MediaAudioProfile.BLUETOOTH -> state.bluetoothAvailable || !bluetoothPermission + else -> true + } + DropdownMenuItem(text = { Text(stringResource(label)) }, enabled = available, + trailingIcon = { if (state.preferred == profile) Text("✓") }, onClick = { + expand(false) + if (profile == MediaAudioProfile.BLUETOOTH && !bluetoothPermission) + permission.launch(Manifest.permission.BLUETOOTH_CONNECT) + else MediaAudioController.select(profile) + }) + } + HorizontalDivider() + DropdownMenuItem(text = { Text(stringResource(R.string.media_audio_actual, state.inputName ?: "—", state.outputName ?: "—")) }, + onClick = {}, enabled = false) + if (state.switching) DropdownMenuItem(text = { Text(stringResource(R.string.media_audio_switching)) }, onClick = {}, enabled = false) + state.notice?.let { notice -> DropdownMenuItem(text = { Text(stringResource(notice), color = MaterialTheme.colorScheme.error) }, onClick = {}) } + } + } +} diff --git a/tools/chatgui-android/app/src/main/res/values-ru/strings.xml b/tools/chatgui-android/app/src/main/res/values-ru/strings.xml index 1597a647..3516ac33 100644 --- a/tools/chatgui-android/app/src/main/res/values-ru/strings.xml +++ b/tools/chatgui-android/app/src/main/res/values-ru/strings.xml @@ -582,4 +582,13 @@ Отклонять входящие звонки vibechat без рингтона Отклонять все вызовы по рации Отклонять приглашения; включённая рация продолжит работать + Аудиоустройство: %1$s + Автоматически + Телефон + Проводное / USB + Bluetooth + Микрофон: %1$s\nВыход: %2$s + Переключение аудиоустройства… + Выбранное устройство отключено. Используется микрофон и динамик телефона. + Не удалось использовать выбранное аудиоустройство. Проверьте текущие микрофон и выход. diff --git a/tools/chatgui-android/app/src/main/res/values/strings.xml b/tools/chatgui-android/app/src/main/res/values/strings.xml index f0c15dd7..d8c905dd 100644 --- a/tools/chatgui-android/app/src/main/res/values/strings.xml +++ b/tools/chatgui-android/app/src/main/res/values/strings.xml @@ -581,4 +581,13 @@ Reject incoming vibechat calls without ringing Reject all radio calls Reject radio invitations; the channel radio keeps working + Audio device: %1$s + Automatic + Phone + Wired / USB + Bluetooth + Microphone: %1$s\nOutput: %2$s + Switching audio device… + The selected device is disconnected. Phone audio is in use. + Could not use the selected audio device. Check the current microphone and output. From 5d10a16baa1c98ada693462f347e43cd7bd2670c Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 22 Oct 2026 15:07:25 +0200 Subject: [PATCH 06/11] Use shared audio controls in radio and stream publication --- .../com/utun/chat/ui/screens/ChatScreen.kt | 32 +---- .../com/utun/chat/ui/screens/StreamScreen.kt | 3 + .../com/utun/chat/viewmodel/ChatViewModel.kt | 111 ------------------ 3 files changed, 5 insertions(+), 141 deletions(-) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt index d0971719..758d9e4d 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt @@ -22,10 +22,8 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.AutoMode import androidx.compose.material.icons.filled.Equalizer import androidx.compose.material.icons.filled.Bluetooth -import androidx.compose.material.icons.filled.HeadsetMic import androidx.compose.material.icons.automirrored.filled.VolumeUp import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.People @@ -56,7 +54,6 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.utun.chat.ChatApplication -import com.utun.chat.data.AudioRoute import com.utun.chat.data.Channel import com.utun.chat.data.Message import com.utun.chat.data.RadioAudioEngine @@ -70,6 +67,7 @@ import com.utun.chat.ui.components.PortableRadioIcon import com.utun.chat.ui.components.GroupAvatar import com.utun.chat.ui.components.GroupPropertiesDialog import com.utun.chat.ui.components.OwnStreamButton +import com.utun.chat.ui.components.MediaAudioDeviceButton import com.utun.chat.data.ConversationTarget import com.utun.chat.viewmodel.ChatViewModel import com.utun.chat.data.StreamSource @@ -369,11 +367,6 @@ private fun RadioPttButton( /* Единый выбор входа/выхода, PTT громкостью, VAD и плавающая PTT-кнопка. */ @Composable private fun RadioSourceBar(viewModel: ChatViewModel) { - val radioAuto by viewModel.radioAuto.collectAsState() - val radioInputSource by viewModel.radioInputSource.collectAsState() - val wiredInputAvailable by viewModel.wiredInputAvailable.collectAsState() - val btInputAvailable by viewModel.btInputAvailable.collectAsState() - val radioRoute by viewModel.radioRoute.collectAsState() val provider = ChatApplication.instance.configProvider val scope = rememberCoroutineScope() val overlayEnabled by provider.radioOverlayEnabled.collectAsState(initial = provider.getRadioOverlay()) @@ -394,28 +387,7 @@ private fun RadioSourceBar(viewModel: ChatViewModel) { horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically ) { - val currentDeviceIcon = if (radioRoute == AudioRoute.HEADSET) Icons.Default.HeadsetMic else Icons.AutoMirrored.Filled.VolumeUp - val routeName = stringResource(if (radioRoute == AudioRoute.HEADSET) R.string.radio_headset else R.string.radio_speaker) - RadioControlButton(Icons.Default.AutoMode, stringResource(R.string.radio_auto_route, routeName), - radioAuto, centerIcon = currentDeviceIcon) { - viewModel.setRadioAuto() - } - RadioControlButton(Icons.AutoMirrored.Filled.VolumeUp, stringResource(R.string.radio_phone_audio), - !radioAuto && radioInputSource == RadioAudioEngine.INPUT_BUILTIN && radioRoute == AudioRoute.SPEAKER) { - viewModel.setRadioInputSource(RadioAudioEngine.INPUT_BUILTIN) - } - if (wiredInputAvailable) { - RadioControlButton(Icons.Default.HeadsetMic, stringResource(R.string.radio_wired_audio), - !radioAuto && radioInputSource == RadioAudioEngine.INPUT_WIRED && radioRoute == AudioRoute.HEADSET) { - viewModel.setRadioInputSource(RadioAudioEngine.INPUT_WIRED) - } - } - if (btInputAvailable) { - RadioControlButton(Icons.Default.HeadsetMic, stringResource(R.string.radio_bluetooth_audio), - !radioAuto && radioInputSource == RadioAudioEngine.INPUT_BT && radioRoute == AudioRoute.HEADSET, bluetooth = true) { - viewModel.setRadioInputSource(RadioAudioEngine.INPUT_BT) - } - } + MediaAudioDeviceButton(tint = Color.White) } VerticalDivider(modifier = Modifier.height(32.dp), color = Color(0xFF344550)) RadioControlButton(Icons.AutoMirrored.Filled.VolumeUp, stringResource(R.string.volume_ptt), diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamScreen.kt index f527f7bf..3931834e 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamScreen.kt @@ -1,5 +1,7 @@ package com.utun.chat.ui.screens +import com.utun.chat.ui.components.MediaAudioDeviceButton + import android.Manifest import android.app.Activity import android.content.pm.PackageManager @@ -264,6 +266,7 @@ private fun StreamCaptureToolbar(cameras: List, config: StreamCapt stringResource(R.string.stream_bitrate_auto_value, bitrateValue) else stringResource(R.string.stream_bitrate, bitrateValue) Row(modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) { + MediaAudioDeviceButton() CaptureControlButton(stringResource(if (audio) R.string.stream_audio_on else R.string.stream_audio_off), enabled = audioEnabled, checked = audio, onClick = { onAudioChange(!audio) }) { Icon(if (audio) Icons.AutoMirrored.Filled.VolumeUp else Icons.AutoMirrored.Filled.VolumeOff, diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt index bc1ce29a..c84aaad1 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt @@ -174,24 +174,6 @@ class ChatViewModel : ViewModel() { private val _radioTalkState = MutableStateFlow(RadioTalkState()) val radioTalkState: StateFlow = _radioTalkState - private val _radioRoute = MutableStateFlow(AudioRoute.SPEAKER) - val radioRoute: StateFlow = _radioRoute - - private val _radioHeadsetAvailable = MutableStateFlow(false) - val radioHeadsetAvailable: StateFlow = _radioHeadsetAvailable - - private val _radioInputSource = MutableStateFlow(RadioAudioEngine.INPUT_AUTO) - val radioInputSource: StateFlow = _radioInputSource - - private val _wiredInputAvailable = MutableStateFlow(false) - val wiredInputAvailable: StateFlow = _wiredInputAvailable - - private val _btInputAvailable = MutableStateFlow(false) - val btInputAvailable: StateFlow = _btInputAvailable - - private val _radioAuto = MutableStateFlow(false) - val radioAuto: StateFlow = _radioAuto - private var radioToggleCount = 0 /* рация: активный канал (null = выключена). Одна рация — один канал. */ @@ -363,22 +345,12 @@ class ChatViewModel : ViewModel() { } init { - radioAudio.router.onChanged = { route, headset -> - _radioRoute.value = route - _radioHeadsetAvailable.value = headset - } - radioAudio.router.onInputDevicesChanged = { wired, bt -> - _wiredInputAvailable.value = wired - _btInputAvailable.value = bt - } viewModelScope.launch { radioAudio.activeGroup.collect { group -> if (group == null) clearRadioControls() else { _radioActive.value = radioChannelId == _currentChannel.value?.id _radioTalkState.value = RadioTalkState(group) - _radioInputSource.value = radioAudio.inputSource() - _radioAuto.value = _radioInputSource.value == RadioAudioEngine.INPUT_AUTO } syncRadioOverlay() } @@ -1064,23 +1036,11 @@ class ChatViewModel : ViewModel() { if (!ready && radioChannelId == ch.id && !radioAudio.isActive()) turnRadioOff() onReady?.invoke(ready) } - _radioRoute.value = radioAudio.route() - _radioHeadsetAvailable.value = radioAudio.headsetAvailable() - _wiredInputAvailable.value = radioAudio.router.wiredInputAvailable - _btInputAvailable.value = radioAudio.router.btInputAvailable - _radioInputSource.value = radioAudio.inputSource() - _radioAuto.value = _radioInputSource.value == RadioAudioEngine.INPUT_AUTO } else { NativeLib.radioSetActive(groupId, false) radioAudio.stop() _radioTalking.value = null _radioTalkState.value = RadioTalkState() - _radioRoute.value = AudioRoute.SPEAKER - _radioHeadsetAvailable.value = false - _wiredInputAvailable.value = false - _btInputAvailable.value = false - _radioInputSource.value = RadioAudioEngine.INPUT_AUTO - _radioAuto.value = false } _radioActive.value = next if (next) RadioOverlayService.requestPermissions() else syncRadioOverlay() @@ -1113,71 +1073,6 @@ class ChatViewModel : ViewModel() { } } - /** Ручной выбор выхода рации (динамик/гарнитура) — синхронизирует вход. */ - fun setRadioRoute(r: AudioRoute) { - if (!_radioActive.value) return - _radioAuto.value = false - radioAudio.setRoute(r) - _radioRoute.value = radioAudio.route() - val linkedInput = when (r) { - AudioRoute.SPEAKER -> RadioAudioEngine.INPUT_BUILTIN - AudioRoute.HEADSET -> when { - _wiredInputAvailable.value -> RadioAudioEngine.INPUT_WIRED - _btInputAvailable.value -> RadioAudioEngine.INPUT_BT - else -> null - } - else -> null - } - if (linkedInput != null && linkedInput != _radioInputSource.value) { - _radioInputSource.value = linkedInput - viewModelScope.launch { - ChatApplication.instance.configProvider.setRadioInputSource(linkedInput) - radioAudio.reconfigure() - } - } - LogManager.addLog("INFO", "VM", "setRadioRoute -> ${radioAudio.route()}") - } - - /** Ручной выбор входного устройства рации — синхронизирует выход. */ - fun setRadioInputSource(src: Int) { - if (!_radioActive.value) return - val clamped = src.coerceIn(RadioAudioEngine.INPUT_AUTO, RadioAudioEngine.INPUT_BT) - if (clamped == _radioInputSource.value) return - _radioAuto.value = false - _radioInputSource.value = clamped - val linkedRoute = when (clamped) { - RadioAudioEngine.INPUT_BUILTIN -> AudioRoute.SPEAKER - RadioAudioEngine.INPUT_WIRED, RadioAudioEngine.INPUT_BT -> AudioRoute.HEADSET - else -> null - } - if (linkedRoute != null) { - radioAudio.setRoute(linkedRoute, preferWired = clamped == RadioAudioEngine.INPUT_WIRED) - _radioRoute.value = radioAudio.route() - } - viewModelScope.launch { - ChatApplication.instance.configProvider.setRadioInputSource(clamped) - radioAudio.reconfigure() - } - LogManager.addLog("INFO", "VM", "setRadioInputSource -> $clamped") - } - - /** Авто: выход следует за гарнитурой, вход — автовыбор. */ - fun setRadioAuto() { - if (!_radioActive.value) return - _radioAuto.value = true - radioAudio.router.setAutoHeadset(true) - radioAudio.router.reapply() - _radioRoute.value = radioAudio.route() - if (_radioInputSource.value != RadioAudioEngine.INPUT_AUTO) { - _radioInputSource.value = RadioAudioEngine.INPUT_AUTO - viewModelScope.launch { - ChatApplication.instance.configProvider.setRadioInputSource(RadioAudioEngine.INPUT_AUTO) - radioAudio.reconfigure() - } - } - LogManager.addLog("INFO", "VM", "setRadioAuto -> route=${radioAudio.route()}") - } - /* Полностью выключить рацию (используется при удалении активного канала). */ private fun turnRadioOff() { val chId = radioChannelId ?: return @@ -1191,12 +1086,6 @@ class ChatViewModel : ViewModel() { _radioActive.value = false _radioTalking.value = null _radioTalkState.value = RadioTalkState() - _radioRoute.value = AudioRoute.SPEAKER - _radioHeadsetAvailable.value = false - _wiredInputAvailable.value = false - _btInputAvailable.value = false - _radioInputSource.value = RadioAudioEngine.INPUT_AUTO - _radioAuto.value = false } private fun syncRadioOverlay() { From 44a3b33c3a98b34b17e20c2b838018dbafa55b7c Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 22 Oct 2026 15:07:26 +0200 Subject: [PATCH 07/11] Expose shared audio devices in stream viewing and document them --- doc/stream.txt | 26 +++++++++++-------- .../utun/chat/ui/screens/StreamGridScreen.kt | 8 +++++- .../chat/ui/screens/StreamViewerScreen.kt | 8 ++++-- 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/doc/stream.txt b/doc/stream.txt index 93f340e6..a3468bcb 100644 --- a/doc/stream.txt +++ b/doc/stream.txt @@ -811,7 +811,8 @@ Android: Разделённые CODEC_CONFIG buffers собираются отдельно с пределом 64KiB до разбора конфигурации. - Camera MediaCodec Surface timestamps и AudioTimestamp.TIMEBASE_MONOTONIC приводятся к общему origin System.nanoTime; время callback/получения блока не заменяет capture PTS. -- Микрофон трансляции использует AudioRecord.CAMCORDER, 48kHz stereo. Перед Opus AUDIO/128kbps +- Встроенный микрофон трансляции использует AudioRecord.CAMCORDER, 48kHz stereo (mono fallback при отказе формата). + Гарнитуры используют разговорный тракт; mono приводится к stereo перед Opus AUDIO/128kbps. Перед кодированием применяется тот же audio_compressor, что в TX рации: общие настройки включения, max_gain и rise_rate, lookback=200ms, lookahead=0, release=rise*5, target=0.25. История и gain принадлежат только audio worker трансляции; оба канала используют общий gain, сохраняющий стереобаланс. Изменения настроек рации применяются на следующем PCM-кадре, без перезапуска микрофона, @@ -934,17 +935,20 @@ Android: Это ограниченный drain, не подтверждение воспроизведения всеми viewers. Abort/core restart очищает очередь сразу. Завершение логирует срок drain, оставшиеся RS-байты выдач и локальных readers; RS=0 не означает подтверждение доставки ETCP. Поздние команды старого session ID не меняют новый запуск. -- Рация работает одновременно с публикацией. При совпадении выбранного входа она получает копию - PCM CAMCORDER 48kHz stereo с исходными timestamp; её source/stereo предпочтения не меняют трансляцию. - Другой явно выбранный вход открывается отдельно; отказ не заменяет его системным default. - Трансляция имеет приоритет глобального AudioManager: рация не включает разговорный режим и не меняет SCO - общего тракта. Перед запуском трансляции ранее работавшая рация освобождает capture и затем подключается - к фактическому входу трансляции либо повторно открывает свой отдельный вход. - Очереди потребителей ограничены; PTT упорядочен с PCM, разрыв сбрасывает историю детектора рации. - Последняя подписка закрывает AudioRecord; stop рации/трансляции не закрывает захват другого потребителя. - Проверяется фактический Android route и системное silencing: конфликтующий отдельный capture рации - завершается с диагностикой, приём рации сохраняется. Окно VAD/экран/разрешения не обходятся общим захватом. +- Рация, публикация и просмотр трансляций используют общий профиль MediaAudioController: AUTO/PHONE/WIRED/BLUETOOTH. + Выбор из любого экрана меняет общий вход и маршруты всех активных AudioTrack; настройка сохраняется одна. + Аппаратный микрофон один, очереди потребителей и обработка PCM независимы. PTT упорядочен с PCM. + Подключение публикации переводит встроенный вход в CAMCORDER stereo; профиль сохраняется до последней подписки. + Смена устройства сохраняет подписки, камеру, видеокодер, source_epoch и временную шкалу эфира. + MediaCaptureTimeline выдаёт 20ms PCM в общей сетке с запасом 200ms; аппаратные timestamps задают положение звука. + Отсутствующий PCM заменяется тишиной. Запоздавшие данные отбрасываются, а не получают новые PTS. + Подтверждаются фактический маршрут, свежий PCM и отсутствие системного silencing. При ошибке — диагностика + и резервный телефонный профиль; при полном отказе входа публикация продолжает видео с тишиной. + Последний потребитель закрывает вход; выходы живут до удаления своих AudioTrack. Просмотр не открывает микрофон. + Окно VAD/экран/разрешения не обходятся общим захватом. Ошибки видны в общем меню выбора устройства. Звонок и voice recording по-прежнему исключают публикацию в обе стороны. + Аппаратная проверка (debug APK, RECORD_AUDIO разрешён): + adb shell am instrument -w com.utun.chat/com.utun.chat.data.MediaAudioProbe Звук трансляции и рация (Android и desktop): - Во время публикации с микрофоном звук можно выключить и включить. Заглушается только копия PCM diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamGridScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamGridScreen.kt index 112f6258..b3d63a78 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamGridScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamGridScreen.kt @@ -1,5 +1,7 @@ package com.utun.chat.ui.screens +import com.utun.chat.ui.components.MediaAudioDeviceButton + import android.os.SystemClock import androidx.activity.compose.BackHandler import androidx.compose.foundation.background @@ -110,7 +112,8 @@ internal fun StreamGridScreen(group: Long, title: String, sources: List Column(Modifier.fillMaxSize().padding(padding).background(Color.Black)) { if (unavailable && focused == null) @@ -185,6 +188,9 @@ private fun StreamGridTile(group: Long, tile: GridStream, visible: Boolean, expa role = Role.Button, onClickLabel = stringResource(if (tile.source.self) R.string.stream_manage else if (expanded) R.string.collapse else R.string.expand), onClick = onClick) .semantics { contentDescription = tile.source.displayName }) + if (expanded) Box(Modifier.align(Alignment.TopEnd).windowInsetsPadding(WindowInsets.safeDrawing)) { + MediaAudioDeviceButton(tint = Color.White) + } } } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamViewerScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamViewerScreen.kt index 1691995c..38dfaf17 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamViewerScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamViewerScreen.kt @@ -1,5 +1,7 @@ package com.utun.chat.ui.screens +import com.utun.chat.ui.components.MediaAudioDeviceButton + import android.app.Activity import android.content.ContextWrapper import android.graphics.SurfaceTexture @@ -68,8 +70,9 @@ internal fun StreamViewerScreen(group: Long, source: Long, epoch: Long, title: S onClose() } } - LaunchedEffect(controls, status, visible, hideTick) { - if (controls && status == R.string.stream_live && visible) { delay(3000); controls = false } + var audioMenu by remember { mutableStateOf(false) } + LaunchedEffect(controls, status, visible, hideTick, audioMenu) { + if (controls && status == R.string.stream_live && visible && !audioMenu) { delay(3000); controls = false } } LaunchedEffect(controls) { LogManager.addLog("DEBUG", "stream", "viewer controls source=$source visible=$controls") } LaunchedEffect(zoom.scale, zoom.offset) { @@ -116,6 +119,7 @@ internal fun StreamViewerScreen(group: Long, source: Long, epoch: Long, title: S Text(name, color = Color.White, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) Text(stringResource(status), color = Color.White, style = MaterialTheme.typography.bodySmall) } + MediaAudioDeviceButton(tint = Color.White, onExpandedChange = { audioMenu = it }) IconButton(onClick = onClose) { Icon(Icons.Filled.Close, stringResource(R.string.stream_close_view), tint = Color.White) } } } From 2b63c680b2f8976be6a0b36e9afc746ea0cb04c9 Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 22 Oct 2026 15:07:26 +0200 Subject: [PATCH 08/11] Verify shared Android audio ownership on a physical device --- doc/service_lifecycle.md | 14 +- .../app/src/debug/AndroidManifest.xml | 5 + .../com/utun/chat/data/MediaAudioProbe.kt | 124 ++++++++++++++++++ 3 files changed, 139 insertions(+), 4 deletions(-) create mode 100644 tools/chatgui-android/app/src/debug/java/com/utun/chat/data/MediaAudioProbe.kt diff --git a/doc/service_lifecycle.md b/doc/service_lifecycle.md index 44e0947d..1c3f1b23 100644 --- a/doc/service_lifecycle.md +++ b/doc/service_lifecycle.md @@ -225,10 +225,16 @@ Microphone lease публикации исключает call/recorder; раци Если первой работала рация, публикация заменяет профиль захвата своим; после остановки публикации оставшаяся рация продолжает тот же захват. Recovery останавливает callbacks, workers TX и один раз открывает общий вход; RX рации сохраняется. Каждый потребитель обрабатывает собственную копию PCM. -На Android `MicrophoneCapture` владеет AudioRecord и reader-потоком, подписчики — ограниченными очередями. -Stop публикации удаляет её подписку до join Opus worker и FIN; AudioRecord остаётся живым для рации. -PTT и PCM рации проходят через одну очередь. Последний подписчик делает stop/read join/release; -приоритет глобального AudioManager сохраняется до освобождения трансляционного захвата. +На Android `MediaAudioController` владеет общим выбором устройства, AudioManager и одним аппаратным reader. +Маршрутизация использует `CallAudioRouter`; AudioTrack рации и микшера просмотра регистрируются у одного владельца. +`MicrophoneCapture` раздаёт независимые ограниченные очереди постоянного PCM 48k stereo; моно апмиксуется. +После подключения публикации встроенный микрофон использует CAMCORDER, сохраняемый до последней подписки. +Смена профиля заменяет только аппаратный AudioRecord: камера, видеокодер и source_epoch продолжают работать. +Общая сетка по capture timestamps имеет запас 200ms; пропуски заполняются тишиной, старый PCM не переносится вперёд. +Stop публикации удаляет её подписку до join Opus worker и FIN, сохраняя захват для оставшейся рации. +PTT и PCM рации проходят через одну очередь. Последняя подписка завершает общий reader; +контроллер закрывает аппаратный вход, сохраняя выходы. Перед Telecom все владельцы снимаются, +а `MediaAudioController.awaitIdle()` дожидается освобождения входа и глобального маршрута. Ограничения фонового захвата рации, её разрешения и foreground-сервис продолжают действовать. Fatal ошибка audio worker передаётся в GUI с session ID и завершает всю публикацию; откат видеоконфигурации применяется только к video/capture. Поздний отказ старого publisher diff --git a/tools/chatgui-android/app/src/debug/AndroidManifest.xml b/tools/chatgui-android/app/src/debug/AndroidManifest.xml index 32a234df..153bf663 100644 --- a/tools/chatgui-android/app/src/debug/AndroidManifest.xml +++ b/tools/chatgui-android/app/src/debug/AndroidManifest.xml @@ -1,4 +1,9 @@ + + + + diff --git a/tools/chatgui-android/app/src/debug/java/com/utun/chat/data/MediaAudioProbe.kt b/tools/chatgui-android/app/src/debug/java/com/utun/chat/data/MediaAudioProbe.kt new file mode 100644 index 00000000..c7c0264d --- /dev/null +++ b/tools/chatgui-android/app/src/debug/java/com/utun/chat/data/MediaAudioProbe.kt @@ -0,0 +1,124 @@ +package com.utun.chat.data + +import android.app.Activity +import android.app.Instrumentation +import android.content.Intent +import android.media.AudioAttributes +import android.media.AudioFormat +import android.media.AudioManager +import android.media.AudioTrack +import android.os.Bundle +import android.widget.TextView + +/** Явная аппаратная проверка через adb am instrument. PCM остаётся в памяти; сеть и камера не используются. */ +class MediaAudioProbeActivity : Activity() { + override fun onCreate(state: Bundle?) { + super.onCreate(state) + if (android.os.Build.VERSION.SDK_INT >= 27) { setShowWhenLocked(true); setTurnScreenOn(true) } + else window.addFlags(android.view.WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or + android.view.WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON) + setContentView(TextView(this).apply { text = "Shared media audio test" }) + } +} + +class MediaAudioProbe : Instrumentation() { + override fun onCreate(arguments: Bundle?) { super.onCreate(arguments); start() } + override fun onStart() { + var activity: Activity? = null + var radio: MicrophoneCapture.Subscription? = null + var stream: MicrophoneCapture.Subscription? = null + var track: AudioTrack? = null + var output: AutoCloseable? = null + var writer: Thread? = null + val writing = java.util.concurrent.atomic.AtomicBoolean(true) + var saved: MediaAudioProfile? = null + var problem: Exception? = null + try { + activity = startActivitySync(Intent(targetContext, MediaAudioProbeActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + check(MicrophonePermission.granted()) { "RECORD_AUDIO is required for the explicit microphone probe" } + MediaAudioController.awaitIdle() + saved = MediaAudioController.state.value.preferred + MediaAudioController.select(MediaAudioProfile.PHONE) + MediaAudioController.awaitIdle() + val sink = AudioTrack.Builder().setAudioAttributes(AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA).build()) + .setAudioFormat(AudioFormat.Builder().setSampleRate(48000).setChannelMask(AudioFormat.CHANNEL_OUT_STEREO) + .setEncoding(AudioFormat.ENCODING_PCM_16BIT).build()) + .setBufferSizeInBytes(15360).setTransferMode(AudioTrack.MODE_STREAM).build() + track = sink + output = MediaAudioController.attachOutput(sink) + sink.play() + writer = Thread({ + val silence = ShortArray(960) + while (writing.get()) { + if (sink.write(silence, 0, silence.size) < 0) break + Thread.sleep(10) + } + }, "media-probe-output").apply { start() } + val first = MediaAudioController.subscribe("radio"); radio = first + repeat(15) { frame(first) } + val second = MediaAudioController.subscribe("stream"); stream = second + var previous = -1L + for (profile in listOf(MediaAudioProfile.PHONE, MediaAudioProfile.AUTO, MediaAudioProfile.PHONE)) { + MediaAudioController.select(profile) + val until = System.nanoTime() + 10_000_000_000L + var count = 0 + do { + val a = frame(second) + if (previous >= 0) check(a.timeNs == previous + 20_000_000L) { "Non-contiguous stream PTS $previous -> ${a.timeNs}" } + previous = a.timeNs + var b = frame(first) + while (b.sequence < a.sequence) b = frame(first) + check(a.sequence == b.sequence && a.timeNs == b.timeNs && a.pcm.contentEquals(b.pcm)) { "Consumers received different capture" } + val unchanged = b.pcm.copyOf() + a.pcm.fill(123) + check(b.pcm.contentEquals(unchanged)) { "PCM copies are shared" } + check(System.nanoTime() < until) { "Profile did not settle: ${MediaAudioController.state.value}" } + count++ + } while (count < 30 || MediaAudioController.state.value.switching) + check(MediaAudioController.state.value.notice == null && MediaAudioController.state.value.inputName != null) { + "Microphone was not confirmed: ${MediaAudioController.state.value}" + } + android.util.Log.i("MediaAudioProbe", "profile verified=$profile frames=$count state=${MediaAudioController.state.value}") + } + first.close(); radio = null + val next = frame(second) + check(next.timeNs == previous + 20_000_000L) { "Closing radio interrupted stream capture" } + val remaining = MediaAudioController.subscribe("radio"); radio = remaining + val beforeDetach = frame(remaining) + second.close(); stream = null + val afterDetach = frame(remaining) + check(afterDetach.timeNs == beforeDetach.timeNs + 20_000_000L && afterDetach.generation == beforeDetach.generation) { + "Closing stream restarted the remaining radio microphone" + } + remaining.close(); radio = null + // Оставшийся output продолжает принадлежать общей подсистеме после закрытия последнего input. + Thread.sleep(250) + check(sink.playState == AudioTrack.PLAYSTATE_PLAYING) { "Closing input stopped shared output" } + } catch (e: Exception) { + problem = e + android.util.Log.e("MediaAudioProbe", "probe failed", e) + } finally { + try { + radio?.close(); stream?.close() + writing.set(false) + track?.stop(); writer?.join() + output?.close(); track?.release() + MediaAudioController.awaitIdle() + saved?.let { MediaAudioController.select(it); MediaAudioController.awaitIdle() } + runOnMainSync { activity?.finish() } + } catch (e: Exception) { + android.util.Log.e("MediaAudioProbe", "cleanup failed", e) + if (problem == null) problem = e + } + } + finish(if (problem == null) 0 else 1, Bundle().apply { + putString("result", problem?.toString() ?: "Shared PCM, CAMCORDER, output route and continuous PTS verified") + }) + } + + private fun frame(subscription: MicrophoneCapture.Subscription): MicrophoneCapture.Frame { + val deadline = System.nanoTime() + 3_000_000_000L + while (System.nanoTime() < deadline) (subscription.poll() as? MicrophoneCapture.Frame)?.let { return it } + error("Shared microphone frame timeout") + } +} From c9a3a1334b773bd7195e20bdbd273d0d95b64fec Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 22 Oct 2026 15:24:13 +0200 Subject: [PATCH 09/11] Restore shared media playback to speaker after headset loss --- .../app/src/main/java/com/utun/chat/data/MediaAudioController.kt | 1 + 1 file changed, 1 insertion(+) 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 index e64b13ba..5a01d730 100644 --- 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 @@ -56,6 +56,7 @@ object MediaAudioController { init { // Наблюдение не занимает микрофон и не меняет глобальный маршрут без потребителей. + router.fallbackRoute = AudioRoute.SPEAKER router.setGlobalRouting(false) router.onChanged = { _, _ -> refresh() } router.onInputDevicesChanged = { _, _ -> refresh() } From bb380441a35f733bd41207f93e19fccc206baf73 Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 22 Oct 2026 16:53:25 +0200 Subject: [PATCH 10/11] Fix shared microphone crackle from timestamp jitter --- .../com/utun/chat/data/MediaAudioProbe.kt | 12 +- .../utun/chat/data/MediaAudioController.kt | 16 ++- .../utun/chat/data/MediaCaptureTimeline.kt | 126 ++++++++++++++---- .../chat/data/MediaCaptureTimelineTest.kt | 93 ++++++++++++- 4 files changed, 216 insertions(+), 31 deletions(-) diff --git a/tools/chatgui-android/app/src/debug/java/com/utun/chat/data/MediaAudioProbe.kt b/tools/chatgui-android/app/src/debug/java/com/utun/chat/data/MediaAudioProbe.kt index c7c0264d..5e9591a6 100644 --- a/tools/chatgui-android/app/src/debug/java/com/utun/chat/data/MediaAudioProbe.kt +++ b/tools/chatgui-android/app/src/debug/java/com/utun/chat/data/MediaAudioProbe.kt @@ -74,12 +74,22 @@ class MediaAudioProbe : Instrumentation() { check(b.pcm.contentEquals(unchanged)) { "PCM copies are shared" } check(System.nanoTime() < until) { "Profile did not settle: ${MediaAudioController.state.value}" } count++ - } while (count < 30 || MediaAudioController.state.value.switching) + } while (count < 300 || MediaAudioController.state.value.switching) check(MediaAudioController.state.value.notice == null && MediaAudioController.state.value.inputName != null) { "Microphone was not confirmed: ${MediaAudioController.state.value}" } android.util.Log.i("MediaAudioProbe", "profile verified=$profile frames=$count state=${MediaAudioController.state.value}") } + // Непрерывный аппаратный участок длиннее окна диагностической сводки PCM clock. + repeat(300) { + val a = frame(second) + check(a.timeNs == previous + 20_000_000L) { "Steady capture PTS discontinuity" } + previous = a.timeNs + var b = frame(first) + while (b.sequence < a.sequence) b = frame(first) + check(a.sequence == b.sequence && a.pcm.contentEquals(b.pcm)) { "Steady consumers differ" } + } + android.util.Log.i("MediaAudioProbe", "steady hardware capture verified frames=300") first.close(); radio = null val next = frame(second) check(next.timeNs == previous + 20_000_000L) { "Closing radio interrupted stream capture" } 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 index 5a01d730..744c049c 100644 --- 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 @@ -281,9 +281,11 @@ object MediaAudioController { /** Постоянный stereo-вход с запасом времени на аппаратный reader и смену маршрута. */ private class InputBridge : MicrophoneCapture.Device { private val lock = Object() - private val timeline = MediaCaptureTimeline(System.nanoTime()) + private val timeline = MediaCaptureTimeline(System.nanoTime()) { level, message -> LogManager.addLog(level, "media_audio", message) } private var running = true private var reportedLate = 0L + private var reportedMissing = 0L + private var reportNs = 0L override val channels = 2 override val inputId: Int? get() = null fun reset(epoch: Long) = synchronized(lock) { timeline.reset(epoch) } @@ -299,7 +301,17 @@ object MediaAudioController { while (running) { val now = System.nanoTime() val wait = timeline.deadlineNs - now - if (wait <= 0) return@synchronized timeline.pull(now) + if (wait <= 0) { + val frame = timeline.pull(now) + if (now - reportNs >= 5_000_000_000L || (reportedMissing == 0L && timeline.missingFrames > 0)) { + val level = if (timeline.missingFrames > reportedMissing) "WARN" else "DEBUG" + LogManager.addLog(level, "media_audio", "PCM clock generation=${timeline.generation} " + + "jitterMaxUs=${timeline.timestampJitterMaxNs / 1000} jumps=${timeline.timestampJumps} ratePpm=${timeline.ratePpm} " + + "missingFrames=${timeline.missingFrames} silenceFrames=${timeline.silentFrames} lateBlocks=${timeline.lateFrames}") + reportedMissing = timeline.missingFrames; reportNs = now + } + return@synchronized frame + } lock.wait(wait / 1_000_000, (wait % 1_000_000).toInt()) } error("Shared microphone stopped") diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/MediaCaptureTimeline.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/MediaCaptureTimeline.kt index a8011be0..62853dab 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/MediaCaptureTimeline.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/MediaCaptureTimeline.kt @@ -1,39 +1,100 @@ package com.utun.chat.data import java.util.ArrayDeque +import kotlin.math.abs +import kotlin.math.floor import kotlin.math.roundToInt +import kotlin.math.roundToLong -/** Общая сетка PCM 48k stereo. Аппаратные timestamps определяют положение отсчётов; - * отсутствующие участки — тишина. Никакого переноса старого звука на новое время. - * Все методы вызываются под lock владельца. */ -internal class MediaCaptureTimeline(val originNs: Long) { +/** Общая сетка PCM 48k stereo. Внутри поколения отсчёты идут по sequence, без перекладки + * отдельных блоков по шумным timestamps. Медленный дрейф часов компенсируется непрерывной + * интерполяцией; только реальные пропуски дают тишину. Все методы — под lock владельца. */ +internal class MediaCaptureTimeline(val originNs: Long, private val log: (String, String) -> Unit = { _, _ -> }) { companion object { const val FRAMES = 960 const val PERIOD_NS = 20_000_000L const val RESERVE_NS = 200_000_000L + private const val NS_PER_FRAME = 1_000_000_000.0 / 48000 } private val pending = ArrayDeque() private var position = 0L private var lastInputNs = -1L + private var lastSequence = -1L + private var lastCreatedNs = 0L + private var anchorNs = 0L + private var anchorSequence = 0L + private var startPosition = 0L + private var anchored = false + private var cursor = Double.NaN + private var clockOffset = 0.0 + private var timestampBiasNs = 0L var generation = 0L private set var lateFrames = 0L private set var silentFrames = 0L private set + var missingFrames = 0L + private set + var timestampJitterMaxNs = 0L + private set + var timestampJumps = 0L + private set + var ratePpm = 0 + private set val nextTimeNs: Long get() = originNs + position * 1_000_000_000 / 48000 val deadlineNs: Long get() = nextTimeNs + PERIOD_NS + RESERVE_NS fun reset(epoch: Long) { - generation = epoch; pending.clear(); lastInputNs = -1 + generation = epoch; pending.clear(); lastInputNs = -1; lastSequence = -1; lastCreatedNs = 0 + anchored = false; cursor = Double.NaN; clockOffset = 0.0; ratePpm = 0; timestampJitterMaxNs = 0; timestampBiasNs = 0; timestampJumps = 0 } fun offer(frame: MicrophoneCapture.Frame): Boolean { - require(frame.pcm.size == FRAMES * 2) - if (frame.generation != generation || frame.timeNs <= lastInputNs) return false - lastInputNs = frame.timeNs - if (frame.timeNs + PERIOD_NS <= nextTimeNs) { lateFrames++; return false } - if (pending.size >= 24) { lateFrames++; return false } + require(frame.pcm.size == FRAMES * 2 && frame.sequence >= 0 && frame.timeNs >= 0) + if (frame.generation != generation) return false + if (lastSequence >= 0) { + if (frame.sequence <= lastSequence) { + log("WARN", "microphone stale PCM generation=$generation sequence=${frame.sequence}/$lastSequence time=${frame.timeNs}/$lastInputNs") + return false + } + val elapsed = (frame.sequence - lastSequence) * 1_000_000_000 / 48000 + val jitter = frame.timeNs - lastInputNs - elapsed + timestampJitterMaxNs = maxOf(timestampJitterMaxNs, abs(jitter)) + // Отличаем перестановку timestamp от остановки аппаратного reader при запуске Bluetooth. + // Свежий звук после реальной остановки привязываем заново, не воспроизводя старую очередь. + if (abs(jitter) > PERIOD_NS * 2) { + val deliveryGap = frame.createdNs - lastCreatedNs - elapsed + val latePcm = !cursor.isNaN() && frame.sequence + FRAMES <= cursor + val reanchor = frame.timeNs + PERIOD_NS > nextTimeNs && (deliveryGap > PERIOD_NS * 2 || latePcm) + if (reanchor) { pending.clear(); anchored = false; cursor = Double.NaN; clockOffset = 0.0; timestampBiasNs = 0 } + else timestampBiasNs += jitter + timestampJumps++ + if (timestampJumps == 1L || timestampJumps % 50L == 0L) + log("WARN", "microphone timestamp jump generation=$generation deltaNs=$jitter deliveryGapNs=$deliveryGap " + + "late=$latePcm reanchor=$reanchor count=$timestampJumps") + } + if (frame.sequence != lastSequence + FRAMES) + log("WARN", "microphone sequence gap generation=$generation expected=${lastSequence + FRAMES} actual=${frame.sequence}") + } + lastInputNs = frame.timeNs; lastSequence = frame.sequence; lastCreatedNs = frame.createdNs + val late = if (anchored && !cursor.isNaN()) frame.sequence + FRAMES <= cursor else frame.timeNs + PERIOD_NS <= nextTimeNs + if (late || pending.size >= 24) { + lateFrames++ + if (lateFrames == 1L || lateFrames % 50L == 0L) + log("WARN", "microphone PCM rejected generation=$generation late=$late queue=${pending.size} " + + "sequence=${frame.sequence} cursor=$cursor ageUs=${(frame.createdNs - frame.timeNs) / 1000} count=$lateFrames") + return false + } + if (!anchored) { + anchorNs = frame.timeNs; anchorSequence = frame.sequence + startPosition = ((anchorNs - originNs) / NS_PER_FRAME).roundToLong() + anchored = true + log("INFO", "microphone clock anchored generation=$generation sequence=$anchorSequence position=$startPosition") + } + // Фильтр около 1с подавляет джиттер измерений; фазовая ошибка не двигает уже прочитанный PCM. + val measured = (frame.timeNs - anchorNs - timestampBiasNs) / NS_PER_FRAME - (frame.sequence - anchorSequence) + clockOffset += (measured - clockOffset) * 0.02 pending.addLast(frame) return true } @@ -42,24 +103,37 @@ internal class MediaCaptureTimeline(val originNs: Long) { check(nowNs >= deadlineNs) val start = nextTimeNs val pcm = ShortArray(FRAMES * 2) - var filled = 0 - while (pending.isNotEmpty()) { - val frame = pending.first - val offset = ((frame.timeNs - start).toDouble() * 48000 / 1_000_000_000).roundToInt() - if (offset >= FRAMES) break - val source = maxOf(0, -offset) - val target = maxOf(0, offset, filled) - val skip = target - maxOf(0, offset) - val count = minOf(FRAMES - source - skip, FRAMES - target) - if (count > 0) { - frame.pcm.copyInto(pcm, target * 2, (source + skip) * 2, (source + skip + count) * 2) - silentFrames += target - filled - filled = target + count + val leading = if (anchored) (startPosition - position).coerceIn(0, FRAMES.toLong()).toInt() else FRAMES + if (anchored && leading < FRAMES && cursor.isNaN()) cursor = anchorSequence + maxOf(0, position - startPosition).toDouble() + var step = 1.0 + if (!cursor.isNaN()) { + val error = anchorSequence + (position + leading - startPosition).toDouble() - clockOffset - cursor + // 1мс мёртвой зоны: обычный джиттер не меняет PCM. Коррекция дрейфа ограничена ±0.2%. + val correction = if (abs(error) <= 48) 0.0 else (error - if (error > 0) 48 else -48) / 96000 + step += correction.coerceIn(-0.002, 0.002) + } + ratePpm = ((step - 1) * 1_000_000).roundToInt() + for (i in 0 until FRAMES) { + if (i < leading) { silentFrames++; continue } + val sample = floor(cursor).toLong() + while (pending.isNotEmpty() && pending.first.sequence + FRAMES <= sample) pending.removeFirst() + val frame = pending.peekFirst() + val offset = if (frame == null) -1 else sample - frame.sequence + if (frame == null || offset < 0 || offset >= FRAMES) { + silentFrames++; missingFrames++ + } else { + val index = offset.toInt() + val fraction = cursor - sample + val next = if (index + 1 < FRAMES) frame else pending.elementAtOrNull(1)?.takeIf { it.sequence == frame.sequence + FRAMES } + val nextIndex = if (index + 1 < FRAMES) index + 1 else 0 + for (channel in 0..1) { + val a = frame.pcm[index * 2 + channel].toInt() + val b = next?.pcm?.get(nextIndex * 2 + channel)?.toInt() ?: a + pcm[i * 2 + channel] = (a + (b - a) * fraction).roundToInt().toShort() + } } - if (offset + FRAMES > FRAMES) break - pending.removeFirst() + cursor += step } - silentFrames += FRAMES - filled return MicrophoneCapture.Frame(pcm, start, position, nowNs, generation).also { position += FRAMES } } } diff --git a/tools/chatgui-android/app/src/test/java/com/utun/chat/data/MediaCaptureTimelineTest.kt b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/MediaCaptureTimelineTest.kt index 461d6fab..95768340 100644 --- a/tools/chatgui-android/app/src/test/java/com/utun/chat/data/MediaCaptureTimelineTest.kt +++ b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/MediaCaptureTimelineTest.kt @@ -5,8 +5,8 @@ import org.junit.Test class MediaCaptureTimelineTest { private val origin = 1_000_000_000L - private fun frame(time: Long, value: Int, epoch: Long = 1) = - MicrophoneCapture.Frame(ShortArray(1920) { value.toShort() }, time, 0, generation = epoch) + private fun frame(time: Long, value: Int, epoch: Long = 1, sequence: Long = (time - origin) * 48000 / 1_000_000_000) = + MicrophoneCapture.Frame(ShortArray(1920) { value.toShort() }, time, sequence, generation = epoch) private fun pull(timeline: MediaCaptureTimeline) = timeline.pull(timeline.deadlineNs) @Test fun switchingKeepsTimeAndSequenceAndRejectsOldDevice() { @@ -68,4 +68,93 @@ class MediaCaptureTimelineTest { assertFalse(timeline.offer(frame(origin + 480_000_000L, 2))) assertEquals(1L, timeline.lateFrames) } + + @Test fun timestampJitterDoesNotInsertSilenceOrChangeAnySample() { + for (jitter in listOf(20_000L, 100_000L, 350_000L)) { + val timeline = MediaCaptureTimeline(origin) + timeline.reset(1) + repeat(510) { index -> + val pcm = ShortArray(1920) { ((index * 1920 + it) % 30000 + 1).toShort() } + val offset = if (index == 0) 0 else if (index % 2 == 0) jitter else -jitter + assertTrue(timeline.offer(MicrophoneCapture.Frame(pcm, origin + index * 20_000_000L + offset, + index * 960L, generation = 1))) + if (index >= 10) { + val output = pull(timeline) + val expected = ShortArray(1920) { (((index - 10) * 1920 + it) % 30000 + 1).toShort() } + assertArrayEquals("jitter=$jitter block=$index", expected, output.pcm) + assertEquals(0L, timeline.missingFrames) + assertEquals(0, timeline.ratePpm) + } + } + } + } + + @Test fun actualSequenceGapIsSilenceAndLaterSoundKeepsItsPosition() { + val timeline = MediaCaptureTimeline(origin) + timeline.reset(1) + assertTrue(timeline.offer(frame(origin, 11))) + assertTrue(timeline.offer(frame(origin + 40_000_000, 22))) + assertTrue(pull(timeline).pcm.all { it == 11.toShort() }) + assertTrue(pull(timeline).pcm.all { it == 0.toShort() }) + assertTrue(pull(timeline).pcm.all { it == 22.toShort() }) + assertEquals(960L, timeline.missingFrames) + } + + @Test fun slowClockDriftIsResampledWithoutHolesOrSharpEdges() { + for (ppm in listOf(-300.0, 300.0)) { + val timeline = MediaCaptureTimeline(origin) + timeline.reset(1) + var sent = 0 + var previous = 0 + repeat(15000) { output -> + val until = output * 20_000_000L + MediaCaptureTimeline.RESERVE_NS + while (sent * 20_000_000.0 * (1 + ppm / 1_000_000) <= until) { + val pcm = ShortArray(1920) { channel -> + (10000 + 4000 * kotlin.math.sin(2 * Math.PI * 200 * (sent * 960L + channel / 2) / 48000)).toInt().toShort() + } + assertTrue(timeline.offer(MicrophoneCapture.Frame(pcm, + origin + (sent * 20_000_000.0 * (1 + ppm / 1_000_000)).toLong(), sent * 960L, generation = 1))) + sent++ + } + val frame = pull(timeline) + for (i in 0 until 960) { + val sample = frame.pcm[i * 2].toInt() + assertTrue("zero/hole ppm=$ppm block=$output", sample >= 5999) + if (previous != 0) assertTrue("click ppm=$ppm block=$output", kotlin.math.abs(sample - previous) <= 110) + assertEquals(frame.pcm[i * 2], frame.pcm[i * 2 + 1]) + previous = sample + } + assertEquals(0L, timeline.missingFrames) + } + assertTrue("clock correction ppm=$ppm actual=${timeline.ratePpm}", kotlin.math.abs(timeline.ratePpm + ppm) < 20) + } + } + + @Test fun bluetoothTimestampJumpsDoNotInterruptContinuousPcm() { + val timeline = MediaCaptureTimeline(origin) + timeline.reset(1) + assertTrue(timeline.offer(frame(origin, 11))) + assertTrue(timeline.offer(frame(origin + 247_000_000, 22, sequence = 960))) + assertTrue(timeline.offer(frame(origin + 40_000_000, 33, sequence = 1920))) + assertTrue(pull(timeline).pcm.all { it == 11.toShort() }) + assertTrue(pull(timeline).pcm.all { it == 22.toShort() }) + assertTrue(pull(timeline).pcm.all { it == 33.toShort() }) + assertEquals(2L, timeline.timestampJumps) + assertEquals(0L, timeline.missingFrames) + assertEquals(0, timeline.ratePpm) + } + + @Test fun realCaptureStallReanchorsFreshPcmInsteadOfRejectingEveryFollowingBlock() { + val timeline = MediaCaptureTimeline(origin) + timeline.reset(1) + assertTrue(timeline.offer(MicrophoneCapture.Frame(ShortArray(1920) { 11 }, origin, 0, origin + 20_000_000, 1))) + assertTrue(pull(timeline).pcm.all { it == 11.toShort() }) + repeat(10) { pull(timeline) } + assertTrue(timeline.offer(MicrophoneCapture.Frame(ShortArray(1920) { 22 }, origin + 240_000_000, + 960, origin + 260_000_000, 1))) + assertTrue(pull(timeline).pcm.all { it == 0.toShort() }) + assertTrue(pull(timeline).pcm.all { it == 22.toShort() }) + assertEquals(1L, timeline.timestampJumps) + assertEquals(0L, timeline.lateFrames) + } } From 6a1c289ab2bd1ee941673e074101f6409d94c54a Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 22 Oct 2026 17:59:11 +0200 Subject: [PATCH 11/11] Keep Bluetooth SCO active for radio reception without capture --- doc/tasks.md | 17 ++++++ .../com/utun/chat/data/MediaAudioProbe.kt | 55 ++++++++++++++++++- .../utun/chat/data/MediaAudioController.kt | 21 +++++-- .../com/utun/chat/data/RadioAudioEngine.kt | 2 +- 4 files changed, 87 insertions(+), 8 deletions(-) diff --git a/doc/tasks.md b/doc/tasks.md index 78ddade9..fcff6491 100644 --- a/doc/tasks.md +++ b/doc/tasks.md @@ -4,6 +4,23 @@ ## Текущие задачи +[+] **Android: приём рации через SCO после отпускания PTT** — исправлено 22.10.2026: + Общий MediaAudioController сохраняет разговорный тракт для SCO-only выхода, даже без подписчиков микрофона. + AudioTrack рации снова использует USAGE_VOICE_COMMUNICATION: Android учитывает playback как разговорную активность. + Добавлены логи фактического routedDevice/mode/SCO и аппаратная проверка после закрытия последнего input. + Samsung + C1 PRO: PHONE → Bluetooth → PHONE, 35с приёма без микрофона и холодный запуск SCO без PTT прошли. + На Q8 прошли общий захват/переключения/сохранение playback. JVM 145/145, сборки ядра и APK успешны. + +[ ] **Android: просмотр трансляции через SCO без работающей рации** — воспроизведено 22.10.2026: + Media-выход с USAGE_MEDIA после закрытия микрофона теряет SCO: mode=0, sco=false, + хотя общий контроллер продолжает владеть выходом и сам больше не закрывает разговорный тракт. + AudioService учитывает VOICE_COMMUNICATION playback/record для активности MODE_IN_COMMUNICATION. + Повтор: adb -s RZ8R72MG64B shell am instrument -w -e output media com.utun.chat/com.utun.chat.data.MediaAudioProbe. + Радио с USAGE_VOICE_COMMUNICATION проходит ту же проверку, media-вариант её не проходит. + Сохранить media-назначение/громкость обычных выходов; для SCO переключать аппаратный AudioTrack на VOICE_COMMUNICATION. + При пересоздании только аудиовыхода сохранить недописанный PCM, учесть уже записанный буфер и непрерывный played; + видео и его кодеки не перезапускать. Проверить оба направления смены выхода и длительный playback без микрофона. + [+] **media_async: файловые примитивы и точные контракты .h** — исправлено 22.10.2026: ma_copy_file проверяет существование/идентичность, сохраняет один regular-файл при ./ или hardlink; ссылки назначения отклоняются, копирование идёт через соседний temp с проверенными записью/close/publish. diff --git a/tools/chatgui-android/app/src/debug/java/com/utun/chat/data/MediaAudioProbe.kt b/tools/chatgui-android/app/src/debug/java/com/utun/chat/data/MediaAudioProbe.kt index 5e9591a6..ea7ba4b3 100644 --- a/tools/chatgui-android/app/src/debug/java/com/utun/chat/data/MediaAudioProbe.kt +++ b/tools/chatgui-android/app/src/debug/java/com/utun/chat/data/MediaAudioProbe.kt @@ -3,6 +3,9 @@ package com.utun.chat.data import android.app.Activity import android.app.Instrumentation import android.content.Intent +import android.media.AudioDeviceInfo +import android.content.Context +import android.os.Build import android.media.AudioAttributes import android.media.AudioFormat import android.media.AudioManager @@ -22,7 +25,10 @@ class MediaAudioProbeActivity : Activity() { } class MediaAudioProbe : Instrumentation() { - override fun onCreate(arguments: Bundle?) { super.onCreate(arguments); start() } + private var mediaOutput = false + override fun onCreate(arguments: Bundle?) { + super.onCreate(arguments); mediaOutput = arguments?.getString("output") == "media"; start() + } override fun onStart() { var activity: Activity? = null var radio: MicrophoneCapture.Subscription? = null @@ -40,7 +46,8 @@ class MediaAudioProbe : Instrumentation() { saved = MediaAudioController.state.value.preferred MediaAudioController.select(MediaAudioProfile.PHONE) MediaAudioController.awaitIdle() - val sink = AudioTrack.Builder().setAudioAttributes(AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA).build()) + val usage = if (mediaOutput) AudioAttributes.USAGE_MEDIA else AudioAttributes.USAGE_VOICE_COMMUNICATION + val sink = AudioTrack.Builder().setAudioAttributes(AudioAttributes.Builder().setUsage(usage).build()) .setAudioFormat(AudioFormat.Builder().setSampleRate(48000).setChannelMask(AudioFormat.CHANNEL_OUT_STEREO) .setEncoding(AudioFormat.ENCODING_PCM_16BIT).build()) .setBufferSizeInBytes(15360).setTransferMode(AudioTrack.MODE_STREAM).build() @@ -100,10 +107,54 @@ class MediaAudioProbe : Instrumentation() { check(afterDetach.timeNs == beforeDetach.timeNs + 20_000_000L && afterDetach.generation == beforeDetach.generation) { "Closing stream restarted the remaining radio microphone" } + // Повторяем обычное отпускание PTT именно на Bluetooth, сохраняя работающий выход. + if (MediaAudioController.state.value.bluetoothAvailable && !MediaAudioController.state.value.wiredAvailable) { + MediaAudioController.select(MediaAudioProfile.BLUETOOTH) + repeat(300) { frame(remaining) } + check(!MediaAudioController.state.value.switching && MediaAudioController.state.value.notice == null) { + "Bluetooth did not settle before receive-only test: ${MediaAudioController.state.value}" + } + } + val receiveOnlyType = sink.routedDevice?.type remaining.close(); radio = null // Оставшийся output продолжает принадлежать общей подсистеме после закрытия последнего input. Thread.sleep(250) check(sink.playState == AudioTrack.PLAYSTATE_PLAYING) { "Closing input stopped shared output" } + if (receiveOnlyType == AudioDeviceInfo.TYPE_BLUETOOTH_SCO) { + check(sink.routedDevice?.type == receiveOnlyType) { "Closing microphone changed SCO playback device" } + val manager = targetContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager + val until = System.nanoTime() + 35_000_000_000L + var head = sink.playbackHeadPosition + while (System.nanoTime() < until) { + check(manager.isBluetoothScoOn && (mediaOutput || manager.mode == AudioManager.MODE_IN_COMMUNICATION)) { + "Receive-only SCO stopped after microphone closed: mode=${manager.mode} sco=${manager.isBluetoothScoOn}" + } + if (Build.VERSION.SDK_INT >= 31) check(manager.communicationDevice?.id == sink.routedDevice?.id) { + "Receive-only SCO communication device differs from AudioTrack" + } + Thread.sleep(1000) + val nextHead = sink.playbackHeadPosition + check(nextHead != head) { "Receive-only SCO output stalled" }; head = nextHead + } + android.util.Log.i("MediaAudioProbe", "receive-only SCO verified usage=$usage seconds=35 device=${sink.routedDevice?.id} mode=${manager.mode}") + // Приём должен открывать SCO и без предшествующего PTT/AudioRecord. + MediaAudioController.select(MediaAudioProfile.PHONE) + var deadline = System.nanoTime() + 6_000_000_000L + while (sink.routedDevice?.type != AudioDeviceInfo.TYPE_BUILTIN_SPEAKER || manager.mode != AudioManager.MODE_NORMAL) { + check(System.nanoTime() < deadline) { "Speaker route did not release SCO" }; Thread.sleep(100) + } + MediaAudioController.select(MediaAudioProfile.BLUETOOTH) + deadline = System.nanoTime() + 30_000_000_000L + while (sink.routedDevice?.type != AudioDeviceInfo.TYPE_BLUETOOTH_SCO || !manager.isBluetoothScoOn || + (!mediaOutput && manager.mode != AudioManager.MODE_IN_COMMUNICATION) || MediaAudioController.state.value.switching) { + check(System.nanoTime() < deadline) { "Receive-only SCO did not start without microphone" }; Thread.sleep(100) + } + check(MediaAudioController.state.value.inputName == null) { "Receive-only routing opened a microphone" } + head = sink.playbackHeadPosition + Thread.sleep(2000) + check(head != sink.playbackHeadPosition && manager.isBluetoothScoOn) { "Cold SCO playback did not progress" } + android.util.Log.i("MediaAudioProbe", "receive-only SCO cold start verified usage=$usage device=${sink.routedDevice?.id} mode=${manager.mode}") + } } catch (e: Exception) { problem = e android.util.Log.e("MediaAudioProbe", "probe failed", e) 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 index 744c049c..772d9b35 100644 --- 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 @@ -107,7 +107,12 @@ object MediaAudioController { fun attachOutput(track: AudioTrack): AutoCloseable { io.submit { check(!CallController.blocksMedia()) { "Media output blocked by call" } - val listener = AudioRouting.OnRoutingChangedListener { refresh() } + val listener = AudioRouting.OnRoutingChangedListener { + val actual = track.routedDevice + LogManager.addLog("INFO", "media_audio", "output routed session=${track.audioSessionId} " + + "device=${actual?.id}/${actual?.type} mode=${am.mode} sco=${am.isBluetoothScoOn}") + refresh() + } check(track !in outputs) { "AudioTrack already registered" } outputs[track] = listener try { @@ -150,9 +155,11 @@ object MediaAudioController { 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 || + private fun isBluetoothMedia(type: Int) = type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP || (Build.VERSION.SDK_INT >= 31 && type == AudioDeviceInfo.TYPE_BLE_HEADSET) + private fun isBluetooth(type: Int) = type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO || isBluetoothMedia(type) + private fun reconcile(force: Boolean = false) { val devices = am.getDevices(AudioManager.GET_DEVICES_ALL).toList() val wired = devices.any { isWired(it.type) } @@ -179,8 +186,12 @@ object MediaAudioController { 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)) + // SCO-only гарнитуре разговорный тракт нужен и при одном playback, после отпускания PTT. + // При доступном A2DP/LE media-выходе возвращаем качественный media-тракт без микрофона. + val bluetoothMediaSink = devices.any { it.isSink && isBluetoothMedia(it.type) } + val voice = (active == MediaAudioProfile.BLUETOOTH && (capture != null || !bluetoothMediaSink)) || + (capture != null && ((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) { @@ -248,7 +259,7 @@ object MediaAudioController { 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}") + LogManager.addLog("INFO", "media_audio", "route confirmed generation=$generation mode=${am.mode} sco=${am.isBluetoothScoOn} state=${mutable.value}") } private fun outputsReady(tracks: Collection): Boolean = tracks.all { track -> diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt index b6abdf3b..ba3579be 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt @@ -241,7 +241,7 @@ class RadioAudioEngine { val track = try { AudioTrack( AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_MEDIA) + .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH).build(), AudioFormat.Builder().setSampleRate(SAMPLE_RATE) .setEncoding(AudioFormat.ENCODING_PCM_16BIT)