diff --git a/doc/service_lifecycle.md b/doc/service_lifecycle.md
index 5f62de63..3219f22d 100644
--- a/doc/service_lifecycle.md
+++ b/doc/service_lifecycle.md
@@ -245,10 +245,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/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/doc/tasks.md b/doc/tasks.md
index ed93e8bd..ff582a69 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/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..ea7ba4b3
--- /dev/null
+++ b/tools/chatgui-android/app/src/debug/java/com/utun/chat/data/MediaAudioProbe.kt
@@ -0,0 +1,185 @@
+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
+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() {
+ 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
+ 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 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()
+ 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 < 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" }
+ 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"
+ }
+ // Повторяем обычное отпускание 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)
+ } 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")
+ }
+}
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/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/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/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..772d9b35
--- /dev/null
+++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/MediaAudioController.kt
@@ -0,0 +1,333 @@
+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.fallbackRoute = AudioRoute.SPEAKER
+ 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 {
+ 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 {
+ 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 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) }
+ 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
+ // 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) {
+ // Смена глобального тракта принадлежит только этому владельцу, до открытия нового 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 mode=${am.mode} sco=${am.isBluetoothScoOn} 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()) { 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) }
+ 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) {
+ 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")
+ }
+ override fun stop() = synchronized(lock) { running = false; lock.notifyAll() }
+ override fun close() {}
+ }
+}
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..62853dab
--- /dev/null
+++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/MediaCaptureTimeline.kt
@@ -0,0 +1,139 @@
+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. Внутри поколения отсчёты идут по 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; 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 && 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
+ }
+
+ fun pull(nowNs: Long): MicrophoneCapture.Frame {
+ check(nowNs >= deadlineNs)
+ val start = nextTimeNs
+ val pcm = ShortArray(FRAMES * 2)
+ 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()
+ }
+ }
+ cursor += step
+ }
+ 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/main/java/com/utun/chat/data/RadioAudioEngine.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt
index 1bdd953c..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
@@ -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
}
@@ -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/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/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")
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/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/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/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/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) }
}
}
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() {
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.
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..95768340
--- /dev/null
+++ b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/MediaCaptureTimelineTest.kt
@@ -0,0 +1,160 @@
+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, 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() {
+ 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)
+ }
+
+ @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)
+ }
+}
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)