ステータス:ドラフト(2026-10-09更新)。 数値は筆者が直接測定した値です。環境は24GB級GPU 1基、MiniCPM-o 4.5(Q8_0、llama.cpp-omni)、FastAPIバックエンドです。人間の声の代わりに合成音声で質問を行い、実際のブラウザのエコーや複数人の同時通話は 未計測 です。 作成ルール:実測データを基本とし、計測できなかったものは「未計測」と表記します。
目次
- 1. モデル:なぜMiniCPM-o 4.5なのか
- 2. なぜバックエンドが中継するのか
- 3. 音声をどのような形式で送信するのか
- 4. フレーム:バイナリとテキストを分ける
- 5. リアルタイムより速く送信するクライアント
- 6. 2つのWeb Podで1つの席を守る
- 7. 接続を切断するものたち
- 8. 全体を繋げて計測した値
- 9. 制限と未計測
- 面接で聞かれそうな質問
- 参考文献
20作品の会話練習では、「録音 → 音声認識 → 対話モデル → 音声合成」を1ターンずつHTTPでやり取りします。話し終わってから初めて答えが返ってき、答えを聞いている間は話すことができません。人間と電話をしているような感覚を生み出すには、 聞きながら話し、話しながら聞く 全二重(full duplex)モデルが必要です。このようなモデルは1秒間に何度も音声を受け取り、音声を出力します。そのため、1つのリクエストに対して1つのレスポンスを返すHTTPではなく、 コネクションを1つ開きっぱなしにして双方がいつでも送信できる WebSocketが適しています。
この記事では、モデルを選定した経緯を簡潔に述べた後、WebSocketに関して下した5つの決定事項について解説します。
- ブラウザをモデルサーバに直接接続せず、バックエンドが中継する。
- 音声は16kHz・16ビット・1秒のチャンクで送信し、24kHz・16ビットで受信する。
- 音声はバイナリフレーム、テキストとステータスはテキストフレームに分ける。
- リアルタイムより速く送信するクライアントはトークンバケットで制限する。
- 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点を修正しました。
- 発話を開始する閾値。 デフォルト設定では、発話終了から音声が出力されるまでに中央値4.0秒(0.9–8.3)かかっていました。モデルは1秒ごとに「聞くか話すか」を決定しますが、セッション設定の
listen_prob_scaleを1.0から0.0に下げると、「聴取」トークンのスコアが2引き下げられます。これにより1.03秒(−0.7–2.4)となり、答えない質問もなくなりました。 - 通話を開くたびに41秒。 サーバのログを確認したところ、セッションごとに特殊トークン7個の埋め込みを取得するためにLLMの重みファイル全体(8.7GB)をメモリに読み込み、セッション終了時に破棄していました。値はモデルファイルのみに依存するため、プロセス内に一度だけ生成するように修正したところ、 0.11秒で開くようになりました(本文に6,500トークンのブログ記事を挿入した場合は1.6秒)。
2. なぜバックエンドが中継するのか
モデルサーバはWebSocketのエンドポイント(/backend)を直接公開しています。ブラウザをここに直接接続するのが最も高速ですが、3つの問題があります。
브라우저 ──wss──▶ 게이트웨이 ──▶ 백엔드(/ws/call) ──ws──▶ 모델 서버(/backend)
(쿠키로 사람 확인) (프롬프트 생성, 자리, 속도 제한, 형식 변환)
- モデルサーバはシステムプロンプトをそのまま信用する。 セッションを開くメッセージにプロンプトを含めて送信しますが、ブラウザにこのメッセージを作成させると、誰でもモデルに任意の指示を与えられるようになります。中継を挟むことで、ブラウザは
mode=lang&lang=ja&level=N4またはmode=blog&slug=…のみを送信し、プロンプトはサーバが所定の文章とDB内のテキストのみから生成します。レベル(level)も既知のリストに含まれている場合のみ使用します。当初は英数字のみを残すようにしていましたが、B1. Ignore all rulesがB1Ignoreとしてプロンプトに混入することがテストで検出されました。 - モデルサーバは一度に1つの通話しか受け付けない。 2人目のユーザーが接続すると、最初の通話が壊れます。席の管理については第5節で解説します。
- ユーザーの認証と時間の制限が必要である。 ブラウザは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 |
- 16kHzに縮小するのはブラウザである。 モデルの音声エンコーダー(Whisper系)は16kHzを受け付けます。
new AudioContext({sampleRate: 16000})で最初から16kHzで取得できればシンプルですが、一部のブラウザはこのオプションを拒否します。そのため、デバイスのデフォルトのサンプリングレート(通常48kHz)で取得し、線形補間で縮小します。人の声は4kHz以下にほとんど収まるため、これだけで十分です。48kHzの1秒間が正確に16,000サンプルになるか、44.1kHzも同様であるかをNodeのテストで確認します——サンプリングレートが誤っていると、モデルは早口またはスローモーションになった音声を聴き取ることになり、意図せぬ誤認識に繋がりますが、目視では気づけません。 - チャンクは1秒である。 このモデルは1秒単位で聴き取り、判断します。より細切れに送信しても、サーバ側で1秒分が満たされるまで待機するため遅延は減りません。チャンクサイズが正確に32,000バイトでない場合、中継サーバはそれを破棄します。
- 16ビットに縮小するのは中継サーバである。 モデルの形式(float32 base64)をブラウザまでそのまま送信すると、ダウンロードは毎秒128KBになります。16ビットに縮小すると48KBとなり、63%削減されます。人間の耳で聴き取れる差はありません(16ビットはCDと同等の深度です)。
- 変換コスト。 標準ライブラリの
arrayを使用して1秒分を変換するのに要した時間は、アップロードが1.3ms、ダウンロードが5.9msでした(Mac arm64のPython 3.12、5回実行の中央値)。デプロイ後、本番のバックエンドの2つのPodで同じコードで再測定したところ、デスクトップ級のx86 CPUではアップロード1.03ms・ダウンロード5.61ms、ノートPC級のx86 CPUでは1.21ms・6.02msとなり、Macとほぼ同等でした(2026-10-09)。モデルが1秒のチャンクを1つ処理するのにかかる50msに比べればごくわずかです。
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() # 연결이 닫히면 잠금도 풀린다
- 通話が終わると接続を閉じ、接続が閉じられるとロックが解除されます。
- ポッドが死んでもスロットが占有されたままになりません。 接続が切断されると、Postgresがそのセッションのロックを自動的に解放します。行としてスロットを残す場合は、このとき有効期限を待つ必要があります。
- コストは通話ごとにDB接続が1つです。同時に1つの通話のみなので、接続も1つです。
- 「今かけられるか」を問い合わせるAPIは、ロックを取得してからすぐに解放して確認します。
7. 接続を切断するもの
- アイドル接続。 CiliumゲートウェイのEnvoyはデフォルト設定で双方が静かな接続を60秒で切断します(
proxy-idle-timeout-seconds)。ウェブターミナルはこのため25秒ごとにハートビートを送信します。通話はブラウザが1秒ごとに音声を送信するため、ハートビートは別途必要ありません。むしろ15秒間音声が来ないと中継側が先に切断します(ウィンドウを閉じたかタブがスリープしたとみなします)。 - デプロイ。 バックエンドを新しく起動すると、開いていたWebSocketは切断されます。通話は10分が上限なので、かけ直せば問題ありません。スロットは上記の理由により自動的に解放されます。
- モデルサーバーの準備ができていないとき。 モデルサーバーの
/healthはモデルをロードする前から200を返します(モデルは最初のセッションでロードされ、70〜80秒かかります)。そのため、サーバー起動直後にセッションを一度開いてモデルをロードさせ、その後に作成したファイルで準備状況を判断するようにしました。中継側はセッションオープンを20秒まで待ち、それを超えると4503で「準備中」を通知します。
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. 限界と未測定
- 合成音声でのみ質問しました。人間の声、方言、雑音は 未測定 です。
- ブラウザのエコーキャンセレーション(
echoCancellation)がWeb Audioで再生した音声まで消去するかどうかはブラウザやデバイスによって異なり、測定していません。画面上でイヤホンを推奨しています。 - サーバーが1:1なので同時通話は設計上1つです。複数の通話を受け付けるにはGPUを増やすか、バッチ処理をサポートするサーバーが必要です(未測定).
- 同系統のRealtime-Venus(MiniCPM-o 4.5ベース、BF16約20GB)とNemotronLabs VoiceChat-11BのGGUF(英語専用)は 未測定 です。
ScriptProcessorNodeは廃止予定です。AudioWorkletに移行するとメインスレッドがビジーなときの途切れを軽減できる可能性がありますが、測定していません。
面接で出そうな質問
- SSEではなくWebSocketである理由は? SSEはサーバー → クライアントの単方向です。全二重通話は1秒ごとに音声を送信し、同時に音声を受信します。送信をHTTPリクエストとして別途送ると、秒間リクエストが1つ発生し、その接続コストが生じます。
- WebRTCを使用しなかった理由は? WebRTCはUDP・ジッターバッファ・エコーキャンセレーションを備えており、通話により適しています。その代わり、TURN/STUNサーバーとメディアサーバー(例:LiveKit)が必要です。このモデルは1秒の塊で判断するため、数十msの遅延の差は体感への影響が小さく、すでに運用していたWebSocket経路(ウェブターミナル)を再利用する方がシンプルでした。利用者が増える場合やモバイル回線を考慮すると、WebRTCに移行するのが適切です。
- base64を避けた理由は? base64は4/3倍に膨らみます。float32は16ビットの2倍です。両方を合わせると、同じ音声が2.7倍になります(48KB → 128KB/s)。
- advisory lockの代わりにRedisロックを使用しないのはなぜか? このサイトはすでにPostgresを使用しており、接続が切断されたときにロックが自動的に解放される性質が必要でした。Redisロックは有効期限を設定する必要があり、通話がそれより長くなった場合は延長しなければなりません。
参考文献
- RFC 6455 The WebSocket Protocol — フレームのopcode(5.2節)、クローズコードの範囲(7.4.2節)
- PostgreSQLドキュメント、Advisory Locks(13.3.5節)、
pg_try_advisory_lock - MiniCPM-o 4.5 GGUF: huggingface.co/openbmb/MiniCPM-o-4_5-gguf, llama.cpp-omni: github.com/tc-mb/llama.cpp-omni
- MDN, Web Audio API —
AudioBufferSourceNode.start(when),BaseAudioContext.createBuffer - 第20話 言語学習リアルタイム音声会話、部品別の実測、第19話 リアルタイム音声会話アプリ向けNVIDIAオープンソース