From 44a3b33c3a98b34b17e20c2b838018dbafa55b7c Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 22 Oct 2026 15:07:26 +0200 Subject: [PATCH] Expose shared audio devices in stream viewing and document them --- doc/stream.txt | 26 +++++++++++-------- .../utun/chat/ui/screens/StreamGridScreen.kt | 8 +++++- .../chat/ui/screens/StreamViewerScreen.kt | 8 ++++-- 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/doc/stream.txt b/doc/stream.txt index 93f340e6..a3468bcb 100644 --- a/doc/stream.txt +++ b/doc/stream.txt @@ -811,7 +811,8 @@ Android: Разделённые CODEC_CONFIG buffers собираются отдельно с пределом 64KiB до разбора конфигурации. - Camera MediaCodec Surface timestamps и AudioTimestamp.TIMEBASE_MONOTONIC приводятся к общему origin System.nanoTime; время callback/получения блока не заменяет capture PTS. -- Микрофон трансляции использует AudioRecord.CAMCORDER, 48kHz stereo. Перед Opus AUDIO/128kbps +- Встроенный микрофон трансляции использует AudioRecord.CAMCORDER, 48kHz stereo (mono fallback при отказе формата). + Гарнитуры используют разговорный тракт; mono приводится к stereo перед Opus AUDIO/128kbps. Перед кодированием применяется тот же audio_compressor, что в TX рации: общие настройки включения, max_gain и rise_rate, lookback=200ms, lookahead=0, release=rise*5, target=0.25. История и gain принадлежат только audio worker трансляции; оба канала используют общий gain, сохраняющий стереобаланс. Изменения настроек рации применяются на следующем PCM-кадре, без перезапуска микрофона, @@ -934,17 +935,20 @@ Android: Это ограниченный drain, не подтверждение воспроизведения всеми viewers. Abort/core restart очищает очередь сразу. Завершение логирует срок drain, оставшиеся RS-байты выдач и локальных readers; RS=0 не означает подтверждение доставки ETCP. Поздние команды старого session ID не меняют новый запуск. -- Рация работает одновременно с публикацией. При совпадении выбранного входа она получает копию - PCM CAMCORDER 48kHz stereo с исходными timestamp; её source/stereo предпочтения не меняют трансляцию. - Другой явно выбранный вход открывается отдельно; отказ не заменяет его системным default. - Трансляция имеет приоритет глобального AudioManager: рация не включает разговорный режим и не меняет SCO - общего тракта. Перед запуском трансляции ранее работавшая рация освобождает capture и затем подключается - к фактическому входу трансляции либо повторно открывает свой отдельный вход. - Очереди потребителей ограничены; PTT упорядочен с PCM, разрыв сбрасывает историю детектора рации. - Последняя подписка закрывает AudioRecord; stop рации/трансляции не закрывает захват другого потребителя. - Проверяется фактический Android route и системное silencing: конфликтующий отдельный capture рации - завершается с диагностикой, приём рации сохраняется. Окно VAD/экран/разрешения не обходятся общим захватом. +- Рация, публикация и просмотр трансляций используют общий профиль MediaAudioController: AUTO/PHONE/WIRED/BLUETOOTH. + Выбор из любого экрана меняет общий вход и маршруты всех активных AudioTrack; настройка сохраняется одна. + Аппаратный микрофон один, очереди потребителей и обработка PCM независимы. PTT упорядочен с PCM. + Подключение публикации переводит встроенный вход в CAMCORDER stereo; профиль сохраняется до последней подписки. + Смена устройства сохраняет подписки, камеру, видеокодер, source_epoch и временную шкалу эфира. + MediaCaptureTimeline выдаёт 20ms PCM в общей сетке с запасом 200ms; аппаратные timestamps задают положение звука. + Отсутствующий PCM заменяется тишиной. Запоздавшие данные отбрасываются, а не получают новые PTS. + Подтверждаются фактический маршрут, свежий PCM и отсутствие системного silencing. При ошибке — диагностика + и резервный телефонный профиль; при полном отказе входа публикация продолжает видео с тишиной. + Последний потребитель закрывает вход; выходы живут до удаления своих AudioTrack. Просмотр не открывает микрофон. + Окно VAD/экран/разрешения не обходятся общим захватом. Ошибки видны в общем меню выбора устройства. Звонок и voice recording по-прежнему исключают публикацию в обе стороны. + Аппаратная проверка (debug APK, RECORD_AUDIO разрешён): + adb shell am instrument -w com.utun.chat/com.utun.chat.data.MediaAudioProbe Звук трансляции и рация (Android и desktop): - Во время публикации с микрофоном звук можно выключить и включить. Заглушается только копия PCM diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamGridScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamGridScreen.kt index 112f6258..b3d63a78 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamGridScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamGridScreen.kt @@ -1,5 +1,7 @@ package com.utun.chat.ui.screens +import com.utun.chat.ui.components.MediaAudioDeviceButton + import android.os.SystemClock import androidx.activity.compose.BackHandler import androidx.compose.foundation.background @@ -110,7 +112,8 @@ internal fun StreamGridScreen(group: Long, title: String, sources: List Column(Modifier.fillMaxSize().padding(padding).background(Color.Black)) { if (unavailable && focused == null) @@ -185,6 +188,9 @@ private fun StreamGridTile(group: Long, tile: GridStream, visible: Boolean, expa role = Role.Button, onClickLabel = stringResource(if (tile.source.self) R.string.stream_manage else if (expanded) R.string.collapse else R.string.expand), onClick = onClick) .semantics { contentDescription = tile.source.displayName }) + if (expanded) Box(Modifier.align(Alignment.TopEnd).windowInsetsPadding(WindowInsets.safeDrawing)) { + MediaAudioDeviceButton(tint = Color.White) + } } } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamViewerScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamViewerScreen.kt index 1691995c..38dfaf17 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamViewerScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamViewerScreen.kt @@ -1,5 +1,7 @@ package com.utun.chat.ui.screens +import com.utun.chat.ui.components.MediaAudioDeviceButton + import android.app.Activity import android.content.ContextWrapper import android.graphics.SurfaceTexture @@ -68,8 +70,9 @@ internal fun StreamViewerScreen(group: Long, source: Long, epoch: Long, title: S onClose() } } - LaunchedEffect(controls, status, visible, hideTick) { - if (controls && status == R.string.stream_live && visible) { delay(3000); controls = false } + var audioMenu by remember { mutableStateOf(false) } + LaunchedEffect(controls, status, visible, hideTick, audioMenu) { + if (controls && status == R.string.stream_live && visible && !audioMenu) { delay(3000); controls = false } } LaunchedEffect(controls) { LogManager.addLog("DEBUG", "stream", "viewer controls source=$source visible=$controls") } LaunchedEffect(zoom.scale, zoom.offset) { @@ -116,6 +119,7 @@ internal fun StreamViewerScreen(group: Long, source: Long, epoch: Long, title: S Text(name, color = Color.White, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) Text(stringResource(status), color = Color.White, style = MaterialTheme.typography.bodySmall) } + MediaAudioDeviceButton(tint = Color.White, onExpandedChange = { audioMenu = it }) IconButton(onClick = onClose) { Icon(Icons.Filled.Close, stringResource(R.string.stream_close_view), tint = Color.White) } } }