웹 앱 구축

이것은 실시간 에이전트를 브라우저에 배치하기 위한 실용적인 가이드입니다: 서버 측 브리지 패턴, WebSocket 프로토콜, 그리고 마이크를 캡처하고 에이전트를 끊김 없이 재생하는 Web Audio 코드. 이 섹션의 모든 웹 예제는 정확히 이 방식으로 구축됩니다; customer_service 서버는 참조 구현입니다.

서버 측 브리지가 필요한 이유

브라우저는 공급자의 실시간 WebSocket을 직접 유지할 수 없습니다:

  • OPENAI_API_KEY / GEMINI_API_KEY이 모든 클라이언트에 배송될 것입니다.
  • 도구는 브라우저에서 실행되어 데이터 및 자격 증명에서 멀어집니다.
  • 클라이언트 코드에서 단일 공급자의 wire format에 고정될 것입니다.

따라서 브라우저는 얇은 오디오/비디오 장치이며 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_transcriptdelta에이전트의 버블에 추가
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을(를) 보낸 다음, 두 개의 동시 루프( 아웃바운드 (실시간 이벤트 → 브라우저) 및 인바운드 (브라우저 → 세션))를 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();
}

올바르게 처리해야 할 두 가지 입력 비대칭성에 유의하세요:

  • 텍스트에는 create_response()이(가) 필요합니다 — 응답을 트리거할 VAD가 없습니다 (아키텍처: 턴 라이프사이클 참조). 서버 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()을(를) 호출하세요 — 실제 대화의 자연스러운 느낌입니다.

카메라 프레임

캔버스 캡처 루프는 멀티모달을(를) 참조하세요 — <video>을(를) 캔버스에 그리고, toDataURL('image/jpeg', 0.6), 접두사를 제거한 다음, 공급자에 적합한 주기(~700ms Gemini, ~2.5s OpenAI)로 video_frame 메시지를 보냅니다.

체크리스트

  • 키 + 도구는 서버에 상주하며, 브라우저에는 절대 상주하지 않습니다.
  • 오디오 전에 input_rate/output_rate와(과) 함께 ready을(를) 보냅니다; 해당 속도로 AudioContext을(를) 구축합니다.
  • input_rate에서 마이크를 PCM16 모노로 캡처합니다.
  • output_rate에서 스케줄링 커서로 에이전트 오디오를 끊김 없이 재생합니다.
  • user_speaking에서 재생을 플러시합니다 (끼어들기).
  • send_text 이후에 create_response()을(를) 호출합니다 (VAD 오디오에는 해당하지 않음).
  • video_frame 전송 오류를 치명적이지 않은 것으로 처리합니다.
  • ServerEvent와(과) 일치할 때 _ => {}을(를) 활성화합니다 (#[non_exhaustive]입니다).

다음: 예시 →