# 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.

| Boost   | 18 | +18 dB |
| ------- | -- | ------ |
| Ceiling | -1 | -1 dB  |

\[x]Limiter on

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`](https://www.npmjs.com/package/@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).

```js
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](https://docs-preview.optiview.dolby.com/pr-913/theoplayer/examples/playback/web-audio-api.md) example for more about routing the player's audio.

Code

demo.html

```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>
```
