ひとりで Chrome 拡張を作っています。ブラウザのタブで開いた Meet、Zoom、Teams の通話に、翻訳付きの字幕をリアルタイムで重ねる拡張です。会議にボットは入らず、音はタブから直接取ります。以下は、いちばん時間がかかった三つです。数値は 2026 年 9 月 17 日時点です。
Manifest V3 でタブの音を取る
MV3 では拡張のバックグラウンドが service worker で、そこには Web Audio も getUserMedia もありません。そのため service worker はストリーム id だけを受け取り、これらの API が使える見えないページ、offscreen ドキュメント (reasons: ['USER_MEDIA']) を作ります。
const streamId = await chrome.tabCapture.getMediaStreamId({ targetTabId: tabId });
// offscreen ドキュメントの中で:
const stream = await navigator.mediaDevices.getUserMedia({
audio: { mandatory: { chromeMediaSource: 'tab', chromeMediaSourceId: streamId } },
});
一つ目の罠。タブをキャプチャした瞬間、そのタブの音がスピーカーに出なくなり、相手の声が聞こえなくなります。ストリームを出力に戻せば直ります。
const ctx = new AudioContext();
ctx.createMediaStreamSource(stream).connect(ctx.destination);
その後 AudioWorklet がチャンネルをモノラルにまとめ、4096 サンプル単位 (48 kHz で約 85 ms) の Int16 PCM を送ります。
二つ目の罠は "Cannot capture a tab with an active stream" です。前のキャプチャが解放されていないときに出ますが、ページの再読み込みでも chrome.runtime.reload() でも消えません。ストリームは offscreen ドキュメントにあるので、まずその文書を閉じます。それでも足りません。Chrome は非同期に解放するため、タイムアウトで当てずっぽうにせず状態を問い合わせます。
// offscreen ドキュメントを閉じた後、50 ms ごと、最大 500 ms
const tabs = await chrome.tabCapture.getCapturedTabs().catch(() => []);
const busy = tabs.some((t) => t.tabId === tabId && t.status === 'active');
三つ目は製品側の話です。getMediaStreamId はそのタブで拡張が呼び出されていること (activeTab) を要求し、その許可は次の遷移までしか保たれません。再読み込みの後にユーザーへ返すべき正直な言葉は「エラー」ではなく「アイコンを押してください」です。そして取れるのはタブの音だけなので、Zoom や Teams のデスクトップアプリ、スマホは対象外です。
サービス2つ対ストリーム1本
最初の版は Deepgram のストリームで音声認識し、DeepL で翻訳していました。翻訳が文末でまとめて出ないように、中間結果も翻訳に送っていました。DeepL は原文の長さで課金され、終わっていない文は伸び続けて毎回まるごと再送されます。これがどれだけ高くつくかはコードからは分からないので、実測しました。テスト音声を本物の Deepgram ソケットに流し、翻訳呼び出しだけ差し替えて中間結果を通します。8 月 29 日の計測で、確定文だけを訳す場合の 2.53 倍の文字数が翻訳に流れ、通話 1 時間あたり約 2.55 ドルでした。
答えは、認識と翻訳を同じストリームで返す提供者でした。Soniox の stt-rt-v5 に移りました。サーバーが一時キーを発行しますが、TTL が制限するのは「キーがストリームを開ける時間」であって、開いたストリームの寿命ではありません。だから数分の TTL でも通話の長さは問いません。セッション設定は音声の最初の 1 バイトより前に、最初のメッセージとして送ります。
{
api_key, model: 'stt-rt-v5',
audio_format: 'pcm_s16le', sample_rate: 48000, num_channels: 1,
language_hints: ['en'], enable_language_identification: true,
enable_endpoint_detection: true,
translation: { type: 'one_way', target_language: 'ja' },
}
ドキュメントに書かれていなかったこと:
- 文の終わりはサーバーが トークンで知らせますが、翻訳は音声より数トークン遅れます。 で即座に文を閉じると翻訳の尻尾が次の文に貼り付くので、700 ms の窓を持たせています。
- 言語コードに地域がありません。pt-BR と pt-PT は pt に、zh-Hans と zh-Hant は zh になります。
- 400、401、402、403 は再接続では直らず、リトライは 20 秒近い空白の画面を生むだけです。即座に致命的として扱い、別提供者のキーで 3 秒ごとのチャンクを処理する遅い経路に切り替えます。
請求書で見た 1 時間の値段
/v1/usage/summary より、8 月 30 日から 9 月 17 日まで: stt-rt-v5、音声 202.6 時間で 31.23 ドル、1 時間あたり 0.154 ドルです。サービス2つのときの 2.55 ドルに対して 17 分の 1 です。
代償はお金ではなく遅延です。テスト環境では 8 月の Deepgram が確定文まで 2 秒強、9 月 8 日の Soniox は 4.3 秒と 7.6 秒でした。文の切れ目をサーバーが決めるようになったからです。
実際の通話なしでテストする
面白いことはすべて Chrome と実際のプラットフォームで起きるので、ユニットテストではあまり捕まりません。そこで Linux 機にテスト環境を作りました。Chrome のプロファイル3つが同じ通話に入り、それぞれの声で「話し」(118 行の面接台本を事前に合成)、拡張を入れた4つ目のプロファイルが字幕を記録します。スクリプトが台本と突き合わせ、何行認識されたか、どの話者名が付いたか、確定まで何ミリ秒かを見ます。
こうして提供者ごとの遅延差が見え、課金のバグも見つかりました。時間が最初の字幕ではなくソケットを開いた時点から数えられていて、音のないタブが分を食っていたのです。今は最初の確定結果を待ちます。無音のタブでソケットが 43.7 秒開いていて、課金された秒数は 0 でした。
まだ解けていないこと
会話に使うには確定の遅延がまだ大きいこと。そして「タブが無音」と「提供者が黙った」をまだ区別できないこと。PCM のカウンタは無音でも増えるからです。
拡張: https://gettrippi.app/go/qiita_mv3
MV3 のキャプチャ部分への批判は歓迎します。getCapturedTabs のポーリングより確実な方法をご存じの方がいれば、ぜひ教えてください