Créer des applications web

Ceci est le guide pratique pour implémenter un Agent en temps réel dans un navigateur : le modÚle de pont cÎté serveur, le protocole WebSocket, et le code Web Audio qui capture le micro et diffuse l'Agent sans interruption. Chaque exemple web de cette section est construit exactement de cette maniÚre ; le serveur customer_service est l'implémentation de référence.

Pourquoi un pont cÎté serveur

Un navigateur ne peut pas maintenir directement le WebSocket en temps réel du fournisseur :

  • votre OPENAI_API_KEY / GEMINI_API_KEY serait envoyĂ© Ă  chaque client,
  • les outils s'exĂ©cuteraient dans le navigateur, loin de vos donnĂ©es et informations d'identification,
  • vous seriez liĂ© au format de fil d'un seul fournisseur dans le code client.

Ainsi, le navigateur est un dispositif audio/vidéo léger et votre serveur Rust possÚde la session :

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

La clé reste sur le serveur, les outils s'exécutent sur le serveur, et vous pouvez changer de fournisseur par connexion (/ws?provider=openai|gemini) sans toucher au client.

Le protocole WebSocket

Un minuscule protocole JSON utilise votre propre WebSocket. Navigateur → serveur :

typeChampsSignification
input_audioaudio (base64 PCM16)Un morceau d'audio du micro
video_framemime, data (base64)Une image de caméra
texttextUn message de chat tapé
hangup—Terminer la session

Serveur → navigateur :

typeChampsRendu comme
readyprovider, input_rate, output_rateDĂ©bits audio nĂ©gociĂ©s — crĂ©ez vos AudioContext
audioaudio (base64 PCM16)Mettre en file d'attente pour une lecture sans interruption
agent_transcriptdeltaAjouter Ă  la bulle de l'agent
user_transcript_deltadeltaSous-titrage en direct de la parole de l'utilisateur
user_transcripttextTranscription finale de l'utilisateur
user_speaking / user_stopped—État VAD (piloter un indicateur de micro ; vider la lecture sur user_speaking pour l'interruption)
toolname, argsUn jeton "outil en cours d'exécution
"
response_done—Tour terminĂ©
errormessageAfficher l'erreur

Ceci est un mappage fin, dĂ©fini par l'application, sur ServerEvent — voir server_event_to_client_json ci-dessous.

Serveur : le pont Axum

Le gestionnaire construit un IntegratedRealtimeRunner, se connecte, envoie ready, puis exĂ©cute deux boucles concurrentes — sortante (Ă©vĂ©nements en temps rĂ©el → navigateur) et entrante (navigateur → session) — jointes avec 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();
}

Notez les deux asymétries d'entrée que vous devez corriger :

  • Le texte nĂ©cessite create_response() — il n'y a pas de VAD pour dĂ©clencher une rĂ©ponse (voir Architecture: cycle de vie d'un tour). L'audio sous VAD du serveur ne le fait pas.
  • Les erreurs vidĂ©o ne sont pas fatales — enregistrez et continuez ; une trame perdue ne devrait pas interrompre l'appel.

Mappage des événements du serveur

La boucle de sortie convertit chaque ServerEvent agnostique au fournisseur en le JSON client compact ci-dessus, renvoyant None pour les événements ignorés par l'interface utilisateur :

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

Navigateur : capture du micro en PCM16

Le fournisseur souhaite du PCM16 mono brut au input_rate négocié. Capturez avec un AudioContext à ce débit, sous-échantillonnez les échantillons flottants en 16 bits, encodez-les en base64, et envoyez-les :

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

Navigateur : lecture sans interruption + interruption

L'audio de l'agent arrive sous forme de flux de blocs PCM16 à output_rate. Décodez chaque bloc en flottant, planifiez-le en continu sur une lecture AudioContext, et maintenez un curseur en cours d'exécution afin que les blocs ne se chevauchent pas ou ne créent pas de lacunes :

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

Appelez flushPlayback() sur le message user_speaking afin que l'agent cesse de parler dùs que l'utilisateur l'interrompt — la sensation naturelle d'une vraie conversation.

Cadres de caméra

Voir Multimodal pour la boucle de capture du canevas — dessinez le <video> sur un canevas, toDataURL('image/jpeg', 0.6), supprimez le prĂ©fixe, et envoyez un message video_frame Ă  une cadence appropriĂ©e au fournisseur (~700 ms Gemini, ~2.5 s OpenAI).

Une liste de contrĂŽle

  • La clĂ© et les outils rĂ©sident sur le serveur, jamais dans le navigateur.
  • Envoyer ready avec input_rate/output_rate avant l'audio ; construire des AudioContexts Ă  ces dĂ©bits.
  • Capturer le micro en PCM16 mono Ă  input_rate.
  • Lire l'audio de l'agent sans interruption avec un curseur de planification Ă  output_rate.
  • Vider la lecture sur user_speaking (barge-in).
  • Appeler create_response() aprĂšs send_text (pas pour l'audio VAD).
  • Traiter les erreurs d'envoi de video_frame comme non-fatales.
  • Armer _ => {} lors de la correspondance avec ServerEvent (c'est #[non_exhaustive]).

Suivant : Exemples →

Créer des applications web - Documentation ADK-Rust | ADK-Rust