Disabling Audio Processing for High-Fidelity Music

A cello, a synth pad, or a room mic on a live band pushed through getUserMedia({ audio: true }) reaches the far end sounding hollow, gated, and oddly breathing — not because Opus is a bad music codec, but because the signal was already ruined before it got there. This guide is part of the Audio Processing & Opus Tuning guide, and it settles one decision: when to bypass the browser’s voice-tuned capture chain for a musical source, which constraint and Opus parameters that actually takes, and what echo liability you sign up for the moment you switch the canceller off.

Context & Trade-offs

The default capture path is a speech pipeline with opinions. It assumes one talker, close to the microphone, in a room with a loudspeaker, and every stage is tuned to make that scenario intelligible at 24–32 kbps. Feed it a piece of music and each of those assumptions turns into a specific, reproducible artifact.

The fixed high-pass filter that sits ahead of everything else corners around 80 Hz to strip desk rumble and HVAC. A five-string bass fundamental at 31 Hz and the body of a kick drum both live below that line, so the low end does not get quieter — it disappears. Noise suppression is worse for music, because its estimator is built to find a stationary noise floor and subtract it: a held organ chord, a cymbal decaying over three seconds, and the reverb tail of a hall are all stationary by that definition, and get pulled down 6–12 dB with the swirling spectral artifacts that come from aggressive per-bin gain. Automatic gain control then flattens what survives, chasing a target loudness with an attack slow enough to be audible as pumping; a passage with 20 dB of intended dynamic range arrives compressed to something closer to 6 dB. Echo cancellation contributes both delay and, through its nonlinear residual suppressor, 8–15 dB of ducking whenever the far end makes any sound at all.

There is a structural problem underneath all four: the processing module runs mono, in 10 ms frames. Requesting channelCount: 2 while the module is engaged gets you a downmix, not a stereo image. Stereo capture and audio processing are mutually exclusive in practice, which is why music mode is an all-or-nothing switch rather than a set of independent toggles.

Listener complaint Stage responsible Mechanism What you give up by disabling it
“The bass vanished” High-pass filter Fixed corner near 80 Hz ahead of the chain Desk rumble and handling noise now transmit
“It sounds underwater / swirly” noiseSuppression Per-bin gain reduction against a stationary floor Fan and HVAC hum ride along at full level
“It breathes and pumps” autoGainControl Slow-attack normalisation to a target loudness Nobody rescues a badly gain-staged input
“It ducks when I talk” echoCancellation Nonlinear residual suppression during doubletalk No echo protection whatsoever
“Everything is centre, no width” Module is mono Stereo capture is downmixed before encoding Roughly double the bitrate for the same quality
Music spectrum with the voice chain on versus bypassed Two stacked bar spectra span 20 Hz to 20 kHz. The upper one, captured with echo cancellation, noise suppression and gain control enabled, has the low bars almost erased by the high-pass filter and the mid and high bars pulled down. The lower one, captured with the processing module bypassed, keeps its full shape. Side panels list what the default costs and what music mode costs. What the voice chain does to a 20 Hz - 20 kHz music source Default: echoCancellation / noiseSuppression / autoGainControl ON high-pass: below 80 Hz gone NS ducks sustained tones 6-12 dB AGC squeezes 20 dB range to ~6 dB AEC ducks 8-15 dB on doubletalk Music mode: capture chain bypassed, stereo, Opus at 160 kbps 20 Hz 100 500 2k 8k 20k What the default costs - fundamental below 80 Hz - cymbal and reverb tails - most of the dynamic range - stereo image (chain is mono) - 10-20 ms of added delay What music mode costs - zero echo protection - 96-160 kbps per sender - no rescue for bad levels - a headset becomes mandatory - iOS may override you anyway
The same source captured through the default voice chain and with the processing module bypassed, with the cost of each choice.

The budget side is easy to underestimate. Conversational voice sits at 24–32 kbps mono; a credible stereo music stream wants 96–160 kbps, and 128 kbps stereo is the point where most listeners stop hearing the encoder at all. That is a five-fold increase per sender, and in a four-way session it moves aggregate audio from roughly 128 kbps to 640 kbps — real money against the same estimate your video layers are bidding for, which is why you should watch the effect on availableOutgoingBitrate rather than assume audio is free. The technique for reading that contention is covered in Interpreting getStats() for Congestion Signals. You do get something back on latency: bypassing the chain returns the 10–20 ms of algorithmic delay it was spending, which matters if musicians are trying to play together rather than take turns.

The decision is therefore not “music sounds better with processing off” — it is whether the session is a performance or a conversation. A remote lesson where a teacher talks over their own playing is the hardest case, because the two modes want opposite settings within the same minute. The workable pattern there is two separate captures from two devices: a headset microphone on the full voice profile for talking, and an interface input on the music profile for the instrument, published as two audio senders and mixed at the far end. That costs an extra 24–32 kbps for the speech track and one more transceiver, and it removes the temptation to renegotiate the capture chain every time somebody picks up a bow.

Minimal Runnable Implementation

Three things have to line up: a capture that the platform genuinely honours, an Opus negotiation that carries stereo in both directions, and a local sender ceiling that permits the bitrate you negotiated.

// --- 1. Capture: ask the platform to bypass the voice chain entirely ------
const MUSIC_CONSTRAINTS = {
  audio: {
    deviceId: { exact: interfaceId }, // a real interface, never the laptop array mic
    echoCancellation: false,          // no adaptive filter, no residual suppressor
    noiseSuppression: false,          // keeps reverb tails and cymbal decay alive
    autoGainControl: false,           // preserves the source's own dynamic range
    channelCount: 2,                  // the chain is mono; stereo needs it bypassed
    sampleRate: 48000                 // Opus is native at 48 kHz; skip the resampler
  }
};
const stream = await navigator.mediaDevices.getUserMedia(MUSIC_CONSTRAINTS);
const track = stream.getAudioTracks()[0];

// Constraints are requests, not commands. Read back what was actually granted
// before promising the user high fidelity.
const got = track.getSettings();
const bypassed = got.echoCancellation === false &&
                 got.noiseSuppression === false &&
                 got.autoGainControl === false;
if (!bypassed || got.channelCount !== 2) {
  console.warn('music mode denied by platform', got); // fall back to voice profile
}

// --- 2. Opus: stereo plus headroom, written into BOTH descriptions --------
function opusMusicLine(sdp) {
  const pt = (sdp.match(/a=rtpmap:(\d+) opus\/48000\/2/i) || [])[1];
  if (!pt) return sdp;                          // Opus not offered; nothing to tune
  const tokens = 'stereo=1;sprop-stereo=1;maxaveragebitrate=160000;' +
                 'usedtx=0';                    // DTX would gate the decay of a note
  const fmtp = new RegExp(`a=fmtp:${pt} [^\\r\\n]*`);
  return fmtp.test(sdp)
    ? sdp.replace(fmtp, `a=fmtp:${pt} ${tokens}`)
    : sdp.replace(/(a=rtpmap:\d+ opus\/48000\/2)/i, `$1\r\na=fmtp:${pt} ${tokens}`);
}

const offer = await pc.createOffer();
offer.sdp = opusMusicLine(offer.sdp);           // declares that WE decode stereo
await pc.setLocalDescription(offer);

// The half that gets forgotten: our own encoder only switches to stereo when
// the description we RECEIVE advertises stereo=1, so rewrite the answer too.
const answer = await signaling.waitForAnswer();
answer.sdp = opusMusicLine(answer.sdp);
await pc.setRemoteDescription(answer);

// --- 3. Lift the local ceiling; the fmtp line alone will not do it --------
pc.addEventListener('connectionstatechange', async () => {
  if (pc.connectionState !== 'connected') return;
  const sender = pc.getSenders().find(s => s.track?.kind === 'audio');
  const params = sender.getParameters();
  if (!params.encodings?.length) params.encodings = [{}];
  params.encodings[0].maxBitrate = 160000;      // bps, mirrors maxaveragebitrate
  await sender.setParameters(params);
});

Nothing about that ordering is decorative. stereo=1 in the description you send is a statement about your decoder; sprop-stereo=1 is a statement about your encoder’s intent; and Chrome only flips its own encoder into stereo when it sees stereo=1 arrive from the far side. That asymmetry is why editing only your own offer produces a perfectly plausible SDP and a stubbornly mono stream.

Two omissions from that snippet are deliberate. Packetisation stays at the default 20 ms: dropping to 10 ms shaves a little delay but adds a second RTP and UDP header to every 20 ms of audio, which is a poor trade at 160 kbps where the payload already dominates. And the encoder is left in variable-bitrate mode rather than pinned with cbr=1, because constant bitrate spends the same bits on a decaying reverb tail as on a downbeat — exactly the allocation music does not want. If you also disable useinbandfec, understand that you are removing the redundancy that covers isolated losses, and at 160 kbps a single lost packet is 400 bytes of a stereo frame that concealment has no good way to invent.

Which description each Opus parameter actually configures The local description you send carries stereo and maxaveragebitrate tokens that govern the peer's encoder. The remote description you apply carries the tokens that govern your own encoder. A separate setParameters call sets the local maximum bitrate ceiling directly on the sender. Which description each Opus parameter actually configures Local description (you send this) m=audio 9 UDP/TLS/RTP/SAVPF 111 a=rtpmap:111 opus/48000/2 a=fmtp:111 stereo=1;sprop-stereo=1; maxaveragebitrate=160000;usedtx=0 Remote description (you apply it) m=audio 9 UDP/TLS/RTP/SAVPF 111 a=rtpmap:111 opus/48000/2 a=fmtp:111 stereo=1;sprop-stereo=1; maxaveragebitrate=160000;usedtx=0 tells the PEER what to send tells YOUR encoder what to send Your Opus encoder obeys the remote fmtp line Peer's Opus encoder obeys the fmtp line you sent sender.setParameters() maxBitrate 160000 - local hard ceiling An fmtp token is a receive capability. It never configures its own encoder.
Stereo and bitrate tokens cross over: yours configure the peer, theirs configure you, and only setParameters binds locally.

Reproduction Steps & Debugging Log Patterns

  1. Capture with the constraint set above from a stereo interface, and immediately log track.getSettings(). Anything that comes back true was refused, not applied.
  2. Play a 30-second excerpt with real low end and a long decay — a piano chord left to ring is the cheapest test signal there is — through the connection twice, once with the voice profile and once with music mode.
  3. Poll pc.getStats() at 1 s intervals and record outbound-rtp.targetBitrate plus the media-source report. A mono clamp shows up instantly as a target near 32000 no matter what you negotiated.
  4. Grep the negotiated SDP on both peers for a=fmtp and confirm stereo=1 is present in the description each side received, not only in the one it sent.
  5. With the far end playing loudly on speakers, look for your own signal reappearing in outbound-rtp — that is the echo you just stopped cancelling.
// getSettings() after a music-mode capture the platform honoured
// { echoCancellation: false, noiseSuppression: false,
//   autoGainControl: false, channelCount: 2, sampleRate: 48000 }
// outbound-rtp  targetBitrate: 158400        // within a few % of 160000
// media-source  audioLevel: 0.41             // transients no longer flattened
// media-source  echoReturnLoss: undefined    // absent = cancellation genuinely off
// --- failure signature: the platform quietly refused ---
// { echoCancellation: false, ..., channelCount: 1 }  // stereo denied
// outbound-rtp  targetBitrate: 32000                 // encoder still in voice mode

An echoReturnLoss field that keeps reporting a value after you asked for echoCancellation: false is the single most useful signal available: it means processing is still running underneath the constraint, which is the normal state of affairs on iOS Safari, where capturing a microphone engages the platform voice-processing unit regardless of what you requested. Treat that combination as “music mode unavailable” and stop debugging it.

Because the risk you accept is entirely about the local acoustic path, gate the feature rather than exposing it as a free toggle. The gate has three checks — output routing, route stability, and an actual measurement — and every one of them can fail open into feedback if you skip it.

Echo-risk gate before granting music mode A request for music mode passes through three decisions: is playback on headphones, is a devicechange handler armed to re-enable cancellation, and does an echo probe stay silent while the far end plays. Any no branch falls back to a safe profile; three yes answers grant music mode. Echo-risk gate before granting music mode User requests music mode Playback on headphones? no Keep cancellation on offer mono 48 kbps instead yes devicechange handler armed to restore the voice profile? no Do not disable it the route can flip mid-call yes Echo probe stays silent with far end at -6 dBFS? no Fall back automatically and log the failed probe yes Music mode granted chain off, stereo=1, 160 kbps
Three gates stand between a music-mode request and a call that howls: routing, route stability, and a real measurement.

The probe in the third gate is cheap to build: play a short sweep to the local output while the microphone is live, integrate totalAudioEnergy from the media-source report over that window, and compare it against the same integral captured in silence. A rise of more than a few decibels means the loudspeaker is reaching the microphone and music mode must not be granted. Re-run it whenever the output route changes, because a headphone unplug turns a clean session into a feedback loop in a single audio callback; the routing events to hook are described in Audio Focus & Echo Cancellation Across Devices.

Common Implementation Mistakes

FAQ

Can I keep echo cancellation on and just disable noise suppression and gain control?

Yes, and for a presenter with a good microphone on speakers that is the right compromise. You keep the echo protection and recover the dynamics and the high frequencies, but you stay mono — the chain downmixes stereo whenever any of its stages is active — and you keep paying the 10–20 ms of processing delay.

How much bitrate does stereo music actually need?

Plan for 96–160 kbps. Opus is legal anywhere in 6–510 kbps, but the audible returns flatten above roughly 128 kbps stereo, and 160 kbps buys headroom for dense material like applause or a full mix rather than better tone on a solo instrument.

Why does my stream sound thin even with every flag disabled?

Check the granted channelCount and sampleRate first, then check the route. A Bluetooth headset negotiating a hands-free profile drops the capture path to a narrowband mono link before your constraints are ever considered, and no amount of Opus tuning recovers bandwidth the transport never carried.

Related: return to Audio Processing & Opus Tuning for the full capture-to-codec path, then read Tuning Opus Bitrate and FEC for Lossy Networks for what to do when that 160 kbps stream meets packet loss, and Measuring Audio Latency and Jitter Buffer Delay to confirm the delay you saved survives the receiver.