Most browser audio failures in TeleQuick Voice are not audio bugs — they are a capability or context problem that stops the pipeline before a single frame is captured or played. The browser capture path has one hard rule: audio is only published as encoded Opus frames tapped by an RTCRtpScriptTransform in a Worker. When that transform (or the WebTransport plane it rides on, or the security context both require) is missing, the SDK refuses rather than degrade — so the fastest diagnosis is to check what the runtime supports, not to trace audio. Work top-to-bottom: the triage table narrows the symptom, then each section below gives the diagnosis and fix.

Symptom → cause → fix

SymptomLikely causeFix
captureMicrophone() throws immediately, before any permission promptNo RTCRtpScriptTransform — the browser lacks the required encoded-frame transformUse a supporting engine (Chrome/Edge 110+, Firefox 133+, recent Safari); gate with encodedTransformSupported() and show a fallback UI
MoqtClient.connect() rejects; no session ever opensNo WebTransport, or connecting with requireEncodedTransform: true on an engine without the transformConfirm WebTransport exists; on the browser SDK there is no WS fallback today — the runtime must support both
Connection fails only on http:// or a bare IP pageInsecure context — getUserMedia and WebTransport both require a secure originServe over HTTPS (or http://localhost); never a plain-HTTP LAN IP
Permission prompt is denied or never appearsMicrophone permission blocked, or no user gesturePrompt from a click handler; re-grant the mic permission for the origin
Capture works but you hear nothingAudioContext suspended by autoplay policyresume() the context inside a user gesture before playback
Playback silent only in Firefox/SafariThe player uses WebCodecs AudioDecoder, which is Chromium-onlyUse a Chromium browser for playback, or provide a decode fallback
Local dev only: WebTransport handshake failsDev certificate hash wrong/expired, or you dialed ::1Use 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 ever asks for the microphone, the SDK never reached getUserMedia. It checks for the encoded-frame transform first and bails if it is absent — it will not acquire hardware it cannot legally publish from.
1

Confirm transform support

import { encodedTransformSupported } from "@telequick/sdk/moqt";

if (!encodedTransformSupported()) {
  // This engine cannot run the required Worker transform.
}
A false here is the whole 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 — and any engine that only ever shipped the main-thread stream — are unsupported by design.
3

Gate your UI, don't retry

Branch on encodedTransformSupported() and present a “browser not supported” state instead of calling captureMicrophone(). Retrying cannot succeed; the capability is binary.
The refusal is enforced at two independent gates so it holds for the whole session: captureMicrophone() throws at capture time, and connecting with requireEncodedTransform: true refuses to open the transport at all. See RTCRtpScriptTransform for why the invariant exists (frame-level E2EE) and how it is wired.

The session never connects (no WebTransport)

If capture is fine but MoqtClient.connect() never resolves, the browser-native QUIC plane is the problem. The browser SDK publishes audio over WebTransport only.
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, there is no automatic WS fallback in the browser. Plan your supported-browser matrix accordingly.
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. A page that “works on localhost but not on the office IP” is almost always this.
2

Check WebTransport exists

typeof WebTransport === "function" on the page. Absent means the engine (or an old build) can’t reach the QUIC plane at all.
3

Confirm the relay URL and reachability

The client dials the relay host (relay.telequick.dev) over QUIC/UDP. If the network blocks UDP/443, the WebTransport handshake stalls — the same condition where native SDKs would drop to WS, but the browser cannot.

You captured audio but hear nothing

When capture succeeds and frames are flowing but playback is silent, the receive side is almost always blocked by browser autoplay policy, not by missing data. Playback runs encoded Opus through a WebCodecs AudioDecoder into an AudioWorklet ring buffer (the OpusPlayer), and 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.
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, as is MediaStreamTrackProcessor. In Firefox or Safari playback can be silent even when capture works elsewhere — validate playback in a Chromium browser, and provide a decode fallback if you must support the others.
3

Confirm frames are arriving

If the context is resumed and you are on Chromium but 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 lacks microphone permission or the call is not driven by a user gesture. The SDK requests the mic with echo cancellation, gain control, and noise suppression enabled, so the browser must actually grant capture for the loopback encoder to run.
1

Prompt from a gesture

Trigger capture from a click/tap handler. Browsers reject or silently ignore permission requests made on load.
2

Re-grant a blocked permission

Once a user has denied the mic for an origin, the prompt won’t reappear — the user must re-enable it in site settings. Detect the rejection and surface that instruction.
3

Check for a device in use

Another tab or app holding the microphone can cause getUserMedia to fail; release the other consumer and retry.

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 and requires a short-lived ECDSA certificate (≤14 days). An RSA cert, or one older than the window, fails the handshake.
  • Use 127.0.0.1, not ::1. Dialing the IPv6 loopback breaks the handshake; the IPv4 literal works.
  • Secure context still applies. http://localhost is a secure context; http://<lan-ip> is not.

RTCRtpScriptTransform

The Worker transform and the hard capture gate, in depth.

Browser Compatibility

Which engines ship the transform and WebTransport, and where capture is refused.

Browser Audio Capture

captureMicrophone, the OpusPlayer, and the full capture wiring.

One-Way Audio

When frames flow one direction only — the next stop if playback is genuinely empty.