You already trust the browser’s WebRTC audio pipeline — echo cancellation, gain control, noise suppression, and a battle-tested Opus encoder. The TeleQuick Voice SDK keeps all of that and throws away only the part you don’t want in a real-time voice product: WebRTC’s own transport. One call to captureMicrophone runs a loopback RTCPeerConnection to drive the encoder, taps the encoded Opus frames with an RTCRtpScriptTransform, and publishes each frame as a MoQT object over QUIC/WebTransport. No ICE, no DTLS, no SRTP, no SFU.
This is the browser capture path shipped in the TypeScript SDK (@@telequick/sdk moqt/audio.ts). The same technique also underpins the LiveKit migration shim — see Migrating from WebRTC / LiveKit.

Why divert instead of transport

A conventional WebRTC call couples three things you’d normally take as a bundle: the capture + encode graph (great), the peer transport (ICE/DTLS/SRTP — you don’t need it when you own a QUIC relay), and an SFU to fan media out (a whole server you’d 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 straight 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 purely to make the encoder run. Its far end is a second in-page peer connection whose decoded output is discarded — a sink so the sender pipeline stays live.

Divert in one call

1

Connect a media session

Open a MoQT client against your relay and require the encoded-frame transform, so the session refuses to start on a browser that can’t uphold the rule.
import { MoqtClient } from "@@telequick/sdk/moqt";

const client = await MoqtClient.connect(
  "https://relay.telequick.dev",
  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.
const publication = client.publishAudio(`voice/${callSid}`, "uplink", {
  capability: "agent.mic.opus",
});
3

Capture and divert

One call wires the loopback encoder to the publication. Every encoded Opus frame is written to the track as it’s produced.
import { captureMicrophone } from "@@telequick/sdk/moqt/audio";

const capture = await captureMicrophone(publication);
// …later
capture.stop(); // tears down the capture graph; leaves the publication open
That’s the whole client. 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 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:
// 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);
The worker-based transform is the only encoded-frame path. The legacy main-thread createEncodedStreams() branch has been removed. Routing every frame through one worker is what lets the same plane carry frame-level end-to-end encryption later — so 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) rather than silently falling back to an insecure path. See Browser Compatibility for the fallback story.

The cost of diverting

Diverting encoded frames onto QUIC instead of SRTP/UDP adds a JS-layer hop — frame → worker → main thread → MoQT write — so it’s fair to ask what that costs. A prototype that proved the technique measured the JS-layer round trip on localhost Chrome against a plain WebRTC echo:
PathJS-layer RTT
Pure WebRTC (SRTP/UDP)0.84 ms
WebRTC capture → QUIC1.20 ms
The +0.36 ms delta was measured in an internal prototype (localhost Chrome, verified audibly end-to-end against a Node echo server) — it proved the diversion is essentially free relative to a full voice turn. It is a prototype figure, not a production SLA; the productionized 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, which decodes with WebCodecs and renders through an AudioWorklet ring buffer. See Browser Audio Capture for the full capture-and-playback loop.

RTCRtpScriptTransform

The encoded-frame tap in detail — the W3C Encoded Transform API and the worker.

Browser Compatibility

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

WebTransport

The browser-native QUIC plane the diverted frames ride on.

Migrating from WebRTC

Move existing WebRTC and LiveKit clients onto the QUIC plane.