# RTCRtpScriptTransform

> How the SDK taps encoded Opus frames off WebRTC in a Worker and forwards them over QUIC.

When you capture a microphone in the browser with FirstByte Voice, the audio
never leaves over WebRTC's own transport. Instead, a loopback
`RTCPeerConnection` runs the browser's real audio pipeline: echo cancellation,
gain control, noise suppression, and the Opus encoder. An
**`RTCRtpScriptTransform`** intercepts the *encoded* Opus frames before
packetization. The SDK hands those frames to the MoQT publication and sends
them over QUIC/WebTransport. This page explains the tap in depth: where it
runs, why the older API is gone, and exactly how each frame moves from the
encoder to the wire.

For the one-call developer surface, see
[One-Line WebRTC Diversion](/modalities/voice/transport-web/webrtc-diversion) and
[Browser Audio Capture](/modalities/voice/transport-web/browser-audio-capture).
This page is the mechanism underneath both.

## The transform runs in a Worker — and only there

`RTCRtpScriptTransform` is the W3C Encoded Transform API. You construct it on
the main thread and attach it to an `RTCRtpSender`, but the transform callback
itself runs in a **dedicated Worker**. The SDK ships exactly one code path for
tapping encoded frames, and it is this Worker-based transform.

> **NOTE:**
> The older, Chromium-only `RTCRtpSender.createEncodedStreams()` exposed the
> encoded stream on the **main thread**. Current browser engines **removed** it,
> and the SDK removed its legacy branch for it too. The Worker transform is the
> only encoded-frame path. There is no main-thread fallback.

The design keeps every media frame inside one Worker. This is a deliberate
choice, not just API hygiene:

- **It is what current browsers ship.** Chrome 110+, Edge, Safari, and
  Firefox 133+ all expose the standard Worker transform. None still expose the
  main-thread stream. One target API keeps the capture path identical
  everywhere.
- **It is the seam for frame-level end-to-end encryption.** Because every frame
  passes through one Worker, the SDK can later encrypt payloads there. Then
  even a relay, or a self-hosted SFU you migrate away from, sees ciphertext
  only.

## The hard gate: capture refuses when the transform is absent

The SDK treats the encoded-frame rule as non-negotiable. If
`RTCRtpScriptTransform` is not a function on `globalThis`, the SDK **refuses to
capture**. It does not fall back to an insecure or main-thread path. A helper
reports support:

```ts
import { encodedTransformSupported } from "clutchcall_connect/moqt";

if (!encodedTransformSupported()) {
  // This browser cannot run the required encoded-frame transform.
}
```

Two independent gates enforce the refusal, so the invariant holds for the
whole session, not just at the moment you acquire the microphone:

  1. **At capture time**
`captureMicrophone()` checks support **before** it calls `getUserMedia`, so
    it never acquires hardware it cannot use. If the transform is unsupported,
    it throws immediately.
  2. **At connect time**
When you connect a media session with `requireEncodedTransform: true`, and
    the runtime lacks the transform, the client stops before it opens the
    transport. You never establish a session on which you cannot legally
    publish audio.

## How a frame is tapped and forwarded

After support is confirmed, the capture graph is a short loopback. The steps
below trace one microphone frame from silicon to the QUIC wire.

  1. **Run the browser audio pipeline in a loopback**
The SDK wires two `RTCPeerConnection`s to each other locally
    (`pc1` → `pc2`). It adds your microphone track to `pc1` as a sender. `pc2`
    is only a sink; it forces the encoder to run. The SDK completes the
    loopback SDP offer/answer locally, which starts the Opus encoder. The SDK
    never uses WebRTC's ICE/DTLS/SRTP transport to leave the machine.
  2. **Attach the transform to the sender**
The SDK creates a Worker and sets it as the sender's `transform`:

    ```ts
    sender.transform = new RTCRtpScriptTransform(worker, { side: "sender" });
    ```

    From here, every encoded Opus frame from the sender goes into the Worker.
    The frames are not packetized for WebRTC.
  3. **Read frames off the transform inside the Worker**
The Worker handles the `rtctransform` event. Each transformer exposes a
    `readable` (incoming encoded frames) and a `writable` (the onward pipeline).
    The Worker loops: read a frame, copy its payload, post it to the main thread,
    then **re-enqueue the frame to `writable`** so the sender pipeline stays
    unblocked.

    ```js
    self.onrtctransform = (event) => {
      const t = event.transformer;
      const reader = t.readable.getReader();
      const writer = t.writable.getWriter();
      (async () => {
        for (;;) {
          const { value, done } = await reader.read();
          if (done) break;
          const buf = value.data.slice(0);            // copy the encoded payload
          self.postMessage({ data: buf, timestamp: value.timestamp }, [buf]);
          await writer.write(value);                  // keep the pipeline flowing
        }
      })();
    };
    ```

    The `timestamp` on each frame is the RTP timestamp on Opus's 48 kHz clock.
  4. **Forward each frame to the MoQT publication**
Back on the main thread, the Worker's message handler converts the 48 kHz
    RTP timestamp to microseconds and writes the raw encoded payload to the
    publication: one MoQT object per Opus frame, carried over WebTransport:

    ```ts
    const tsUs = BigInt(Math.round((rtpTimestamp / 48000) * 1_000_000));
    publication.write(tsUs, new Uint8Array(data));
    ```

> **TIP:**
> The SDK ships the Worker **inlined as a Blob URL**, so the whole capture path
> is a single module. There is no extra worker file to host or configure.
> Nothing about the transform requires a separate build step.

### What crosses the wire

The transform hands off **encoded Opus payloads**, not PCM and not RTP packets.
On the wire, the Opus stays `opus/48000/2` with a 48 kHz RTP clock, even though
the encoder's internal rate is driven narrowband for voice. The receiver side
reverses this: an Opus decoder (WebCodecs) feeds an audio worklet ring buffer.
See the `OpusPlayer` covered in
[Browser Audio Capture](/modalities/voice/transport-web/browser-audio-capture).

## Teardown

The handle returned by capture tears down the graph deterministically: it
terminates the Worker, stops the microphone track (unless you supplied your
own), and closes both loopback peer connections. It does **not** close the MoQT
publication. You manage that lifecycle on the transport.

## Lifecycle at a glance

  1. **Gate**
Check `encodedTransformSupported()`. Refuse otherwise.
  2. **Capture**
`getUserMedia` runs with AEC/AGC/NS enabled.
  3. **Encode**
The loopback `RTCPeerConnection` runs the Opus encoder.
  4. **Tap**
The Worker `RTCRtpScriptTransform` reads encoded frames and re-enqueues them.
  5. **Forward**
The main thread writes each frame to the MoQT publication over QUIC.

## Related

  - **[One-Line WebRTC Diversion](/modalities/voice/transport-web/webrtc-diversion)** — The developer-facing wrapper that hides this whole graph behind one call.
  - **[Browser Audio Capture](/modalities/voice/transport-web/browser-audio-capture)** — `captureMicrophone`, the `OpusPlayer`, and end-to-end capture wiring.
  - **[Browser Compatibility](/modalities/voice/transport-web/browser-compatibility)** — Which engines ship the transform, and where capture is refused.
  - **[WebTransport](/modalities/voice/transport-web/webtransport)** — The browser-native QUIC plane the tapped frames ride on.
