1
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?

会議にボットを参加させずに、リアルタイムAI提案を実装した話

1
Posted at

はじめに

多くのAI会議ツールは、会議が終わった後の処理を中心に設計されています。

  • 文字起こし
  • 要約
  • アクションアイテムの抽出
  • 決定事項の整理

もちろん、これらは便利です。

しかし、今回考えたのは少し違う問題でした。

会議が終わった後ではなく、会話している「その瞬間」にAIがユーザーを支援できないか?

たとえば、

  • 技術用語の意味がすぐに分からない
  • 質問されたが、うまく答えを整理できない
  • 外国語で適切な表現がすぐに出てこない
  • 重要なポイントを見落としている
  • 「今これを言えばよかった」と数秒後に気付く

といった場面です。

この問題を検証するために、私は LiveSuggest というリアルタイム会議支援ツールを開発しています。

一般的な会議AIと大きく違うのは、ZoomやTeamsの会議に「ボット参加者」として入らないことです。

この記事では、実装して分かった以下のポイントを紹介します。

  1. ブラウザから会議音声を取得する
  2. 音声を低遅延でストリーミングする
  3. 文字起こしをそのままLLMに投げない
  4. 「いつAIを呼ぶか」を制御する
  5. LLMの出力もストリーミングする
  6. 会話内容を保存せずに品質を計測する

なぜ「会議ボット」を使わないのか

会議AIを作る場合、分かりやすい方法の一つは、AI用の参加者を会議に入れることです。

Zoom / Teams / Meet
        ↓
 AI Bot Participant
        ↓
      Audio
        ↓
   Transcription

この方式には技術的なメリットがあります。

会議プラットフォーム側から音声を取得できるため、クライアント環境への依存を減らせます。

一方でUX上の問題もあります。

  • 会議参加者全員にボットが見える
  • ボットの参加を禁止している組織がある
  • 外部ボットを会議に入れたくないケースがある
  • 自分だけが使いたい支援でも、他の参加者に影響する

そこでLiveSuggestでは、会議そのものには参加せず、ユーザーがブラウザに明示的に共有した音声を処理する方式を採用しました。

つまり、Zoom、Teams、Google Meetなどに個別に統合する必要はありません。


ブラウザから会議音声を取得する

音声ソースは大きく2種類あります。

  • マイク音声
  • システム音声 / ブラウザタブ音声

マイクの場合は通常の getUserMedia() が使えます。

const micStream = await navigator.mediaDevices.getUserMedia({
  audio: true
})

問題は、相手の音声です。

ブラウザでは getDisplayMedia() を使うことで、共有対象に含まれる音声トラックを取得できます。

const systemStream = await navigator.mediaDevices.getDisplayMedia({
  video: true,
  audio: {
    systemAudio: "include"
  } as MediaTrackConstraints
})

ここには重要な制約があります。

Windows + Chromium

Windows上のChrome / Edgeなどでは、画面全体を共有してシステム音声を有効にすると、PCで再生されている音声を取得できます。

そのため、ブラウザ版だけでなく、

  • Teams desktop
  • Zoom desktop
  • その他のデスクトップ通話アプリ

の音声も取得できます。

ただしユーザーが共有ダイアログで、システム音声の共有を明示的に有効にする必要があります。

「ウィンドウ共有」には音声がない

実装時に特に注意が必要だったのがこれです。

アプリケーションのウィンドウだけを共有しても、音声トラックは取得できません。

つまり、

Zoomのウィンドウを共有
        ↓
映像は取得できる
        ↓
音声トラックはない

という状態が発生します。

見た目上は正常に共有されているため、ユーザーからすると「なぜ文字起こしされないのか」が分かりにくい問題です。

このため、ブラウザAPIだけではなく、UI上での説明も重要になります。

macOS / Linux

macOSやLinuxでは、Windowsと同じ方法でブラウザからシステム全体の音声を取得することはできません。

一方、Chromeタブの音声共有は利用できます。

したがって、ブラウザ版Meetなどを利用する場合は、タブ音声を共有する方法が現実的です。

このように、リアルタイム音声処理ではブラウザAPIだけではなく、OS × Browser × Share Surface の組み合わせまで考える必要があります。


AudioWorkletで音声をPCM16に変換する

音声ストリームを取得したら、そのまま数秒単位で送るのではなく、小さいチャンクとしてバックエンドに送ります。

LiveSuggestでは AudioWorklet を使っています。

大まかなパイプラインは以下です。

Microphone / System Audio
          ↓
     Web Audio API
          ↓
      AudioWorklet
          ↓
    PCM16 / 16 kHz
          ↓
      WebSocket
          ↓
 Streaming Speech-to-Text

音声はモノラルのPCM16、16 kHzに変換します。

const SAMPLE_RATE = 16000
const CHUNK_SAMPLES = 1600

約100ms程度の小さい単位で送ることで、音声認識側が早い段階から処理を開始できます。

interface AudioChunk {
  sessionId: string
  audio: string
  timestamp: number
  format: "pcm16"
  duration: number
}

リアルタイムシステムでは、一つ一つの処理時間は小さく見えても、合計すると大きな遅延になります。

Audio Buffer
   +
Network
   +
Speech-to-Text
   +
Context Processing
   +
LLM TTFT
   +
LLM Generation
   +
Frontend Rendering

ユーザーが感じるのは、この合計です。

そのため、特定の1箇所だけ高速化しても十分ではありません。


マイク音声とシステム音声をミックスする

会話全体を理解するには、相手側だけではなく自分の発話も必要です。

そのため必要に応じて、

Microphone
     ↘
      AudioContext → Mixed Stream
     ↗
System Audio

という構成で2つの音声をミックスします。

Web Audio APIでは MediaStreamDestination を利用できます。

const audioContext = new AudioContext({
  sampleRate: 16000
})

const micSource =
  audioContext.createMediaStreamSource(micStream)

const systemSource =
  audioContext.createMediaStreamSource(systemStream)

const destination =
  audioContext.createMediaStreamDestination()

micSource.connect(destination)
systemSource.connect(destination)

const mixedStream = destination.stream

このストリームをAudioWorkletに流し、同じパイプラインで処理します。


文字起こしをそのままLLMに送らない

最初は次のような設計が自然に見えます。

Final Transcript
      ↓
     LLM
      ↓
 Suggestion

しかし、実際の会話ではうまくいきません。

会話は意味的にきれいな単位で区切られないからです。

たとえば、

"I think the main problem is..."

という文字起こしだけでLLMを呼び出しても、まだ重要な情報がありません。

次の発話で、

"...that the API becomes much slower
when several clients connect simultaneously."

と続くかもしれません。

最初の断片だけで生成を開始すると、AIは不十分なコンテキストから提案を作ることになります。

また、すべての文字起こしでLLMを呼ぶと、提案が多すぎて逆に邪魔になります。

そこで、文字起こしを一定量蓄積してから生成する方式にしました。

Transcript
    ↓
Context Buffer
    ↓
Enough new information?
    ↓
    Yes
    ↓
LLM

難しかったのは「何を生成するか」より「いつ生成するか」

リアルタイムAIでは、このタイミング制御が非常に重要でした。

生成が早すぎると、

情報不足 → 的外れな提案

になります。

遅すぎると、

良い提案 → しかし会話はすでに次の話題

になります。

頻度が高すぎると、

AIが常に何か言っている
→ ノイズ

になります。

低すぎると、

何も起きない
→ ユーザーがAIの存在を忘れる

という問題になります。

そのためLiveSuggestでは、前回の提案から増えた単語数を蓄積し、一定のしきい値に達したときに生成を開始します。

概念的には次のようなロジックです。

wordsSinceLastSuggestion += newWords

if (wordsSinceLastSuggestion >= threshold) {
  generateSuggestion(context)
  wordsSinceLastSuggestion = 0
}

さらにユーザーが希望する提案量によって、このしきい値を変えています。

More suggestions
→ lower threshold

Fewer suggestions
→ higher threshold

最初の提案だけ少し早くする

もう一つ面白かったのが、最初の提案の体感速度です。

ユーザーがセッションを開始して、しばらく何も表示されないと、

本当に動いているのか?

という不安が生まれます。

そこで最初の提案だけは、通常より少ないコンテキストでも生成を開始できるようにしています。

これは純粋なモデル性能ではなく、UX上の最適化です。

リアルタイムAIでは、

平均レスポンスタイム

だけでなく、

ユーザーが最初に価値を感じるまでの時間

も重要だと感じました。


LLMの出力もストリーミングする

もう一つ重要なのが表示方法です。

LLMが文章をすべて生成するまで待つと、ユーザーにはその生成時間すべてが遅延として見えます。

そこで、生成結果もWebSocket経由でストリーミングします。

概念的には以下のイベントを使います。

suggestion-start
suggestion-title
suggestion-delta
suggestion-delta
suggestion-delta
...
suggestion-end

フロントエンドでは、受信したトークンを順番に表示します。

socket.on("suggestion-delta", ({ content }) => {
  currentSuggestion += content
})

これによって、モデルが文章全体を生成し終わる前からユーザーは読み始められます。

リアルタイムAIでは、完全な回答までの時間より、

最初の有用な情報が表示されるまでの時間

のほうが重要な場合があります。

会議中では特にそうです。

3秒後に完璧な回答が来るより、1秒後から読める回答のほうが役立つことがあります。


AIは「何も言わない」ことも必要

AI機能を作っていると、つい「たくさん生成するほど便利」と考えてしまいます。

しかし、会議支援では逆でした。

LiveSuggestでは、例えば以下の種類の提案があります。

  • 技術用語や概念の補足
  • フォローアップすべき内容
  • 新しいアイデア
  • 外国語表現の説明
  • 直接質問されたときの回答案

しかし、数秒ごとにこれらが表示されたら集中できません。

したがってシステムには2種類の判断が必要になります。

1. 十分な新しいコンテキストがあるか?

これは比較的機械的に判定できます。

2. 本当に今、何か言う価値があるか?

こちらのほうが難しい問題です。

会議支援AIでは、

何も表示しない

という結果も正しい動作です。

最適化したいのは生成トークン数ではなく、

Useful Interventions

だと考えるようになりました。


会話内容を保存せずに品質を測る

リアルタイム会議AIでは、プライバシーも重要です。

一方で、サービスを改善するためには、

  • 提案が遅すぎないか
  • ユーザーに役立っているか
  • モデルごとに差があるか

といった情報は必要です。

そこで、会話コンテンツとプロダクト分析を分離しています。

保存するのは例えば、

suggestion latency
time to first token
thumbs up
thumbs down
copy
expand

といったイベントです。

一方で、品質分析のために

会話全文
提案本文

を保存する必要はありません。

つまり、

Content
    ✕ persistent analytics

Behavioral signal
    ✓ persistent analytics

という考え方です。

AIプロダクトでは、テレメトリを取ることと、ユーザーコンテンツを保存することは同じではありません。

この分離は重要だと感じています。


最終的な構成

現在のパイプラインをかなり単純化すると、次のようになります。

Conversation
     ↓
Browser Audio Capture
     ↓
AudioWorklet
     ↓
PCM16 Chunks
     ↓
WebSocket
     ↓
Streaming Speech-to-Text
     ↓
Final Transcripts
     ↓
Context Accumulation
     ↓
Generation Decision
     ↓
LLM
     ↓
Token Streaming
     ↓
Suggestion UI
     ↓
Human

この中で、LLMそのものは1つの部品にすぎません。


まとめ

リアルタイムAIを作ってみて最も強く感じたのは、

リアルタイムAIプロダクトは、必ずしもLLMそのものが一番難しいわけではない

ということでした。

特に難しかったのは、

  • ブラウザから適切な音声を取得する
  • 音声認識の遅延を抑える
  • 会話コンテキストを適切に蓄積する
  • 生成を開始するタイミングを決める
  • 最初のトークンをできるだけ早く表示する
  • AIが邪魔にならない頻度に抑える

といった部分です。

リアルタイム会話では、AIの提案が役に立つ時間帯はかなり短いです。

Too early
→ context不足

Just in time
→ useful

Too late
→ conversation moved on

モデルの回答品質だけではなく、この「Just in time」にどう入れるかがプロダクトの重要な部分になります。

私は現在、この考え方を使って LiveSuggest を開発しています。

会議にボットを参加させず、ユーザーが明示的に共有した音声からリアルタイムに会話を理解し、その場で使える提案を表示するツールです。

興味があればこちらで試せます。

今後は特に、

  • 提案タイミングの改善
  • 不要な提案の抑制
  • 多言語・コードスイッチング対応
  • 「本当に役立った提案」の評価方法

あたりをさらに改善していく予定です。

1
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
1
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?