Steven
Steven9 мин четене

Три глагола, които поддържат Web Audio жив

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

Инженерство
Audio
Desktop
Издания на GeekBye
Три глагола, които поддържат Web Audio жив

През повечето време Web Audio AudioContext се държи като обикновен обект: конструираш един, свързваш няколко node-а и забравяш за него. Две издания на GeekBye са това, което се случи, когато онова допускане се счупи — два пъти, в две различни части на приложението, на два месеца разстояние. Нито едното не беше планирано като продължение на другото. Но постави ги едно до друго и разказват една история: AudioContext на браузъра не е обект за еднократна употреба. Той е оскъден, притежаван от операционната система ресурс с истинска машина на състоянията, а трите глагола на тази машина на състоянията — resume, suspend, close — са целият сюжет на двете издания.

GeekBye има два отделни аудио pipeline-а, което е причината един и същ урок да трябва да бъде научен два пъти. Единият е пътят на видео записа (ScreenRecordingService), който изгражда Web Audio граф, за да смеси микрофона и системното аудио в записан видео файл. Другият е пътят на транскрипцията в реално време (RealtimeAudioCaptureService), който изгражда собствен граф, за да захранва speech-to-text. Всеки има свой собствен AudioContext. Всяко издание по-долу поправя един от тях.

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

Първият бъг е разцепен мозък. Записваш среща, превключваш за момент към друго приложение и се случва нещо неуловимо: видео файлът губи микрофона ти, докато транскрипцията на живо продължава да се движи, все едно нищо не се е променило. Транскрипцията се кълне, че си говорил. Видеото е беззвучно. Това е влудяващ вид бъг, защото двете неща, които би трябвало да са съгласни — „чу ли те микрофонът" — категорично не са.

Commit-ът, който го поправя, назовава причината с точност:

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, който става аудио track-ът на видеото, GainNode за заглушаване и двойка ScriptProcessorNode-ове (през createScriptProcessor), които изпомпват аудио frame-ове към VAD/STT логиката. Когато macOS суспендира контекста — което може да направи, когато приложението загуби фокус по време на запис на екрана — частите от графа, задвижвани от хардуерния аудио часовник, спират: MediaStreamDestination спира да получава аудио от микрофона, така че записаното видео замлъква. Но onaudioprocess callback-ът на 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 listener-и на MediaStreamTrack-а на микрофона — не за да промени поведението, а за да може заглушаване на ниво операционна система или изключване на устройство да се появи в логовете, вместо да е невидима причина за тишина. Когато цялата трудност на един бъг е „защо спря аудиото", правенето на спирането на аудиото наблюдаемо е половината поправка.

Последващ commit пристигна няколко минути по-късно, за да разчисти след онези нови listener-и: cleanup() сега занулява micTrack.onmute/onunmute/onended и задава audioContext.onstatechange = null, преди да разруши графа. Бележката на commit-а казва, че „съответства на cleanup шаблона на RealtimeAudioCaptureService" — другата аудио услуга на приложението. Задръж тази подробност, защото два месеца по-късно точно този шаблон е онова, което следващото издание трябваше да пренапише.

v2.0.2: аудиото и транскрипциите умират след около шест сесии

Вторият бъг живее в другия pipeline, 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() върху него. Да затвориш ресурс, когато си приключил с него, е точно това, на което те учат. Имаше дори inline коментар, който рационализираше разхищението — че презареждането на AudioWorklet модула на всеки свеж контекст е било наред, защото „свеж контекст на всеки start() означава, че винаги плащаме тази цена веднъж".

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

Поправката е да спреш да третираш контекста като за еднократна употреба. Създай го веднъж и го използвай повторно за живота на услугата:

  • В setupMonoMixingPipeline конструирай new AudioContext(...) само ако още няма такъв или съществуващият е closed. Ако съществува, но е suspended, направи await audioContext.resume() вместо да създаваш нов.
  • В cleanup замени audioContext.close() с audioContext.suspend() (защитено, така че никога да не се извиква на вече затворен контекст). Контекстът преживява сесията.
  • Понеже контекстът преживява, зареденият AudioWorklet модул преживява с него — кодът вече не нулира флага си „worklet зареден" при cleanup, така че audioWorklet.addModule(...) се плаща веднъж за целия живот на услугата, вместо всяка сесия.

Частите на сесия — createMediaStreamSource node-овете за микрофон и системно аудио, mixer worklet-ът и беззвучният passthrough, който поддържа жив loopback tap-а за системно аудио на Chromium — все още се изграждат и разрушават всеки цикъл. Само самият AudioContext устоява, така че в даден момент съществува най-много един. Таванът е недостижим, защото никога не трупаш контексти срещу него.

Една честна бележка, която commit-ът прави за себе си: това беше проверено на ръка — „проверено ръчно: 8+ последователни сесии поддържат системното аудио чуваемо и транскрипциите течащи (беше мъртво на сесия 7)" — а авторът е откровен, че жизненият цикъл на Web Audio „не е тестваем на ниво unit в jsdom без голям нов mock harness". Това е правилният вид честност. Провалът се появява само срещу истинско аудио устройство през истински сесии; зелен unit пакет не би доказал нищо. „Приблизително шест" и „мъртво на сесия 7" са наблюденията на екипа за това къде ухапа лимитът на Chromium, не документирана константа — самият код се подсигурява с тилда, и така би трябвало всеки, който го повтаря.

Resume, suspend, close

Подреди двете издания едно до друго и симетрията е почти прекалено спретната. И двете са за едни и същи три метода върху един и същ вид обект, подходени от противоположни посоки:

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

Те са двете половини на един-единствен принцип: спри да третираш AudioContext като за изхвърляне. Едната половина казва да не го оставяш да умре, когато не бива; другата казва да не го убиваш, когато не се налага. И има малка ирония в хронологията — cleanup-ът на v1.8.16 беше написан, за да „съответства на cleanup шаблона на RealtimeAudioCaptureService", а после v2.0.2 пренаписа точно този шаблон в RealtimeAudioCaptureService, защото навикът на шаблона да прави close() всеки път се оказа бъгът. Двете услуги продължават да се учат една от боравенето с аудио жизнения цикъл на другата, по една поправка наведнъж.

Три неща, на които ни научиха тези издания

  1. AudioContext има машина на състоянията — третирай state като носещ. running, suspended и closed не са дреболии. ОС може да мести контекст между тях, без да те пита, и различни части от графа ти реагират различно (ScriptProcessorNode на главната нишка продължава; дестинацията, тактувана от хардуера, не). Ако не следиш onstatechange, се доверяваш, че контекстът остава там, където си го оставил — а на истински desktop, под промени на фокуса и запис на екрана, няма да остане.
  2. close() не е free(). Интуицията, че своевременното затваряне на ресурс е винаги правилно, е точно това, което причини втория бъг. Когато ресурсът е слот в твърд, наложен от браузъра пул, който се освобождава само по време на GC, агресивното затваряне е начинът, по който изчерпваш пула. Понякога правилният жизнен цикъл е създай-веднъж-суспендирай-често, не създай-и-унищожи-при-всяко-използване.
  3. Някои бъгове са достижими само през вратата на истинския хардуер и истинското време. „Умира след около шест сесии" и „замлъква, когато превключиш" не могат да бъдат хванати от unit тест, който подменя аудио устройството — а екипът го каза направо, вместо да го замаже с фалшиво минаващ тест. Назови границите на своя тестов harness; бъг, който има нужда от осем истински сесии, за да се появи, заслужава честно „проверено ръчно", не зелена отметка, която не означава нищо.

За предишната глава, да различиш обаждане от отворено приложение (v1.8.15–v1.8.19); а за цялата дъга, анатомията на доставянето на софтуер до съвършенство.

Свързани статии

Тишината беше носеща
Steven
Steven7 мин четене

Тишината беше носеща

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

Инженерство
Audio
Надеждност
Да различиш обаждане от отворено приложение
Steven
Steven8 мин четене

Да различиш обаждане от отворено приложение

GeekBye може да забележи, че си се присъединил към видео среща, и да ти предложи да я запише. Разпознаването се оказва лесната половина — Swift binary, който чете заглавия на прозорци на всеки десет секунди. Трудната половина е прецизността: да не се задейства, когато Zoom е просто отворен, да не пита за среща, която вече записваш, и да не заглушава микрофона в обаждането, в което всъщност си. Три издания, всяко от които е предпазител, който трябваше да се научи да не побеждава сам себе си.

Инженерство
macOS
Desktop
Изваждане на backend-а от пътя на качването
Steven
Steven8 мин четене

Изваждане на backend-а от пътя на качването

GeekBye записва екрана ти и запазва видеото в твоя Google Drive. Първата версия прекарваше всеки запис през собствените сървъри на GeekBye по пътя натам; едно издание по-късно, файлът тръгваше директно от машината ти към Drive, а backend-ът беше понижен до това да държи един-единствен указател. Интересната част е колко малко код всъщност съдържа „директната, resumable" версия — защото resumability дойде от изтриването на един proxy, не от написването на такъв.

Инженерство
Архитектура
Desktop