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_KEY se 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:

typeCamposSignificado
input_audioaudio (base64 PCM16)Un fragmento de audio del micrófono
video_framemime, data (base64)Un fotograma de cámara
texttextUn mensaje de chat escrito
hangupFinalizar la sesión

Servidor → navegador:

typeCamposRepresentar como
readyprovider, input_rate, output_rateTasas de audio negociadas — crea tus AudioContexts
audioaudio (base64 PCM16)Poner en cola para reproducción sin interrupciones
agent_transcriptdeltaAñadir a la burbuja del agente
user_transcript_deltadeltaSubtítulo en vivo del habla del usuario
user_transcripttextTranscripción final del usuario
user_speaking / user_stoppedEstado de VAD (activar un indicador de micrófono; vaciar reproducción en user_speaking para interrupción forzada)
toolname, argsUn chip de "herramienta en ejecución…"
response_doneTurno terminado
errormessageMostrar 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 ready con input_rate/output_rate antes del audio; construir AudioContexts 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 de send_text (no para audio VAD).
  • Tratar los errores de envío de video_frame como no fatales.
  • Armar _ => {} cuando coincide con ServerEvent (es #[non_exhaustive]).

Siguiente: Ejemplos →

Creación de aplicaciones web - Documentación ADK-Rust | ADK-Rust