Merge shared Android media audio routing and microphone fixes

This commit is contained in:
evgeny committed 2026-10-22 18:04:49 +02:00
commit 07cd150b47
25 files changed
+1146 -502

No files matched your search

+10 -4
View File
@@ -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
+15 -11
View File
@@ -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
+17
View File
@@ -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.
@@ -1,4 +1,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<instrumentation android:name="com.utun.chat.data.CallVideoProbe"
android:targetPackage="com.utun.chat" android:label="Call video codec probe" />
<instrumentation android:name="com.utun.chat.data.MediaAudioProbe"
android:targetPackage="com.utun.chat" android:label="Shared media audio probe" />
<application>
<activity android:name="com.utun.chat.data.MediaAudioProbeActivity" android:exported="false" />
</application>
</manifest>
@@ -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")
}
}
@@ -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}")
}
}
@@ -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,
@@ -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")
@@ -99,6 +99,7 @@ class ConfigProvider(private val context: Context) {
}
private val mutedChannelsKey = stringSetPreferencesKey("muted_channels")
private val callAudioRouteKey = stringPreferencesKey("call_audio_route")
private val mediaAudioProfileKey = stringPreferencesKey("media_audio_profile")
private val radioAutoEnabledKey = intPreferencesKey("radio_auto_enabled")
private val radioAutoModeKey = intPreferencesKey("radio_auto_mode")
private val radioLevelOnKey = intPreferencesKey("radio_level_on_dbfs")
@@ -109,7 +110,6 @@ class ConfigProvider(private val context: Context) {
private val radioVolumePttKey = booleanPreferencesKey("radio_volume_ptt")
private val radioOverlayKey = booleanPreferencesKey("radio_overlay")
private val radioMicSourceKey = intPreferencesKey("radio_mic_source")
private val radioInputSourceKey = intPreferencesKey("radio_input_source")
private val radioOpusModeKey = intPreferencesKey("radio_opus_mode")
private val radioOpusBitrateKey = intPreferencesKey("radio_opus_bitrate")
private val radioCaptureStereoKey = intPreferencesKey("radio_capture_stereo")
@@ -179,6 +179,16 @@ class ConfigProvider(private val context: Context) {
LogManager.addLog("INFO", "CallSettings", "Video bitrate range saved: ${range.encode()} kbps")
}
suspend fun getMediaAudioProfile(): MediaAudioProfile {
val value = context.dataStore.data.first()[mediaAudioProfileKey]
return MediaAudioProfile.entries.firstOrNull { it.name == value } ?: MediaAudioProfile.AUTO
}
suspend fun setMediaAudioProfile(profile: MediaAudioProfile) {
context.dataStore.edit { it[mediaAudioProfileKey] = profile.name }
LogManager.addLog("INFO", "media_audio", "common profile saved=$profile")
}
suspend fun saveStreamSettings(subblocks: Int, redundant: Int) {
require(subblocks in 1..255 && redundant in 0 until subblocks)
context.dataStore.edit { prefs ->
@@ -552,23 +562,6 @@ class ConfigProvider(private val context: Context) {
context.dataStore.edit { prefs -> prefs[radioMicSourceKey] = source }
}
@Volatile private var radioInputSourceCached = 0
@Volatile private var radioInputSourceLoaded = false
fun getRadioInputSource(): Int {
if (!radioInputSourceLoaded) {
radioInputSourceCached = runBlocking { context.dataStore.data.first()[radioInputSourceKey] ?: 0 }
radioInputSourceLoaded = true
}
return radioInputSourceCached
}
suspend fun setRadioInputSource(source: Int) {
radioInputSourceCached = source
radioInputSourceLoaded = true
context.dataStore.edit { prefs -> prefs[radioInputSourceKey] = source }
}
@Volatile private var radioCaptureStereoCached = 0
@Volatile private var radioCaptureStereoLoaded = false
@@ -0,0 +1,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<MediaAudioState> = mutable
private val inputLock = Any()
private var shared: MicrophoneCapture? = null // Только под inputLock.
private var bridge: InputBridge? = null // Только под inputLock.
private var record: AndroidMicrophoneCapture? = null
private var camcorderCapture: MicrophoneCapture? = null
private var inputReady = false
private var generation = 0L
private var routeKey: List<Any?>? = null
private var systemRoute: Pair<MediaAudioProfile, Boolean>? = null
private var preferred = MediaAudioProfile.AUTO
private var committed = preferred
private var forcedPhone = false
private var savePending = false
private var modeOwned = false
private val outputs = linkedMapOf<AudioTrack, AudioRouting.OnRoutingChangedListener>()
private var wantedOutput: AudioDeviceInfo? = null
private var wantedInputName: String? = null
private var routeAvailable = false
private var availability: Pair<Boolean, Boolean>? = null
init {
// Наблюдение не занимает микрофон и не меняет глобальный маршрут без потребителей.
router.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<AudioTrack>): Boolean = tracks.all { track ->
val actual = if (track in outputs) track.routedDevice else null
track !in outputs || (actual != null && actual.id == wantedOutput?.id)
}
private fun saveSelection() {
if (!savePending) return
savePending = false; committed = preferred
kotlinx.coroutines.runBlocking { app.configProvider.setMediaAudioProfile(preferred) }
}
private fun recover(error: Exception) {
LogManager.addLog("ERROR", "media_audio", "route failed generation=$generation preferred=$preferred: $error")
if (savePending) { preferred = committed; savePending = false }
mutable.value = mutable.value.copy(notice = R.string.media_audio_failed, switching = false)
if (!forcedPhone) {
forcedPhone = true
try { reconcile(force = true) }
catch (e: Exception) { recover(e) }
} else {
++generation
synchronized(inputLock) { bridge }?.reset(generation)
record?.close(); record = null; inputReady = false
mutable.value = mutable.value.copy(switching = false)
}
}
/** Постоянный stereo-вход с запасом времени на аппаратный reader и смену маршрута. */
private class InputBridge : MicrophoneCapture.Device {
private val lock = Object()
private val timeline = MediaCaptureTimeline(System.nanoTime()) { 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() {}
}
}
@@ -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<MicrophoneCapture.Frame>()
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 }
}
}
@@ -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<Subscription>()
@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<String> 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() }
}
}
@@ -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<Boolean> = _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<Long> {
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<AudioRecord?, Int> {
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}")
}
}
}
@@ -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"
@@ -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")
@@ -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 = {}) }
}
}
}
@@ -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),
@@ -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<StreamSo
Scaffold(modifier = if (ownPreviewVisible) Modifier else Modifier.clearAndSetSemantics {}, topBar = {
if (focused == null)
TopAppBar(title = { Text(stringResource(R.string.stream_all_title, title), maxLines = 1, overflow = TextOverflow.Ellipsis) },
navigationIcon = { IconButton(onClick = onClose) { Icon(Icons.Filled.Close, stringResource(R.string.stream_close_view)) } })
navigationIcon = { IconButton(onClick = onClose) { Icon(Icons.Filled.Close, stringResource(R.string.stream_close_view)) } },
actions = { MediaAudioDeviceButton() })
}, contentWindowInsets = if (focused == null) ScaffoldDefaults.contentWindowInsets else WindowInsets(0, 0, 0, 0)) { padding ->
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)
}
}
}
@@ -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<StreamCamera>, 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,
@@ -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) }
}
}
@@ -174,24 +174,6 @@ class ChatViewModel : ViewModel() {
private val _radioTalkState = MutableStateFlow(RadioTalkState())
val radioTalkState: StateFlow<RadioTalkState> = _radioTalkState
private val _radioRoute = MutableStateFlow(AudioRoute.SPEAKER)
val radioRoute: StateFlow<AudioRoute> = _radioRoute
private val _radioHeadsetAvailable = MutableStateFlow(false)
val radioHeadsetAvailable: StateFlow<Boolean> = _radioHeadsetAvailable
private val _radioInputSource = MutableStateFlow(RadioAudioEngine.INPUT_AUTO)
val radioInputSource: StateFlow<Int> = _radioInputSource
private val _wiredInputAvailable = MutableStateFlow(false)
val wiredInputAvailable: StateFlow<Boolean> = _wiredInputAvailable
private val _btInputAvailable = MutableStateFlow(false)
val btInputAvailable: StateFlow<Boolean> = _btInputAvailable
private val _radioAuto = MutableStateFlow(false)
val radioAuto: StateFlow<Boolean> = _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() {
@@ -582,4 +582,13 @@
<string name="block_incoming_calls_description">Отклонять входящие звонки vibechat без рингтона</string>
<string name="block_incoming_radio">Отклонять все вызовы по рации</string>
<string name="block_incoming_radio_description">Отклонять приглашения; включённая рация продолжит работать</string>
<string name="media_audio_devices">Аудиоустройство: %1$s</string>
<string name="media_audio_auto">Автоматически</string>
<string name="media_audio_phone">Телефон</string>
<string name="media_audio_wired">Проводное / USB</string>
<string name="media_audio_bluetooth">Bluetooth</string>
<string name="media_audio_actual">Микрофон: %1$s\nВыход: %2$s</string>
<string name="media_audio_switching">Переключение аудиоустройства…</string>
<string name="media_audio_fallback">Выбранное устройство отключено. Используется микрофон и динамик телефона.</string>
<string name="media_audio_failed">Не удалось использовать выбранное аудиоустройство. Проверьте текущие микрофон и выход.</string>
</resources>
@@ -581,4 +581,13 @@
<string name="block_incoming_calls_description">Reject incoming vibechat calls without ringing</string>
<string name="block_incoming_radio">Reject all radio calls</string>
<string name="block_incoming_radio_description">Reject radio invitations; the channel radio keeps working</string>
<string name="media_audio_devices">Audio device: %1$s</string>
<string name="media_audio_auto">Automatic</string>
<string name="media_audio_phone">Phone</string>
<string name="media_audio_wired">Wired / USB</string>
<string name="media_audio_bluetooth">Bluetooth</string>
<string name="media_audio_actual">Microphone: %1$s\nOutput: %2$s</string>
<string name="media_audio_switching">Switching audio device…</string>
<string name="media_audio_fallback">The selected device is disconnected. Phone audio is in use.</string>
<string name="media_audio_failed">Could not use the selected audio device. Check the current microphone and output.</string>
</resources>
@@ -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)
}
}
@@ -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)