
Web Audioを生かし続ける三つの動詞
二ヶ月を隔てて別々のファイルで出た二つのGeekByeポイントリリースが、私たちのオーディオコードに同じ教訓を正反対の端から教えました: ブラウザのAudioContextを使い捨てとして扱うのをやめよ、と。一方のリリースは、macOSが録画中にこっそりsuspendしたコンテキストをresume()することを学びました; もう一方は、連続するセッションがChromiumのおよそ六コンテキストの上限に激突するのをやめさせるべく、close()する代わりにsuspend()することを学びました。resume、suspend、close — それが筋の全部です。
たいていの場合、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、そしてオーディオフレームをVAD/STTのロジックへ送り込む一対のScriptProcessorNode(createScriptProcessor経由)を組み立てます。macOSがコンテキストをサスペンドすると — 画面録画中にアプリがフォーカスを失うと、それは起こりえます — オーディオハードウェアのクロックによって駆動されるグラフの部分が止まります: MediaStreamDestinationはマイクのオーディオを受け取るのをやめ、だから録画されるビデオは静かになります。しかしScriptProcessorNodeのonaudioprocessコールバックはJavaScriptのメインスレッドで走り、そのスレッドは時を刻み続けます。だからSTT側は最後に持っていたものから文字起こしのエントリを生み出し続け、同じマイクの二人の消費者は分岐します。
修正は、そのサスペンドに気づいてそれを元に戻すことです:
audioContext.onstatechange = () => {
if (audioContext.state === 'suspended' && this.status === 'recording') {
logger.info('AudioContext suspended during recording, resuming')
audioContext.resume()
}
}
それが正しさの変更の全部です: コンテキストのstateを見張り、私たちがまだ録画しているはずなのにそれがsuspendedに落ちたら、resume()を呼ぶ。このリリースはまた、マイクのMediaStreamTrack上にonmute、onunmute、onendedのリスナーを配線します — 振る舞いを変えるためではなく、OSレベルのミュートやデバイスの切断が、無音の見えざる原因である代わりにログに現れるようにするためです。バグの難しさのすべてが「なぜオーディオは止まったのか」であるとき、オーディオの停止を_観測可能に_することは、修正の半分です。
数分後に後続のコミットが着地し、それらの新しいリスナーの後片付けをしました: cleanup()は今や、グラフを取り壊す前にmicTrack.onmute/onunmute/onendedをnullにし、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()はあなたが呼んだその瞬間に枠を返してはくれません。クローズされたコンテキストは、ガベージコレクトされるまでその枠を占め続け、それはJSエンジンが手が空いたときにいつでも起こります。だから、几帳面な毎回クローズの方針こそが_まさに_、あなたを天井へと歩ませるものです: 六回の作成/クローズのサイクルの後、六つの枠はすべてまだ回収されていない死骸に握られ、七つ目のnew AudioContext()は死んで返り、あなたがそれに取り付けるどのノードも静かに何もしません。
修正は、コンテキストを使い捨てとして扱うのをやめることです。それを一度だけ作り、サービスの寿命のあいだ再利用します:
setupMonoMixingPipelineでは、まだ一つも無いか、既存のものがclosedである場合にのみnew AudioContext(...)を構築します。存在するがsuspendedなら、新しいものを作る代わりにawait audioContext.resume()します。cleanupでは、audioContext.close()をaudioContext.suspend()に置き換えます(すでにクローズされたコンテキストに対して決して呼ばれないようガードされます)。コンテキストはセッションを生き延びます。- コンテキストが生き延びるので、読み込み済みの
AudioWorkletモジュールもそれと共に生き延びます — コードはもはやクリーンアップで「ワークレット読み込み済み」フラグをリセットしないので、audioWorklet.addModule(...)はセッションごとにではなく、サービスの寿命全体に対して一度だけ支払われます。
セッションごとの部品 — マイクとシステムオーディオのためのcreateMediaStreamSourceノード、ミキサーのワークレット、そしてChromiumのシステムオーディオのループバックのタップを生かし続ける無音のパススルー — は、依然として毎サイクル組み立てられ、取り壊されます。AudioContextそのものだけが存続するので、どの時点でもせいぜい一つしか存在しません。天井には決して届きません、なぜならあなたはコンテキストをそれに積み上げることが決してないからです。
コミットが自分自身について記す一つの正直なメモ: これは手作業で検証されました — 「手動で検証: 8つ以上の連続セッションでシステムオーディオは聞こえ続け、文字起こしは流れ続ける(セッション7で死んでいた)」 — そして著者は、Web Audioのライフサイクルが「大きな新しいモックハーネスなしにはjsdomでユニットテスト可能ではない」ことに率直です。それは正しい種類の正直さです。その失敗は、本物のセッションをまたいだ本物のオーディオデバイスに対してのみ現れます; 緑のユニットスイートは何も証明しなかったでしょう。「およそ六つ」と「セッション7で死んだ」は、Chromiumの上限が噛みついた場所についてのチームの観察であって、文書化された定数ではありません — コード自身がチルダで濁しており、それを繰り返す誰もがそうすべきです。
resume、suspend、close
二つのリリースを並べると、その対称性はほとんど整いすぎています。どちらも、同じ種類のオブジェクト上の同じ三つのメソッドについてのもので、正反対の方向から近づかれています:
- v1.8.16は
resume()することを学びました。 コンテキストはOSによって足元からsuspendedにされ、修正はそれを捕らえてresumeすることです — 動いているはずのコンテキストを、動いたままに保つ。 - v2.0.2は
close()する代わりにsuspend()することを学びました。 古いコードはコンテキストを破壊するのに性急すぎ、修正はそれをサスペンドして再利用することです — 作り直すのが高くつくコンテキストを、生かしたままに保つ。
それらは単一の原則の二つの半分です: AudioContextを使い捨てとして扱うのをやめよ。 一方の半分は、そうすべきでないときにそれを死なせるな、と言う; もう一方は、そうする必要がないときにそれを殺すな、と言う。そしてタイムラインには小さな皮肉があります — v1.8.16のクリーンアップは「RealtimeAudioCaptureServiceのクリーンアップパターンに合わせる」ように書かれ、それからv2.0.2がRealtimeAudioCaptureServiceの中でまさにそのパターンを書き直しました、なぜならそのパターンの毎回close()する習慣こそがバグだと分かったからです。二つのサービスは、一度に一つの修正ずつ、互いのオーディオライフサイクルの扱いから学び続けています。
これらのリリースが教えてくれた三つのこと
AudioContextはステートマシンを持つ —stateを耐荷重として扱え。running、suspended、closedは雑学ではありません。OSはあなたに尋ねることなくコンテキストをそれらのあいだで移すことができ、あなたのグラフの異なる部分は異なる反応をします(メインスレッド上のScriptProcessorNodeは動き続ける; ハードウェアのクロックで駆動される宛先はそうではない)。もしあなたがonstatechangeを見張らなければ、あなたはコンテキストがあなたの残した場所に留まると信じていることになります — そして本物のデスクトップでは、フォーカスの変化と画面録画のもとで、それは留まりません。close()はfree()ではない。 リソースを速やかにクローズするのが常に正しいという直観こそが、二つ目のバグを引き起こしたものです。リソースが、GCの時にのみ回収される、固くブラウザに課されたプールの中の一つの枠であるとき、積極的なクローズはプールを使い果たすやり方です。時には正しいライフサイクルは、使うたびに作って破壊するのではなく、一度作って何度もサスペンドすることです。- あるバグは、本物のハードウェアと本物の時間という扉を通してのみ届く。 「およそ六セッション後に死ぬ」と「タブを移ると無音になる」は、オーディオデバイスをモックするユニットテストでは捕らえられません — そしてチームは、偽りの合格テストでそれを取り繕う代わりに、はっきりそう言いました。あなたのテストハーネスの限界に名前をつけなさい; 現れるのに八つの本物のセッションを要するバグは、何も意味しない緑のチェックマークではなく、正直な「手動で検証済み」に値します。
前の章については、開いているアプリと通話中を見分ける(v1.8.15–v1.8.19)を; そして弧の全体については、ソフトウェアを完璧に出荷することの解剖学をご覧ください。