Browse Source

Validate Android input routes and isolate capture queue diagnostics

master
evgeny 3 days ago
parent
commit
051d453c2d
  1. 31
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/AndroidMicrophoneCapture.kt
  2. 16
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/MicrophoneCapture.kt
  3. 7
      tools/chatgui-android/app/src/test/java/com/utun/chat/data/MicrophoneCaptureTest.kt

31
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")

16
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<Subscription>()
@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() }
}

7
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<String>(); val capture = MicrophoneCapture(device, { logs.add(it) })
val device = Device(); val logs = LinkedBlockingQueue<String>()
val capture = MicrophoneCapture(device, { _, message -> logs.add(message) })
val stream = capture.subscribe("stream"); val radio = capture.subscribe("radio")
try {
repeat(40) {

Loading…
Cancel
Save