# From WebRTC

> Move a raw WebRTC audio client onto the QUIC media plane. Divert the encoded Opus frames. Keep your capture graph. Drop ICE/DTLS/SRTP and the SFU.

You already have a working WebRTC audio client. `getUserMedia` runs echo
cancellation, gain control, and noise suppression. The browser's Opus encoder
produces clean 48 kHz frames. An `RTCPeerConnection` binds it all together: it
negotiates ICE/DTLS/SRTP and pushes media to an SFU. A migration to
FirstByte Voice keeps the good half (capture and encode) and removes only
the transport. One call to `captureMicrophone` does the work. It runs a
loopback `RTCPeerConnection` to drive the same encoder. It taps the **encoded**
Opus frames with an `RTCRtpScriptTransform`. It publishes each frame as a MoQT
object over QUIC/WebTransport. There is no signaling server, no ICE, no DTLS,
no SRTP, and no SFU.

> **NOTE:**
> This is the browser capture path in the TypeScript SDK
> (`@@clutchcall/agents` `moqt/audio.ts`). It runs today in Chromium, Edge, Safari,
> and Firefox. Mobile (Swift / Kotlin) diversion is planned, not shipped. See
> [Mobile Apps](/modalities/voice/transport-web/mobile-app).

## What migrates and what doesn't

The unbundling is the whole point. A conventional WebRTC call binds three
functions together. The diversion keeps one and drops two.

| Piece of your WebRTC stack | Migrates? | Becomes |
| --- | --- | --- |
| `getUserMedia` + AEC/AGC/noise suppression | **Keep** | Unchanged — the browser still runs it |
| Browser Opus encoder | **Keep** | Unchanged — standard 48 kHz Opus frames |
| ICE / DTLS / SRTP peer transport | **Drop** | Encoded frames move over your QUIC media plane instead |
| Signaling server (offer/answer exchange) | **Drop** | A MoQT publish is only a namespace + name |
| SFU (media fan-out) | **Replace** | A MoQT relay on the same single-`:443` plane |

The browser's echo cancellation, gain control, and noise suppression all carry
over. This is because the loopback peer connection runs the identical capture
graph. On the QUIC side, the media plane adds its own on-ingress noise
suppression before audio reaches an agent.

## Before and after

The change is almost entirely subtractive. The tabs below show a representative
"before" (a raw WebRTC client that publishes to an SFU) and the "after" on the
QUIC plane.

```ts
// Your existing client: capture, encode, and transport over ICE/DTLS/SRTP.
const stream = await navigator.mediaDevices.getUserMedia({
  audio: { echoCancellation: true, autoGainControl: true, noiseSuppression: true },
});

const pc = new RTCPeerConnection({ iceServers: [{ urls: "stun:…" }, { urls: "turn:…" }] });
pc.addTrack(stream.getAudioTracks()[0], stream);

// Signaling round-trip with your SFU / peer, then ICE + DTLS + SRTP take over.
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const answer = await signalingServer.exchange(offer); // your transport
await pc.setRemoteDescription(answer);
// Media now flows over SRTP/UDP to the SFU.
```

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

// Connect the QUIC media plane; require the encoded-frame transform up front.
const client = await MoqtClient.connect(
  "https://relay.firstbyte.fast",
  token,
  (state) => console.log("moqt", state),
  { requireEncodedTransform: true },
);

// A publication is just a namespace + name. Scope it by the caller's call_sid.
const publication = client.publishAudio(`voice/${callSid}`, "uplink", {
  capability: "agent.mic.opus",
});

// One call: acquire the mic, run the loopback encoder, divert encoded Opus.
const capture = await captureMicrophone(publication);
// …later
capture.stop(); // tears down the capture graph; leaves the publication open
```

The signaling server, the ICE server config, and the answer exchange all
disappear. `captureMicrophone` does four things:

1. It acquires the mic.
2. It starts the loopback peer connections.
3. It installs the transform.
4. It completes the loopback SDP so the encoder starts.

You never touch WebCodecs or the raw WebRTC API directly. The full mechanics of
the loopback graph and the worker transform are in
[One-Line WebRTC Diversion](/modalities/voice/transport-web/webrtc-diversion).

## Migrate in three moves

1. **Point capture at a publication instead of a peer connection**
Find the code where your old client called `pc.addTrack(...)` and negotiated
with a signaling server. Replace it: connect a MoQT client and open a
publication instead. Set `requireEncodedTransform: true`. Then a browser that
cannot uphold the encoded-frame rule fails the connection, not the capture.

You may already hold a processed `MediaStreamTrack` — a track that you run
through your own graph. In that case, give the track to the SDK. Do not let
the SDK call `getUserMedia`:

```ts
const capture = await captureMicrophone(publication, { track: myProcessedTrack });
```

2. **Replace playback with the downlink track**
The reverse leg is symmetric. Your old client attached a remote
`MediaStreamTrack` to an `<audio>` element. Instead, subscribe to the downlink
track. Give each Opus frame to the SDK's `OpusPlayer`. The `OpusPlayer` decodes
with WebCodecs and renders through an `AudioWorklet` ring buffer. The
[Browser Audio Capture](/modalities/voice/transport-web/browser-audio-capture)
page has the full capture-and-playback loop.

3. **Delete the transport plumbing**
Remove the signaling server calls. Remove the ICE/TURN server configuration.
Remove any SFU that you ran for fan-out. Fan-out is now a publish/subscribe on
the MoQT relay. Keep only your `getUserMedia` constraints. They still drive the
browser's AEC/AGC/noise suppression.

## If you also run a WebRTC media server

The diversion covers the **client**. Part of your deployment can terminate
WebRTC on the server — a media process that decrypts DTLS-SRTP and handles ICE.
For that case, the stack ships a server-side WebRTC termination path. It is a
fallback leg for endpoints that cannot run the diversion (for example, a
browser without the encoded transform, or a third party that you do not
control). It sits with the SIP/RTP media plane and gives decoded audio to the
same agent runtime. Thus you do not need a separate SFU there either. Use it
only as a fallback. The diverted client is the primary path.

> **WARNING:**
> The worker-based `RTCRtpScriptTransform` is the **only** encoded-frame path.
> The legacy main-thread `createEncodedStreams()` branch was removed. If the
> transform is absent, the SDK **refuses to capture**. It does not fall back to
> an insecure path. Also, the browser SDK is **WebTransport-only today**. The
> QUIC→WebSocket fallback ladder ships in the native SDK cores, not the browser
> build. Check [Browser Compatibility](/modalities/voice/transport-web/browser-compatibility)
> before you commit to a browser matrix.

## Related

- **[One-Line WebRTC Diversion](/modalities/voice/transport-web/webrtc-diversion)** — The loopback encoder and worker transform in full — the mechanics behind `captureMicrophone`.
- **[Browser Audio Capture](/modalities/voice/transport-web/browser-audio-capture)** — The complete capture-and-playback loop, with the `OpusPlayer` downlink.
- **[Browser Compatibility](/modalities/voice/transport-web/browser-compatibility)** — Where the transform ships, and why capture refuses when the transform is absent.
- **[From LiveKit](/modalities/voice/migration/from-livekit)** — The same technique, applied to a self-hosted LiveKit migration.
