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.- Before — raw WebRTC to an SFU
- After — diverted onto QUIC
captureMicrophone does four things:
- It acquires the mic.
- It starts the loopback peer connections.
- It installs the transform.
- It completes the loopback SDP so the encoder starts.
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.Related
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.

