0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

getDisplayMediaで別タブの音声を取り込む:Web Audioに流すまでのハマりどころ

0
Posted at

ブラウザで動くリアルタイム文字起こし・翻訳アプリを作っていると、必ず聞かれるのが「別のタブで再生しているYouTubeやGoogle Meetの音声を文字にしたい」という要望です。

昔からの答えは仮想オーディオデバイス(WindowsのVB-CABLE、MacのBlackHole)ですが、ドライバのインストールをお願いした時点で多くのユーザーが離脱します。Chromium系ブラウザなら、拡張機能なしで navigator.mediaDevices.getDisplayMedia() を使ってタブの音声を取り込めます。実際にリアルタイム処理のパイプラインへ組み込んだときにハマった点をまとめます。

最小構成

const stream = await navigator.mediaDevices.getDisplayMedia({
  video: true,
  audio: true,
});
const [track] = stream.getAudioTracks();

共有ダイアログでタブを選び、「タブの音声も共有する」をオンにしてもらうと MediaStream が返ってきます。あとはマイクと同じようにWeb Audioへ流せます。

const ctx = new AudioContext({ sampleRate: 48000 });
const source = ctx.createMediaStreamSource(stream);
source.connect(processingNode);

ここまでは簡単です。問題はここからです。

1. 音声だけ欲しくても video: true が必要

Chromeでは getDisplayMedia({ audio: true }) のように映像なしで呼ぶとエラーになります。video: true で取得して、映像トラックは使わずに無効化しておきます。

stream.getVideoTracks().forEach(t => { t.enabled = false; });

stop() で止めると環境によっては共有そのものが終了してしまうことがあったため、enabled = false にしておくのが無難でした。

2. 「許可された」=「音声が取れた」ではない

ユーザーがウィンドウを選んだり、「タブの音声も共有する」のチェックを忘れたりすると、Promise は音声トラックのないストリームで正常に resolve します。明示的にチェックして、何をすればいいかを伝えます。

if (!stream.getAudioTracks().length) {
  stream.getTracks().forEach(t => t.stop());
  throw Object.assign(new Error('音声が共有されていません'), { name: 'NoSharedAudioError' });
}

表示するメッセージは「タブを選んで『タブの音声も共有する』をオンにしてください」のように具体的に。NotAllowedError はこの場合たいてい「ダイアログを閉じた」だけなので、マイク権限のエラーと同じ案内(サイト設定を変えてください)を出さないよう区別しておきます。

3. ユーザー操作の中で、最初に呼ぶ

getDisplayMedia はクリックなどのユーザー操作(user activation)の中で呼ぶ必要があります。最初の実装では、開始ボタンのハンドラでセッションAPIの呼び出しやWebSocket接続を await してから音声を取得していました。getUserMedia ならこれでも動きますが、画面共有では不安定になります。

対策はシンプルで、音声ソースが「タブ」のときはクリックハンドラの一番最初に getDisplayMedia を呼び、取得したストリームを後続の処理で使い回します。

4. 再起動のたびに共有ダイアログを出さない

リアルタイム処理のセッションは意外と頻繁に作り直します。言語の切り替え、接続断からの復帰、認識設定の変更……。そのたびに getDisplayMedia を呼ぶと、配信を見ている最中に共有ダイアログが何度も出てしまいます。

ストリームをモジュールレベルで保持し、音声トラックが生きている間は再利用します。

let displayStream = null;

async function getTabAudio() {
  if (displayStream?.getAudioTracks().some(t => t.readyState === 'live')) {
    return displayStream;
  }
  displayStream = await navigator.mediaDevices.getDisplayMedia({
    video: true,
    audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false },
  });
  // …2.のチェック…
  displayStream.getAudioTracks()[0].addEventListener('ended', onShareEnded);
  return displayStream;
}

その代わり、後片付けの処理は「停止(すべて解放)」と「再起動(共有中のタブは残す)」を区別する必要があります。私は stop({ restart: true }) という明示的な経路を用意し、再起動時は共有ストリームのトラックを止めないようにしました。

5. ブラウザの「共有を停止」ボタンに対応する

Chromeは共有中に「共有を停止」バーを表示します。ユーザーがこれを押すと音声トラックで ended が発火します。無視すると、アプリは無音を聞き続けることになります。セッションを止めて、理由を表示しましょう。

function onShareEnded() {
  displayStream = null;
  if (running) {
    notify('タブの共有が終了したため、聞き取りを停止しました。');
    stopSession();
  }
}

6. 音声処理(エコー除去など)をオフにする

ブラウザの音声取得のデフォルトは「人がマイクに向かって話す」前提で、エコーキャンセル、ノイズ抑制、自動ゲイン調整が有効です。タブの音声ではこれが逆効果で、動画のBGMや声が「エコー」や「ノイズ」として削られることがあります。

audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false }

getUserMedia 経由で仮想オーディオデバイスを使う場合も同じなので、アプリには「パソコンの音声」というチェックボックスを用意して、この3つをまとめてオフにできるようにしています。

7. 48kHzステレオ → 16kHzモノラルPCMに変換する

タブの音声はたいてい48kHzのステレオです。多くの音声認識APIは16kHz・モノラル・16bit PCMを求めるので、変換が必要です。ScriptProcessorNode は非推奨なので、AudioWorklet で書くとこうなります(単純な間引きなので、品質を上げたい場合はローパスフィルタを挟んでください)。

// pcm-worklet.js
class PcmWorklet extends AudioWorkletProcessor {
  constructor() {
    super();
    this.ratio = sampleRate / 16000; // sampleRate は AudioWorkletGlobalScope のグローバル
    this.pos = 0;
  }
  process(inputs) {
    const input = inputs[0];
    if (!input.length) return true;
    const frames = input[0].length;
    const out = [];
    for (; this.pos < frames; this.pos += this.ratio) {
      const i = Math.floor(this.pos);
      let v = 0;
      for (const ch of input) v += ch[i];   // ステレオ → モノラル
      v /= input.length;
      out.push(Math.max(-1, Math.min(1, v)) * 0x7fff);
    }
    this.pos -= frames;
    this.port.postMessage(Int16Array.from(out).buffer);
    return true;
  }
}
registerProcessor('pcm-worklet', PcmWorklet);
await ctx.audioWorklet.addModule('/pcm-worklet.js');
const node = new AudioWorkletNode(ctx, 'pcm-worklet');
node.port.onmessage = e => socket.send(e.data); // 16kHz Int16 のチャンク
ctx.createMediaStreamSource(stream).connect(node);

8. 使えない環境を知っておく

  • スマホのブラウザ:タブ音声の取り込みはできません。失敗させるより、モバイルでは選択肢自体を出さない方が親切です。
  • macOS:Chromeの「タブ」の音声は安定して取れますが、画面全体やデスクトップアプリの音声は制約があります。Macのデスクトップアプリ(Zoomのアプリ版など)にはBlackHoleのような仮想デバイスが現実的です。
  • Windows:画面全体の共有でシステム音声も含められるので、デスクトップアプリの音声もカバーできます。
  • Firefox・Safari:getDisplayMedia の音声対応は異なります。最新の対応状況はMDNの互換性表で確認し、UAで判定せず機能検出で分岐しましょう。

9. セッション中にマイク ⇔ タブを切り替えられるようにする

「マイク」と「タブの音声」を同じ関数の裏にある2つのソースとして扱っておくと、実行中に切り替えられます。古い MediaStreamAudioSourceNode を disconnect() し、新しいストリームから作ったノードを同じ処理ノードにつなぐだけです。サーバーへの再接続は不要です。「ソースを間違えた!」をやり直しなしで直せるのは、ユーザーにかなり喜ばれました。

まとめ

  • video: true で取得し、映像トラックは無効化。音声トラックの有無を必ず確認する
  • クリックハンドラの最初に呼ぶ
  • 再起動ではストリームを使い回し、明示的な停止のときだけ解放する
  • 音声トラックの ended を監視する
  • エコーキャンセル・ノイズ抑制・自動ゲインをオフにする
  • 機能検出で分岐し、使えない環境では仮想オーディオデバイスを案内する

実際の動きは、自作のブラウザ向けリアルタイム文字起こし・翻訳ツール LiveToka の「タブまたは画面の音声」で試せます。YouTubeを別タブで開いて共有してみてください。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?