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, and everything is glued together with an RTCPeerConnection that negotiates ICE/DTLS/SRTP and pushes media to an SFU. Migrating to TeleQuick Voice keeps the good half — capture and encode — and throws away only the transport. One call to captureMicrophone runs a loopback RTCPeerConnection to drive the same encoder, taps the encoded Opus frames with an RTCRtpScriptTransform, and publishes each frame as a MoQT object over QUIC/WebTransport. No signaling server, no ICE, no DTLS, no SRTP, no SFU.
This is the browser capture path shipped in the TypeScript SDK (@@telequick/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 ties three things together; the diversion keeps one and drops two.
Piece of your WebRTC stackMigrates?Becomes
getUserMedia + AEC/AGC/noise suppressionKeepUnchanged — the browser still runs it
Browser Opus encoderKeepUnchanged — standard 48 kHz Opus frames
ICE / DTLS / SRTP peer transportDropEncoded frames ride your QUIC media plane instead
Signaling server (offer/answer exchange)DropA MoQT publish is just a namespace + name
SFU (media fan-out)ReplaceA MoQT relay on the same single-:443 plane
The browser’s echo cancellation, gain control, and noise suppression all carry over 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 publishing to an SFU) and the “after” on the QUIC plane.
// 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.
The signaling server, ICE server config, and answer exchange all disappear. captureMicrophone acquires the mic, spins up the loopback peer connections, installs the transform, and 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 live in One-Line WebRTC Diversion.

Migrate in three moves

1

Point capture at a publication instead of a peer connection

Wherever your old code called pc.addTrack(...) and negotiated with a signaling server, connect a MoQT client and open a publication instead. Set requireEncodedTransform: true so a browser that can’t uphold the encoded-frame rule fails the connection rather than the capture.If you already hold a processed MediaStreamTrack — a track you run through your own graph — hand it to the SDK instead of letting it call getUserMedia:
const capture = await captureMicrophone(publication, { track: myProcessedTrack });
2

Replace playback with the downlink track

The reverse leg is symmetric. Instead of attaching a remote MediaStreamTrack to an <audio> element, subscribe to the downlink track and hand each Opus frame to the SDK’s OpusPlayer, which 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, ICE/TURN server configuration, and any SFU you were running 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. If part of your deployment terminates WebRTC on the server — decrypting DTLS-SRTP and handling ICE in a media process — the stack ships a server-side WebRTC termination path as a fallback leg for endpoints that can’t run the diversion (for example, a browser without the encoded transform, or a third party you don’t control). It sits alongside the SIP/RTP media plane and hands decoded audio to the same agent runtime, so you don’t need a separate SFU there either. Reach for 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 has been removed. If the transform is absent the SDK refuses to capture rather than falling back to an insecure path, and 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, including the OpusPlayer downlink.

Browser Compatibility

Where the transform ships, and why capture refuses when it doesn’t.

From LiveKit

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