From 051d453c2d1a5fac438bfb64223a57922a300c2d Mon Sep 17 00:00:00 2001 From: evgeny Date: Fri, 16 Oct 2026 01:20:34 +0200 Subject: [PATCH] Validate Android input routes and isolate capture queue diagnostics --- .../chat/data/AndroidMicrophoneCapture.kt | 31 ++++++++++++++++--- .../com/utun/chat/data/MicrophoneCapture.kt | 16 +++++----- .../utun/chat/data/MicrophoneCaptureTest.kt | 7 +++-- 3 files changed, 38 insertions(+), 16 deletions(-) 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 d103b35e..14bd969f 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 @@ -5,17 +5,22 @@ import android.media.AudioFormat import android.media.AudioRecord 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 - private class Record(private val record: AudioRecord) : MicrophoneCapture.Device { + 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) @@ -26,20 +31,36 @@ object AndroidMicrophoneCapture { used += count } 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 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) } override fun stop() { running = false; record.stop() } - override fun close() { record.release() } + override fun close() { record.release(); if (isStream) streamStatus = null } } - fun own(record: AudioRecord, released: () -> Unit = {}): MicrophoneCapture { + fun own(record: AudioRecord, isStream: Boolean = false, released: () -> Unit = {}): MicrophoneCapture { try { record.startRecording() check(record.recordingState == AudioRecord.RECORDSTATE_RECORDING) { "Microphone did not start" } - return MicrophoneCapture(Record(record), { LogManager.addLog("WARN", "radio", it) }, released) + return MicrophoneCapture(Record(record, isStream), { level, message -> LogManager.addLog(level, "radio", message) }, released) } catch (e: Exception) { record.release(); throw e } } @@ -52,7 +73,7 @@ object AndroidMicrophoneCapture { if (record.state != AudioRecord.STATE_INITIALIZED || record.channelCount != 2) { record.release(); error("CAMCORDER stereo capture unavailable") } - val capture = own(record, released) + 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") 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 db1cd62d..89642c40 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,7 @@ import java.util.concurrent.TimeUnit /** Один аппаратный reader, независимые ограниченные очереди потребителей. PCM каждого потребителя — своя копия. * Закрытие подписки не останавливает соседей; последний владелец останавливает read, делает join и release. * PTT попадает в ту же очередь, что PCM, поэтому native TX всегда имеет одного последовательного владельца. */ -class MicrophoneCapture(private val device: Device, private val log: (String) -> Unit, private val released: () -> Unit = {}) { +class MicrophoneCapture(private val device: Device, private val log: (String, String) -> Unit, private val released: () -> Unit = {}) { interface Device { val channels: Int val inputId: Int? @@ -15,7 +15,7 @@ class MicrophoneCapture(private val device: Device, private val log: (String) -> fun close() } sealed interface Event - data class Frame(val pcm: ShortArray, val timeNs: Long, val sequence: Long) : Event + data class Frame(val pcm: ShortArray, val timeNs: Long, val sequence: Long, val createdNs: Long = System.nanoTime()) : Event data class Ptt(val pressed: Boolean) : Event private val consumers = mutableListOf() @Volatile private var reading = true @@ -35,14 +35,14 @@ class MicrophoneCapture(private val device: Device, private val log: (String) -> if (!attached) return if (events.size >= 24 || !events.offer(frame.copy(pcm = frame.pcm.copyOf()))) { dropped++ - if (dropped == 1L || dropped % 50L == 0L) log("capture queue overflow consumer=$name dropped=$dropped") + if (dropped == 1L || dropped % 50L == 0L) log("WARN", "capture queue overflow consumer=$name dropped=$dropped") } } /** Только управляющий поток; восемь мест зарезервированы под команды. */ @Synchronized fun ptt(pressed: Boolean): Boolean { if (!attached) return false if (events.offer(Ptt(pressed))) return true - log("capture control queue full consumer=$name pressed=$pressed") + log("ERROR", "capture control queue full consumer=$name pressed=$pressed") return false } fun poll(): Event? { @@ -67,21 +67,21 @@ class MicrophoneCapture(private val device: Device, private val log: (String) -> for (target in targets) target.deliver(frame) } } catch (e: Exception) { - if (reading) { failure = e; log("capture reader failed: ${e.message}") } + if (reading) { failure = e; log("ERROR", "capture reader failed: ${e.message}") } } }, "microphone-capture").apply { start() } - log("capture attached consumer=$name channels=$channels consumers=${consumers.size}") + log("INFO", "capture attached consumer=$name channels=$channels consumers=${consumers.size}") return subscription } private fun detach(subscription: Subscription) { val last = synchronized(this) { consumers.remove(subscription) - log("capture detached consumer=${subscription.name} remaining=${consumers.size}") + log("INFO", "capture detached consumer=${subscription.name} remaining=${consumers.size}") consumers.isEmpty().also { if (it) reading = false } } if (!last) return - try { device.stop() } catch (e: Exception) { log("capture stop failed: ${e.message}") } + try { device.stop() } catch (e: Exception) { log("WARN", "capture stop failed: ${e.message}") } worker?.join() try { device.close() } finally { released() } } diff --git a/tools/chatgui-android/app/src/test/java/com/utun/chat/data/MicrophoneCaptureTest.kt b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/MicrophoneCaptureTest.kt index 4b340a28..da9cc885 100644 --- a/tools/chatgui-android/app/src/test/java/com/utun/chat/data/MicrophoneCaptureTest.kt +++ b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/MicrophoneCaptureTest.kt @@ -22,7 +22,7 @@ class MicrophoneCaptureTest { } @Test fun consumersShareCaptureFormatAndTimeButOwnTheirSamples() { - val device = Device(); val capture = MicrophoneCapture(device, {}) + val device = Device(); val capture = MicrophoneCapture(device, { _, _ -> }) val stream = capture.subscribe("stream"); val radio = capture.subscribe("radio") try { device.input.add(MicrophoneCapture.Frame(shortArrayOf(10, 20), 987654321, 960)) @@ -38,7 +38,7 @@ class MicrophoneCaptureTest { } @Test fun stoppingRadioKeepsStreamAndPttIsOrderedWithPcm() { - val device = Device(); val capture = MicrophoneCapture(device, {}) + val device = Device(); val capture = MicrophoneCapture(device, { _, _ -> }) val stream = capture.subscribe("stream"); val radio = capture.subscribe("radio") try { assertTrue(radio.ptt(true)) @@ -53,7 +53,8 @@ class MicrophoneCaptureTest { } @Test fun slowConsumerHasBoundedQueueAndReservedControlCapacity() { - val device = Device(); val logs = LinkedBlockingQueue(); val capture = MicrophoneCapture(device, { logs.add(it) }) + val device = Device(); val logs = LinkedBlockingQueue() + val capture = MicrophoneCapture(device, { _, message -> logs.add(message) }) val stream = capture.subscribe("stream"); val radio = capture.subscribe("radio") try { repeat(40) {