Steven
Steven8 min czytania

Trzy czasowniki, które utrzymują Web Audio przy życiu

Dwa wydania punktowe GeekBye, dwa miesiące od siebie i w dwóch różnych plikach, nauczyły nasz kod audio tej samej lekcji z przeciwnych stron: przestań traktować przeglądarkowy AudioContext jak obiekt jednorazowy. Jedno wydanie nauczyło się resume() kontekstu, który macOS po cichu zawiesił w środku nagrywania; drugie nauczyło się suspend() zamiast close(), żeby kolejne po sobie sesje przestały uderzać w narzucony przez Chromium pułap mniej więcej sześciu kontekstów. Resume, suspend, close — oto cała fabuła.

Inżynieria
Audio
Desktop
Wydania GeekBye
Trzy czasowniki, które utrzymują Web Audio przy życiu

Przez większość czasu przeglądarkowy AudioContext zachowuje się jak zwykły obiekt: konstruujesz jeden, podpinasz kilka węzłów i zapominasz o nim. Dwa wydania GeekBye to właśnie to, co się stało, gdy to założenie się załamało — dwukrotnie, w dwóch różnych częściach aplikacji, dwa miesiące od siebie. Żadne nie było planowane jako kontynuacja drugiego. Ale zestaw je obok siebie, a opowiadają jedną historię: przeglądarkowy AudioContext nie jest obiektem jednorazowym. To rzadki, należący do systemu operacyjnego zasób z prawdziwą maszyną stanów, a trzy czasowniki tej maszyny — resume, suspend, close — to cała fabuła obu wydań.

GeekBye ma dwa osobne potoki audio i właśnie dlatego tej samej lekcji trzeba było nauczyć się dwa razy. Jeden to ścieżka nagrywania wideo (ScreenRecordingService), która buduje graf Web Audio, by zmiksować mikrofon i dźwięk systemu do nagrywanego pliku wideo. Drugi to ścieżka transkrypcji w czasie rzeczywistym (RealtimeAudioCaptureService), która buduje własny graf zasilający zamianę mowy na tekst. Każdy ma własny AudioContext. Każde z poniższych wydań naprawia jeden z nich.

v1.8.16: wideo zamilkło, ale transkrypcja nie

Pierwszy błąd to rozdwojenie jaźni. Nagrywasz spotkanie, przełączasz się na chwilę do innej aplikacji i dzieje się coś subtelnego: plik wideo traci twój mikrofon, podczas gdy transkrypcja na żywo przewija się dalej, jakby nic się nie zmieniło. Transkrypcja przysięga, że mówiłeś. Wideo milczy. To doprowadzający do szału rodzaj błędu, bo dwie rzeczy, które powinny się zgadzać — „czy mikrofon cię usłyszał" — otwarcie się nie zgadzają.

Commit, który to naprawia, precyzyjnie nazywa przyczynę:

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.

Oto mechanika. ScreenRecordingService.setupMonoMixingPipeline() buduje jeden AudioContext, MediaStreamDestination, który staje się ścieżką audio wideo, GainNode do wyciszania oraz parę węzłów ScriptProcessorNode (przez createScriptProcessor), które pompują ramki audio do logiki VAD/STT. Gdy macOS zawiesza kontekst — a może to zrobić, gdy aplikacja traci fokus podczas nagrywania ekranu — części grafu napędzane zegarem sprzętowym audio zatrzymują się: MediaStreamDestination przestaje odbierać dźwięk z mikrofonu, więc nagrywane wideo cichnie. Ale callback onaudioprocess węzła ScriptProcessorNode działa na głównym wątku JavaScriptu, a ten wątek tyka dalej. Więc strona STT wciąż produkuje wpisy transkrypcji z tego, co miała ostatnio, i dwaj konsumenci tego samego mikrofonu się rozjeżdżają.

Poprawka polega na tym, by zauważyć zawieszenie i je cofnąć:

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

To cała zmiana poprawności: obserwuj state kontekstu, a jeśli spada do suspended, gdy wciąż mamy nagrywać, wywołaj resume(). Wydanie podpina też nasłuchiwacze onmute, onunmute i onended na MediaStreamTrack mikrofonu — nie po to, by zmienić zachowanie, lecz by wyciszenie na poziomie systemu albo odłączenie urządzenia pojawiło się w logach, zamiast być niewidzialną przyczyną ciszy. Gdy cała trudność błędu sprowadza się do „dlaczego dźwięk się zatrzymał", uczynienie zatrzymania dźwięku obserwowalnym to połowa poprawki.

Kolejny commit wylądował parę minut później, by posprzątać po tych nowych nasłuchiwaczach: cleanup() zeruje teraz micTrack.onmute/onunmute/onended i ustawia audioContext.onstatechange = null, zanim rozłoży graf. Notka commita mówi, że „dopasowuje wzorzec cleanupu RealtimeAudioCaptureService" — drugiego serwisu audio aplikacji. Zapamiętaj ten szczegół, bo dwa miesiące później to właśnie ten wzorzec musiało przepisać następne wydanie.

v2.0.2: dźwięk i transkrypcje umierają po mniej więcej sześciu sesjach

Drugi błąd mieszka w drugim potoku, RealtimeAudioCaptureService, i ma cudowny kształt: wszystko działa idealnie, a potem, gdy uruchomisz i zatrzymasz nagrywanie kilka razy w jednej sesji aplikacji, dźwięk po prostu... umiera. Najpierw cichnie dźwięk systemowy, potem zatrzymuje się transkrypcja. Zrestartuj aplikację i znów jest dobrze — aż do kolejnej garstki sesji.

Notka o pierwotnej przyczynie warta jest przeczytania w całości, bo to podręcznikowy błąd-za-błędem:

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'.

Pułapka polega na tym, że stary kod robił rzecz „odpowiedzialną". Każdy start() tworzył świeży AudioContext; każdy cleanup() wywoływał na nim close(). Zamykanie zasobu, gdy się z nim skończyło, to dokładnie to, czego cię uczą. Był nawet komentarz w kodzie racjonalizujący to marnotrawstwo — że przeładowywanie modułu AudioWorklet przy każdym świeżym kontekście jest w porządku, bo „świeży kontekst na każdy start() znaczy, że zawsze płacimy ten koszt raz".

Ale Chromium ogranicza, ile sprzętowo wspieranych kontekstów AudioContext może przechowywać jedna strona — mniej więcej sześć — i, co kluczowe, close() nie oddaje slotu w chwili, gdy go wywołasz. Zamknięty kontekst zajmuje swój slot, dopóki nie zostanie odśmiecony przez GC, co dzieje się, kiedy tylko silnik JS znajdzie na to czas. Więc skrupulatna polityka zamykania-za-każdym-razem to dokładnie ta rzecz, która wprowadza cię pod sufit: sześć cykli utworzenia/zamknięcia później wszystkie sześć slotów trzymają jeszcze niezebrane trupy, siódmy new AudioContext() wraca martwy, a każdy węzeł, który do niego podepniesz, po cichu nic nie robi.

Poprawka polega na tym, by przestać traktować kontekst jak jednorazowy. Utwórz go raz i używaj ponownie przez cały czas życia serwisu:

  • W setupMonoMixingPipeline konstruuj new AudioContext(...) tylko, jeśli żadnego jeszcze nie ma albo istniejący jest closed. Jeśli istnieje, ale jest suspended, wywołaj await audioContext.resume() zamiast tworzyć nowy.
  • W cleanup zastąp audioContext.close() przez audioContext.suspend() (z zabezpieczeniem, by nigdy nie wywołać go na już zamkniętym kontekście). Kontekst przeżywa sesję.
  • Ponieważ kontekst przeżywa, przeżywa z nim załadowany moduł AudioWorklet — kod nie resetuje już flagi „worklet załadowany" przy cleanupie, więc audioWorklet.addModule(...) płaci się raz na cały czas życia serwisu, a nie co sesję.

Elementy przypadające na sesję — węzły createMediaStreamSource dla mikrofonu i dźwięku systemowego, worklet miksera oraz cichy passthrough, który utrzymuje przy życiu pętlę przechwytu dźwięku systemowego Chromium — są nadal budowane i rozkładane w każdym cyklu. Trwa tylko sam AudioContext, więc w każdej chwili istnieje najwyżej jeden. Sufit jest nieosiągalny, bo nigdy nie ustawiasz pod nim kontekstów jeden na drugim.

Jedna uczciwa uwaga, jaką commit czyni o sobie samym: zostało to zweryfikowane ręcznie — „ręcznie zweryfikowane: 8+ kolejnych sesji utrzymuje słyszalny dźwięk systemowy i płynące transkrypcje (było martwe przy sesji 7)" — a autor szczerze przyznaje, że cyklu życia Web Audio „nie da się przetestować jednostkowo w jsdom bez dużego nowego mocka". To właściwy rodzaj uczciwości. Awaria pojawia się tylko na prawdziwym urządzeniu audio na przestrzeni prawdziwych sesji; zielony zestaw testów jednostkowych niczego by nie dowiódł. „Mniej więcej sześć" i „martwe przy sesji 7" to obserwacje zespołu, gdzie ugryzł limit Chromium, a nie udokumentowana stała — sam kod asekuruje się tyldą, i tak samo powinien każdy, kto to powtarza.

Resume, suspend, close

Ustaw oba wydania obok siebie, a symetria jest niemal zbyt zgrabna. Oba dotyczą tych samych trzech metod na tym samym rodzaju obiektu, podejść z przeciwnych kierunków:

  • v1.8.16 nauczyło się resume(). Kontekst został suspended spod nas przez system, a poprawka polega na tym, by to wychwycić i wznowić — utrzymać w ruchu kontekst, który ma działać.
  • v2.0.2 nauczyło się suspend() zamiast close(). Stary kod był zbyt skory do niszczenia kontekstu, a poprawka polega na tym, by go zawiesić i użyć ponownie — utrzymać przy życiu kontekst, który jest kosztowny w odtworzeniu.

To dwie połowy jednej zasady: przestań traktować AudioContext jako coś na wyrzucenie. Jedna połowa mówi: nie pozwól mu umrzeć, gdy nie powinien; druga mówi: nie zabijaj go, gdy nie musisz. I jest mała ironia w tej osi czasu — cleanup z v1.8.16 napisano tak, by „dopasować wzorzec cleanupu RealtimeAudioCaptureService", a potem v2.0.2 przepisało dokładnie ten wzorzec w RealtimeAudioCaptureService, bo nawyk „close() za każdym razem" okazał się właśnie tym błędem. Oba serwisy wciąż uczą się od siebie nawzajem obsługi cyklu życia audio, jedna poprawka na raz.

Trzy rzeczy, których nauczyły te wydania

  1. AudioContext ma maszynę stanów — traktuj state jak coś nośnego. running, suspended i closed to nie ciekawostki. System może przenosić kontekst między nimi, nie pytając cię o zdanie, a różne części twojego grafu reagują różnie (ScriptProcessorNode na głównym wątku działa dalej; napędzane zegarem sprzętowym miejsce docelowe nie). Jeśli nie obserwujesz onstatechange, ufasz, że kontekst zostanie tam, gdzie go zostawiłeś — a na prawdziwym pulpicie, przy zmianach fokusu i nagrywaniu ekranu, nie zostanie.
  2. close() to nie free(). Intuicja, że szybkie zamykanie zasobu jest zawsze słuszne, to dokładnie to, co wywołało drugi błąd. Gdy zasobem jest slot w twardej, narzuconej przez przeglądarkę puli, którą odzyskuje się dopiero w czasie GC, agresywne zamykanie to sposób na wyczerpanie tej puli. Czasem właściwym cyklem życia jest utwórz-raz-zawieszaj-często, a nie utwórz-i-zniszcz-na-każde-użycie.
  3. Do niektórych błędów da się dotrzeć tylko przez drzwi prawdziwego sprzętu i prawdziwego czasu. „Umiera po mniej więcej sześciu sesjach" i „cichnie, gdy się przełączysz" nie da się wychwycić testem jednostkowym, który mockuje urządzenie audio — a zespół powiedział to wprost, zamiast zakleić to fałszywie zielonym testem. Nazwij granice swojego zestawu testowego; błąd, który potrzebuje ośmiu prawdziwych sesji, by się pojawić, zasługuje na szczere „ręcznie zweryfikowane", a nie na zielony ptaszek, który nic nie znaczy.

Po poprzedni rozdział — odróżnić rozmowę od otwartej aplikacji (v1.8.15–v1.8.19); a po cały łuk — anatomia dostarczania oprogramowania do perfekcji.

Powiązane artykuły

Cisza była nośna
Steven
Steven7 min czytania

Cisza była nośna

Dwa ostatnie wydania GeekBye v1 mówią o tej samej niewygodnej prawdzie: transkrypcja w czasie rzeczywistym przez prawdziwą sieć nie jest bezstratna, a uczciwym ruchem jest przestać udawać, że jest. v1.8.20 zachowywało kopię każdego fragmentu dźwięku na dysku, zanim porzuciło go podczas ponownego połączenia, i zaczęło głośno oznaczać luki w transkrypcji. v1.9.0 przestało wysyłać ciszę, by oszczędzić pasmo — i odkryło, że cisza była dokładnie tym sygnałem, po którym transkryber poznawał, że zdanie się skończyło. Dwa wydania o koszcie wyrzucania rzeczy.

Inżynieria
Audio
Niezawodność
Odróżnić rozmowę od otwartej aplikacji
Steven
Steven8 min czytania

Odróżnić rozmowę od otwartej aplikacji

GeekBye potrafi zauważyć, że dołączyłeś do spotkania wideo, i zaproponować jego nagranie. Wykrywanie okazuje się łatwiejszą połową — natywny plik Swift czytający tytuły okien co dziesięć sekund. Trudną połową jest precyzja: nie odpalać, gdy Zoom jest tylko otwarty, nie proponować nagrywania spotkania, które już nagrywasz, i nie wyciszać mikrofonu w rozmowie, w której faktycznie jesteś. Trzy wydania, a każde z nich to zabezpieczenie, które musiało nauczyć się nie pokonywać samego siebie.

Inżynieria
macOS
Desktop
Wyjmowanie backendu ze ścieżki przesyłania
Steven
Steven7 min czytania

Wyjmowanie backendu ze ścieżki przesyłania

GeekBye nagrywa twój ekran i zapisuje wideo na twój Google Drive. Pierwsza wersja przesyłała każde nagranie przez własne serwery GeekBye po drodze; wydanie później plik szedł prosto z twojej maszyny na Drive, a backend został zdegradowany do trzymania jednego wskaźnika. Ciekawą częścią jest to, jak mało kodu naprawdę zawiera wersja „bezpośrednia, wznawialna" — bo wznawialność wzięła się z usunięcia proxy, a nie z napisania go.

Inżynieria
Architektura
Desktop