Webアプリの構築

これは、リアルタイムエージェントをブラウザに配置するための実践的なガイドです。サーバーサイドブリッジパターン、WebSocketプロトコル、そしてマイクをキャプチャし、エージェントを途切れることなく再生するWeb Audioコードについて説明します。このセクションのすべてのWeb例は、まさにこの方法で構築されています。customer_serviceサーバーがリファレンス実装です。

サーバーサイドブリッジが必要な理由

ブラウザはプロバイダーのリアルタイムWebSocketを直接保持することはできません

  • あなたのOPENAI_API_KEY / GEMINI_API_KEYがすべてのクライアントに送信されてしまうため、
  • ツールがブラウザで実行され、データや認証情報から離れてしまうため、
  • クライアントコードで1つのプロバイダーのワイヤーフォーマットにロックされてしまうためです。

そのため、ブラウザは薄いオーディオ/ビデオデバイスであり、Rustサーバーがセッションを所有します。

  browser ──mic PCM16 + camera JPEG (base64 over your WS)──▶  Axum /ws
  browser ◀──agent PCM16 + transcripts + tool events───────   IntegratedRealtimeRunner ──▶ provider

キーはサーバー上に残り、ツールはサーバー上で実行され、クライアントに触れることなく接続ごとにプロバイダーを切り替えることができます (/ws?provider=openai|gemini)。

WebSocketプロトコル

小さなJSONプロトコルが独自のWebSocketに乗って動作します。ブラウザ → サーバー:

typeフィールド意味
input_audioaudio (base64 PCM16)マイク音声のチャンク
video_framemime, data (base64)カメラフレーム
texttext入力されたチャットメッセージ
hangupセッションを終了する

サーバー → ブラウザ:

typeフィールド表示形式
readyprovider, input_rate, output_rateネゴシエートされたオーディオレート — あなたの AudioContext を作成します
audioaudio (base64 PCM16)ギャップレス再生のためにキューに入れる
agent_transcriptdeltaAgent のバブルに追加
user_transcript_deltadeltaユーザーの発話のライブキャプション
user_transcripttext最終的なユーザーのトランスクリプト
user_speaking / user_stoppedVAD ステータス (マイクインジケーターを駆動; バーージインのために user_speaking で再生をフラッシュ)
toolname, args「ツール実行中…」チップ
response_doneターン終了
errormessageエラーを表示

これは、ServerEvent の上に構築された、アプリ定義の薄いマッピングです — 詳細は以下のserver_event_to_client_jsonを参照してください。

サーバー: Axum ブリッジ

ハンドラーはIntegratedRealtimeRunnerを構築し、接続し、readyを送信した後、アウトバウンド(リアルタイムイベント → ブラウザ)とインバウンド(ブラウザ → セッション)の2つの並行ループをtokio::select!と結合して実行します。

async fn handle_ws(socket: WebSocket, provider: Provider) {
    let session_id = uuid::Uuid::new_v4().to_string();
    let (mut sender, mut receiver) = socket.split();

    let runner = Arc::new(build_runner(provider, &session_id).await.unwrap());
    runner.connect().await.unwrap();

    // Negotiate audio rates to the browser BEFORE any audio flows.
    let (input_rate, output_rate) = provider.audio_rates();
    sender.send(Message::Text(json!({
        "type": "ready", "provider": provider.name(),
        "input_rate": input_rate, "output_rate": output_rate,
    }).to_string().into())).await.ok();

    // Outbound: realtime events → browser.
    let out_runner = runner.clone();
    let outbound = async move {
        while let Some(event) = out_runner.next_event().await {
            if let Ok(ev) = event {
                if let Some(payload) = server_event_to_client_json(ev) {
                    if sender.send(Message::Text(payload.to_string().into())).await.is_err() { break; }
                }
            }
        }
    };

    // Inbound: browser mic/camera/text → session.
    let in_runner = runner.clone();
    let inbound = async move {
        while let Some(Ok(Message::Text(text))) = receiver.next().await {
            match serde_json::from_str::<ClientMsg>(&text) {
                Ok(ClientMsg::InputAudio { audio }) => { in_runner.send_audio(&audio).await.ok(); }
                Ok(ClientMsg::VideoFrame { mime, data }) => { in_runner.send_video_frame(&mime, &data).await.ok(); }
                Ok(ClientMsg::Text { text }) => {
                    if in_runner.send_text(&text).await.is_ok() { in_runner.create_response().await.ok(); }
                }
                Ok(ClientMsg::Hangup) => break,
                _ => {}
            }
        }
    };

    tokio::select! { _ = outbound => {}, _ = inbound => {} }
    runner.close().await.ok();
}

正しく理解すべき2つの入力非対称性があります。

  • テキストにはcreate_response()が必要です — 返信をトリガーするVADはありません(Architecture: turn lifecycleを参照)。サーバーVAD下のオーディオはそうではありません。
  • ビデオエラーは致命的ではありません — ログに記録して続行します。フレームがドロップされても通話が切断されるべきではありません。

サーバーイベントのマッピング

アウトバウンドループは、プロバイダーに依存しない各ServerEventを上記のコンパクトなクライアントJSONに変換し、UIが無視するイベントに対してはNoneを返します。

fn server_event_to_client_json(event: ServerEvent) -> Option<serde_json::Value> {
    match event {
        ServerEvent::AudioDelta { delta, .. } =>
            Some(json!({ "type": "audio", "audio": BASE64.encode(&delta) })),
        ServerEvent::TranscriptDelta { delta, .. } =>
            Some(json!({ "type": "agent_transcript", "delta": delta })),
        ServerEvent::InputTranscriptDelta { delta, .. } =>
            Some(json!({ "type": "user_transcript_delta", "delta": delta })),
        ServerEvent::SpeechStarted { .. } => Some(json!({ "type": "user_speaking" })),
        ServerEvent::FunctionCallDone { name, arguments, .. } =>
            Some(json!({ "type": "tool", "name": name, "args": arguments })),
        ServerEvent::ResponseDone { .. } => Some(json!({ "type": "response_done" })),
        ServerEvent::Error { error, .. } => Some(json!({ "type": "error", "message": error.message })),
        _ => None,   // ServerEvent is #[non_exhaustive]
    }
}

ブラウザ: マイクをPCM16としてキャプチャする

プロバイダーは、ネゴシエートされたinput_rate生のPCM16モノラルを要求します。そのレートでAudioContextを使用してキャプチャし、浮動小数点サンプルを16ビットにダウンサンプリングし、base64エンコードして送信します。

let inputRate, outputRate;
ws.onmessage = (e) => {
  const msg = JSON.parse(e.data);
  if (msg.type === 'ready') { inputRate = msg.input_rate; outputRate = msg.output_rate; startMic(); }
  // …handle audio / transcripts / tool / etc.
};

async function startMic() {
  const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
  const ctx = new AudioContext({ sampleRate: inputRate });
  const src = ctx.createMediaStreamSource(stream);
  const node = ctx.createScriptProcessor(4096, 1, 1);  // or an AudioWorklet
  node.onaudioprocess = (ev) => {
    const f32 = ev.inputBuffer.getChannelData(0);
    const pcm16 = new Int16Array(f32.length);
    for (let i = 0; i < f32.length; i++) {
      const s = Math.max(-1, Math.min(1, f32[i]));
      pcm16[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
    }
    ws.send(JSON.stringify({ type: 'input_audio', audio: bytesToBase64(new Uint8Array(pcm16.buffer)) }));
  };
  src.connect(node); node.connect(ctx.destination);
}

ブラウザ: ギャップレス再生 + バーイン

エージェントのオーディオは、output_rateでPCM16チャンクのストリームとして到着します。各チャンクを浮動小数点数にデコードし、再生AudioContextで連続してスケジュールし、チャンクが重なったり途切れたりしないように実行中のカーソルを維持します。

const playCtx = new AudioContext({ sampleRate: outputRate });
let playHead = 0;
const sources = [];

function playChunk(base64) {
  const bytes = base64ToBytes(base64);
  const pcm16 = new Int16Array(bytes.buffer);
  const buf = playCtx.createBuffer(1, pcm16.length, outputRate);
  const ch = buf.getChannelData(0);
  for (let i = 0; i < pcm16.length; i++) ch[i] = pcm16[i] / 0x8000;

  const node = playCtx.createBufferSource();
  node.buffer = buf;
  node.connect(playCtx.destination);
  const startAt = Math.max(playCtx.currentTime, playHead);
  node.start(startAt);
  playHead = startAt + buf.duration;
  sources.push(node);
}

// Barge-in: when the user starts speaking, stop the agent immediately.
function flushPlayback() {
  for (const n of sources) { try { n.stop(); } catch {} }
  sources.length = 0;
  playHead = 0;
}

ユーザーが中断した瞬間にエージェントが話すのをやめるように、user_speakingメッセージでflushPlayback()を呼び出します — これは実際の会話の自然な感覚です。

カメラフレーム

キャンバスキャプチャループについてはMultimodalを参照してください — <video>をキャンバスに描き、toDataURL('image/jpeg', 0.6)し、プレフィックスを削除し、プロバイダーに適した頻度(Geminiで約700ミリ秒、OpenAIで約2.5秒)でvideo_frameメッセージを送信します。

チェックリスト

  • キーとツールはサーバー上に存在し、ブラウザ上には存在しません。
  • オーディオの前にinput_rate/output_rateとともにreadyを送信します。これらのレートでAudioContextsを構築します。
  • マイクをinput_rateでPCM16モノラルとしてキャプチャします。
  • output_rateでスケジューリングカーソルを使用してエージェントオーディオをギャップレスに再生します。
  • user_speaking(バーイン)で再生をフラッシュします。
  • send_textの後にcreate_response()を呼び出します(VADオーディオ用ではありません)。
  • video_frame送信エラーは致命的ではないものとして扱います。
  • ServerEventに一致する場合に_ => {}をアームします(それは#[non_exhaustive]です)。

次へ: Examples →