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();
<!-- 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();
Comments
Loading comments...