Tuning Opus Bitrate and FEC for Lossy Networks

In-band FEC is the only loss-recovery mechanism WebRTC audio gets for free, and almost everyone who enables it misunderstands what they bought. useinbandfec=1 does not add a redundant stream alongside your audio; it takes bits away from the primary encoding and spends them on a coarse copy of the previous frame. This guide is part of the Audio Processing & Opus Tuning guide, and it answers one question: on a link measuring 3–8% packet loss, what should maxaveragebitrate be, and how do you prove from concealedSamples that the trade paid off?

Context & Trade-offs

Opus’s in-band FEC is called LBRR — low bit-rate redundancy. When the encoder believes the network is losing packets, it encodes frame N−1 a second time at a much lower rate, typically a third to a fifth of the primary rate, and staples that copy into the payload of frame N. If packet N−1 never arrives but packet N does, the decoder extracts the LBRR copy, plays a degraded but intelligible version of the missing 20 ms, and nobody hears a warble. The cost is arithmetic: your negotiated maxaveragebitrate is a ceiling on the whole payload, so every bit the LBRR copy consumes is a bit the primary encoding does not get.

Two consequences fall straight out of that mechanism. First, recovery is delayed by exactly one packet interval — the decoder cannot repair frame N−1 until packet N arrives, so the jitter buffer must hold at least one extra packet of depth for FEC to be usable at all. On a 20 ms ptime that is 20 ms of added playout delay whenever FEC actually fires. Second, LBRR only protects against isolated loss. If packets N−1 and N are both dropped — the normal shape of Wi-Fi and cellular loss, which arrives in bursts — the redundant copy dies with the packet carrying it, and NetEq falls back to synthetic concealment.

How Opus LBRR recovers isolated loss but not burst loss Two rows of RTP packets. In the first row each packet carries a primary frame plus a low bit-rate redundant copy of the previous frame; packet 43 is lost and frame 43 is recovered from the copy embedded in packet 44. In the second row packets 43 and 44 are both lost, so frame 44 is recovered from the copy inside packet 45 but frame 43 has no surviving copy and is concealed. Each packet carries one primary frame plus an LBRR copy of the frame before it Isolated loss — packet 43 dropped, fully recovered pkt 41 F41 LBRR F40 pkt 42 F42 LBRR F41 pkt 43 DROPPED pkt 44 F44 LBRR F43 pkt 45 F45 LBRR F44 decode copy F41 clean F42 clean F43 from LBRR F44 clean F45 clean Burst loss — packets 43 and 44 dropped, one gap is unrecoverable pkt 41 F41 LBRR F40 pkt 42 F42 LBRR F41 pkt 43 DROPPED pkt 44 DROPPED carried the F43 copy pkt 45 F45 LBRR F44 F41 clean F42 clean F43 concealed F44 from LBRR F45 clean Recovery always costs one packet interval of playout delay: frame N-1 cannot be repaired until packet N arrives.
LBRR repairs single-packet gaps from the copy in the following packet; a two-packet burst destroys the copy along with its carrier.

There is a second gate nobody documents on the API surface: LBRR is a SILK feature. Opus only emits redundancy while the SILK or hybrid layer is active, which in practice means speech-oriented configurations up to roughly 40 kbps. Push maxaveragebitrate high enough — or negotiate stereo=1 with fullband content — and libopus switches to CELT-only, where useinbandfec=1 is silently a no-op. That is the mechanism behind the surprising result that a 128 kbps music stream is less loss-tolerant than a 24 kbps voice stream, and it is why a fullband stereo configuration needs a transport-level resilience strategy rather than a codec-level one.

The amount of redundancy is driven by the encoder’s expected loss percentage, not by the flag. There is no JavaScript API for it — no packetLossPercentage field on RTCRtpEncodingParameters — because the browser derives it from the RTCP receiver reports coming back from the far side and feeds it to libopus internally. Chrome’s audio network adaptor makes this a closed loop: it watches the smoothed fraction-lost and its own uplink estimate, and it enables LBRR only when there is bitrate headroom to pay for it, releasing it again with hysteresis once loss subsides. Your leverage is entirely indirect. You choose the ceiling; the encoder decides how to split it.

It helps to see LBRR next to the alternatives, because the choice is rarely “FEC or nothing” — it is which recovery mechanism you are willing to pay for, in bits or in delay.

Mechanism Bitrate cost Recovers Added playout delay
Opus LBRR (useinbandfec=1) 20–30% of payload at 3–8% loss isolated single-packet gaps, at reduced quality one packet interval (20 ms)
RED (RFC 2198) duplication +100% for one redundant level gaps up to the redundancy distance, at full quality one to two packet intervals
NetEq concealment (always on) none nothing — it synthesises, it does not recover none
Lowering maxaveragebitrate negative nothing, but fewer bytes exposed to loss none

RED is the escape hatch above roughly 8% loss or on paths where drops arrive in pairs: Chrome negotiates a red/48000/2 payload type that carries whole previous frames rather than a coarse re-encoding, so a burst that swallows LBRR’s carrier can still be repaired from a packet two or three positions later. It roughly doubles the audio bitrate, which is why it is a targeted fallback rather than a default.

The bitrate answer falls straight out of the LBRR row. At 3–8% expected loss, LBRR consumes roughly 20–30% of the payload. Negotiate 24000 and the primary encoding drops to about 17–19 kbps — audibly thinner, and users report the codec got worse right when the network did. Negotiate 32000 instead and the primary holds around 23–25 kbps after redundancy, which is the conversational band the encoder was tuned for. The rule is simple: when you turn FEC on, raise the ceiling by roughly a third to keep the primary encoding where it was.

Minimal Runnable Implementation

Both directions must be declared. useinbandfec=1 in your local description tells the remote encoder it may send you redundancy; it does nothing to your own outbound stream. The peer’s description does the same for you. Set it on both sides, then give the encoder the headroom it needs through maxBitrate on your own sender.

// Opus fmtp profile for a link that is expected to lose packets.
// These are RECEIVE capabilities: they shape what the far side sends us.
const LOSSY_PROFILE = {
  useinbandfec: 1,          // permit LBRR — the peer's encoder decides when to spend on it
  maxaveragebitrate: 32000, // ~24 kbps primary survives after 20–30% LBRR overhead
  maxplaybackrate: 16000,   // wideband: stop paying for 8–16 kHz we do not need
  usedtx: 1,                // silence costs ~0.4 kbps of SID frames instead of 24 kbps
  stereo: 0,                // mono keeps SILK engaged, which is what makes LBRR possible
  cbr: 0                    // CBR would spend the FEC budget even at zero loss
};

function withOpusFmtp(sdp, params) {
  const rtpmap = sdp.match(/a=rtpmap:(\d+) opus\/48000\/2/i);
  if (!rtpmap) return sdp;                                  // no Opus offered
  const pt = rtpmap[1];                                     // dynamic PT — never hardcode
  const line = new RegExp(`a=fmtp:${pt} ([^\\r\\n]*)`);
  const merged = new Map();
  const found = sdp.match(line);
  if (found) {
    for (const pair of found[1].split(';')) {               // keep params we do not own
      const [k, v] = pair.split('=');
      if (k) merged.set(k.trim(), v);
    }
  }
  for (const [k, v] of Object.entries(params)) merged.set(k, String(v));
  const rebuilt = [...merged].map(([k, v]) => `${k}=${v}`).join(';');
  return found
    ? sdp.replace(line, `a=fmtp:${pt} ${rebuilt}`)
    : sdp.replace(rtpmap[0], `${rtpmap[0]}\r\na=fmtp:${pt} ${rebuilt}`);
}

async function offerLossyAudio(pc, track) {
  const tx = pc.addTransceiver(track, { direction: 'sendrecv' });
  const offer = await pc.createOffer();
  offer.sdp = withOpusFmtp(offer.sdp, LOSSY_PROFILE);
  await pc.setLocalDescription(offer);                      // commit exactly what we send

  pc.addEventListener('connectionstatechange', async () => {
    if (pc.connectionState !== 'connected') return;
    const p = tx.sender.getParameters();
    if (!p.encodings?.length) p.encodings = [{}];
    // Headroom, not a target: below ~26 kbps the adaptor drops LBRR to protect
    // the primary encoding, and FEC quietly stops happening on a lossy link.
    p.encodings[0].maxBitrate = 32000;
    p.encodings[0].networkPriority = 'high';                // audio ahead of video in the pacer
    await tx.sender.setParameters(p);
  });
  return tx;
}

The ceiling is the part people get wrong in the other direction. If a congestion controller has clamped the audio sender to 20 kbps because video is eating the estimate, the encoder will not spend a quarter of that on redundancy — it will drop LBRR and keep the primary encoding intelligible, which is the correct engineering decision and also the reason your FEC “stopped working” the moment screen sharing started. Audio needs a reserved floor in the overall budget; the allocation side of that is covered in Tuning WebRTC Bandwidth Estimator for Unstable Networks.

Choosing Opus parameters from measured packet loss A root node showing the 60 second median RTCP fraction lost branches into four configuration panels: below one percent, one to three percent, three to eight percent, and above eight percent, each listing the bitrate, FEC, playback rate and DTX settings to use. 60 s median RTCP fraction lost measured on the receiving peer, not averaged over the call under 1% 1–3% 3–8% over 8% Clean link maxaveragebitrate 24000 useinbandfec 1 maxplaybackrate 16000 usedtx 1 LBRR stays dormant; the flag costs nothing conceal target under 0.3% Mild loss maxaveragebitrate 24000 useinbandfec 1 maxplaybackrate 16000 usedtx 1 LBRR takes 5–10%; primary barely moves conceal target under 0.8% Lossy link maxaveragebitrate 32000 useinbandfec 1 maxplaybackrate 16000 usedtx 1, ptime 20 raise ceiling by a third so primary holds 24 kbps conceal target under 1.5% Burst-dominated maxaveragebitrate 32000 negotiate RED as well maxplaybackrate 16000 usedtx 0 on recorded calls LBRR alone cannot span consecutive drops expect audible artefacts Branch on the median, not the peak: one 20% spike during a handover should not repin the whole session. Re-evaluate at most every 30 s — each change is a renegotiation, and Opus adapts faster than your signalling can.
Mapping the measured loss band onto a concrete fmtp profile, and the concealment ratio each band should produce.

Reproduction Steps & Debugging Log Patterns

  1. Establish a call, then shape the uplink with tc qdisc add dev eth0 root netem loss 5% so the loss is genuinely random rather than bursty — this is the regime LBRR is designed for.
  2. Poll getStats() at 1 s intervals and pull fecPacketsReceived, fecPacketsDiscarded, concealedSamples, silentConcealedSamples, concealmentEvents and totalSamplesReceived from the audio inbound-rtp report.
  3. Compute the concealment ratio over the delta between polls, subtracting silent concealment: (Δ concealedSamples − Δ silentConcealedSamples) / Δ totalSamplesReceived. With usedtx=1 the silent term is large and constant, and leaving it in makes a healthy call look catastrophic.
  4. Run the same shape twice — once with useinbandfec=0 negotiated, once with useinbandfec=1 and the ceiling raised to 32000 — and compare the two ratios over the same three-minute window.
  5. Repeat with netem loss 5% 40% to add burst correlation, and watch the FEC benefit collapse.
// Expected deltas at 5% random loss, 20 ms ptime, one-second poll window.
// useinbandfec=0, maxaveragebitrate=24000
//   audio 23.8 kbps | fecRecv 0    | conceal 4.71% | concealEvents 12
// useinbandfec=1, maxaveragebitrate=32000
//   audio 31.2 kbps | fecRecv 47   | conceal 1.08% | concealEvents 3
//   -> ~47 of the 50 packets/s carry usable LBRR; residual conceal = the
//      handful of back-to-back drops netem still produced.
// Same profile, netem loss 5% 40% (bursty)
//   audio 31.4 kbps | fecRecv 21   | conceal 3.60% | concealEvents 9
//   -> fecPacketsReceived halves because the carrier packets die too.

A rising fecPacketsDiscarded alongside a healthy fecPacketsReceived is the one counter-intuitive signal worth naming: it means redundancy arrived but too late to be useful, because the jitter buffer had already concealed and moved on. That is a buffer-depth problem, not a FEC problem: the receiver needs at least one packet interval of target delay above what the path’s jitter alone demands, and no fmtp change will fix it.

Bit budget split and the concealment ratio it buys On the left, three stacked bars show a 32 kbps budget at zero, three and eight percent expected loss, with the redundancy share growing from nothing to about nine kbps and the primary encoding shrinking correspondingly. On the right, two horizontal bars compare a concealment ratio of 4.8 percent with FEC disabled against 1.1 percent with FEC enabled at the same measured five percent loss. A 32 kbps ceiling is a budget, not a bonus — LBRR is paid for out of the primary encoding primary LBRR redundancy 32 kbps 27 5 23 9 0% expected 3% expected 8% expected Drop the ceiling to 24000 here and the primary falls to 17–19 kbps Measured at 5% random loss, 20 ms ptime useinbandfec=0 conceal 4.8% — robotic useinbandfec=1 at 32000 conceal 1.1% — clean Same network, same codec, 7 kbps more spend. Under 1% is inaudible; over 2% is what users describe as breaking up.
The budget split at three expected-loss levels, and the concealment ratio the extra spend actually buys.

Common Implementation Mistakes

FAQ

Should I just leave useinbandfec=1 on permanently?

Yes. On a clean link the encoder’s expected-loss estimate sits near zero and it emits no redundancy at all, so the flag costs nothing measurable. The thing you should not leave on permanently is the elevated bitrate ceiling — that one is paid for whether or not FEC is active.

Why does fecPacketsReceived stay at zero even though loss is 6%?

Three usual causes, in order of frequency: the peer never saw useinbandfec=1 in the description you sent, so its encoder is not producing LBRR; the sender is bitrate-clamped below roughly 26 kbps and the adaptor dropped redundancy to protect the primary; or the negotiated configuration pushed Opus into CELT-only mode, where LBRR does not exist. Confirm the first by reading the fmtp line out of pc.remoteDescription.sdp, and the second from targetBitrate on the outbound report alongside your other Interpreting getStats() for Congestion Signals counters.

Does DTX interfere with FEC?

At the boundaries, yes. The first packet after a silence gap has no useful predecessor to protect, so a loss landing exactly on a speech onset is unrecoverable and clips the start of a word — the artefact users describe as swallowed first syllables. It is a real but small cost, and it does not outweigh the 24–32 kbps per silent stream that DTX saves in a room where most participants are muted.

Related: return to Audio Processing & Opus Tuning for the full capture-to-encoder path, or read Measuring Audio Latency and Jitter Buffer Delay for the delay side of the same trade and Disabling Audio Processing for High-Fidelity Music for the case where none of this applies.