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 |
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.
Reproduction Steps & Debugging Log Patterns
- Capture with the constraint set above from a stereo interface, and immediately log
track.getSettings(). Anything that comes backtruewas refused, not applied. - 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.
- Poll
pc.getStats()at 1 s intervals and recordoutbound-rtp.targetBitrateplus themedia-sourcereport. A mono clamp shows up instantly as a target near 32000 no matter what you negotiated. - Grep the negotiated SDP on both peers for
a=fmtpand confirmstereo=1is present in the description each side received, not only in the one it sent. - 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.
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
- Toggling the flags with
applyConstraints()on a live track. Chrome builds the capture chain when the track is created; flippingechoCancellationafterwards resolves successfully and changes nothing. CallgetUserMedia()again with the new profile and swap the track on the existing sender. - Trusting the constraint instead of
getSettings(). Requested and granted are different values, and usingexacton a device the platform will not honour throwsOverconstrainedErrorrather than degrading — the failure semantics are covered in exact vs ideal Constraints Without OverconstrainedError. - Editing only your own SDP. Your local
stereo=1describes your decoder. Without the same token in the description you receive, your encoder stays mono and the bitrate stays clamped near 32 kbps. - Leaving
usedtx=1in the music profile. Discontinuous transmission treats the tail of a decaying note as silence and stops sending, so sustained passages arrive truncated with a comfort-noise floor pasted underneath. - Routing a system-audio source through the microphone path. If the material is playing on the same machine, capture it directly rather than through a mic and a bypassed chain — see Capturing System Audio with getDisplayMedia.
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.