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_KEYseria 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:
type | Campos | Significado |
|---|---|---|
input_audio | audio (base64 PCM16) | Um pedaço de áudio do microfone |
video_frame | mime, data (base64) | Um quadro de câmera |
text | text | Uma mensagem de chat tipada |
hangup | — | Encerrar a sessão |
Servidor → navegador:
type | Campos | Renderizar como |
|---|---|---|
ready | provider, input_rate, output_rate | Taxas de áudio negociadas — crie seus AudioContexts |
audio | audio (base64 PCM16) | Enfileirar para reprodução contínua |
agent_transcript | delta | Anexar à bolha do agente |
user_transcript_delta | delta | Legenda ao vivo da fala do usuário |
user_transcript | text | Transcrição final do usuário |
user_speaking / user_stopped | — | VAD state (controlar um indicador de microfone; limpar a reprodução em user_speaking para barge-in) |
tool | name, args | Um chip "executando ferramenta…" |
response_done | — | Turno finalizado |
error | message | Mostrar 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
readycominput_rate/output_rateantes do áudio; construirAudioContexts 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óssend_text(não para áudio VAD). - Tratar erros de envio de
video_framecomo não fatais. _ => {}armar ao corresponderServerEvent(é#[non_exhaustive]).
Próximo: Exemplos →