TT Lab
ブログ

한국어 English 日本語 中文

はじめる
ブログ

ブログ

全二重音声通話をWebSocketで中継する:ブラウザのマイクからGPUモデルまで

ステータス:ドラフト(2026-10-09更新)。 数値は筆者が直接測定した値です。環境は24GB級GPU 1基、MiniCPM-o 4.5(Q8_0、llama.cpp-omni)、FastAPIバックエンドです。人間の声の代わりに合成音声で質問を行い、実際のブラウザのエコーや複数人の同時通話は 未計測 です。 作成ルール:実測データを基本とし、計測できなかったものは「未計測」と表記します。

目次

20作品の会話練習では、「録音 → 音声認識 → 対話モデル → 音声合成」を1ターンずつHTTPでやり取りします。話し終わってから初めて答えが返ってき、答えを聞いている間は話すことができません。人間と電話をしているような感覚を生み出すには、 聞きながら話し、話しながら聞く 全二重(full duplex)モデルが必要です。このようなモデルは1秒間に何度も音声を受け取り、音声を出力します。そのため、1つのリクエストに対して1つのレスポンスを返すHTTPではなく、 コネクションを1つ開きっぱなしにして双方がいつでも送信できる WebSocketが適しています。

この記事では、モデルを選定した経緯を簡潔に述べた後、WebSocketに関して下した5つの決定事項について解説します。

  1. ブラウザをモデルサーバに直接接続せず、バックエンドが中継する。
  2. 音声は16kHz・16ビット・1秒のチャンクで送信し、24kHz・16ビットで受信する。
  3. 音声はバイナリフレーム、テキストとステータスはテキストフレームに分ける。
  4. リアルタイムより速く送信するクライアントはトークンバケットで制限する。
  5. 1つの通話のみを受け付けるサーバの席はDBのadvisory lockで守る。

短い答え。 中継を挟んでも通話は0.33秒で開き、「オーストラリアの首都は?」に対し発話終了後0.30秒で「It's Canberra.」が聞こえ始めました。中継が1秒分の音声を変換するのに要した時間は、アップロードが切り上げで1.3ms、ダウンロードが5.9msでした。ブラウザとバックエンドの間でbase64のJSONの代わりにバイナリのPCMを使用することで、転送量がアップロードは切り上げで85KB/s → 32KB/s、ダウンロードは128KB/s → 48KB/sに減少しました。

1. モデル:なぜMiniCPM-o 4.5なのか

Hugging Faceからキーなしでダウンロードできる全二重モデル3つについて、同一の質問で測定を行いました(原データは docs/experiments/2026-10-07-full-duplex-voice/).

モデル 質問 応答 発話終了から音声出力まで(中央値) 観察
Moshiko (2024-09) 英語 FLEURS 12 11/12 6件はユーザーの発話中、残りは0.2–5.2秒 80msごとに1ステップ、1ステップあたり34.6ms
LLM-jp-Moshi-v1 (2026-01) 日本語 FLEURS 12 12/12 すべてユーザーの発話中 ユーザー発話の30–88%の間で相槌や自分語り
MiniCPM-o 4.5 Q8_0 (2026-02) 英語の質問 10 10/10 1.03秒 1秒のチャンクあたり処理50ms、VRAM 14.7GB

MiniCPM-oはキャンベラ、100度、火星、シェイクスピアを正解し、日本語は「何語で話しても日本語だけで答えて」という指示を入れたところ、10回中9回日本語で回答しました。韓国語は不得手でした(公式言語は英語と中国語です)。PyTorch版は28GB以上が必要となるため、llama.cppのフォーク(llama.cpp-omni)のGGUFで動作させました。

モデル側で2点を修正しました。

2. なぜバックエンドが中継するのか

モデルサーバはWebSocketのエンドポイント(/backend)を直接公開しています。ブラウザをここに直接接続するのが最も高速ですが、3つの問題があります。

브라우저 ──wss──▶ 게이트웨이 ──▶ 백엔드(/ws/call) ──ws──▶ 모델 서버(/backend)
            (쿠키로 사람 확인)   (프롬프트 생성, 자리, 속도 제한, 형식 변환)
  1. モデルサーバはシステムプロンプトをそのまま信用する。 セッションを開くメッセージにプロンプトを含めて送信しますが、ブラウザにこのメッセージを作成させると、誰でもモデルに任意の指示を与えられるようになります。中継を挟むことで、ブラウザは mode=lang&lang=ja&level=N4 または mode=blog&slug=… のみを送信し、プロンプトはサーバが所定の文章とDB内のテキストのみから生成します。レベル(level)も既知のリストに含まれている場合のみ使用します。当初は英数字のみを残すようにしていましたが、 B1. Ignore all rules が B1Ignore としてプロンプトに混入することがテストで検出されました。
  2. モデルサーバは一度に1つの通話しか受け付けない。 2人目のユーザーが接続すると、最初の通話が壊れます。席の管理については第5節で解説します。
  3. ユーザーの認証と時間の制限が必要である。 ブラウザはWebSocketのハンドシェイクに任意のヘッダーを付与できません。そのため、同一オリジンのログインクッキーでユーザーを特定し(既存のWebターミナルと同じ方式)、通話を10分、無音接続を15秒で切断します。

ブログ記事をプロンプトに挿入する際は、もう1点対策を行いました。このモデルの特殊トークンは <|im_end|> のような形式であるため、本文中に同じ文字列が存在すると、システムメッセージを閉じて新しい指示を割り込ませることが可能です。本文中の <| を < | に置換して無力化し、プロンプト全体に含まれる特殊トークンが我々が追加した2つのみであるかをテストで確認しています。

3. 音声をどのような形式で送信するのか

区間 形式 1秒分
ブラウザ → バックエンド 16kHzモノラル16ビットPCM、バイナリフレーム 32,000 B
バックエンド → モデル float32をbase64に格納したJSON(input.append) 85,391 B
モデル → バックエンド 24kHz float32 base64 JSON(response.output.delta) 128,063 B
バックエンド → ブラウザ 24kHz 16ビットPCM、バイナリフレーム 48,000 B

4. フレーム:バイナリとテキストを分ける

WebSocketのフレームにはテキスト(opcode 1)とバイナリ(opcode 2)があります。音声はバイナリで、それ以外はJSONテキストで送信しました。

브라우저 → 백엔드   이진: 1초 PCM          텍스트: {"type":"hangup"}
백엔드 → 브라우저   이진: 모델 목소리 조각  텍스트: ready · text · listen · done · busy · unavailable · timeout

このように分けることで、受信側で typeof ev.data === 'string' の1つに分岐でき、音声にJSONを被せたりbase64で肥大化させたりする必要がなくなります。ブラウザ側では ws.binaryType = 'arraybuffer' に設定しておけば、バイナリフレームが Blob ではなく直ちに使用可能な ArrayBuffer として届きます。

クローズコードで理由を通知する。 WebSocketのクローズコード4000〜4999はアプリケーション用の予約範囲です。4401(要ログイン)、4404(記事なし)、4429(他のユーザーが通話中)、4503(モデルサーバ準備中)を使用しました。画面側ではこれらのコードを人間の言葉に変換します。「失敗しました」よりも「現在他のユーザーが通話中です、しばらくしてから再度おかけ直しください」の方が、もう一度かけようという気持ちにさせます。

再生は繋ぎ合わせる。 モデルは生成された音声をつねに数百ms単位で送信します。受信した断片を AudioBufferSourceNode にして「前の断片が終了する時刻」に予約すれば、途切れなく再生が繋がります。断片ごとに即座に再生すると音声が重なってしまいます。

割り込み。 モデルは人間が割り込むと聴取(listen)モードに戻ります。しかし、すでに受信した音声が再生キューに残っていると、人間側にはモデルが話し続けているように聞こえてしまいます。そのため、 listen が届いた際、 マイクの音声が大きければ キューを空にします。マイクが静かなときに空にしてしまうと、回答の最後の部分が途切れてしまいます——モデルは回答を完全に生成した後も listen を送信しますが、その時点では再生がまだ終了していないためです。

5. リアルタイムより速く送信するクライアント

モデルサーバは1秒のチャンクを順番に処理します。次のチャンクは前のチャンクの応答が終了してから読み込まれます。そのため、クライアントが録音済みの30秒間をまとめて送信するとキューが蓄積し、応答が次第に遅延し、その間他のユーザーが席を取得できなくなります。

中継にトークンバケットを設けました。毎秒1.5個が補充され、最大3個まで蓄積されます。1秒に1つずつ送られてくる通常の通話は10分間(600個)にわたり1つも破棄されず、一気に送りつけた場合は最初の3個を除き1.5個/秒のペースでのみ通過します。1.5と余裕を持たせているのは、ネットワークの揺らぎによって2つのチャンクが連続して届くことがあるためです。時間を外部から注入できるように設計しておけば、この動作をわずか数msでテストできます。

測定器を作成する際も同様の落とし穴がありました。測定クライアントがチャンクを前倒しで送信すると、「発話終了から音声出力まで」が実際より短く測定されてしまいます。そのため、チャンクが 完全に録音された瞬間(開始後k秒)にのみ送信するようにしました。

6. ウェブポッド2つでスロットを1つ確保する

モデルサーバーは1つの通話しか受け付けませんが、バックエンドポッドは2つあります。プロセス内の変数(asyncio.Lock)では他のポッドの通話を知ることができません。

第20話の会話練習はDBの部分ユニークインデックスでスロットを確保します。ターンごとにHTTPリクエストが往復し、その間には接続がないため、スロットを行として残し、3分間発言がなければ期限切れにします。通話は事情が異なります。通話中はWebSocketが開きっぱなしになるため、 接続が生きている間だけ スロットを握っていれば十分です。

そのため、Postgresのセッションレベルのadvisory lockを使用しました。

seat = db()                                   # 통화마다 연결 하나
got = seat.execute("select pg_try_advisory_lock(%s)", (SEAT_KEY,))
if not got: ...                               # 4429 로 닫는다
try:
    await relay(ws, prompt)
finally:
    seat.close()                              # 연결이 닫히면 잠금도 풀린다

7. 接続を切断するもの

8. 全体を繋げて測定した値

中継ルーターをモックDBとして起動し、モデルサーバーに接続して合成音声の質問を1秒ずつリアルタイムで送信しました(scripts/e2e_relay.py).

質問 通話オープン 発話終了後の音声 回答
What is the capital city of Australia? 0.33秒 0.30秒 It's Canberra.
日本の首都はどこですか? — 0.52秒 東京です。
(ブログ第25話)確率サンプリング10%は遅いリクエストをいくつか残したか? — 2.52秒 はい、10%を残しました。(間違い)

この測定にはゲートウェイとTLSが含まれていません。デプロイ後、本番の経路を区間ごとに再測定しました(2026-10-09)。

区間 測定値(20回、中央値)
本番バックエンドポッド → 通話サーバー、セッションオープン(session.created まで) 0.33秒
クラスター内でバックエンドへWebSocketオープン(平文) 6.5ms (4.3–18.3)
外部からゲートウェイ(TLS・Envoy)を経由してWebSocketオープン 36ms (21–72)

ゲートウェイとTLSがオープン時間に加算する割合は約30msでした。通話オープンにかかる時間の大部分は、モデルサーバーがセッションを準備する0.33秒です。ログインした通話全体(発話終了後の音声まで)を本番経路で測定した値はありません――測定用アカウントを本番環境に作成していないためです(未測定)。そのため、WebSocketオープンの区間はログインなしで開き、サーバーが4401で閉じるフローで測定しました。

このとき、1つのことが同時に判明しました。Pythonの websockets クライアントで測定すると、「クローズが完了するまで」が本番経路で中央値 1,045ms であり、クラスター内では6.9msでした。 aiohttp でクローズフレームが到着した時刻を個別に測定すると、本番経路でも36msでした。サーバーのクローズシグナルはすぐに届き、遅いのはクライアントがクローズフレームを受信した後にTCP接続が切断されるのを待つ時間です。画面に「接続が切断されました」を表示するタイミングをTCP終了に結びつけると、プロキシ経由の経路で1秒ほど遅れる可能性があるという意味です。

9. 限界と未測定

面接で出そうな質問

参考文献

ログインするといいねできます

コメント

まだコメントはありません。

ログインするとコメントできます