Skip to main content
Version: 11.13.0

Peak limiter

Make quiet audio louder without clipping: boost the player's audio and keep its peaks under a ceiling with a lookahead limiter.

Tip: open your browser's developer console and use the player and THEOplayer variables to interact with this demo.

+18 dB
-1 dB
Output peak-∞ dB

Turn the limiter off to hear the boosted audio clip: in loud scenes, the output peak goes above 0 dB and the meter turns red.

The Web Audio API has no built-in peak limiter. Its DynamicsCompressorNode adds makeup gain automatically, so it can't guarantee that peaks stay under a ceiling. This example uses @synthlet/lookahead-limiter, an open source (MIT) limiter that runs in an AudioWorklet. It looks 2 ms ahead, so it lowers the gain before a peak arrives, and it also catches peaks between samples (true peaks).

import { registerLookaheadLimiterWorklet, LookaheadLimiter } from '@synthlet/lookahead-limiter';

const context = new AudioContext();
await registerLookaheadLimiterWorklet(context);

const source = player.audio.createAudioSourceNode(context);
const limiter = LookaheadLimiter(context, {
gain: 12, // boost in dB, applied before limiting
threshold: -1, // ceiling in dB
});
source.connect(limiter).connect(context.destination);

The limiter delays the audio by about 2 ms. Browsers start an AudioContext in the suspended state until the viewer interacts with the page, so call context.resume() when playback starts. See the Web Audio API example for more about routing the player's audio.

Code
demo.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Peak limiter</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;700&display=swap" rel="stylesheet" />
<style>
html,
body {
margin: 0;
padding: 0;
font-family: 'Noto Sans', sans-serif;
}

theoplayer-default-ui:not(:defined) {
display: inline-block;
box-sizing: border-box;
}

theoplayer-default-ui {
width: 100%;
aspect-ratio: 16 / 9;
font-family: 'Noto Sans', sans-serif;
background: #000;
}
</style>
<script src="https://cdn.theoplayer.com/dash/theoplayer/THEOplayer.js"></script>
<script nomodule src="https://unpkg.com/@theoplayer/web-ui@2/polyfills"></script>
<script async src="https://unpkg.com/@theoplayer/web-ui@2"></script>
<script type="importmap">
{
"imports": {
"@synthlet/lookahead-limiter": "https://unpkg.com/@synthlet/lookahead-limiter@0.2.0/dist/index.mjs"
}
}
</script>
</head>
<body>
<!--
libraryLocation: For demonstration purposes, we use the THEOplayer CDN.
For production use, host the library yourself and update this URL and the script above.

licenseUrl: Point this to your license file, or replace it with a license property.
-->
<theoplayer-default-ui
configuration='{"libraryLocation":"https://cdn.theoplayer.com/dash/theoplayer/","licenseUrl":"../../../../theoplayer-license.txt"}'
source='{"sources":{"src":"https://cdn.theoplayer.com/video/sintel.mp4","type":"video/mp4","crossOrigin":"anonymous"},"poster":"https://cdn.theoplayer.com/video/posters/sintel.jpg","metadata":{"title":"Sintel"}}'
></theoplayer-default-ui>
<script src="../utils.js"></script>
<script type="module">
import { registerLookaheadLimiterWorklet, LookaheadLimiter } from '@synthlet/lookahead-limiter';

onPlayerReady(async (player) => {
const context = new AudioContext();
await registerLookaheadLimiterWorklet(context);

let boostDecibels = 18;
let ceilingDecibels = -1;
let limiterEnabled = true;

// With the limiter on: source -> limiter (boost + ceiling) -> meter -> speakers
// With the limiter off: source -> boost -> meter -> speakers
const source = player.audio.createAudioSourceNode(context);
const limiter = LookaheadLimiter(context, {
gain: boostDecibels,
threshold: ceilingDecibels,
});
const boost = new GainNode(context, { gain: decibelsToGain(boostDecibels) });
const meter = new AnalyserNode(context, { fftSize: 2048 });

limiter.connect(meter);
boost.connect(meter);
meter.connect(context.destination);

function connectSource() {
source.disconnect();
source.connect(limiterEnabled ? limiter : boost);
}
connectSource();

// Browsers start an AudioContext suspended until the user interacts with the page.
// While it's suspended, the player's audio is silent, so resume it on playback and on any interaction.
function resumeContext() {
if (context.state === 'suspended') context.resume();
}
player.addEventListener('play', resumeContext);
document.addEventListener('pointerdown', resumeContext);
document.addEventListener('keydown', resumeContext);

onParentMessage('limiter-boost', ({ decibels }) => {
limiter.gain.setTargetAtTime(decibels, context.currentTime, 0.02);
boost.gain.setTargetAtTime(decibelsToGain(decibels), context.currentTime, 0.02);
});

onParentMessage('limiter-ceiling', ({ decibels }) => {
limiter.threshold.setTargetAtTime(decibels, context.currentTime, 0.02);
});

onParentMessage('limiter-enabled', ({ enabled }) => {
limiterEnabled = enabled;
connectSource();
});

// Report the highest sample of the output a few times per second. Above 0 dB, the audio clips.
const samples = new Float32Array(meter.fftSize);
let peakHold = 0;
let peakHoldUntil = 0;
let lastReport = 0;

function measurePeak(now) {
requestAnimationFrame(measurePeak);
meter.getFloatTimeDomainData(samples);
const peak = samples.reduce((max, sample) => Math.max(max, Math.abs(sample)), 0);
if (peak >= peakHold || now > peakHoldUntil) {
peakHold = peak;
peakHoldUntil = now + 1000;
}
if (now - lastReport < 100) return;
lastReport = now;
const decibels = 20 * Math.log10(peakHold);
sendToParent('limiter-peak', { decibels: Number.isFinite(decibels) ? decibels : null });
}
requestAnimationFrame(measurePeak);
});

function decibelsToGain(decibels) {
return 10 ** (decibels / 20);
}
</script>
</body>
</html>