Skip to content

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 или forced vg.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 проверяет Android HTMLAudioElement -> WebAudio path, playing/unmuted элемент и управление mute через GainNode;
  • изменение browser audio routing не принимается, если эти тесты удалены, ослаблены или обходятся по user-agent.

Минимальная ручная проверка перед релизом, затрагивающим audio routing:

  1. Проверить минимум Samsung/One UI и Android другого производителя в Chrome или Chromium-based browser.
  2. Начать конференцию с включенным внешним динамиком и убедиться, что звук не переходит в разговорный динамик.
  3. Сравнить громкость до входа в конференцию и во время разговора; заметного системного падения быть не должно.
  4. Проверить переключение speaker, проводной/USB-гарнитуры и Bluetooth, если они доступны на устройстве.
  5. Выключить и включить микрофон, камеру и демонстрацию; маршрут и громкость входящего звука не должны меняться.
  6. Проверить, что боковые кнопки Android регулируют слышимую громкость конференции во всем диапазоне.
  7. Завершить конференцию и убедиться, что обычное медиавоспроизведение устройства осталось в нормальном маршруте.

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=true remote 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"; на iOS play-and-record допустим только вместе с реальным receiver output и успешным setSinkId.
  • Android remote WebRTC audio идет через playing native <audio> в WebAudio graph; mute применяется GainNode, без параллельного native output.
  • Local browser mic publish использует один sendrecv peer; его отдельный 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, local device_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-source energy; не перезапускать 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.