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.
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.
Reproduction Steps & Debugging Log Patterns
- 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. - Poll
getStats()at 1 s intervals and pullfecPacketsReceived,fecPacketsDiscarded,concealedSamples,silentConcealedSamples,concealmentEventsandtotalSamplesReceivedfrom the audioinbound-rtpreport. - Compute the concealment ratio over the delta between polls, subtracting silent concealment:
(Δ concealedSamples − Δ silentConcealedSamples) / Δ totalSamplesReceived. Withusedtx=1the silent term is large and constant, and leaving it in makes a healthy call look catastrophic. - Run the same shape twice — once with
useinbandfec=0negotiated, once withuseinbandfec=1and the ceiling raised to 32000 — and compare the two ratios over the same three-minute window. - 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.
Common Implementation Mistakes
- Enabling FEC without raising the ceiling. Leaving
maxaveragebitrate=24000while turning on redundancy squeezes the primary encoding to 17–19 kbps. Quality degrades exactly when the network does, and the fix looks like “disable FEC” when the real fix is a higher ceiling. - Counting
silentConcealedSamplesas loss. With DTX enabled, NetEq conceals every silence gap by design, and those samples land inconcealedSamplestoo. Dashboards that skip the subtraction report 30–60% concealment on a perfectly healthy muted participant. - Expecting FEC to survive a handover. Wi-Fi-to-cellular transitions produce multi-hundred-millisecond bursts, and LBRR spans exactly one packet. Recovery there belongs to the transport, not the codec — see Handling Wi-Fi to Cellular Network Handover.
- Setting
useinbandfec=1on one side only. It is a receive-side declaration. Put it in your offer and the peer protects the stream it sends you; omit it from their answer and your outbound audio stays bare. - Combining
cbr=1with FEC. Constant bitrate removes the encoder’s freedom to vary the redundancy share per frame, so LBRR either steals from every frame equally or gets dropped. Keep VBR. - Renegotiating the profile on every loss spike. Each fmtp change is an offer/answer round trip, and the mechanics of editing that line without breaking the session are covered in Munging SDP to Prefer Opus DTX. Opus adapts within a second; your signalling cannot.
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.