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 ClutchCall 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.
This is the browser capture path in the TypeScript SDK (@@clutchcall/sdk moqt/audio.ts). It runs today in Chromium, Edge, Safari, and Firefox. Mobile (Swift / Kotlin) diversion is planned, not shipped. See Mobile Apps.

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. 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.
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.

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:
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 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.
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 before you commit to a browser matrix.

One-Line WebRTC Diversion

The loopback encoder and worker transform in full — the mechanics behind captureMicrophone.

Browser Audio Capture

The complete capture-and-playback loop, with the OpusPlayer downlink.

Browser Compatibility

Where the transform ships, and why capture refuses when the transform is absent.

From LiveKit

The same technique, applied to a self-hosted LiveKit migration.