The problem isn't your speakers. It's that there is no standard for loudness on the internet. Every streaming platform targets a different level, and browser tabs don't know these standards exist.
| Platform | Target Loudness | Standard |
|---|---|---|
| Spotify | -14 LUFS | EBU R128 |
| Apple Music | -16 LUFS | EBU R128 |
| YouTube | -13 to -14 LUFS | ITU-R BS.1770 |
| Netflix | -27 LUFS | Dolby Atmos target |
| Browser tab (default) | no standard | β |
When you jump from a Netflix movie (quiet, -27 LUFS) to a YouTube ad (loud, -13 LUFS), your system volume hasn't changed β but the perceived loudness difference is over 14 decibels. That's roughly the difference between a whisper and a conversation. Your browser has no idea it just happened.
Why This Happens
LUFS stands for Loudness Units relative to Full Scale, a perceptual measurement defined by the ITU-R BS.1770 standard. It accounts for how human hearing weights different frequencies β the same way the A-weighting filter works in SPL meters. A signal at -14 LUFS sounds the same loudness to your ears regardless of what's making that sound.
The problem is that loudness normalization in streaming is applied at the platform level β inside the player, before the audio reaches your OS. YouTube normalizes within YouTube. Spotify normalizes within Spotify. But there's no API that lets browsers enforce a cross-tab standard. Tabs are audio islands. Each one is a separate pipeline.
OS-level solutions exist for music players (ReplayGain on desktop, Apple's Sound Check), but nothing touches browser tabs. Until the Web Audio API.
The Technical Solution: Web Audio API + Dynamic Range Compression
The Web Audio API gives JavaScript access to a full audio processing graph. Every node in the graph transforms audio in real time β oscillators, filters, effects, analysers, and the one we care about: the DynamicsCompressorNode.
The key capability that makes cross-tab normalization possible is tab audio capture.
Chrome and Edge expose getDisplayMedia() with an audio capture option that can grab
the output of any browser tab as a MediaStream. That stream can then be fed into a Web Audio processing
chain β including a DynamicsCompressorNode β before reaching your speakers.
Source tab audio β getDisplayMedia() captures it as a MediaStream β
Web Audio API routes it through a DynamicsCompressorNode β processed audio plays through your speakers.
The source tab is muted to prevent double-playback.
The DynamicsCompressorNode Parameters
The compressor has five parameters that control how it responds to loud audio:
- threshold β the dB level above which compression starts (e.g., -24 dB)
- knee β how smoothly compression ramps in around the threshold (in dB)
- ratio β how much to reduce signal above threshold (e.g., 20:1 β hard limiting)
- attack β how fast the compressor kicks in (seconds; lower = faster response)
- release β how fast it backs off after the loud signal passes
For normalization (vs. creative compression), you want a high ratio, fast attack, and moderate release β behavior closer to a limiter than a classic compressor.
Code Walkthrough
Here's the core of a real-time normalization chain. This is stripped down β no UI, no error handling, just the audio graph:
// 1. Capture audio from a browser tab
const stream = await navigator.mediaDevices.getDisplayMedia({
video: false,
audio: {
suppressLocalAudioPlayback: true, // mute source tab
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false
}
});
// 2. Build the Web Audio graph
const ctx = new AudioContext();
const source = ctx.createMediaStreamSource(stream);
// 3. Create and configure the compressor
const compressor = ctx.createDynamicsCompressor();
compressor.threshold.value = -24; // start compressing at -24 dBFS
compressor.knee.value = 6; // 6 dB soft knee
compressor.ratio.value = 20; // 20:1 = near-limiting
compressor.attack.value = 0.003;// 3ms β fast
compressor.release.value = 0.25; // 250ms β moderate
// 4. Optional makeup gain to restore perceived loudness
const makeupGain = ctx.createGain();
makeupGain.gain.value = 1.5; // adjust to taste
// 5. Connect the chain
source
.connect(compressor)
.connect(makeupGain)
.connect(ctx.destination); // β speakers
The suppressLocalAudioPlayback: true constraint tells Chrome to mute the captured tab β
without it, you'd hear everything twice. Support for this constraint is Chrome/Edge-only; on other
browsers you'd need to work around it by playing through the AudioContext destination without
also letting the OS play the raw stream.
getDisplayMedia() with audio capture is only available in desktop Chrome and Edge.
Firefox and Safari don't support tab audio capture at all. Mobile browsers add another constraint:
they restrict getDisplayMedia() entirely. This is a hard platform limitation, not
a workaround problem.
Real-World Implementation: SoundBound
SoundBound is built on exactly this chain, with a few additions that matter for real-world use:
Dynamic Floor Boost
The compressor alone handles the ceiling β it prevents loud content from blowing out. But it doesn't help with the opposite problem: a podcast recorded at -30 dBFS that's too quiet even at max volume.
We added a dynamic gain stage before the compressor. An AnalyserNode measures the real-time RMS level of the input. When the signal drops below the user's floor threshold, a GainNode automatically boosts the signal (up to a cap of +30 dB) to bring it up. When the input is within range, gain stays at 1.0. The result: quiet content gets lifted, loud content gets capped, and everything in between passes through clean.
User-Configurable Range
Different content has different "right" settings. Talk radio wants a narrower range than gaming streams. SoundBound exposes two sliders β a floor (default -40 dBFS) and a ceiling (default -6 dBFS) β that update the compressor threshold and gain values in real time without rebuilding the graph. The Web Audio API lets you modify AudioParam values on a live graph.
Mobile Fallback
On mobile, getDisplayMedia() isn't available. Rather than a blank error, SoundBound
detects mobile on load and switches to a file-based normalizer: upload an audio file, process it
entirely client-side using a Web Audio offline context, download the normalized version.
Different use case, same engine.
Why This Matters: Hearing Protection
The CDC recommends limiting exposure to sounds above 85 dB SPL for extended periods. At louder levels, the recommended exposure time drops steeply β at 100 dB, safe exposure is under 15 minutes. Repeated peaks above this threshold cause cumulative hearing damage.
Modern streaming has a loudness war problem. Ad networks know that louder ads feel more urgent and drive higher click-through rates β so they normalize upward, not downward. You've heard it: the ad that's 15 dB louder than the YouTube video it interrupted. (For a deep dive on why this happens and what the CALM Act doesn't cover, see our article on why ads are louder than videos. For a YouTube-specific guide with step-by-step instructions, see how to normalize audio on YouTube.)
ITU-R BS.1770 and EBU R128 exist to address this at the broadcast level. But the internet isn't broadcast. There's no regulatory enforcement, no standard player, and no OS-level loudness normalization across arbitrary tabs.
What we can do is enforce it in the browser, on the output path. A DynamicsCompressorNode with a tight ceiling isn't just audio engineering β it's a ceiling on potential hearing damage from unexpected loud content. Gamers with long sessions especially benefit β see our guide to gaming audio settings for recommended ceiling levels per scenario (competitive FPS, Discord + game, stream viewing). The same normalization chain that limits loud ads also solves the cross-platform volume inconsistency problem β if you're constantly re-adjusting when switching between Netflix, Spotify, YouTube, and podcasts, see our guide on fixing volume differences between streaming services for the full breakdown of why each platform uses a different LUFS target. If you're fuzzy on how compression and normalization differ technically β they're often confused β our audio compression vs normalization explainer covers both side by side with the key distinctions.
Limitations
Real-time tab normalization in the browser has three hard constraints:
-
Desktop Chrome/Edge only. The
getDisplayMedia()tab audio API doesn't exist in Firefox, Safari, or any mobile browser. There's no workaround β it's a platform capability gap. - User permission required on every capture. Chrome shows a tab-picker dialog each time you start capture. There's no way to pre-grant tab audio access or make it persistent across sessions. This is an intentional browser security constraint.
- One tab at a time. You can normalize one captured tab's audio. You can't simultaneously normalize all tabs β there's no system-wide audio API in browsers today. (At the OS level, tools like EQ APO on Windows, or BlackHole + Audio MIDI Setup on macOS, can intercept system audio before it reaches the speakers β but that's outside the browser entirely.)
Tab-level real-time normalization in Chrome/Edge: yes. System-wide normalization from the browser: not yet. That constraint lives at the OS audio API level, not the Web Audio API.
Ready to try it? SoundBound normalizes your browser audio in real-time. No downloads, no extensions.
Stop fighting with volume controls.
SoundBound keeps your audio within your chosen dB range β YouTube, Spotify, gaming, everything. No downloads, no extensions. Works in Chrome and Edge on desktop.
Try SoundBound FreeFirst 100 users get a free trial