# One-Line WebRTC Diversion

> Reuse the browser's WebRTC audio pipeline for capture and Opus encoding, then divert the encoded frames onto QUIC in a single call.

You already trust the browser's WebRTC audio pipeline: echo cancellation, gain
control, noise suppression, and a proven Opus encoder. The
FirstByte Voice SDK keeps all of that. It removes only the part you do not
want in a real-time voice product: WebRTC's own transport. One call to
`captureMicrophone` runs a **loopback `RTCPeerConnection`** to drive the
encoder. It taps the **encoded** Opus frames with an `RTCRtpScriptTransform`
and publishes each frame as a MoQT object over QUIC/WebTransport. The path
uses no ICE, no DTLS, no SRTP, and no SFU.

> **NOTE:**
> This is the browser capture path shipped in the TypeScript SDK
> (`@@clutchcall/agents` `moqt/audio.ts`). The same technique also underpins the
> LiveKit migration shim. See
> [Migrating from WebRTC / LiveKit](/modalities/voice/migration/overview).

## Why divert instead of transport

A conventional WebRTC call couples three things into one bundle:
the **capture + encode** graph (useful), the **peer transport** (ICE/DTLS/SRTP —
you do not need it when you own a QUIC relay), and an **SFU** to fan media out
(a whole server you would rather not run). The diversion unbundles them:

- **Keep** the capture graph. `getUserMedia` still runs AEC/AGC/noise
  suppression; the browser's Opus encoder still produces standard 48 kHz Opus.
- **Drop** the transport. The encoded frames never touch ICE or SRTP. They go
  directly onto your QUIC media plane.
- **Replace** the SFU with a MoQT relay: publish/subscribe fan-out on the same
  single-`:443` QUIC plane the rest of the stack uses.

The loopback `RTCPeerConnection` exists only to make the encoder run. Its far
end is a second in-page peer connection. The SDK discards that connection's
decoded output; it is a sink that keeps the sender pipeline live.

## Divert in one call

1. **Connect a media session**
Open a MoQT client against your relay and require the encoded-frame transform.
The session then refuses to start on a browser that cannot uphold the rule.

```ts
import { MoqtClient } from "@clutchcall_connect/moqt";

const client = await MoqtClient.connect(
  "https://relay.firstbyte.fast",
  token,
  (state) => console.log("moqt", state),
  { requireEncodedTransform: true },
);
```

2. **Publish an audio track**
A publication is just a namespace + name on the MoQT plane. Use the caller's
`call_sid` to scope the track so the rest of the stack can find it.

```ts
const publication = client.publishAudio(`voice/${callSid}`, "uplink", {
  capability: "agent.mic.opus",
});
```

3. **Capture and divert**
One call wires the loopback encoder to the publication. The SDK writes every
encoded Opus frame to the track as the encoder produces it.

```ts
import { captureMicrophone } from "@clutchcall_connect/moqt/audio";

const capture = await captureMicrophone(publication);
// …later
capture.stop(); // tears down the capture graph; leaves the publication open
```

That is the whole client. `captureMicrophone` acquires the mic, starts the
loopback peer connections, installs the transform, and completes the loopback
SDP so the encoder starts. You never touch WebCodecs or the WebRTC API
directly.

## What happens inside `captureMicrophone`

The interesting mechanics are the loopback graph and the transform worker. This
is the shipped implementation, trimmed to the load-bearing lines:

```ts
// Hard gate: the worker transform is the ONLY encoded-frame path.
if (!encodedTransformSupported()) {
  throw new Error("RTCRtpScriptTransform unavailable; refusing to capture");
}

const track = (await navigator.mediaDevices.getUserMedia({
  audio: { echoCancellation: true, autoGainControl: true, noiseSuppression: true },
})).getAudioTracks()[0];

// Loopback: pc1 encodes (Opus), pc2 is a sink so the encoder actually runs.
const pc1 = new RTCPeerConnection();
const pc2 = new RTCPeerConnection();
pc1.onicecandidate = (e) => e.candidate && pc2.addIceCandidate(e.candidate);
pc2.onicecandidate = (e) => e.candidate && pc1.addIceCandidate(e.candidate);

const sender = pc1.addTrack(track);

// Install the standard encoded transform on the sender (runs in a worker).
const worker = new Worker(scriptTransformWorkerUrl());
worker.onmessage = (ev) => publication.write(toUs(ev.data.timestamp), ev.data.data);
(sender as any).transform = new RTCRtpScriptTransform(worker, { side: "sender" });

// Complete the loopback handshake so the encoder starts.
const offer = await pc1.createOffer();
await pc1.setLocalDescription(offer);
await pc2.setRemoteDescription(offer);
const answer = await pc2.createAnswer();
await pc2.setLocalDescription(answer);
await pc1.setRemoteDescription(answer);
```

```js
// The RTCRtpScriptTransform worker: read encoded Opus frames off the
// transform's readable, forward {data, timestamp} to the main thread, and
// re-enqueue each frame so the sender pipeline stays unblocked.
self.onrtctransform = (event) => {
  const t = event.transformer;
  const reader = t.readable.getReader();
  const writer = t.writable.getWriter();
  (async () => {
    for (;;) {
      const { value, done } = await reader.read();
      if (done) break;
      const buf = value.data.slice(0);
      self.postMessage({ data: buf, timestamp: value.timestamp }, [buf]);
      await writer.write(value);
    }
  })();
};
```

> **WARNING:**
> The worker-based transform is the **only** encoded-frame path. The legacy
> main-thread `createEncodedStreams()` branch was removed. Every frame goes
> through one worker, which lets the same plane carry frame-level end-to-end
> encryption later — then even a relay only ever sees ciphertext. If
> `RTCRtpScriptTransform` is absent, the SDK **refuses to capture**, and
> `MoqtClient.connect` refuses to connect when `requireEncodedTransform` is set.
> It does not silently fall back to an insecure path. See
> [Browser Compatibility](/modalities/voice/transport-web/browser-compatibility)
> for the fallback status.

## The cost of diverting

Diversion of encoded frames onto QUIC instead of SRTP/UDP adds a JS-layer hop:
frame → worker → main thread → MoQT write. What does that cost?
A prototype that proved the technique measured the JS-layer round trip on
localhost Chrome against a plain WebRTC echo:

| Path | JS-layer RTT |
| --- | --- |
| Pure WebRTC (SRTP/UDP) | 0.84 ms |
| WebRTC capture → QUIC | 1.20 ms |

> **NOTE:**
> An internal prototype measured the **+0.36 ms** delta
> (localhost Chrome, checked audibly end-to-end against a Node echo server). It
> proved the diversion cost is negligible relative to a full voice turn. It is
> a prototype figure, not a production SLA. The production version is the
> shipped SDK path above.

## Playing audio back

The reverse direction is symmetric. Subscribe to the downlink track and hand
each Opus frame to the SDK's `OpusPlayer`. The player decodes with WebCodecs
and renders through an `AudioWorklet` ring buffer. See
[Browser Audio Capture](/modalities/voice/transport-web/browser-audio-capture)
for the full capture-and-playback loop.

## Related

- **[RTCRtpScriptTransform](/modalities/voice/transport-web/rtcrtpscripttransform)** — The encoded-frame tap in detail — the W3C Encoded Transform API and the worker.
- **[Browser Compatibility](/modalities/voice/transport-web/browser-compatibility)** — Where the transform ships, and why the SDK refuses to capture when it doesn't.
- **[WebTransport](/modalities/voice/transport-web/webtransport)** — The browser-native QUIC plane the diverted frames ride on.
- **[Migrating from WebRTC](/modalities/voice/migration/overview)** — Move existing WebRTC and LiveKit clients onto the QUIC plane.
