Steven
Steven8 мин. чтения

Три глагола, что держат Web Audio живым

Два точечных релиза GeekBye, разделённые двумя месяцами и в двух разных файлах, преподали нашему аудиокоду один и тот же урок с противоположных концов: перестань обращаться с браузерным AudioContext как с одноразовым. Один релиз научился делать resume() контексту, который macOS тихо приостановил посреди записи; другой научился suspend() вместо close(), чтобы идущие подряд сессии перестали врезаться в навязанный Chromium потолок примерно в шесть контекстов. Resume, suspend, close — вот и весь сюжет.

Инженерия
Аудио
Десктоп
Релизы GeekBye
Три глагола, что держат Web Audio живым

Большую часть времени браузерный AudioContext ведёт себя как обычный объект: конструируешь один, подключаешь несколько узлов и забываешь о нём. Два релиза GeekBye — это то, что случилось, когда это допущение сломалось — дважды, в двух разных частях приложения, с разницей в два месяца. Ни один не планировался как продолжение другого. Но поставь их рядом — и они расскажут одну историю: браузерный AudioContext не одноразовый объект. Это дефицитный, принадлежащий операционной системе ресурс с настоящим автоматом состояний, и три глагола этого автомата — resume, suspend, close — это весь сюжет обоих релизов.

У GeekBye два отдельных аудиоконвейера, и именно поэтому один и тот же урок пришлось выучить дважды. Один — это путь записи видео (ScreenRecordingService), который строит граф Web Audio, чтобы смешать микрофон и системный звук в записываемый видеофайл. Другой — путь транскрипции в реальном времени (RealtimeAudioCaptureService), который строит свой граф, питающий распознавание речи. У каждого свой AudioContext. Каждый релиз ниже чинит один из них.

v1.8.16: видео замолчало, а транскрипт — нет

Первый баг — это раздвоение сознания. Ты записываешь встречу, на миг переключаешься в другое приложение, и происходит нечто тонкое: видеофайл теряет твой микрофон, пока живой транскрипт прокручивается дальше, будто ничего не изменилось. Транскрипт клянётся, что ты говорил. Видео молчит. Это сводящий с ума род бага, потому что две вещи, которые должны совпадать — «услышал ли тебя микрофон» — в открытую не совпадают.

Коммит, который это чинит, точно называет причину:

AudioContext can be suspended by macOS when the app loses focus during screen recording. This silences the mic in the video file while the ScriptProcessorNode (STT path) keeps running on the JS main thread, causing a split where transcripts continue but the video has no mic.

Вот механика. ScreenRecordingService.setupMonoMixingPipeline() строит один AudioContext, MediaStreamDestination, который становится аудиодорожкой видео, GainNode для заглушения и пару узлов ScriptProcessorNode (через createScriptProcessor), которые качают аудиокадры в логику VAD/STT. Когда macOS приостанавливает контекст — а он может это сделать, когда приложение теряет фокус во время записи экрана — части графа, управляемые аппаратными часами звука, останавливаются: MediaStreamDestination перестаёт получать звук микрофона, так что записываемое видео затихает. Но колбэк onaudioprocess узла ScriptProcessorNode работает на главном потоке JavaScript, а этот поток продолжает тикать. Так что сторона STT продолжает выдавать записи транскрипта из того, что у неё было последним, и два потребителя одного микрофона расходятся.

Фикс в том, чтобы заметить приостановку и отменить её:

audioContext.onstatechange = () => {
  if (audioContext.state === 'suspended' && this.status === 'recording') {
    logger.info('AudioContext suspended during recording, resuming')
    audioContext.resume()
  }
}

Это и есть вся правка корректности: следи за state контекста, и если он падает до suspended, пока мы ещё должны записывать, вызывай resume(). Релиз также подключает слушатели onmute, onunmute и onended на MediaStreamTrack микрофона — не чтобы изменить поведение, а чтобы заглушение на уровне ОС или отключение устройства появилось в логах, а не было невидимой причиной тишины. Когда вся трудность бага — «почему звук остановился», сделать остановку звука наблюдаемой — это половина фикса.

Следующий коммит приземлился парой минут позже, чтобы прибраться за этими новыми слушателями: cleanup() теперь обнуляет micTrack.onmute/onunmute/onended и ставит audioContext.onstatechange = null, прежде чем разобрать граф. Заметка коммита говорит, что он «повторяет паттерн очистки RealtimeAudioCaptureService» — другого аудиосервиса приложения. Запомни эту деталь, потому что двумя месяцами позже именно этот паттерн пришлось переписать следующему релизу.

v2.0.2: звук и транскрипты умирают примерно после шести сессий

Второй баг живёт в другом конвейере, RealtimeAudioCaptureService, и у него чудесная форма: всё работает идеально, а потом, после того как ты запустил и остановил запись несколько раз за одну сессию приложения, звук просто... умирает. Сначала затихает системный звук, потом останавливается транскрипт. Перезапусти приложение — и снова всё хорошо, пока не пройдёт ещё горстка сессий.

Заметку о корневой причине стоит прочитать целиком, потому что это учебниковый баг-за-багом:

Root cause: setupMonoMixingPipeline created a new AudioContext on every start() and cleanup() closed it. Chromium hard-caps hardware AudioContexts at ~6 per page and closed-but-not-yet-GC'd contexts keep their slot, so ~6 back-to-back sessions exhaust the cap. The next context is dead → the silent-monitor passthrough can't play (no system audio) AND the mixer worklet can't capture (no transcript) — the reported 'after 6+ sessions audio dies, then transcript dies'.

Ловушка в том, что старый код делал «ответственную» вещь. Каждый start() создавал свежий AudioContext; каждый cleanup() вызывал на нём close(). Закрывать ресурс, когда с ним закончил, — это ровно то, чему тебя учат. Был даже комментарий в коде, рационализирующий эту трату — что перезагружать модуль AudioWorklet на каждый свежий контекст нормально, потому что «свежий контекст на каждый start() значит, что мы всегда платим эту цену один раз».

Но Chromium ограничивает, сколько аппаратно поддержанных AudioContext может держать одна страница — примерно шесть — и, что критично, close() не возвращает слот в тот же миг, как ты его вызвал. Закрытый контекст продолжает занимать свой слот, пока не будет собран GC, что случается, когда движок JS до этого доберётся. Так что скрупулёзная политика закрывать-каждый-раз — это именно то, что заводит тебя под потолок: шестью циклами создания/закрытия позже все шесть слотов держат ещё не собранные трупы, седьмой new AudioContext() возвращается мёртвым, и каждый узел, что ты к нему подключаешь, тихо ничего не делает.

Фикс в том, чтобы перестать обращаться с контекстом как с одноразовым. Создай его один раз и переиспользуй на весь срок жизни сервиса:

  • В setupMonoMixingPipeline конструируй new AudioContext(...) только если его ещё нет или существующий closed. Если он существует, но suspended, вызови await audioContext.resume() вместо создания нового.
  • В cleanup замени audioContext.close() на audioContext.suspend() (с защитой, чтобы он никогда не вызывался на уже закрытом контексте). Контекст переживает сессию.
  • Поскольку контекст переживает, вместе с ним переживает загруженный модуль AudioWorklet — код больше не сбрасывает флаг «ворклет загружен» при очистке, так что audioWorklet.addModule(...) платится один раз на весь срок жизни сервиса, а не каждую сессию.

Элементы уровня сессии — узлы createMediaStreamSource для микрофона и системного звука, ворклет микшера и тихий passthrough, который держит живым отвод петли системного звука Chromium — по-прежнему строятся и разбираются каждый цикл. Сохраняется только сам AudioContext, так что в любой момент существует максимум один. Потолок недостижим, потому что ты никогда не складываешь контексты друг на друга под ним.

Одна честная заметка, которую коммит делает о себе: это было проверено вручную — «проверено вручную: 8+ идущих подряд сессий сохраняют системный звук слышимым, а транскрипты — текущими (было мертво на сессии 7)» — и автор откровенен, что жизненный цикл Web Audio «не поддаётся юнит-тестированию в jsdom без большого нового мок-каркаса». Это правильный род честности. Сбой проявляется только на настоящем аудиоустройстве через настоящие сессии; зелёный набор юнит-тестов не доказал бы ничего. «Примерно шесть» и «мертво на сессии 7» — это наблюдения команды о том, где укусил лимит Chromium, а не задокументированная константа — сам код подстраховывается тильдой, и так же следует делать каждому, кто это повторяет.

Resume, suspend, close

Выстрой два релиза в ряд — и симметрия почти слишком аккуратна. Оба про одни и те же три метода на одном и том же роде объекта, подходы с противоположных направлений:

  • v1.8.16 научился делать resume(). Контекст стал suspended из-под нас силами ОС, и фикс в том, чтобы поймать это и возобновить — держать в работе контекст, который должен работать.
  • v2.0.2 научился делать suspend() вместо close(). Старый код был слишком рьян в уничтожении контекста, и фикс в том, чтобы приостановить и переиспользовать его — держать живым контекст, который дорого пересоздавать.

Это две половины одного принципа: перестань обращаться с AudioContext как с расходником. Одна половина говорит: не дай ему умереть, когда не должен; другая говорит: не убивай его, когда не обязан. И есть маленькая ирония в хронологии — очистку v1.8.16 написали, чтобы «повторить паттерн очистки RealtimeAudioCaptureService», а потом v2.0.2 переписал ровно этот паттерн в RealtimeAudioCaptureService, потому что привычка паттерна «close() каждый раз» и оказалась багом. Два сервиса продолжают учиться у обработки аудиожизненного цикла друг друга, по одному фиксу за раз.

Три вещи, которым научили эти релизы

  1. У AudioContext есть автомат состояний — относись к state как к несущему. running, suspended и closed — не мелочь. ОС может двигать контекст между ними, не спрашивая тебя, и разные части твоего графа реагируют по-разному (ScriptProcessorNode на главном потоке продолжает идти; управляемое аппаратными часами назначение — нет). Если ты не следишь за onstatechange, ты доверяешь, что контекст останется там, где ты его оставил, — а на настоящем десктопе, при сменах фокуса и записи экрана, не останется.
  2. close() — это не free(). Интуиция, что закрывать ресурс сразу всегда правильно, — ровно то, что вызвало второй баг. Когда ресурс — это слот в жёстком, навязанном браузером пуле, который освобождается только во время GC, агрессивное закрытие — это способ исчерпать пул. Иногда правильный жизненный цикл — создать-один-раз-приостанавливать-часто, а не создавать-и-уничтожать-на-каждое-использование.
  3. К некоторым багам можно добраться только через дверь настоящего железа и настоящего времени. «Умирает примерно после шести сессий» и «затихает, когда переключаешься» невозможно поймать юнит-тестом, который мокает аудиоустройство, — и команда сказала это прямо, а не замазала фальшиво проходящим тестом. Назови пределы своего тестового каркаса; баг, которому нужно восемь настоящих сессий, чтобы проявиться, заслуживает честного «проверено вручную», а не зелёной галочки, которая ничего не значит.

Про предыдущую главу — отличить звонок от открытого приложения (v1.8.15–v1.8.19); а про всю дугу — анатомию доведения софта до совершенства.

Похожие статьи

Тишина была несущей
Steven
Steven7 мин. чтения

Тишина была несущей

Два последних релиза GeekBye v1 — об одной и той же неудобной правде: транскрипция в реальном времени по настоящей сети не бесстратна, и честный ход — перестать притворяться, что это так. v1.8.20 хранил копию каждого фрагмента звука на диске, прежде чем отбросить его при переподключении, и начал вслух отмечать пропуски в транскрипте. v1.9.0 перестал отправлять тишину, чтобы сэкономить трафик — и обнаружил, что тишина была ровно тем сигналом, по которому транскрайбер понимал, что предложение закончилось. Два релиза о цене выбрасывания вещей.

Инженерия
Аудио
Надёжность
Отличить звонок от открытого приложения
Steven
Steven8 мин. чтения

Отличить звонок от открытого приложения

GeekBye умеет заметить, что ты присоединился к видеовстрече, и предложить её записать. Обнаружение оказывается лёгкой половиной — нативный Swift-бинарник читает заголовки окон каждые десять секунд. Трудная половина — точность: не срабатывать, когда Zoom просто открыт, не предлагать записать встречу, которую ты уже записываешь, и не заглушать микрофон в звонке, в котором ты на самом деле сидишь. Три релиза, и каждый из них — предохранитель, которому пришлось научиться не побеждать самого себя.

Инженерия
macOS
Десктоп
Убираем backend с пути загрузки
Steven
Steven7 мин. чтения

Убираем backend с пути загрузки

GeekBye записывает твой экран и сохраняет видео в твой Google Drive. Первая версия прогоняла каждую запись через собственные серверы GeekBye по пути туда; релизом позже файл шёл прямо с твоей машины на Drive, а backend был понижен до хранения единственного указателя. Интересная часть в том, как мало кода на самом деле содержит «прямая, возобновляемая» версия — потому что возобновляемость пришла из удаления proxy, а не из его написания.

Инженерия
Архитектура
Десктоп