Merge shared Android media audio routing and microphone fixes
This commit is contained in:
commit
07cd150b47
25 files changed
+1146
-502
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+100
-66
@@ -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")
|
||||
|
||||
+66
@@ -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),
|
||||
|
||||
+7
-1
@@ -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,
|
||||
|
||||
+6
-2
@@ -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>
|
||||
+160
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user