Introduction

A three-minute song in uncompressed CD quality weighs about 30 MB. The same song in MP3 at 128 kbps weighs about 3 MB. Both sound, to most listeners, essentially identical.

That factor-of-ten miracle is not just clever arithmetic. It rests on a property of your own auditory system: a loud sound makes nearby quieter sounds inaudible, even if they are physically present in the signal. This phenomenon is called auditory masking, and it was understood by psychoacousticians long before the digital age.

The engineers who designed the MPEG Audio Layer III standard in the late 1980s and early 1990s asked a radical question: what if we simply did not encode the parts of a signal that the ear cannot hear anyway? If a sound is masked — drowned out by a louder neighbor — its absence causes no perceptible distortion. Throwing it away is lossless from the listener's point of view, even though it is lossy in the mathematical sense.

This idea, called perceptual coding, is what separates MP3 from a zip file. A zip file preserves every bit exactly. MP3 discards bits strategically, guided by a model of human hearing, and bets that you will never miss them.

Watch Masking Live

The bar chart below shows 32 frequency bands, like the subbands an MP3 encoder divides audio into. Drag the slider to move a loud masker tone across the spectrum, or adjust its level. The red bars show the masking threshold — any quieter signal below that threshold (gray bars) would be inaudible and can be discarded by the encoder.

<!-- MP3 psychoacoustics masking demo — move the masker, watch bands go silent -->
<p class="hint">Move the <b>Masker position</b> slider to place a loud tone in any frequency band. Move <b>Masker level</b> to change its volume. The red curve is the <b>masking threshold</b>: any band whose signal bar (blue) falls below it is inaudible — the encoder discards it (shown in gray).</p>
<div class="controls">
  <label>Masker position (band): <span id="pos-val">16</span>
    <input type="range" id="masker-pos" min="0" max="31" value="16" step="1">
  </label>
  <label>Masker level: <span id="lvl-val">80</span> dB
    <input type="range" id="masker-level" min="40" max="96" value="80" step="1">
  </label>
</div>
<div class="chart-wrap">
  <canvas id="chart" width="660" height="260"></canvas>
</div>
<div class="legend">
  <span class="dot signal"></span> Signal energy
  <span class="dot mask"></span> Masking threshold
  <span class="dot kept"></span> Kept (above threshold)
  <span class="dot cut"></span> Discarded (masked)
</div>
<div id="status" class="status"></div>
<div class="btns">
  <button id="reset-btn" class="ghost">Reset</button>
</div>
/* Styles for the MP3 masking demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .6rem; line-height: 1.45; }
.controls { display: flex; flex-wrap: wrap; gap: .5rem 1.2rem; margin-bottom: .5rem; }
label { font-size: .85rem; display: flex; flex-direction: column; gap: .15rem; color: #333; }
input[type=range] { width: 180px; accent-color: #1d3557; }
.chart-wrap { width: 100%; overflow-x: auto; }
canvas { display: block; max-width: 100%; }
.legend { display: flex; flex-wrap: wrap; gap: .3rem .8rem; font-size: .8rem;
          color: #555; margin: .4rem 0; align-items: center; }
.dot { display: inline-block; width: 10px; height: 10px; border-radius: 2px; }
.dot.signal { background: #4a90d9; }
.dot.mask   { background: #e63946; }
.dot.kept   { background: #57ab5a; }
.dot.cut    { background: #ccc; }
.status { font-size: .9rem; font-weight: 600; min-height: 1.4em; margin: .3rem 0; color: #1d3557; }
.btns { display: flex; gap: .5rem; }
button { font: 600 13px system-ui; padding: .4rem .85rem; border: 1px solid #1d3557;
         background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));

/* MP3-like psychoacoustic constants */
const BANDS = 32;
const MASKER_DB = 96;

/* Generate a pseudo-random pink-noise-like signal across 32 bands */
function makeSignal() {
  const sig = new Float32Array(BANDS);
  for (let i = 0; i < BANDS; i++) {
    /* Pink-noise shape: decreases slightly with frequency, plus randomness */
    sig[i] = 50 + 18 * Math.random() - i * 0.25;
  }
  return sig;
}

/* Simplified spreading-function masking threshold (ISO 11172-3 inspired) */
/* Returns threshold[b] in dB for each of the 32 bands given a single masker */
function maskingThreshold(maskerBand, maskerDb) {
  const t = new Float32Array(BANDS);
  for (let b = 0; b < BANDS; b++) {
    const delta = b - maskerBand;
    let spread;
    if (delta <= 0) {
      /* Below masker: steep 27 dB/band drop-off (upward masking is weaker) */
      spread = delta * 27;
    } else {
      /* Above masker: shallower drop, depends on masker level (downward masking stronger) */
      spread = delta * (-17 - 0.4 * maskerDb);
    }
    /* Add absolute threshold of hearing (floor, roughly ISO 226 simplified) */
    const absHearing = 3.64 * Math.pow((b + 1) / 10, -0.8) - 6.5 * Math.exp(-0.6 * Math.pow((b + 1) / 10 - 3.3, 2)) + 0.001 * Math.pow((b + 1) / 10, 4);
    t[b] = Math.max(Math.max(maskerDb + spread, 0), absHearing);
  }
  return t;
}

const signal = makeSignal();
let maskerBand = 16;
let maskerDb = 80;

const canvas = document.getElementById('chart');
const ctx = canvas.getContext('2d');

function draw() {
  const W = canvas.width, H = canvas.height;
  const PAD = { top: 20, right: 10, bottom: 32, left: 40 };
  const bw = Math.floor((W - PAD.left - PAD.right) / BANDS);
  const gw = bw - 2;
  const chartH = H - PAD.top - PAD.bottom;

  /* Clear canvas before redrawing */
  ctx.clearRect(0, 0, W, H);

  const threshold = maskingThreshold(maskerBand, maskerDb);
  const dbMax = 100;

  function yForDb(db) {
    return PAD.top + chartH * (1 - Math.max(0, Math.min(db, dbMax)) / dbMax);
  }

  /* Horizontal dB grid lines */
  ctx.strokeStyle = '#e0e4e8';
  ctx.lineWidth = 1;
  for (let db = 0; db <= 100; db += 20) {
    const y = yForDb(db);
    ctx.beginPath(); ctx.moveTo(PAD.left, y); ctx.lineTo(W - PAD.right, y); ctx.stroke();
    ctx.fillStyle = '#999'; ctx.font = '10px system-ui'; ctx.textAlign = 'right';
    ctx.fillText(db + ' dB', PAD.left - 4, y + 3);
  }

  /* Draw signal and threshold bars for each band */
  for (let b = 0; b < BANDS; b++) {
    const x = PAD.left + b * bw;
    const ismasker = b === maskerBand;
    const masked = signal[b] < threshold[b];

    /* Blue signal bar (dark navy for the masker itself) */
    const sigTop = yForDb(signal[b]);
    ctx.fillStyle = ismasker ? '#1d3557' : (masked ? '#b0c8e8' : '#4a90d9');
    ctx.fillRect(x, sigTop, gw, yForDb(0) - sigTop);

    /* Red threshold overlay */
    if (!ismasker) {
      const maskTop = yForDb(threshold[b]);
      ctx.fillStyle = masked ? 'rgba(230,57,70,0.35)' : 'rgba(230,57,70,0.15)';
      ctx.fillRect(x, maskTop, gw, yForDb(0) - maskTop);

      if (masked) {
        /* Gray overlay for masked (discarded) bands */
        ctx.fillStyle = 'rgba(204,204,204,0.7)';
        ctx.fillRect(x, sigTop, gw, yForDb(0) - sigTop);
      }
    }
  }

  /* Connect threshold centers with a red line */
  ctx.beginPath();
  ctx.strokeStyle = '#e63946';
  ctx.lineWidth = 2;
  for (let b = 0; b < BANDS; b++) {
    const x = PAD.left + b * bw + gw / 2;
    const y = yForDb(threshold[b]);
    b === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
  }
  ctx.stroke();

  /* Dot marking the masker position */
  const mx = PAD.left + maskerBand * bw + gw / 2;
  ctx.beginPath(); ctx.arc(mx, yForDb(maskerDb), 5, 0, Math.PI * 2);
  ctx.fillStyle = '#1d3557'; ctx.fill();

  /* X-axis band labels */
  ctx.fillStyle = '#888'; ctx.font = '10px system-ui'; ctx.textAlign = 'center';
  for (let b = 0; b < BANDS; b += 4) {
    const x = PAD.left + b * bw + gw / 2;
    ctx.fillText(b, x, H - PAD.bottom + 14);
  }
  ctx.fillText('Frequency band', PAD.left + (W - PAD.left - PAD.right) / 2, H);

  /* Count and report masked bands */
  const maskedCount = Array.from(signal).filter((s, i) => i !== maskerBand && s < threshold[i]).length;
  document.getElementById('status').textContent =
    i18nFmt('%n% of %total% bands masked — encoder saves those bits.', { n: maskedCount, total: BANDS - 1 });
}

function update() {
  maskerBand = +document.getElementById('masker-pos').value;
  maskerDb   = +document.getElementById('masker-level').value;
  document.getElementById('pos-val').textContent = maskerBand;
  document.getElementById('lvl-val').textContent = maskerDb;
  draw();
}

document.getElementById('masker-pos').addEventListener('input', update);
document.getElementById('masker-level').addEventListener('input', update);

document.getElementById('reset-btn').addEventListener('click', () => {
  document.getElementById('masker-pos').value = 16;
  document.getElementById('masker-level').value = 80;
  update();
});

update();
Result
Console

Notice how the masking threshold is asymmetric: it spreads further toward higher frequencies than lower ones. This matches real hearing data. The encoder allocates bits only to bands whose signal energy rises above the threshold — bands hidden underneath get zero bits.

The Real Complexity

The psychoacoustic model is a solved, finished algorithm — not an open research problem. Here is what it actually does for each short frame of audio (roughly 26 ms):

  • Subband decomposition: the encoder splits the audio into 32 frequency subbands using a polyphase filterbank. Each subband's energy is measured.
  • Spectral analysis: a fast Fourier transform (FFT) of the same frame gives a finer frequency picture, used to locate tonal components (pure tones) and noise-like components separately.
  • Masking threshold computation: for each tonal and noise masker, the model computes a spreading function that describes how much the masker raises the hearing threshold in nearby bands. The individual contributions are summed to get a global masking threshold T(b)T(b) for each subband bb.
  • Signal-to-mask ratio (SMR): the ratio of the subband's actual energy E(b)E(b) to its masking threshold T(b)T(b). Bands with a high SMR need many bits; bands where E(b)<T(b)E(b) < T(b) need zero.
  • Bit allocation: bits are distributed iteratively across subbands until the quantization noise in each band falls below T(b)T(b). This inner loop is O(B2)O(B^{2}) in the number of subbands B=32B = 32, fast in practice.

The whole model runs in real time on hardware that was slow by today's standards. Its computational cost is dominated by the FFT — O(Nlog⁡N)O(N \log N) per frame — and the bit-allocation loop, both cheap enough to run faster than playback speed even on a 1993 PC.

The standard (ISO/IEC 11172-3, published 1993) leaves the psychoacoustic model as a recommendation, not a mandate — encoders compete by tuning their models. But the mathematical structure is fixed: measure energy, compute thresholds, allocate bits, quantize, pack.

Where It Matters

Perceptual coding built the modern media landscape. Every format you encounter relies on the same masking insight:

  • Music streaming: Spotify, Apple Music, and YouTube all serve AAC or Opus files — direct descendants of the MP3 masking model, with better bit-allocation algorithms.
  • Video audio tracks: Dolby AC-3 (the audio in most DVDs and cinema prints) and DTS both use perceptual models closely related to ISO 11172-3.
  • Voice and video calls: the Opus codec (used in WebRTC, Discord, WhatsApp) applies a simpler masking model tuned for speech — discarding masked components lets calls sound clear at as little as 6 kbps.
  • Spatial audio: modern formats like Dolby Atmos encode dozens of audio objects by exploiting both spectral masking and temporal masking (a loud sound briefly masks quieter sounds that follow it by a few milliseconds).
  • Hearing research: the models built for MP3 became precision tools for audiologists. Devices that measure auditory masking thresholds in clinical settings use the same spreading-function mathematics.

Learn how MP3 masking works and you understand the engine under virtually all compressed audio — a direct line from information theory and the physics of perception to the earbuds in your pocket.

Conclusion

MP3 is a story about exploiting a limitation. Your auditory system evolved to extract useful signals from a noisy world, and in doing so it developed blind spots — frequency bands that a loud neighbor renders temporarily inaudible. The MPEG engineers turned those blind spots into storage.

The core idea is simple enough to state in a sentence: encode only what the listener can actually hear. But applying it required a careful model of human perception, a clever bit-allocation algorithm, and the courage to build a format that is mathematically lossy yet perceptually lossless.

That trade-off is now everywhere — in every stream you play, every call you make, every movie you watch. And it all traces back to the masking threshold, a curve drawn not by mathematics alone but by the shape of human hearing itself. See also compression for the broader story of what we throw away and why.

Share this article

Pick a channel — or use your device's native share sheet.

Comments

Loading comments...

https://www.kipuhub.com/en/article/mp3-psychoacoustics/Content licensed under CC BY-NC 4.0.