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, а не з його написання.

Інженерія
Архітектура
Десктоп