会議サイレントアラーム

Web Audio API 80 行で作る会議サイレント検出ツール

公開日:

getDisplayMedia でタブ音声を取得し、AnalyserNode で RMS レベルを計算し、会議が沈黙したらマルチチャネルで通知を発火させる仕組みを解説。すべてブラウザ内、バックエンドなし。

長いリモート会議に出ていて、終わってから 5 分後にようやく「あ、もうみんないなかった」と気づいた経験はありませんか。会議用タイマー拡張は色々ありますが、ホストが早めに切り上げたり長引いたりすると固定時間のタイマーは役に立ちません。ほしいのは 「会話が一定時間沈黙したら教えてほしい」 という機能です。

会議サイレントアラーム はまさにそれをやる小さなブラウザツールです。

  1. 会議タブをページに共有する。
  2. ページがそのタブの音声を監視し、音量を継続的に読み取る。
  3. 音量が閾値を下回った状態が設定時間続いたら、4 つのチャネル(モーダル、システム通知、合成アラーム音、タブタイトル点滅)で同時に通知する。

肝心の処理は TypeScript で約 80 行。すべてクライアントサイドで動き、音声はブラウザ外に出ません。本記事では中身がどう動いているかを順に説明します。

使う 2 つのブラウザ API

必要なのはたった 2 つです。

外部ライブラリも、バックエンドも、録音もなしです。

タブ音声の取得

const stream = await navigator.mediaDevices.getDisplayMedia({
  video: { displaySurface: 'browser' },
  audio: true,
  // 非標準だが Chromium 系で広くサポート——
  // Chrome のピッカーをデフォルトで「タブ」セクションにする。
  selfBrowserSurface: 'exclude',
  systemAudio: 'include',
});

ぱっと見ではわからない注意点がいくつかあります。

audio: true 単独はダメ。 仕様上、video も同時に指定する必要があります(実際には映像はいらないのに)。両方取得し、映像トラックはすぐに停止してリソースを解放します。

stream.getVideoTracks().forEach((track) => track.stop());

音声取得はオプトイン。 Chrome のピッカー下部に「タブの音声を共有」というチェックボックスがあります。ユーザーがチェックを入れないと getAudioTracks().length === 0 のストリームが返ります。検出して再共有を促すべきです。

const audioTracks = stream.getAudioTracks();
if (audioTracks.length === 0) {
  stream.getTracks().forEach((track) => track.stop());
  throw new Error('音声を取得できませんでした。「タブの音声を共有」にチェックを入れてください。');
}

Firefox は非対応。 Firefox の画面共有ダイアログには「タブの音声を共有」というチェックボックス自体が存在しません。navigator.mediaDevices?.getDisplayMedia で特性検出して優雅にフォールバックする必要があります。

RMS → dB

MediaStream を取得できたら、Web Audio グラフに繋いでタイマーで振幅を読み取ります。

const ctx = new AudioContext();
const source = ctx.createMediaStreamSource(stream);
const analyser = ctx.createAnalyser();
analyser.fftSize = 2048;
source.connect(analyser);
// 注意:analyser を ctx.destination には繋がない。
// 音声を *測定* したいだけで、ユーザーのスピーカーに *再生* したいわけではない。

const buffer = new Float32Array(analyser.fftSize);

setInterval(() => {
  analyser.getFloatTimeDomainData(buffer);
  let sumSquares = 0;
  for (let i = 0; i < buffer.length; i++) {
    sumSquares += buffer[i] ** 2;
  }
  const rms = Math.sqrt(sumSquares / buffer.length);
  const db = rms > 0 ? 20 * Math.log10(rms) : -100;
  // db は概ね [-100, 0] の範囲に収まる
}, 200);

ポイント:

バックグラウンドタブのスロットリング(影響なし)

ユーザーが別タブに切り替えると、setInterval は最低 1 秒に 1 回までスロットリングされます。リアルタイム音声監視には不利に聞こえますが、本ユースケースでは問題になりません。

実際の使用感としては、会議を別タブ、本ツールをもう一つ別タブで動かし、自分は 3 つ目のタブで別作業——という状況でも無音検出はちゃんと発火します。

アラーム音の合成

無音を検出したら、ユーザーの注意を惹く必要があります。アラーム音を鳴らすのが一番ハードルが低い手段ですが、音声アセットは載せたくありません(リクエストもビルド成果物も増える)。Web Audio API なら無料で合成できます。

class AlarmSound {
  private ctx: AudioContext | null = null;
  private oscillator: OscillatorNode | null = null;
  private gain: GainNode | null = null;
  private timer: number | null = null;

  start() {
    this.ctx = new AudioContext();
    this.gain = this.ctx.createGain();
    this.gain.gain.value = 0;
    this.gain.connect(this.ctx.destination);

    this.oscillator = this.ctx.createOscillator();
    this.oscillator.type = 'square';
    this.oscillator.frequency.value = 880;
    this.oscillator.connect(this.gain);
    this.oscillator.start();

    const beep = () => {
      if (!this.ctx || !this.gain) return;
      const t = this.ctx.currentTime;
      const g = this.gain.gain;
      g.cancelScheduledValues(t);
      g.setValueAtTime(0, t);
      g.linearRampToValueAtTime(0.25, t + 0.02);  // attack
      g.setValueAtTime(0.25, t + 0.18);            // sustain
      g.linearRampToValueAtTime(0, t + 0.22);      // release
    };

    beep();
    this.timer = window.setInterval(beep, 600);
  }

  stop() {
    if (this.timer !== null) clearInterval(this.timer);
    try { this.oscillator?.stop(); } catch {}
    this.ctx?.close();
  }
}

880 Hz の方形波オシレーターは、ユーザーが他のことをしていても十分突き抜けて聞こえます。GainNode で各ビープに小さな ADSR エンベロープをかけることで、連続音が貼り付いたような不快な音にならないようにしています。setInterval(beep, 600) でエンベロープをループさせると、定番の「ピッ—ピッ—ピッ」のアラームパターンが得られます。

3 重通知

音声だけだと取りこぼします——スピーカーがミュートかもしれないし、出力デバイスがそのタブを聞いていないかもしれない。なので 3 つの追加チャネルを並行して発火させます。

  1. ページ内モーダル —— フルスクリーンで全てを覆い、明示的に閉じるまで残る。
  2. Notification —— ブラウザのネイティブシステム通知。ユーザーがタブを離れている前提の本ユースケースに最適。権限はユーザージェスチャーコンテキスト内でしか要求できないので、getDisplayMedia を呼ぶ同じクリックハンドラ内で要求し、画面共有のプロンプトと並べて出します。
  3. タブタイトル点滅 —— 800 ミリ秒ごとに元のタイトルと ”⚠ 会議が終了したかも” を入れ替え。ページがバックグラウンドでもタブストリップで見えます。

プライバシー設計

これは強調しておきたいポイントです:会議音声は最初から最後までブラウザ外に出ません。

ユーザーから見ると、これは典型的な会議ツールよりも強いプライバシー保証です。そもそも何も取得していないので、漏らしようがない。

おかげで、純粋な静的サイト(私たちの場合は Cloudflare Workers Static Assets)として、サーバーサイドランタイムゼロでデプロイできています。

試してみる

同じパターン(「X が止まったら起こして」型のツール)で何か作ったら、ぜひ issue tracker で教えてください。気になります。