# Browser Audio Issues

> Why browser capture or playback fails with Voice, and the fix for each symptom, from the encoded-transform gate to autoplay.

Most browser audio failures in FirstByte Voice are not audio bugs. They are
a capability or context problem that stops the pipeline before the browser
captures or plays one frame. The browser capture path has one hard rule: the
SDK publishes audio only as **encoded Opus frames that an
`RTCRtpScriptTransform` taps in a Worker**. If that transform is missing, or
the WebTransport plane is missing, or the security context is missing, the SDK
refuses to run. It does not degrade. So the fastest diagnosis is to check
*what the runtime supports*, not to trace audio.

Work from top to bottom. The triage table narrows the symptom. Each section
below gives the diagnosis and fix.

## Symptom → cause → fix

| Symptom | Likely cause | Fix |
| --- | --- | --- |
| `captureMicrophone()` throws immediately, before any permission prompt | No `RTCRtpScriptTransform` — the browser does not have the required encoded-frame transform | Use an engine that has support (Chrome/Edge 110+, Firefox 133+, recent Safari). Gate with `encodedTransformSupported()` and show a fallback UI |
| `MoqtClient.connect()` rejects; no session opens | No WebTransport, or you connect with `requireEncodedTransform: true` on an engine without the transform | Check that WebTransport exists. The browser SDK has **no WS fallback** today — the runtime must support both |
| Connection fails only on `http://` or a bare IP page | Insecure context — `getUserMedia` and WebTransport both require a secure origin | Serve over HTTPS (or `http://localhost`). Never use a plain-HTTP LAN IP |
| The permission prompt is denied or never appears | The microphone permission is blocked, or there is no user gesture | Prompt from a click handler. Re-grant the mic permission for the origin |
| Capture works but you hear nothing | The autoplay policy suspended the `AudioContext` | Call `resume()` on the context inside a user gesture before playback |
| Playback is silent only in Firefox/Safari | The player uses WebCodecs `AudioDecoder`, which is Chromium-only | Use a Chromium browser for playback, or supply a decode fallback |
| Local dev only: the WebTransport handshake fails | The dev certificate hash is wrong or expired, or you dialed `::1` | Use a fresh ECDSA cert (≤14 days) via `serverCertificateHashes`. Dial `127.0.0.1`, not IPv6 `::1` |

## Capture throws before the permission prompt

If `captureMicrophone()` throws *before* the browser asks for the microphone,
the SDK never reached `getUserMedia`. The SDK checks for the encoded-frame
transform first. If the transform is absent, the SDK stops. It does not
acquire hardware that it cannot legally publish from.

  1. **Confirm transform support**
```ts
    import { encodedTransformSupported } from "clutchcall_connect/moqt";

    if (!encodedTransformSupported()) {
      // This engine cannot run the required Worker transform.
    }
    ```
    A `false` result is the full explanation: the browser does not expose
    `RTCRtpScriptTransform`. There is no main-thread fallback. The legacy
    `createEncodedStreams()` path was removed, so the Worker transform is the
    only capture path.
  2. **Check the engine version**
The standard Worker transform ships in Chrome/Edge 110+, Firefox 133+, and
    recent Safari. Older builds are unsupported by design. Engines that only
    shipped the main-thread stream are also unsupported by design.
  3. **Gate your UI, don't retry**
Branch on `encodedTransformSupported()`. Show a "browser not supported"
    state instead of a call to `captureMicrophone()`. A retry cannot succeed.
    The capability is binary.

> **NOTE:**
> Two independent gates apply the refusal, so it holds for the whole session:
> `captureMicrophone()` throws at capture time, and a connection with
> `requireEncodedTransform: true` refuses to open the transport at all. See
> [RTCRtpScriptTransform](/modalities/voice/transport-web/rtcrtpscripttransform)
> for why the invariant exists (frame-level E2EE) and how it is wired.

## The session never connects (no WebTransport)

If capture works but `MoqtClient.connect()` never resolves, the browser-native
QUIC plane is the problem. The browser SDK publishes audio over WebTransport
only.

> **WARNING:**
> The QUIC→WebSocket fallback ladder ships **only in the native SDK core**
> (Python, Go, Rust, Java, .NET wrappers). The browser/TypeScript SDK is
> **WebTransport-only today**. If the runtime or network cannot establish
> WebTransport, the browser has no automatic WS fallback. Plan your
> supported-browser matrix with this limit in mind.

  1. **Verify a secure context**
WebTransport and `getUserMedia` both require a secure origin. `https://…`
    or `http://localhost` work. A plain `http://` page served on a LAN IP does
    not work. A page that "works on localhost but not on the office IP" almost
    always has this cause.
  2. **Check WebTransport exists**
Check `typeof WebTransport === "function"` on the page. If it is absent,
    the engine (or an old build) cannot reach the QUIC plane at all.
  3. **Confirm the relay URL and reachability**
The client dials the relay host (`relay.firstbyte.fast`) over QUIC/UDP. If
    the network blocks UDP/443, the WebTransport handshake stalls. In the same
    condition, the native SDKs drop to WS, but the browser cannot.

## You captured audio but hear nothing

When capture succeeds and frames flow but playback is silent, the browser
autoplay policy almost always blocks the receive side. Missing data is almost
never the cause. Playback runs encoded Opus through a WebCodecs `AudioDecoder`
into an `AudioWorklet` ring buffer (the `OpusPlayer`). The underlying
`AudioContext` starts **suspended** until a user gesture resumes it.

  1. **Resume the AudioContext on a gesture**
Call `audioContext.resume()` from inside a real user interaction (a click
    or tap handler), not on page load. A suspended context silently drops
    output.

    ```ts
    playButton.addEventListener("click", async () => {
      await audioContext.resume();
      // now start / attach the OpusPlayer
    });
    ```
  2. **Rule out a decoder gap**
The player's `AudioDecoder` (WebCodecs) is Chromium-only.
    `MediaStreamTrackProcessor` is also Chromium-only. In Firefox or Safari,
    playback can be silent even when capture works in other browsers. Validate
    playback in a Chromium browser. Supply a decode fallback if you must
    support the others.
  3. **Confirm frames are arriving**
If the context is resumed, and you are on Chromium, and you still hear
    nothing, the problem is upstream delivery, not playback. Treat it as a
    one-way-audio issue below.

## The microphone permission itself fails

`getUserMedia` rejects if the origin does not have microphone permission, or
if a user gesture did not start the call. The SDK requests the mic with echo
cancellation, gain control, and noise suppression enabled. The browser must
grant capture for the loopback encoder to run.

  1. **Prompt from a gesture**
Start capture from a click/tap handler. Browsers reject or silently ignore
    permission requests made on load.
  2. **Re-grant a blocked permission**
After a user denies the mic for an origin, the prompt does not appear
    again. The user must enable the permission again in site settings. Detect
    the rejection and show that instruction.
  3. **Check for a device in use**
If another tab or app holds the microphone, `getUserMedia` can fail.
    Release the other consumer and try again.

## Local development gotchas

Most "works in prod, fails locally" reports come from the dev certificate
flow, not the audio path.

- **Certificate hash.** WebTransport in dev is trusted via
  `serverCertificateHashes`. It requires a short-lived **ECDSA** certificate
  (≤14 days). An RSA cert fails the handshake. A cert older than the window
  also fails the handshake.
- **Use `127.0.0.1`, not `::1`.** The IPv6 loopback breaks the handshake. The
  IPv4 literal works.
- **A secure context is still required.** `http://localhost` is a secure
  context. `http://<lan-ip>` is not.

## Related

  - **[RTCRtpScriptTransform](/modalities/voice/transport-web/rtcrtpscripttransform)** — The Worker transform and the hard capture gate, in depth.
  - **[Browser Compatibility](/modalities/voice/transport-web/browser-compatibility)** — Which engines ship the transform and WebTransport, and where the SDK refuses capture.
  - **[Browser Audio Capture](/modalities/voice/transport-web/browser-audio-capture)** — `captureMicrophone`, the `OpusPlayer`, and the full capture wiring.
  - **[One-Way Audio](/modalities/voice/troubleshooting/one-way-audio)** — When frames flow in one direction only — the next stop if playback is empty.
