Creación de aplicaciones web
Esta es la guía práctica para integrar un agente en tiempo real en un navegador: el patrón de puente del lado del servidor, el protocolo WebSocket y el código de Web Audio que captura el micrófono y reproduce al agente sin interrupciones. Cada ejemplo web en esta sección se construye exactamente de esta manera; el servidor customer_service es la implementación de referencia.
Por qué un puente del lado del servidor
Un navegador no puede mantener el WebSocket en tiempo real del proveedor directamente:
- tus
OPENAI_API_KEY/GEMINI_API_KEYse enviarían a cada cliente, - las herramientas se ejecutarían en el navegador, lejos de tus datos y credenciales,
- estarías limitado al formato de comunicación de un proveedor en el código del cliente.
Por lo tanto, el navegador es un dispositivo de audio/video ligero y tu servidor Rust es el propietario de la sesión:
browser ──mic PCM16 + camera JPEG (base64 over your WS)──▶ Axum /ws
browser ◀──agent PCM16 + transcripts + tool events─────── IntegratedRealtimeRunner ──▶ provider
La clave permanece en el servidor, las herramientas se ejecutan en el servidor, y puedes cambiar de proveedor por conexión (/ws?provider=openai|gemini) sin tocar el cliente.
El protocolo WebSocket
Un pequeño protocolo JSON viaja sobre tu propio WebSocket. Navegador → servidor:
type | Campos | Significado |
|---|---|---|
input_audio | audio (base64 PCM16) | Un fragmento de audio del micrófono |
video_frame | mime, data (base64) | Un fotograma de cámara |
text | text | Un mensaje de chat escrito |
hangup | — | Finalizar la sesión |
Servidor → navegador:
type | Campos | Representar como |
|---|---|---|
ready | provider, input_rate, output_rate | Tasas de audio negociadas — crea tus AudioContexts |
audio | audio (base64 PCM16) | Poner en cola para reproducción sin interrupciones |
agent_transcript | delta | Añadir a la burbuja del agente |
user_transcript_delta | delta | Subtítulo en vivo del habla del usuario |
user_transcript | text | Transcripción final del usuario |
user_speaking / user_stopped | — | Estado de VAD (activar un indicador de micrófono; vaciar reproducción en user_speaking para interrupción forzada) |
tool | name, args | Un chip de "herramienta en ejecución…" |
response_done | — | Turno terminado |
error | message | Mostrar el error |
Esto es un mapeo delgado, definido por la aplicación, sobre ServerEvent — ver
server_event_to_client_json a continuación.
Servidor: el puente Axum
El manejador construye un IntegratedRealtimeRunner, se conecta, envía ready, luego
ejecuta dos bucles concurrentes — de salida (eventos en tiempo real → navegador) y
de entrada (navegador → sesión) — unidos con 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();
}
Tenga en cuenta las dos asimetrías de entrada que debe abordar correctamente:
- El texto necesita
create_response()— no hay VAD para activar una respuesta (ver Arquitectura: ciclo de vida del turno). El audio bajo VAD del servidor no lo necesita. - Los errores de video no son fatales — registre y continúe; un fotograma perdido no debería interrumpir la llamada.
Mapeo de eventos del servidor
El bucle de salida convierte cada ServerEvent agnóstico de proveedor en el JSON de cliente compacto anterior, devolviendo None para eventos que la 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 el micrófono como PCM16
El proveedor quiere PCM16 mono en bruto a la input_rate negociada. Captúralo con un AudioContext a esa velocidad, reduce el muestreo de las muestras de coma flotante a 16 bits, codifícalas en base64 y envía:
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: reproducción sin interrupciones + interrupción
El audio del agente llega como un flujo de fragmentos de PCM16 a output_rate. Decodifica cada uno a coma flotante, prográmalo consecutivamente en una AudioContext, y mantén un cursor en ejecución para que los fragmentos no se superpongan ni tengan huecos:
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;
}
Llama a flushPlayback() en el mensaje user_speaking para que el agente deje de hablar
en el instante en que el usuario interrumpe — la sensación natural de una conversación real.
Fotogramas de cámara
Consulta Multimodal para el bucle de captura
del lienzo — dibuja el <video> a un lienzo, toDataURL('image/jpeg', 0.6),
elimina el prefijo y envía un mensaje video_frame con una
cadencia apropiada para el proveedor (~700 ms Gemini, ~2.5 s OpenAI).
Una lista de verificación
- La clave y las herramientas residen en el servidor, nunca en el navegador.
- Enviar
readyconinput_rate/output_rateantes del audio; construirAudioContexts a esas tasas. - Capturar micrófono como PCM16 mono a
input_rate. - Reproducir audio del agente sin interrupciones con un cursor de programación en
output_rate. - Vaciar la reproducción en
user_speaking(interrupción). - Llamar a
create_response()después desend_text(no para audio VAD). - Tratar los errores de envío de
video_framecomo no fatales. - Armar
_ => {}cuando coincide conServerEvent(es#[non_exhaustive]).
Siguiente: Ejemplos →