ミツカリ技術ブログ

株式会社ミツカリの開発チームのブログです

会議の音声を処理するWebアプリを作るための技術調査

こんにちは、ミツカリCTOの塚本こと、つかびー(@tsukaby0) です。

近年、音声を処理するWebアプリが急速に増えています。たとえば会議の文字起こし・要約を行う Otter.aiFireflies.ai、日本語特化の NottaRimo Voice、商談解析の amptalkMiiTelなど、音声×AIの領域は群雄割拠という状況です。

こうしたサービスを見ていると、自分でも会議中の発言を文字起こししたり、話者ごとの発言量や内容を分析するWebアプリを作りたくなります。そう考えたとき、まずぶつかるのが「そもそもブラウザでどうやって音声を扱うのか?」という問題です。

本記事では、ブラウザの音声関連APIの整理から始めて、オンライン会議・リアル会議の両方に対応した会議分析アプリの技術調査やアーキテクチャ検討をしてみます。

結論

  • ブラウザ標準の SpeechRecognition はマイク入力専用で、スピーカー音声(会議相手の声)は拾えません。また、音声がブラウザベンダーのサーバーに送信される点にも注意が必要です
  • MediaStream Recording API + getDisplayMedia を組み合わせればマイク+タブ音声の同時録音は可能ですが、画面共有ダイアログの操作が必要になるなどUX上の課題があります
  • 音声認識はサーバーサイドSTT(Speech-to-Text)に任せるのが現実的です。話者分離(ダイアライゼーション)もサーバー側で行います
  • オンライン会議の音声取得には、Recall.ai のようなミーティングボットPaaSを使うと、Google Meet・Zoom・Teamsなどの差異を吸収できます
  • リアル会議(対面)はブラウザの getUserMedia + 会議用マイクで収音し、同じサーバーサイドSTTに流せば対応可能です

ブラウザで音声を扱うWeb API

Web Speech API

まずは基礎知識として、ブラウザで音声を扱うAPIである Web Speech API を整理しておきます。

developer.mozilla.org

詳細は上記の記事の通りですが、音声合成の SpeechSynthesis と 音声認識の SpeechRecognition 二つで構成されています。音声合成というのはテキストの音声化のことです。

商談のロープレをAIがしてくれる、というようなサービスを作る場合は SpeechSynthesis が使えそうです。テキストをブラウザ上で簡単にスピーチさせることができます。

会議の文字起こしがしたい、というようなケースでは SpeechRecognition が使えそうです。

他にも SpeechGrammarSpeechSynthesisUtterance など様々なインタフェースがありますが、補助的な設定などであり、代表的なのは前述した二つです。

使い方は割愛します。SpeechRecognition のデモはGoogleが用意しているため、簡単に試すことができます。

https://www.google.com/intl/ja/chrome/demos/speech.html

こちらで日本語を選択してマイクボタンを押してから喋ると文字起こしされます。なかなか精度は良いですね。

ブラウザが標準で用意してくれており便利ですが、欠点もあります。

メモ: Chrome など一部のブラウザーでは、ウェブページ上で音声認識を使用するとサーバーベースの認識エンジンが使用されます。音声を認識処理するためにウェブサービスへ送信するため、オフラインでは動作しません。 引用: SpeechRecognition - https://developer.mozilla.org/ja/docs/Web/API/SpeechRecognition

文字起こしはブラウザで完結する訳ではなく、サーバーに送られることがあります。これは事業者側としても利用者側としても許容できない場合があります。

またブラウザの対応状況も異なります。詳細はcaniuseなどで確認するとわかりますが、例えば SpeechRecognition はEdgeやFirefoxでは不完全な状態だったりします。

さらに重要なポイントとして、SpeechRecognitionマイク入力しか受け付けない という仕様があります。 スピーカーの音がマイクに入った場合は文字起こしされますが、基本的にはそれは期待できないですし、システム音声を取り込むようなことはできないので、ZoomやMeetで流れる音声を拾うことはできません。

MediaStream Recording API (Media Recording API または MediaRecorder API)

https://developer.mozilla.org/ja/docs/Web/API/MediaStream_Recording_API

単純に音声を録音したい場合はこちらを使います。文字起こしはされないので、録音したBlobをサーバーに送ってSTTを行う必要があります。STTとはSpeech-To-Textのことであり、文字起こしという意味です。STTについては後述します。

先ほどの Web Speech API ではマイクは取れてもスピーカー(システム音声)は取れないと説明しましたが、こちらの方式ではそれが可能です。以下に簡単なコードを用意して実験してみます。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>マイク+スピーカー音声キャプチャテスト</title>
  <style>
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
      background: #f5f5f5;
      color: #333;
      padding: 2rem;
      max-width: 720px;
      margin: 0 auto;
    }
    h1 { font-size: 1.4rem; margin-bottom: 0.5rem; }
    .desc { color: #666; font-size: 0.9rem; margin-bottom: 1.5rem; line-height: 1.6; }
    .controls { display: flex; gap: 0.75rem; margin-bottom: 1.5rem; flex-wrap: wrap; }
    button {
      padding: 0.6rem 1.2rem;
      border: none;
      border-radius: 6px;
      font-size: 0.95rem;
      cursor: pointer;
      transition: opacity 0.2s;
    }
    button:disabled { opacity: 0.4; cursor: not-allowed; }
    button:hover:not(:disabled) { opacity: 0.85; }
    #btnStart { background: #2563eb; color: #fff; }
    #btnStop { background: #dc2626; color: #fff; }
    .status {
      padding: 0.75rem 1rem;
      border-radius: 6px;
      margin-bottom: 1rem;
      font-size: 0.9rem;
      line-height: 1.5;
    }
    .status.idle { background: #e5e7eb; }
    .status.recording { background: #fef3c7; }
    .status.done { background: #d1fae5; }
    .status.error { background: #fee2e2; }
    .recording-indicator {
      display: inline-block;
      width: 10px;
      height: 10px;
      background: #dc2626;
      border-radius: 50%;
      margin-right: 6px;
      animation: blink 1s infinite;
    }
    @keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
    #result { margin-top: 1rem; }
    #result audio { width: 100%; margin-top: 0.5rem; }
    .note {
      margin-top: 1.5rem;
      padding: 1rem;
      background: #eff6ff;
      border-radius: 6px;
      font-size: 0.85rem;
      line-height: 1.6;
      color: #1e40af;
    }
  </style>
</head>
<body>
  <h1>マイク+スピーカー音声キャプチャテスト</h1>
  <p class="desc">
    マイク入力(自分の声)とタブ/システム音声(スピーカーから出る相手の声)を<br>
    同時にキャプチャして録音するデモです。
  </p>

  <div class="controls">
    <button id="btnStart">録音開始</button>
    <button id="btnStop" disabled>録音停止</button>
  </div>

  <div id="status" class="status idle">待機中</div>
  <div id="result"></div>

  <div class="note">
    <strong>使い方:</strong><br>
    1.「録音開始」を押すと、まずマイクの許可を求められます<br>
    2. 次に画面共有ダイアログが出ます。<strong>Chromeタブ</strong>を選び、「タブの音声も共有」にチェックを入れてください<br>
    3. 録音中は別タブで音楽や動画を再生すると、スピーカー音声が録れているか確認できます<br>
    4.「録音停止」でオーディオプレーヤーが表示されます
  </div>

  <script>
    const btnStart = document.getElementById('btnStart');
    const btnStop = document.getElementById('btnStop');
    const statusEl = document.getElementById('status');
    const resultEl = document.getElementById('result');

    let recorder = null;
    let chunks = [];
    let micStream = null;
    let displayStream = null;
    let audioCtx = null;

    function setStatus(text, type) {
      statusEl.className = 'status ' + type;
      statusEl.innerHTML = text;
    }

    btnStart.addEventListener('click', async () => {
      try {
        setStatus('マイクの許可を確認中...', 'idle');

        // 1. マイク音声を取得
        micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
        setStatus('タブ音声の共有ダイアログを待っています...', 'idle');

        // 2. タブ/システム音声を取得
        displayStream = await navigator.mediaDevices.getDisplayMedia({
          video: true,  // video は必須(Chrome の仕様)
          audio: true
        });

        // displayStream に音声トラックがあるか確認
        const displayAudioTracks = displayStream.getAudioTracks();
        if (displayAudioTracks.length === 0) {
          setStatus('タブ音声が取得できませんでした。共有時に「タブの音声も共有」にチェックを入れてください。', 'error');
          cleanup();
          return;
        }

        // 3. Web Audio API で両方をミックス
        audioCtx = new AudioContext();
        const dest = audioCtx.createMediaStreamDestination();

        const micSource = audioCtx.createMediaStreamSource(micStream);
        micSource.connect(dest);

        // displayStream から音声トラックだけの MediaStream を作る
        const displayAudioStream = new MediaStream(displayAudioTracks);
        const displaySource = audioCtx.createMediaStreamSource(displayAudioStream);
        displaySource.connect(dest);

        // 4. ミックスしたストリームを録音
        chunks = [];
        recorder = new MediaRecorder(dest.stream);

        recorder.ondataavailable = (e) => {
          if (e.data.size > 0) chunks.push(e.data);
        };

        recorder.onstop = () => {
          const blob = new Blob(chunks, { type: 'audio/webm' });
          const url = URL.createObjectURL(blob);

          resultEl.innerHTML = `
            <p style="font-size: 0.9rem; color: #666;">録音完了(${(blob.size / 1024).toFixed(1)} KB)</p>
            <audio controls src="${url}"></audio>
            <br>
            <a href="${url}" download="recording.webm"
               style="display: inline-block; margin-top: 0.5rem; font-size: 0.85rem; color: #2563eb;">
              ダウンロード
            </a>
          `;

          setStatus('録音完了', 'done');
          cleanup();
        };

        recorder.start(1000); // 1秒ごとにデータを取得

        setStatus('<span class="recording-indicator"></span>録音中... マイク+タブ音声をキャプチャしています', 'recording');
        btnStart.disabled = true;
        btnStop.disabled = false;

        // 画面共有が停止されたら録音も止める
        displayStream.getVideoTracks()[0].addEventListener('ended', () => {
          if (recorder && recorder.state === 'recording') {
            recorder.stop();
          }
        });

      } catch (err) {
        console.error(err);
        if (err.name === 'NotAllowedError') {
          setStatus('マイクまたは画面共有の許可が拒否されました', 'error');
        } else {
          setStatus(`エラー: ${err.message}`, 'error');
        }
        cleanup();
      }
    });

    btnStop.addEventListener('click', () => {
      if (recorder && recorder.state === 'recording') {
        recorder.stop();
      }
    });

    function cleanup() {
      if (micStream) {
        micStream.getTracks().forEach(t => t.stop());
        micStream = null;
      }
      if (displayStream) {
        displayStream.getTracks().forEach(t => t.stop());
        displayStream = null;
      }
      if (audioCtx) {
        audioCtx.close();
        audioCtx = null;
      }
      btnStart.disabled = false;
      btnStop.disabled = true;
    }
  </script>
</body>
</html>

このコードをlocalに保存して実行します。

ブラウザが許可を求めてくるので許可しつつ、音声を取りたいタブを指定します。今回は実験用に用意したYouTubeのタブを選択しますが、実運用上ではMeetやZoom(ブラウザ版)のタブを指定します。

最近のWindowsは分かりませんが、Macの場合はシステム側の許可も出るので、それもONにしておきます。

適当にYouTubeを再生しつつ、自分も喋ってみたところ無事両方とも録音されていました。このBlobをサーバーに送って別途処理(STT)する必要はありますが、やりたいことはできました。

欠点としては別タブの共有を選択しないといけないことと、システム側での許可もいるということです。今回はシステム側の許可は事前にしてあったので(おそらくMeet利用などで事前にしてあったので)、そこは操作しませんでしたが、ユーザーにこの二つの操作をさせるというのは少し嫌ですね。

同じタブであれば共有を省略できます。つまり自分の提供するWebアプリ内にWeb会議機能やロープレ機能を内蔵できるコストが払えるのであればこの操作を一つ減らせます。

また、別ブラウザ・別アプリ(例:Zoomデスクトップ版)にブラウザのWeb APIからは直接アクセスできません。そのため、それらの音声が拾えるかどうかはOS依存の挙動になります。ちなみに私のMacで実験したところ、共有タブ設定範囲外、つまり別アプリの音声も拾えていました。一応音声はクリアなので大丈夫だと思いますが、もしかするとマイク経由で音を拾っている可能性もあるとは思います。

サーバーサイドSTTという選択

ここまでの説明で SpeechRecognition ではスピーカー(自分以外)の音声をテキスト化できない問題があり、 MediaStream Recording API ならば可能だが、テキスト化の機能は持っていないという説明をしました。

後者の技術を使う場合、音声ファイルはサーバー側でテキスト化する必要があります。これをSTT(Speech-to-Text)と言います。ここからはSTTを調査してみます。

主なSTTサービス

サービス 特徴
Google Cloud Speech-to-Text ストリーミング対応、話者分離対応
Azure Speech Services リアルタイム対応、話者分離対応
Amazon Transcribe AWSマネージド、ストリーミング対応、話者分離対応(最大10人)、日本語対応
OpenAI Whisper API 高精度で安価
OpenAI Whisper (OSS) OSS版、セルフホスト可能
Deepgram 低レイテンシ、リアルタイム特化
AssemblyAI 高精度、高機能

今回はどれか一つを選定したり、詳しい調査を行いませんが、使うものによって精度は異なります。

WhisperについてはOSS、セルフホスト版もあり、簡単に試すことができます。以前当社のたなしゅんが以下のような記事を書いてくれました。

tech-blog.mitsucari.com

whisper-small-mlx 実行時間はとてもターボよりもさらに速い結果となりましたが、精度は酷いですね。

whisper-large-v3-mlx 遅い、遅すぎる。しかし精度は最高ですね。やっぱりトレードオフなんですね。

私もWhisperのセルフホスト版は試したのですが精度は微妙でした。利用するモデル次第でもありますが、GPU性能と速度のトレードオフです。WhisperのAPI版は従量課金となりますが、他のクラウドのSTTよりだいぶ安いですし、高性能GPUマシンを用意しなくて済む点は嬉しいです。

導入時はコストやデータの秘匿性を考慮してセルフホストとするか、リスク受容しつつマネージド優先でAPIとするかをまず考え、その後で各サービスのコストおよび性能、機能を比較すると良いかと思います。

話者分離(ダイアライゼーション)

商談ロープレのような単一の話者音声だけであれば問題ないのですが、会議のようなシーンでは「誰が話したか」の判別が重要です。この技術をダイアライゼーション(Diarization)と呼びます。

Google Cloud STTやAzure Speech Servicesはダイアライゼーション機能を内蔵しています。Whisperにはその機能はありません。導入の際は要件に応じた機能を備えているかを検討する必要があります。ただ、Whisperの場合は pyannote-audio などの話者分離ライブラリを組み合わせることで実現もできます。

例えば以下のyousanさんの記事などが参考になると思います。

ayousanz.hatenadiary.jp

注意点として、ダイアライゼーションが出力するのは「Speaker 1」「Speaker 2」のような匿名ラベルです。「田中さん」「佐藤さん」のように名前が自動で付くわけではないので、実名との紐付けはアプリケーション側で別途実装する必要があります。

会議分析Webアプリのアーキテクチャ

ここまでで基礎的な知識や要素をまとめてきました。ここからが本題です。 オンライン会議の音声を取得して分析するアプローチ、アーキテクチャは大きく4つあると思います。

1. Chrome拡張方式

Chrome拡張の chrome.tabCapture APIを使えば、Google Meetなどのタブ音声をキャプチャできます。ユーザーは拡張をインストールするだけで、追加のソフトウェアは不要です。

chrome.tabCapture については以下をご覧ください。

developer.chrome.com

この方式は例えば

などの海外サービスで採用されています。日本製のサービスとしては以下があります。

  • Notta — 日本語含む58言語対応の文字起こし&AI要約。Chrome拡張あり

ユーザーの導入が簡単という一方、Chrome限定、拡張の審査・配布が必要というデメリットがあります。

2. ボット参加者方式

会議にボットを参加者として送り込み、ボットが音声を受信して処理する方式です。

ただし、各プラットフォーム(Meet, Zoom, Teams, etc)でのボット参加方法は異なります。

自前で全プラットフォームに対応するのはメンテナンスコストが高いです。特にヘッドレスブラウザで操作しなければならない場合もあるため、UIがある日突然変わって、ボットがログインできなくなる、というリスクがあります。

この問題を解決するPaaSとして最近はRecall.aiが流行り始めています。デファクトになりつつあるかもしれません。これを利用すると簡単にBotを会議に送り込むことができ、統一されたインタフェースで操作ができます。

Recall.aiの使い方はこちらのドキュメントをご覧ください。

curl -X POST https://$RECALLAI_REGION.recall.ai/api/v1/bot \
    -H 'Authorization: Token $RECALLAI_API_KEY' \
    -H 'Content-Type: application/json' \
    -d '
    {
      "meeting_url": "$MEETING_URL",
      "bot_name": "My Bot",
      "recording_config": {"transcript": {"provider": {"meeting_captions": {}}}}
    }'

会議URLを渡すだけで、プラットフォーム判別・ボット入室・音声取得を裏側で処理してくれます。プラットフォームごとの差異を吸収してくれるのは非常にありがたいですね。

メリットはマルチプラットフォーム対応、ユーザーのインストール不要という点です。特にインストール不要というのは良いですね。デメリットはボットの参加が会議参加者に見える、外部サービスへの依存、リアル会議(対面会議)では使えない、という点です。

※リアル会議でも誰かがMeet等を開いてBotが自動で入れば良い訳ですが。

ただ、Recall.aiはそのような欠点も当然認識しているようであり、Desktop Recording SDKというものも用意しています。場合によってはこちらの方がユースケースに合いそうです。

3. 自前WebRTC会議実装方式

自分でWebRTCベースの会議機能を構築する方式です。各参加者の音声が独立したストリームになるため、ダイアライゼーション不要で「誰の発言か」が最初からわかります。

が有名な選択肢です。

メリットとしては最も柔軟で音声データを扱いやすいです。デメリットとしてはライブラリを使ったとしても会議機能自体の開発コストが大きく、ユーザーに別ツールの利用を求めるという点があります。

ユーザーにとっては会社の基本的な会議アプリはMeetと決まっているのに、特定の会議でだけ別の会議アプリを使わなければならない、というのは不便です。

4. プラットフォームAPI連携

各プラットフォーム(Zoom API、Google Workspace API、Microsoft Graph API)が提供する録音データやトランスクリプトを事後取得して分析する方式です。

メリットとしては実装がシンプルになります。デメリットとしてはリアルタイム性がない、取得できるデータがプラットフォーム依存、場合によってはデータが取れない、というような点があります。

例えばMeetでは会議中に録画ボタンを押すのを忘れた場合、後でその録画データをとることはできません。Meetの設定で会議が始まる前に自動で録画が開始されるような設定はできますが、API等で自動で録画開始することはできないため、録画ミスという問題がつきまといます。

リアル会議への対応

オンライン会議だけでなく、物理的な会議室での会議にも対応したいケースがあると思います。ボット方式はオンライン会議には強いですが、リアル会議には使えません(やりようによっては使えるとは思います)。

リアル会議では、ブラウザでWebアプリを開いてデバイスのマイクから getUserMedia で収音し、WebSocket経由でサーバーにストリーミングしてSTTにかける方式が良いかもしれません。Webアプリ上にそういう機能を実装しても良いですし、Chrome拡張方式でも良いと思います。

どんな方式を採用するにしても、リアル会議の場合、マイクの選定が重要です。以下のような全指向マイクかつエコーキャンセリングやノイズリダクションを備えた製品を会議室に置いておくと良いです。

https://www.ankerjapan.com/products/a3316

ただしどんなマイクを使うかは我々のような事業者がコントロールできる部分ではないので、リアル会議の対応は少し大変ではありますね。

また、リアル会議では全員の声が1つのマイクに入るため、サーバーサイドのダイアライゼーションが必須になります。オンライン会議では各参加者の音声が分かれていますが、リアル会議ではそうはいかないので、ここはSTT側の話者分離に頼ることになります。

アーキテクチャ案

どんな要件があるか次第ではありますが、アーキテクチャも簡単に考えてみます。

  • 入力レイヤ: Recall.aiによるボット参加・音声収集 または ブラウザアプリかChrome拡張によるスピーカーとマイクの音声収集
  • 処理レイヤ: STT(Google Cloud Speech-to-Text)による文字起こしとダイアライゼーション
  • DBレイヤ: 会話テキストはRDBMSかベクトルDB、全文検索エンジンなどに要件に応じて格納。音声ファイルはオブジェクトストレージへ
  • UIレイヤ: Next.js等好きなFWやChrome拡張を使って音声収集を可能にする、または設定等を通じてbotが会議に参加できるようにする

おわり

今回は実際に作るところや動かすところまでは触れませんでしたが、別の機会にRecall.aiなどを使って簡単に実装してみたいと思います。

今回の調査や考察で得た音声処理Webアプリを作る上でのポイントを振り返ります。

  • ブラウザ標準のSpeechRecognitionだけでは、会議の双方向音声の文字起こしは困難
  • スピーカー音声のキャプチャはOS・ブラウザの制約が大きく、ブラウザのJSだけでは限界がある
  • サーバーサイドSTT + ダイアライゼーションが、実用的な音声分析の基盤
  • オンライン会議の音声取得には、Recall.aiのようなミーティングボットPaaSが良い
  • リアル会議はブラウザの getUserMedia + 会議マイクで対応可能

現在、ミツカリではITエンジニアを募集しています。興味のある方はぜひお気軽にご連絡ください!

herp.careers