Construindo Aplicativos Web

Este é o guia prático para colocar um agente em tempo real em um navegador: o padrão de ponte do lado do servidor, o protocolo WebSocket e o código Web Audio que captura o microfone e reproduz o agente sem interrupções. Cada exemplo web nesta seção é construído exatamente desta forma; o servidor customer_service é a implementação de referência.

Por que uma ponte do lado do servidor

Um navegador não pode manter o WebSocket em tempo real do provedor diretamente:

  • seu OPENAI_API_KEY / GEMINI_API_KEY seria enviado para cada cliente,
  • as Tool seriam executadas no navegador, longe de seus dados e credenciais,
  • você ficaria preso ao formato de comunicação de um provedor no código do cliente.

Então o navegador é um dispositivo de áudio/vídeo fino e seu servidor Rust é o proprietário da Session:

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

A chave permanece no servidor, as Tool são executadas no servidor, e você pode trocar de provedor por conexão (/ws?provider=openai|gemini) sem tocar no cliente.

O protocolo WebSocket

Um pequeno protocolo JSON trafega no seu próprio WebSocket. Navegador → servidor:

typeCamposSignificado
input_audioaudio (base64 PCM16)Um pedaço de áudio do microfone
video_framemime, data (base64)Um quadro de câmera
texttextUma mensagem de chat tipada
hangupEncerrar a sessão

Servidor → navegador:

typeCamposRenderizar como
readyprovider, input_rate, output_rateTaxas de áudio negociadas — crie seus AudioContexts
audioaudio (base64 PCM16)Enfileirar para reprodução contínua
agent_transcriptdeltaAnexar à bolha do agente
user_transcript_deltadeltaLegenda ao vivo da fala do usuário
user_transcripttextTranscrição final do usuário
user_speaking / user_stoppedVAD state (controlar um indicador de microfone; limpar a reprodução em user_speaking para barge-in)
toolname, argsUm chip "executando ferramenta…"
response_doneTurno finalizado
errormessageMostrar o erro

Esta é uma mapeamento fino, definido pelo aplicativo, sobre ServerEvent — veja server_event_to_client_json abaixo.

Servidor: a ponte Axum

O handler constrói um IntegratedRealtimeRunner, conecta, envia ready, então executa dois loops concorrentes — de saída (eventos em tempo real → navegador) e de entrada (navegador → sessão) — unidos com 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();
}

Observe as duas assimetrias de entrada que você deve acertar:

  • Texto precisa de create_response() — não há VAD para acionar uma resposta (veja Arquitetura: ciclo de vida do turno). Áudio sob VAD do servidor não precisa.
  • Erros de vídeo não são fatais — registre e continue; um quadro perdido não deve interromper a chamada.

Mapeando eventos do servidor

O loop de saída converte cada ServerEvent agnóstico ao provedor no JSON compacto do cliente acima, retornando None para eventos que a UI ignora:

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]
    }
}

Navegador: capturando o microfone como PCM16

O provedor quer raw PCM16 mono na taxa negociada input_rate. Capture com um AudioContext nessa taxa, faça o downsample das amostras float para 16 bits, codifique-as em base64 e envie:

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);
}

Navegador: reprodução sem interrupções + interrupção de fala

O áudio do agente chega como um fluxo de PCM16 chunks em output_rate. Decodifique cada um para float, agende-o consecutivamente em um AudioContext, e mantenha um cursor em execução para que os blocos não se sobreponham ou tenham lacunas:

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;
}

Chame flushPlayback() na mensagem user_speaking para que o agente pare de falar no instante em que o usuário interrompe — a sensação natural de uma conversa real.

Quadros da câmera

Veja Multimodal para o loop de captura do canvas — desenhe o <video> em um canvas, toDataURL('image/jpeg', 0.6), remova o prefixo e envie uma mensagem video_frame em uma cadência apropriada para o provedor (~700 ms Gemini, ~2.5 s OpenAI).

Uma lista de verificação

  • Chave + tools residem no servidor, nunca no navegador.
  • Enviar ready com input_rate/output_rate antes do áudio; construir AudioContexts nessas taxas.
  • Capturar microfone como PCM16 mono em input_rate.
  • Reproduzir áudio do agent sem interrupções com um cursor de agendamento em output_rate.
  • Limpar reprodução em user_speaking (barge-in).
  • Chamar create_response() após send_text (não para áudio VAD).
  • Tratar erros de envio de video_frame como não fatais.
  • _ => {} armar ao corresponder ServerEvent#[non_exhaustive]).

Próximo: Exemplos →