Build a click-to-talk button that connects a browser user to a server-side AI voice agent. The user’s microphone is captured, Opus-encoded, and published as the call’s uplink; the agent’s synthesized speech arrives as the downlink and plays back through WebCodecs. Media rides MoQT over QUIC / WebTransport end-to-end — the browser’s WebRTC stack is used only as a codec + capture tap, never as the transport, and there is no SFU in the path. The three helpers that make this a few lines instead of a WebCodecs project:
  • captureMicrophone — runs the browser’s AEC / AGC / noise-suppression and Opus encoder in a loopback RTCPeerConnection, then diverts the encoded frames (raw PCM never crosses the wire) onto your uplink track.
  • OpusPlayer — decodes received Opus with WebCodecs and renders it through an AudioWorklet ring buffer.
  • attachCaller — the browser-caller side of the audio bridge: it subscribes the downlink (voice/<sid>/downlink, what the cloud sends the caller) and publishes the uplink (voice/<sid>/uplink, the caller’s mic).
The browser media plane is WebTransport-only today — there is no WebSocket fallback rung in the browser SDK yet (the QUIC→WebSocket ladder ships only in the native cores). captureMicrophone also hard-refuses to run if RTCRtpScriptTransform is missing rather than falling back to an insecure main-thread path. In practice you need a current Chromium (also required for the WebCodecs AudioDecoder playback), Edge, Firefox 133+, or Safari TP. See Browser compatibility.

How the pieces connect

1

Mint the call server-side

Your backend originates the call with an agent attached and hands the browser back the sid plus a browser-scoped token. Your secret API key never ships to the client.
2

Start playback

Create and start() an OpusPlayer (must happen inside a user gesture so the AudioContext can resume).
3

Attach as the caller

attachCaller(sid, { codec: "opus", onDownlink }) subscribes the agent’s audio and pushes each frame into the player.
4

Capture the mic

captureMicrophone(...) forwards encoded Opus frames onto the uplink so the agent hears the user.

Front end: the click-to-talk component

This is the whole browser side. It assumes a page with a dial button and holds a browser-scoped key — a publishable, voice-scoped, short-TTL token, not your server secret (see the backend below). The browser mic capture is inherently a Web API, so this half is TypeScript only; the backend shows both TypeScript and Python.
import { Voice } from "@telequick/sdk/voice";
import { captureMicrophone, OpusPlayer } from "@telequick/sdk/moqt";

// A publishable, voice-scoped token your backend hands the page — never the
// server API key. See the backend section.
async function mintSession(): Promise<{ sid: string; token: string }> {
  const res = await fetch("/api/voice/session", { method: "POST" });
  if (!res.ok) throw new Error(`session mint failed: ${res.status}`);
  return res.json();
}

async function startAgentCall() {
  const { sid, token } = await mintSession();

  const v = new Voice({
    baseUrl:   "https://engine.telequick.dev",
    apiKey:    token,            // browser-scoped, short-lived
    orgId:     "org_abc",
    relayHost: "relay.telequick.dev",
  });

  // 1. Playback graph. Opus decodes at 48 kHz mono. Create + start() inside the
  //    click handler so the AudioContext is allowed to produce sound.
  const ctx    = new AudioContext();
  const player = new OpusPlayer(ctx, { sampleRate: 48000, channels: 1 });
  await player.start();

  // 2. Attach as the caller: subscribe the downlink (agent → user), publish the
  //    uplink (user mic → agent). onDownlink is (frame, tsUs); player.push is
  //    (tsUs, frame) — note the argument order flips.
  const bridge = await v.audioBridge.attachCaller(sid, {
    codec:      "opus",
    onDownlink: (frame, tsUs) => player.push(tsUs, frame),
  });

  // 3. Capture the mic. captureMicrophone writes encoded frames to an
  //    AudioPublication; adapt the bridge's publishUplink to that shape.
  const mic = await captureMicrophone(
    { write: (tsUs, frame) => bridge.publishUplink(frame, tsUs) } as any,
    {
      audioConstraints: {
        echoCancellation: true,   // the browser's AEC — the engine has none
        autoGainControl:  true,
        noiseSuppression: true,
      },
    },
  );

  // 4. Teardown. Stop capture and playback, then close the bridge (which drops
  //    both tracks and the transport).
  async function hangUp() {
    mic.stop();
    player.close();
    await ctx.close();
    await bridge.close();
  }

  return { sid, hangUp };
}

// Wire it to a button. The first click starts the call; the AudioContext and
// getUserMedia both need this user gesture.
const btn = document.querySelector<HTMLButtonElement>("#talk")!;
let session: { sid: string; hangUp: () => Promise<void> } | null = null;

btn.addEventListener("click", async () => {
  if (session) {
    await session.hangUp();
    session = null;
    btn.textContent = "Talk to the agent";
    return;
  }
  btn.disabled = true;
  session = await startAgentCall();
  btn.disabled = false;
  btn.textContent = "Hang up";
});
captureMicrophone runs the browser’s echo-cancellation, gain control, and noise suppression, then diverts the encoded Opus frames onto the uplink — raw PCM never leaves the tab. The engine has no server-side AEC/AGC, so those constraints doing their job in the browser is what keeps the agent from hearing its own voice. Details in WebRTC diversion and RTCRtpScriptTransform.

Minimal backend

The browser must not hold your secret API key, so a tiny server route mints the call — attaching the AI agent that will answer — and returns the sid plus a publishable token scoped to this one call. The call is created over a voice-routing trunk you provision for web sessions; to / from label the leg (they need not be dialable numbers for a pure-web agent).
// POST /api/voice/session  → { sid, token }
import { Voice } from "@telequick/sdk/voice";

const v = new Voice({
  baseUrl: "https://engine.telequick.dev",
  apiKey:  process.env.TELEQUICK_API_KEY!,   // server secret — stays here
  orgId:   "org_abc",
});

export async function handleSession() {
  const call = await v.calls.originate({
    to:      "web:agent",          // label for the web leg
    from:    "web:caller",
    trunkId: "trunk_web",          // your web-routing trunk
    agent:   "healthcare-assistant",
  });

  // Issue a short-lived, voice-scoped token bound to this sid with your own
  // token service, then return it alongside the sid.
  const token = await issueScopedToken({ sid: call.sid, scope: "voice", ttlSec: 300 });
  return { sid: call.sid, token };
}
agent binds a server-side agent (ASR→LLM→TTS or a speech-to-speech provider) that answers the call and drives both legs — you don’t open an AudioBridge on the server. To run your own dialog logic instead, drop agent and bridge the audio yourself; see Bring your own ASR / LLM / TTS.

Local development

WebTransport with a browser needs a secure context and a valid certificate. For local work:
  • Serve the page over HTTPS (secure context is required for getUserMedia, WebTransport, and WebCodecs).
  • Use a short-lived ECDSA dev certificate (≤14 days) and pass its SHA-256 to the transport via serverCertificateHashes — RSA certs are rejected by the QUIC handshake.
  • Dial 127.0.0.1, not ::1 — the IPv6 loopback breaks the WebTransport handshake.
See WebTransport and Browser audio troubleshooting for the full setup and common failures (no downlink audio, mic refused, one-way audio).
Native mobile (Swift / Kotlin) apps are on the roadmap, not shipped — there is no native mobile SDK today. A browser tab on the device is the supported path; track the plan in Mobile apps.

Browser audio capture

The capture pipeline in depth — loopback PC, the worker transform, framing.

JavaScript SDK

Full signatures for Voice, attachCaller, captureMicrophone, OpusPlayer.

Turn detection

Tune VAD, barge-in, and idle timeouts for the agent leg.

Support handoff

Escalate the browser call from the AI agent to a human, sid preserved.