The demo below synthesizes a short dry click and a simplified cathedral impulse response entirely in JavaScript, then convolves them using the browser's Web Audio API (which runs FFT-based convolution internally). Press the buttons to hear each sound and watch the waveform.
<!-- Demo layout: hint, two buttons (dry / wet), waveform canvas, status line -->
<p class="hint">A dry click (impulse) vs its cathedral reverb — computed by convolving the click with a 2.5-second room impulse response. The Web Audio API uses FFT-based convolution internally. Click a button, hear the difference, and watch the waveform change.</p>
<div class="controls">
<button id="btn-dry" type="button">Play Dry Click</button>
<button id="btn-wet" type="button">Play Reverb</button>
</div>
<div class="label-row">
<span id="lbl-signal">Dry click waveform</span>
</div>
<canvas id="canvas" width="560" height="180"></canvas>
<div class="status" id="status">Press a button to play and visualize.</div>
/* Styles: hint text, button row, canvas border, status line */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: .4rem; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .8rem; line-height: 1.45; }
.controls { display: flex; gap: .5rem; flex-wrap: wrap; margin-bottom: .5rem; }
button {
font: 600 14px system-ui, sans-serif;
padding: .45rem 1rem;
border: 1px solid #1d3557;
background: #1d3557;
color: #fff;
border-radius: 8px;
cursor: pointer;
transition: background .15s;
}
button:hover { background: #2a4a70; }
button.active { background: #e63946; border-color: #c92f3c; }
.label-row { font-size: .82rem; font-weight: 600; color: #555; margin-bottom: .3rem; min-height: 1.1em; }
canvas { width: 100%; max-width: 560px; height: 180px; display: block;
border: 1px solid #cdd9e3; border-radius: 8px; background: #f7f9fb; }
.status { font-size: .9rem; font-weight: 600; margin-top: .5rem; min-height: 1.4em; color: #1d3557; }
/* Convolution reverb demo — synthesize dry click + cathedral IR, play via Web Audio ConvolverNode */
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
const SAMPLE_RATE = 44100;
/* Cathedral impulse response: 2.5 seconds at 44100 Hz */
const IR_LEN = Math.round(SAMPLE_RATE * 2.5);
/* Dry click: 50 ms */
const CLICK_LEN = Math.round(SAMPLE_RATE * 0.05);
/* Build a short percussive click (impulse + short exponential tail) */
function buildDryClick() {
const buf = new Float32Array(CLICK_LEN);
buf[0] = 1.0;
/* Short exponential decay to smooth the click */
for (let i = 1; i < 80; i++) buf[i] = Math.exp(-i * 0.15) * 0.4;
return buf;
}
/* Build a synthetic cathedral impulse response (exponentially decaying noise + early reflections) */
function buildCathedralIR() {
const buf = new Float32Array(IR_LEN);
/* Deterministic pseudo-random noise seeded by sample index */
const rand = (s) => { let x = Math.sin(s * 9301 + 49297) * 233280; return x - Math.floor(x); };
for (let i = 0; i < IR_LEN; i++) {
const t = i / SAMPLE_RATE;
/* Exponential decay envelope: RT60 ~ 1/1.4 * ln(1000) ~ 4.9 s */
const decay = Math.exp(-t * 1.4);
/* Early reflections boost within first ~18 ms */
const early = (i < 800 ? 1.0 : 0.0) * Math.exp(-i * 0.004);
buf[i] = (rand(i) * 2 - 1) * decay + early * 0.5;
}
/* Normalize IR to peak = 1 */
let peak = 0;
for (let i = 0; i < IR_LEN; i++) if (Math.abs(buf[i]) > peak) peak = Math.abs(buf[i]);
for (let i = 0; i < IR_LEN; i++) buf[i] /= peak;
return buf;
}
const dryData = buildDryClick();
const irData = buildCathedralIR();
/* Draw a signal's waveform onto the canvas */
function drawWave(data, color, label) {
const cvs = document.getElementById('canvas');
const ctx = cvs.getContext('2d');
const W = cvs.width, H = cvs.height;
ctx.clearRect(0, 0, W, H);
/* Clear background */
ctx.fillStyle = '#f7f9fb';
ctx.fillRect(0, 0, W, H);
/* Horizontal center line */
ctx.strokeStyle = '#dde4ea';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, H / 2); ctx.lineTo(W, H / 2); ctx.stroke();
/* Draw the waveform by sampling at canvas pixel resolution */
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.beginPath();
const step = Math.max(1, Math.floor(data.length / W));
for (let x = 0; x < W; x++) {
const idx = Math.min(Math.floor(x * data.length / W), data.length - 1);
let maxV = 0;
for (let s = idx; s < Math.min(idx + step, data.length); s++) {
if (Math.abs(data[s]) > Math.abs(maxV)) maxV = data[s];
}
const y = H / 2 - maxV * (H / 2 - 4);
x === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.stroke();
document.getElementById('lbl-signal').textContent = label;
}
/* Play a Web Audio AudioBuffer through the default output */
function playBuffer(audioBuffer, ctx) {
const src = ctx.createBufferSource();
src.buffer = audioBuffer;
src.connect(ctx.destination);
src.start();
}
let audioCtx = null;
function getAudioCtx() {
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: SAMPLE_RATE });
if (audioCtx.state === 'suspended') audioCtx.resume();
return audioCtx;
}
/* Play the dry click without any reverb */
function playDry() {
const ctx = getAudioCtx();
const buf = ctx.createBuffer(1, dryData.length, SAMPLE_RATE);
buf.copyToChannel(dryData, 0);
playBuffer(buf, ctx);
drawWave(dryData, '#1d3557', 'Dry click waveform');
document.getElementById('status').textContent = 'Playing dry click — over in milliseconds.';
document.getElementById('btn-dry').classList.add('active');
document.getElementById('btn-wet').classList.remove('active');
}
/* Convolve the dry click with the cathedral IR using ConvolverNode (FFT internally) */
function playWet() {
const ctx = getAudioCtx();
/* Load the impulse response into a ConvolverNode — the browser FFT-convolves it with the source */
const irBuf = ctx.createBuffer(1, irData.length, SAMPLE_RATE);
irBuf.copyToChannel(irData, 0);
const dryBuf = ctx.createBuffer(1, dryData.length, SAMPLE_RATE);
dryBuf.copyToChannel(dryData, 0);
const src = ctx.createBufferSource();
src.buffer = dryBuf;
/* ConvolverNode uses partitioned FFT convolution internally */
const conv = ctx.createConvolver();
conv.normalize = false;
conv.buffer = irBuf;
const gain = ctx.createGain();
gain.gain.value = 0.4;
src.connect(conv);
conv.connect(gain);
gain.connect(ctx.destination);
src.start();
/* Compute a visual approximation of the convolved output for the canvas */
const wetPreview = new Float32Array(IR_LEN);
const dLen = dryData.length;
/* Direct convolution for waveform preview (short dry signal keeps this fast) */
for (let i = 0; i < IR_LEN; i++) {
let sum = 0;
for (let j = 0; j < dLen && j <= i; j++) sum += dryData[j] * irData[i - j];
wetPreview[i] = sum;
}
/* Normalize preview waveform */
let pk = 0;
for (let i = 0; i < wetPreview.length; i++) if (Math.abs(wetPreview[i]) > pk) pk = Math.abs(wetPreview[i]);
if (pk > 0) for (let i = 0; i < wetPreview.length; i++) wetPreview[i] /= pk;
drawWave(wetPreview, '#e63946', 'Convolved reverb tail (2.5 s)');
document.getElementById('status').textContent = 'Playing convolved reverb — the FFT did the heavy lifting.';
document.getElementById('btn-wet').classList.add('active');
document.getElementById('btn-dry').classList.remove('active');
}
document.getElementById('btn-dry').addEventListener('click', playDry);
document.getElementById('btn-wet').addEventListener('click', playWet);
/* Show dry waveform on load */
drawWave(dryData, '#1d3557', 'Dry click waveform');
<!-- Demo layout: hint, two buttons (dry / wet), waveform canvas, status line -->
<p class="hint">A dry click (impulse) vs its cathedral reverb — computed by convolving the click with a 2.5-second room impulse response. The Web Audio API uses FFT-based convolution internally. Click a button, hear the difference, and watch the waveform change.</p>
<div class="controls">
<button id="btn-dry" type="button">Play Dry Click</button>
<button id="btn-wet" type="button">Play Reverb</button>
</div>
<div class="label-row">
<span id="lbl-signal">Dry click waveform</span>
</div>
<canvas id="canvas" width="560" height="180"></canvas>
<div class="status" id="status">Press a button to play and visualize.</div>
/* Styles: hint text, button row, canvas border, status line */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: .4rem; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .8rem; line-height: 1.45; }
.controls { display: flex; gap: .5rem; flex-wrap: wrap; margin-bottom: .5rem; }
button {
font: 600 14px system-ui, sans-serif;
padding: .45rem 1rem;
border: 1px solid #1d3557;
background: #1d3557;
color: #fff;
border-radius: 8px;
cursor: pointer;
transition: background .15s;
}
button:hover { background: #2a4a70; }
button.active { background: #e63946; border-color: #c92f3c; }
.label-row { font-size: .82rem; font-weight: 600; color: #555; margin-bottom: .3rem; min-height: 1.1em; }
canvas { width: 100%; max-width: 560px; height: 180px; display: block;
border: 1px solid #cdd9e3; border-radius: 8px; background: #f7f9fb; }
.status { font-size: .9rem; font-weight: 600; margin-top: .5rem; min-height: 1.4em; color: #1d3557; }
/* Convolution reverb demo — synthesize dry click + cathedral IR, play via Web Audio ConvolverNode */
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
const SAMPLE_RATE = 44100;
/* Cathedral impulse response: 2.5 seconds at 44100 Hz */
const IR_LEN = Math.round(SAMPLE_RATE * 2.5);
/* Dry click: 50 ms */
const CLICK_LEN = Math.round(SAMPLE_RATE * 0.05);
/* Build a short percussive click (impulse + short exponential tail) */
function buildDryClick() {
const buf = new Float32Array(CLICK_LEN);
buf[0] = 1.0;
/* Short exponential decay to smooth the click */
for (let i = 1; i < 80; i++) buf[i] = Math.exp(-i * 0.15) * 0.4;
return buf;
}
/* Build a synthetic cathedral impulse response (exponentially decaying noise + early reflections) */
function buildCathedralIR() {
const buf = new Float32Array(IR_LEN);
/* Deterministic pseudo-random noise seeded by sample index */
const rand = (s) => { let x = Math.sin(s * 9301 + 49297) * 233280; return x - Math.floor(x); };
for (let i = 0; i < IR_LEN; i++) {
const t = i / SAMPLE_RATE;
/* Exponential decay envelope: RT60 ~ 1/1.4 * ln(1000) ~ 4.9 s */
const decay = Math.exp(-t * 1.4);
/* Early reflections boost within first ~18 ms */
const early = (i < 800 ? 1.0 : 0.0) * Math.exp(-i * 0.004);
buf[i] = (rand(i) * 2 - 1) * decay + early * 0.5;
}
/* Normalize IR to peak = 1 */
let peak = 0;
for (let i = 0; i < IR_LEN; i++) if (Math.abs(buf[i]) > peak) peak = Math.abs(buf[i]);
for (let i = 0; i < IR_LEN; i++) buf[i] /= peak;
return buf;
}
const dryData = buildDryClick();
const irData = buildCathedralIR();
/* Draw a signal's waveform onto the canvas */
function drawWave(data, color, label) {
const cvs = document.getElementById('canvas');
const ctx = cvs.getContext('2d');
const W = cvs.width, H = cvs.height;
ctx.clearRect(0, 0, W, H);
/* Clear background */
ctx.fillStyle = '#f7f9fb';
ctx.fillRect(0, 0, W, H);
/* Horizontal center line */
ctx.strokeStyle = '#dde4ea';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, H / 2); ctx.lineTo(W, H / 2); ctx.stroke();
/* Draw the waveform by sampling at canvas pixel resolution */
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.beginPath();
const step = Math.max(1, Math.floor(data.length / W));
for (let x = 0; x < W; x++) {
const idx = Math.min(Math.floor(x * data.length / W), data.length - 1);
let maxV = 0;
for (let s = idx; s < Math.min(idx + step, data.length); s++) {
if (Math.abs(data[s]) > Math.abs(maxV)) maxV = data[s];
}
const y = H / 2 - maxV * (H / 2 - 4);
x === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.stroke();
document.getElementById('lbl-signal').textContent = label;
}
/* Play a Web Audio AudioBuffer through the default output */
function playBuffer(audioBuffer, ctx) {
const src = ctx.createBufferSource();
src.buffer = audioBuffer;
src.connect(ctx.destination);
src.start();
}
let audioCtx = null;
function getAudioCtx() {
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: SAMPLE_RATE });
if (audioCtx.state === 'suspended') audioCtx.resume();
return audioCtx;
}
/* Play the dry click without any reverb */
function playDry() {
const ctx = getAudioCtx();
const buf = ctx.createBuffer(1, dryData.length, SAMPLE_RATE);
buf.copyToChannel(dryData, 0);
playBuffer(buf, ctx);
drawWave(dryData, '#1d3557', 'Dry click waveform');
document.getElementById('status').textContent = 'Playing dry click — over in milliseconds.';
document.getElementById('btn-dry').classList.add('active');
document.getElementById('btn-wet').classList.remove('active');
}
/* Convolve the dry click with the cathedral IR using ConvolverNode (FFT internally) */
function playWet() {
const ctx = getAudioCtx();
/* Load the impulse response into a ConvolverNode — the browser FFT-convolves it with the source */
const irBuf = ctx.createBuffer(1, irData.length, SAMPLE_RATE);
irBuf.copyToChannel(irData, 0);
const dryBuf = ctx.createBuffer(1, dryData.length, SAMPLE_RATE);
dryBuf.copyToChannel(dryData, 0);
const src = ctx.createBufferSource();
src.buffer = dryBuf;
/* ConvolverNode uses partitioned FFT convolution internally */
const conv = ctx.createConvolver();
conv.normalize = false;
conv.buffer = irBuf;
const gain = ctx.createGain();
gain.gain.value = 0.4;
src.connect(conv);
conv.connect(gain);
gain.connect(ctx.destination);
src.start();
/* Compute a visual approximation of the convolved output for the canvas */
const wetPreview = new Float32Array(IR_LEN);
const dLen = dryData.length;
/* Direct convolution for waveform preview (short dry signal keeps this fast) */
for (let i = 0; i < IR_LEN; i++) {
let sum = 0;
for (let j = 0; j < dLen && j <= i; j++) sum += dryData[j] * irData[i - j];
wetPreview[i] = sum;
}
/* Normalize preview waveform */
let pk = 0;
for (let i = 0; i < wetPreview.length; i++) if (Math.abs(wetPreview[i]) > pk) pk = Math.abs(wetPreview[i]);
if (pk > 0) for (let i = 0; i < wetPreview.length; i++) wetPreview[i] /= pk;
drawWave(wetPreview, '#e63946', 'Convolved reverb tail (2.5 s)');
document.getElementById('status').textContent = 'Playing convolved reverb — the FFT did the heavy lifting.';
document.getElementById('btn-wet').classList.add('active');
document.getElementById('btn-dry').classList.remove('active');
}
document.getElementById('btn-dry').addEventListener('click', playDry);
document.getElementById('btn-wet').addEventListener('click', playWet);
/* Show dry waveform on load */
drawWave(dryData, '#1d3557', 'Dry click waveform');
Notice how the dry click is over in milliseconds while the reverb tail rings out for seconds. Every sample of that tail was produced by a single pointwise multiplication in the frequency domain — the FFT did the heavy lifting so you don't have to wait for a billion direct multiply-and-add operations.
Comments
Loading comments...