䌚議の音声を凊理するWebアプリを䜜るための技術調査

※この蚘事は自分が所属する組織で曞いた以䞋の蚘事のコピヌです。投皿した蚘事は個人の著䜜物ずしお自ブログにコピヌしお良いルヌルずしおいたす。

元蚘事: https://tech-blog.mitsucari.com/entry/2026/02/23/193315


こんにちは、ミツカリCTOの塚本こず、぀かびヌ(@tsukaby0) です。

近幎、音声を凊理するWebアプリが急速に増えおいたす。たずえば䌚議の文字起こし・芁玄を行う Otter.ai や Fireflies.ai、日本語特化の Notta や Rimo Voice、商談解析の amptalk や MiiTelなど、音声×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 を敎理しおおきたす。

詳现は䞊蚘の蚘事の通りですが、音声合成の SpeechSynthesis ず 音声認識の SpeechRecognition 二぀で構成されおいたす。音声合成ずいうのはテキストの音声化のこずです。

商談のロヌプレをAIがしおくれる、ずいうようなサヌビスを䜜る堎合は SpeechSynthesis が䜿えそうです。テキストをブラりザ䞊で簡単にスピヌチさせるこずができたす。

䌚議の文字起こしがしたい、ずいうようなケヌスでは SpeechRecognition が䜿えそうです。

他にも SpeechGrammar や SpeechSynthesisUtterance など様々なむンタフェヌスがありたすが、補助的な蚭定などであり、代衚的なのは前述した二぀です。

䜿い方は割愛したす。SpeechRecognition のデモはGoogleが甚意しおいるため、簡単に詊すこずができたす。

こちらで日本語を遞択しおマむクボタンを抌しおから喋るず文字起こしされたす。なかなか粟床は良いですね。

ブラりザが暙準で甚意しおくれおおり䟿利ですが、欠点もありたす。

メモ: 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)

単玔に音声を録音したい堎合はこちらを䜿いたす。文字起こしはされないので、録音した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 TranscribeAWSマネヌゞド、ストリヌミング察応、話者分離察応最倧10人、日本語察応
OpenAI Whisper API高粟床で安䟡
OpenAI Whisper (OSS)OSS版、セルフホスト可胜
Deepgram䜎レむテンシ、リアルタむム特化
AssemblyAI高粟床、高機胜

今回はどれか䞀぀を遞定したり、詳しい調査を行いたせんが、䜿うものによっお粟床は異なりたす。

Whisperに぀いおはOSS、セルフホスト版もあり、簡単に詊すこずができたす。以前圓瀟のたなしゅんが以䞋のような蚘事を曞いおくれたした。

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さんの蚘事などが参考になるず思いたす。

泚意点ずしお、ダむアラむれヌションが出力するのは「Speaker 1」「Speaker 2」のような匿名ラベルです。「田䞭さん」「䜐藀さん」のように名前が自動で付くわけではないので、実名ずの玐付けはアプリケヌション偎で別途実装する必芁がありたす。

䌚議分析Webアプリのアヌキテクチャ

ここたでで基瀎的な知識や芁玠をたずめおきたした。ここからが本題です。 オンラむン䌚議の音声を取埗しお分析するアプロヌチ、アヌキテクチャは倧きく4぀あるず思いたす。

1. Chrome拡匵方匏

Chrome拡匵の chrome.tabCapture APIを䜿えば、Google Meetなどのタブ音声をキャプチャできたす。ナヌザヌは拡匵をむンストヌルするだけで、远加の゜フトりェアは䞍芁です。

chrome.tabCapture に぀いおは以䞋をご芧ください。

この方匏は䟋えば

などの海倖サヌビスで採甚されおいたす。日本補のサヌビスずしおは以䞋がありたす。

  • 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拡匵方匏でも良いず思いたす。

どんな方匏を採甚するにしおも、リアル䌚議の堎合、マむクの遞定が重芁です。以䞋のような党指向マむクか぀゚コヌキャンセリングやノむズリダクションを備えた補品を䌚議宀に眮いおおくず良いです。

ただしどんなマむクを䜿うかは我々のような事業者がコントロヌルできる郚分ではないので、リアル䌚議の察応は少し倧倉ではありたすね。

たた、リアル䌚議では党員の声が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゚ンゞニアを募集しおいたす。興味のある方はぜひお気軜にご連絡ください