
Три дієслова, що тримають Web Audio живим
Два точкові релізи GeekBye, розділені двома місяцями і в двох різних файлах, дали нашому аудіокоду той самий урок із протилежних кінців: перестань поводитися з браузерним AudioContext як з одноразовим. Один реліз навчився робити resume() контексту, який macOS тихо призупинив посеред запису; інший навчився suspend() замість close(), щоб сесії одна за одною перестали врізатися в накинуту Chromium стелю приблизно в шість контекстів. Resume, suspend, close — ось і весь сюжет.
Здебільшого браузерний 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() щоразу» і виявилася багом. Два сервіси далі вчаться в обробки аудіожиттєвого циклу одне одного, по одному фіксу за раз.
Три речі, яких навчили ці релізи
- У
AudioContextє автомат станів — стався доstateяк до несучого.running,suspendedіclosed— не дрібниця. ОС може рухати контекст між ними, не питаючи тебе, а різні частини твого графа реагують по-різному (ScriptProcessorNodeна головному потоці далі йде; кероване апаратним годинником призначення — ні). Якщо ти не стежиш заonstatechange, ти довіряєш, що контекст лишиться там, де ти його залишив, — а на справжньому десктопі, при змінах фокусу і записі екрана, не лишиться. close()— це неfree(). Інтуїція, що закривати ресурс одразу завжди правильно, — рівно те, що спричинило другий баг. Коли ресурс — це слот у жорсткому, накинутому браузером пулі, який звільняється лише під час GC, агресивне закриття — це спосіб вичерпати пул. Іноді правильний життєвий цикл — створити-один-раз-призупиняти-часто, а не створювати-і-знищувати-на-кожне-використання.- До деяких багів можна дістатися лише через двері справжнього заліза і справжнього часу. «Помирає приблизно після шести сесій» і «стихає, коли перемикаєшся» неможливо впіймати юніт-тестом, який мокає аудіопристрій, — і команда сказала це прямо, а не замазала фальшиво прохідним тестом. Назви межі свого тестового каркаса; баг, якому треба вісім справжніх сесій, щоб проявитися, заслуговує на чесне «перевірено вручну», а не на зелену галочку, яка нічого не значить.
Про попередній розділ — відрізнити дзвінок від відкритого застосунку (v1.8.15–v1.8.19); а про всю дугу — анатомію доставки програмного забезпечення до досконалості.