Audio flow
Audio flow в VideoGrace состоит из двух независимых направлений:
- publish - локальный микрофон захватывается, кодируется в Opus и отправляется в media transport;
- receive - удаленный audio device подключается по событию
device_connect, RTP frames принимаются, декодируются и воспроизводятся.
Control channel управляет жизненным циклом устройств. Media transport переносит только media payload и не должен создавать или удалять server-side devices сам по себе.
Для web-client основной audio transport - WebRTC:
- local microphone publish идет через
WebRTCPublishSession; - remote microphone receive идет через
WebRTCSubscribeSession; - WSM/
MediaChannelостается fallback для initial WebRTC failure или forcedvg.mediaTransport=wsm.
Local microphone publish через WebRTC
sequenceDiagram
participant UI
participant Client as VideograceClient
participant Control as Control channel
participant Mic as MicSession
participant Pub as WebRTCPublishSession
participant RTC as RTC node / WebRTC Gateway
participant Translator
UI->>Client: toggleMic(true)
Client->>Mic: start capture
Mic-->>Client: capture ready(sample_rate, channels)
Client->>Control: device_params(device_type=mic, codec=opus)
Control-->>Client: device_connect(my=1, device_id, author_ssrc, port)
Control-->>Client: local audio device created
Client->>Pub: create RTCPeerConnection + add microphone track
Pub->>Control: webrtc_offer(scope=audio, binding)
Control-->>Pub: webrtc_answer + ICE
Pub->>RTC: ICE/DTLS/SRTP Opus
RTC->>Translator: plain RTP(author_ssrc, port)
Binding для publish берется из self device_connect: device_id, author_ssrc, port, secure_key. Повторный publish после stale использует тот же sourceDevice и не отправляет новый device_params.
Remote audio receive через WebRTC
sequenceDiagram
participant Control as Control channel
participant Client as VideograceClient
participant Sub as WebRTCSubscribeSession
participant RTC as RTC node / WebRTC Gateway
participant Translator
participant Player as Browser playback router
Control-->>Client: device_connect(my=0, device_type=mic, receiver_ssrc, port)
Client->>Sub: create RTCPeerConnection(recvonly)
Sub->>Control: webrtc_offer(scope=audio-subscribe, binding)
Control-->>Sub: webrtc_answer + ICE
RTC->>Translator: register receiver_ssrc / RTP init
Translator-->>RTC: plain RTP(author_ssrc)
RTC-->>Sub: WebRTC audio track
Sub->>Player: attach remote MediaStream
Remote WebRTC audio использует нативный <audio> там, где он дает корректный маршрут вывода. На Android скрытый playing <audio> подключается к WebAudio через createMediaElementSource(), GainNode и AudioContext.destination. Элемент сохраняет системный media playback и управление аппаратными кнопками громкости, а GainNode реализует mute без второго audible output. Это не WSM-декодирование: Opus по-прежнему декодируется WebRTC-стеком браузера, а WebAudio управляет только воспроизведением уже принятого PCM.
Android background microphone limitation
На Android живой и немьютированный MediaStreamTrack может перестать отдавать реальную audio energy через 3-5 секунд после ухода browser/PWA в фон. ICE, RTP sender и сам track при этом могут оставаться connected/live/enabled, поэтому проблему нельзя лечить автоматическим stopMic/startMic после возврата в приложение.
Стенд Tool/WebRTCMicBackgroundRepro и проверка на production опровергли гипотезу одного только full-duplex keepalive: немьютированный обратный Opus track доходил до Android, но система все равно могла останавливать аудио микрофона. Текущий RTC path передает getUserMedia track напрямую в RTCPeerConnection, держит playing media element для Android audio focus, а VAD берет уровень из уже существующего RTC stats probe без параллельного PCM reader.
Product-проверка playing HTMLAudioElement -> WebAudio path подтвердила аппаратную регулировку громкости, но не предотвратила остановку microphone energy после сворачивания браузера. Playback audio focus и право Android на background microphone capture являются независимыми механизмами. Надежный web workaround остается только PiP, если пользователь включил его жестом; для гарантированной фоновой передачи без PiP требуется нативный Android foreground service.
При обычном mute на Android capture физически останавливается: server-side device отключается, outbound RTC publish закрывается, а исходный и обработанный MediaStreamTrack получают stop(). Повторный unmute заново вызывает getUserMedia и создает RTC publish. На отдельных Android это дает короткую задержку или временное изменение входящего звука при переключении, но гарантирует освобождение микрофона и сохраняет ожидаемое звучание при выключенном capture.
После успешного getUserMedia клиент обязан повторно выставить browser call session в auto, переиграть remote audio elements и применить настройки выхода сразу, через 250 мс и через 1 с. Android может завершить переход в communication route уже после разрешения getUserMedia; без post-capture recovery боковые кнопки регулируют не слышимый media stream либо входящий звук остается на максимальной громкости.
Pipeline
flowchart LR
subgraph Publish[Local WebRTC publish]
Capture[Microphone capture]
Track[MediaStreamTrack audio]
PCOut[RTCPeerConnection sendonly/sendrecv]
GatewayIn[RTC node]
RTPOut[Translator RTP author_ssrc]
end
subgraph Receive[Remote WebRTC receive]
RTPIn[Translator RTP author_ssrc]
GatewayOut[RTC node]
PCIn[RTCPeerConnection recvonly]
TrackIn[Remote audio track]
Play[Browser playback router<br/>HTMLAudioElement or WebAudio]
end
Capture --> Track --> PCOut --> GatewayIn --> RTPOut
RTPIn --> GatewayOut --> PCIn --> TrackIn --> Play
Browser audio routing contract
navigator.audioSession и navigator.mediaSession решают разные задачи. MediaSession используется для системной карточки активной конференции и metadata. AudioSession задает категорию аудиосессии, но не подтверждает выбор конкретного физического выхода.
На Android запрещено включать play-and-record
Web-client обязан при настройке и завершении browser call session очищать audioSession.onstatechange. На Android всегда используется audioSession.type = "auto"; нельзя добавлять workaround с play-and-record для фоновой работы микрофона.
На iOS play-and-record разрешен только как дополнительная call-категория после выбора реального receiver audiooutput, предоставленного Safari. Физический выход выбирается через HTMLMediaElement.setSinkId(), но успешный Promise и новое значение sinkId не доказывают переключение динамика. Если Safari не выдал такой output, клиент не показывает бутафорский пункт «Телефон у уха».
Известное ограничение: WebKit 320087
описывает успешный setSinkId() без смены физического выхода при одиночном
входящем WebRTC-аудиотреке. Исправление WebKit
переносит выбор выхода на регистрацию источника воспроизведения; номер версии
Safari с этим исправлением в отчете не указан. Это возможная причина наблюдаемого
залипания разговорного динамика, а не подтвержденный диагноз конкретного iPhone.
Проверенный на устройстве обход через приглушение элемента на время setSinkId()
и последующее включение не помог: добавил паузу, но сохранил прежний выход.
Этот цикл удален. Клиент не прерывает звук специально при выборе выхода,
не перезапускает микрофон/RTC и не повторяет смену маршрута по таймеру.
Проверка физического маршрута остается ручной на iPhone в обе стороны,
с включенным и выключенным микрофоном; unit-тесты проверяют только вызовы API.
Разделение playback recovery и выбора выхода
RemoteAudioOutputController предоставляет три отдельных операции:
applyPlayback(reason)применяет актуальные mute/deaf, громкость и возобновляет воспроизведение. Не вызываетsetSinkId().selectRoute(reason)применяет явный выбор пользователя ко всем выходам. Пустой ID означает возврат наdefault.initializeOutputs(reason)применяет сохраненный выход к новым audio-элементам и AudioContext. Повторные readiness-события используют уже выполненную или ожидающую операцию; остальные потоки не перенастраиваются. Неудачная инициализация может повториться при следующем readiness-событии.
RemoteAudioPlaybackRecoveryController имеет доступ только к playback-операции,
без callback для изменения категории AudioSession. Таймеры восстановления после
старта микрофона, появления remote audio и запроса пользовательского жеста не
меняют маршрут. Восстановление native playback не создает лишний AudioContext.
Настройка browser call session не перезаписывает audioSession.type, если категория
уже соответствует выбранной. Android остается на auto.
В диагностике output_settings_applied.route_action различает none, selected
и initialize. Тесты проверяют, что recovery не вызывает выбор выхода даже при
исполнении всех отложенных задач, а новые элементы получают сохраненный выход.
Это устраняет повторные команды маршрутизации со стороны клиента, но не доказывает
устранение всех физических переключений iOS: они требуют проверки на устройстве.
На части Android-устройств play-and-record включает системный communication mode. Наблюдавшиеся последствия:
- звук переключается на разговорный динамик;
- громкость внешнего динамика становится существенно ниже;
- выбранный пользователем speaker/Bluetooth route сбрасывается;
- проблема может проявляться только на отдельных моделях и не воспроизводиться в desktop browser.
Контракт Android playback в webrtc_subscribe_session.js:
remote WebRTC MediaStream
-> hidden HTMLAudioElement (playing, muted=false, volume=1)
-> AudioContext.createMediaElementSource(element)
-> GainNode
-> AudioContext.destination
MediaElementAudioSourceNode перехватывает output элемента и направляет его только в WebAudio graph, поэтому native element не создает второй звук. Нельзя одновременно оставлять прямой native output и параллельный MediaStream -> WebAudio: это дает двойное воспроизведение. audioSession.type при этом остается auto, чтобы Android не выбирал call/earpiece route.
Автоматические regression guards:
VideograceClient.conferences.test.tsпроверяет, что call session всегда сбрасываетaudioSessionвautoи удаляетonstatechange;RemoteAudioPlaybackRecoveryController.test.tsпроверяет повторное применение call session и remote output после физического старта микрофона;webrtc_subscribe_session.test.tsпроверяет AndroidHTMLAudioElement -> WebAudiopath, playing/unmuted элемент и управление mute черезGainNode;- изменение browser audio routing не принимается, если эти тесты удалены, ослаблены или обходятся по user-agent.
Минимальная ручная проверка перед релизом, затрагивающим audio routing:
- Проверить минимум Samsung/One UI и Android другого производителя в Chrome или Chromium-based browser.
- Начать конференцию с включенным внешним динамиком и убедиться, что звук не переходит в разговорный динамик.
- Сравнить громкость до входа в конференцию и во время разговора; заметного системного падения быть не должно.
- Проверить переключение speaker, проводной/USB-гарнитуры и Bluetooth, если они доступны на устройстве.
- Выключить и включить микрофон, камеру и демонстрацию; маршрут и громкость входящего звука не должны меняться.
- Проверить, что боковые кнопки Android регулируют слышимую громкость конференции во всем диапазоне.
- Завершить конференцию и убедиться, что обычное медиавоспроизведение устройства осталось в нормальном маршруте.
WSM fallback audio path
WSM fallback сохраняет старую схему:
- local
MicSessionкодирует Opus в JS/WASM и отправляет RTP черезMediaMuxSocket; - remote
MediaChannelпринимает RTP поreceiver_ssrc, декодирует Opus и воспроизводит PCM; - один
MediaMuxSocketобслуживает много SSRC.
Fallback разрешен только если WebRTC не поднялся изначально в режиме auto или если выбран forced vg.mediaTransport=wsm. После уже успешного WebRTC publish/subscribe stale recovery не должен переключать audio в WSM.
Reconnect и stale behavior
stateDiagram-v2
[*] --> Capturing
Capturing --> PublishingRTC: device_connect my=1 + WebRTC publish connected
PublishingRTC --> PublishStalled: ICE/connection disconnected
PublishStalled --> RepublishRTC: local publish recovery timer
RepublishRTC --> PublishingRTC: new RTCPeerConnection, same device_id
PublishingRTC --> Stopping: toggleMic(false) / leave
Stopping --> [*]
Reconnect media transport не должен пересоздавать microphone device. После stale restore локальная WebRTC publish-сессия перезапускается с тем же device_id/author_ssrc/port.
Текущие тайминги web-client:
restore_stale_session=trueпинает active local publish recovery через700ms;ice_state/connection_state=failed- recovery через250ms;disconnectedдля audio - recovery через1200ms;- successful
connected/completedочищает pending recovery timer.
Remote audio receive recovery:
- health
stalledзапускает remote WebRTC restart; answer timeoutна restart не считается финальным error, а ставит retry с backoff;- лимит restart-попыток -
6; - после
restore_stale_session=trueremote sessions вstalled/errorполучают retry через900ms.
Invariants
device_paramsсоздает server-side microphone device; media transport его не создает.device_idживет в control lifecycle.author_ssrcиспользуется локальнойMicSessionдля отправки.receiver_ssrcиспользуется remote audio session для приема.- Повторный
device_connectдля того же remote audio device не должен создавать вторую playback session. toggleMic(false)должен остановить capture, закрыть outbound publish и отправитьdisconnect_deviceна всех платформах, включая Android.- Reconnect media transport не должен менять
device_id. - WebRTC recovery после stale должен переиспользовать
sourceDevice, а не делать новыйdevice_params. - WSM fallback не должен включаться после уже успешного WebRTC audio endpoint.
- Browser call lifecycle всегда оставляет на Android
navigator.audioSession.type = "auto"; на iOSplay-and-recordдопустим только вместе с реальным receiver output и успешнымsetSinkId. - Android remote WebRTC audio идет через playing native
<audio>в WebAudio graph; mute применяетсяGainNode, без параллельного native output. - Local browser mic publish использует один
sendrecvpeer; его отдельный service playback element остается unmuted и не содержит пользовательский/конференционный звук.
Diagnostics
Для audio-инцидента в логах нужны:
device_id,client_id,device_type;author_ssrcилиreceiver_ssrc;- capture format: sample rate, channels;
- codec mode: Opus channels, bitrate;
- WebRTC endpoint id,
rtc_node_id, offer/answer timing; - ICE/connection state;
- first RTP packet sent/received на gateway/translator bridge;
- decoder init/reinit для WSM fallback;
- playback underrun/overrun;
navigator.audioSession.type, WebAudio attach и muted-состояние native<audio>;media-source.audioLevelили дельтыtotalAudioEnergy/totalSamplesDurationв RTC sender stats;- media transport close/reconnect reason.
Typical failures
- Локальный индикатор микрофона активен, но удаленный участник не слышит звук: проверить
device_params, localdevice_connect my=1, WebRTC publish offer/answer, ICE connected, first RTP на RTC node/Translator. - Remote WebRTC connected, но звука нет: проверить inbound RTP progress, audio element
play(), output device, mute state. - На Android звук идет через разговорный динамик, орет на максимуме или не реагирует на боковые кнопки: проверить
audioSession.type=auto, playing/unmuted<audio>,createMediaElementSource()и отсутствие второго прямого output. - Android перестал передавать микрофон после ухода в фон: сравнить outbound RTP и
media-sourceenergy; не перезапускать live track только из-за нулевой energy и не включатьaudioSession.type=play-and-record. - После reconnect звук не возвращается: проверить
restore_stale_session, local publish recovery, remote subscribe restart retry/backoff, без новогоdevice_params. - WSM неожиданно поднялся после stale: это нарушение политики; WSM допустим только initial fallback/forced mode.
- После выключения/включения микрофона появляется несколько audio sessions: проверить идемпотентность
device_connectи очистку старойMicSession.