The demo below synthesises a short chord, runs it through a simplified phase vocoder, and lets you choose how much to stretch or compress it. The waveform display shows both the original and the time-scaled output.
<!-- Demo HTML layout for the phase vocoder interactive widget -->
<p class="hint">Adjust the stretch factor, then click <b>Run Phase Vocoder</b> to process a synthesised C-major chord (C4-E4-G4). The blue waveform is the original (1 second); the red is the time-scaled output. Click <b>Play output</b> to hear it — pitch stays the same even as duration changes.</p>
<div class="controls">
<label>Stretch / compress factor: <strong id="stretchVal">1.0×</strong>
<input type="range" id="stretchSlider" min="0.25" max="2.0" step="0.05" value="1.0">
</label>
</div>
<div class="canvas-wrap">
<div class="canvas-label">Original signal (1 s)</div>
<canvas id="origCanvas" width="560" height="100"></canvas>
<div class="canvas-label">Output signal (time-scaled)</div>
<canvas id="outCanvas" width="560" height="100"></canvas>
</div>
<div class="status" id="status">Adjust the slider and click Run.</div>
<div class="btns">
<button id="btnRun" type="button">Run Phase Vocoder</button>
<button id="btnPlay" type="button" class="ghost" disabled>Play output</button>
<button id="btnReset" type="button" class="ghost">Reset</button>
</div>
/* Styles for the phase vocoder demo widget */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: .5rem; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
.controls { margin-bottom: .6rem; }
label { display: flex; flex-direction: column; gap: .25rem; font-size: .9rem; font-weight: 600; }
input[type=range] { width: 100%; max-width: 400px; cursor: pointer; }
.canvas-wrap { display: flex; flex-direction: column; gap: .25rem; margin: .5rem 0; }
.canvas-label { font-size: .78rem; font-weight: 700; color: #1d3557; text-transform: uppercase; letter-spacing: .04em; }
canvas { width: 100%; height: 100px; border-radius: 8px; background: #e8eef3; display: block; }
.status { font-size: .95rem; font-weight: 600; min-height: 1.4em; margin: .4rem 0; color: #1d3557; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem;
border: 1px solid #1d3557; background: #1d3557; color: #fff;
border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .45; cursor: not-allowed; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Phase vocoder parameters
const SR = 22050; // sample rate (Hz)
const DURATION = 1.0; // source duration (seconds)
const N = 1024; // DFT frame size (kept small so the browser does not freeze)
const HOP_A = N / 4; // analysis hop = N/4 (75% overlap)
// Synthesise a C-major chord: C4, E4, G4
// frequencies in Hz
const FREQS = [261.63, 329.63, 392.00];
function synthesise() {
// Mix three sine waves at equal amplitude
const len = Math.round(SR * DURATION);
const buf = new Float32Array(len);
for (let i = 0; i < len; i++) {
const t = i / SR;
for (const f of FREQS) buf[i] += (1 / FREQS.length) * Math.sin(2 * Math.PI * f * t);
}
return buf;
}
// Hann window (reduces spectral leakage at frame edges)
function hann(n) {
const w = new Float32Array(n);
for (let i = 0; i < n; i++) w[i] = 0.5 * (1 - Math.cos(2 * Math.PI * i / (n - 1)));
return w;
}
// Naive DFT — O(N^2) but fine for the small N=1024 used here
function dft(real, imag) {
const n = real.length;
const re = new Float32Array(n), im = new Float32Array(n);
for (let k = 0; k < n; k++) {
for (let t = 0; t < n; t++) {
const ang = -2 * Math.PI * k * t / n;
re[k] += real[t] * Math.cos(ang) - imag[t] * Math.sin(ang);
im[k] += real[t] * Math.sin(ang) + imag[t] * Math.cos(ang);
}
}
return { re, im };
}
// Inverse DFT
function idft(re, im) {
const n = re.length;
const real = new Float32Array(n), imag = new Float32Array(n);
for (let t = 0; t < n; t++) {
for (let k = 0; k < n; k++) {
const ang = 2 * Math.PI * k * t / n;
real[t] += re[k] * Math.cos(ang) - im[k] * Math.sin(ang);
imag[t] += re[k] * Math.sin(ang) + im[k] * Math.cos(ang);
}
real[t] /= n;
imag[t] /= n;
}
return real;
}
// Wrap angle to the interval (-pi, pi]
function principalAngle(a) {
return ((a % (2 * Math.PI)) + 3 * Math.PI) % (2 * Math.PI) - Math.PI;
}
// Phase vocoder time-scale modification
function phaseVocoder(input, alpha) {
const W = hann(N);
const hopA = HOP_A;
const hopS = Math.round(hopA * alpha);
const numFrames = Math.floor((input.length - N) / hopA);
const outLen = (numFrames * hopS) + N;
const output = new Float32Array(outLen);
const phaseAcc = new Float32Array(N);
let prevPhase = null;
for (let frame = 0; frame < numFrames; frame++) {
// Extract and window one analysis frame
const inOffset = frame * hopA;
const re = new Float32Array(N), im = new Float32Array(N);
for (let i = 0; i < N; i++) re[i] = (input[inOffset + i] || 0) * W[i];
// Transform to frequency domain
const spec = dft(re, im);
const mag = new Float32Array(N);
const phase = new Float32Array(N);
for (let k = 0; k < N; k++) {
mag[k] = Math.sqrt(spec.re[k] * spec.re[k] + spec.im[k] * spec.im[k]);
phase[k] = Math.atan2(spec.im[k], spec.re[k]);
}
if (frame === 0) {
// First frame — seed the phase accumulator directly
for (let k = 0; k < N; k++) phaseAcc[k] = phase[k];
prevPhase = phase.slice();
} else {
// Compute instantaneous frequency and advance synthesis phase
for (let k = 0; k < N; k++) {
const expectedAdv = 2 * Math.PI * k * hopA / N;
const delta = principalAngle(phase[k] - prevPhase[k] - expectedAdv);
const instFreq = 2 * Math.PI * k / N + delta / hopA;
phaseAcc[k] += instFreq * hopS;
}
prevPhase = phase.slice();
}
// Reconstruct from magnitudes + accumulated phase
const outRe = new Float32Array(N), outIm = new Float32Array(N);
for (let k = 0; k < N; k++) {
outRe[k] = mag[k] * Math.cos(phaseAcc[k]);
outIm[k] = mag[k] * Math.sin(phaseAcc[k]);
}
// Convert back to time domain and overlap-add
const rebuilt = idft(outRe, outIm);
const outOffset = frame * hopS;
const norm = 1.5;
for (let i = 0; i < N; i++) {
output[outOffset + i] += rebuilt[i] * W[i] / norm;
}
}
return output;
}
// Draw a waveform buffer onto a canvas element
function drawWave(canvas, buf, color) {
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = '#e8eef3';
ctx.fillRect(0, 0, W, H);
ctx.beginPath();
ctx.strokeStyle = color;
ctx.lineWidth = 1.2;
const step = buf.length / W;
let maxAmp = 0;
for (let i = 0; i < buf.length; i++) if (Math.abs(buf[i]) > maxAmp) maxAmp = Math.abs(buf[i]);
if (maxAmp < 0.001) maxAmp = 1;
for (let x = 0; x < W; x++) {
const idx = Math.floor(x * step);
const y = H / 2 - (buf[idx] / maxAmp) * (H / 2 - 4);
x === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.stroke();
}
let origBuf = null, outBuf = null;
// Play a Float32Array through the Web Audio API
function playBuffer(floatBuf) {
const ac = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: SR });
const ab = ac.createBuffer(1, floatBuf.length, SR);
ab.copyToChannel(floatBuf, 0);
const src = ac.createBufferSource();
src.buffer = ab;
src.connect(ac.destination);
src.start();
}
const stretchSlider = document.getElementById('stretchSlider');
const stretchValEl = document.getElementById('stretchVal');
const statusEl = document.getElementById('status');
const btnRun = document.getElementById('btnRun');
const btnPlay = document.getElementById('btnPlay');
const btnReset = document.getElementById('btnReset');
const origCanvas = document.getElementById('origCanvas');
const outCanvas = document.getElementById('outCanvas');
stretchSlider.addEventListener('input', () => {
stretchValEl.textContent = parseFloat(stretchSlider.value).toFixed(2) + '×';
});
btnRun.addEventListener('click', () => {
const alpha = parseFloat(stretchSlider.value);
statusEl.textContent = i18nFmt('Running phase vocoder at %alpha%× …', { alpha: alpha.toFixed(2) });
statusEl.className = 'status';
btnRun.disabled = true;
btnPlay.disabled = true;
// Defer heavy work so the browser can repaint the status first
setTimeout(() => {
try {
origBuf = synthesise();
drawWave(origCanvas, origBuf, '#1d3557');
outBuf = phaseVocoder(origBuf, alpha);
drawWave(outCanvas, outBuf, '#e63946');
const ratio = (outBuf.length / origBuf.length).toFixed(2);
statusEl.textContent = i18nFmt('Done — stretch %alpha%× · output length ratio: %ratio%', { alpha: alpha.toFixed(2), ratio });
statusEl.className = 'status ok';
btnPlay.disabled = false;
} catch (e) {
statusEl.textContent = 'Something went wrong. Try again.';
statusEl.className = 'status bad';
}
btnRun.disabled = false;
}, 30);
});
btnPlay.addEventListener('click', () => {
if (outBuf) playBuffer(outBuf);
});
btnReset.addEventListener('click', () => {
origBuf = null; outBuf = null;
const ctx1 = origCanvas.getContext('2d'); ctx1.clearRect(0, 0, origCanvas.width, origCanvas.height);
ctx1.fillStyle = '#e8eef3'; ctx1.fillRect(0, 0, origCanvas.width, origCanvas.height);
const ctx2 = outCanvas.getContext('2d'); ctx2.clearRect(0, 0, outCanvas.width, outCanvas.height);
ctx2.fillStyle = '#e8eef3'; ctx2.fillRect(0, 0, outCanvas.width, outCanvas.height);
statusEl.textContent = 'Adjust the slider and click Run.';
statusEl.className = 'status';
btnPlay.disabled = true;
stretchSlider.value = '1.0';
stretchValEl.textContent = '1.00×';
});
// Paint blank canvases on load
(function init() {
const ctx1 = origCanvas.getContext('2d');
ctx1.fillStyle = '#e8eef3'; ctx1.fillRect(0, 0, origCanvas.width, origCanvas.height);
const ctx2 = outCanvas.getContext('2d');
ctx2.fillStyle = '#e8eef3'; ctx2.fillRect(0, 0, outCanvas.width, outCanvas.height);
})();
<!-- Demo HTML layout for the phase vocoder interactive widget -->
<p class="hint">Adjust the stretch factor, then click <b>Run Phase Vocoder</b> to process a synthesised C-major chord (C4-E4-G4). The blue waveform is the original (1 second); the red is the time-scaled output. Click <b>Play output</b> to hear it — pitch stays the same even as duration changes.</p>
<div class="controls">
<label>Stretch / compress factor: <strong id="stretchVal">1.0×</strong>
<input type="range" id="stretchSlider" min="0.25" max="2.0" step="0.05" value="1.0">
</label>
</div>
<div class="canvas-wrap">
<div class="canvas-label">Original signal (1 s)</div>
<canvas id="origCanvas" width="560" height="100"></canvas>
<div class="canvas-label">Output signal (time-scaled)</div>
<canvas id="outCanvas" width="560" height="100"></canvas>
</div>
<div class="status" id="status">Adjust the slider and click Run.</div>
<div class="btns">
<button id="btnRun" type="button">Run Phase Vocoder</button>
<button id="btnPlay" type="button" class="ghost" disabled>Play output</button>
<button id="btnReset" type="button" class="ghost">Reset</button>
</div>
/* Styles for the phase vocoder demo widget */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: .5rem; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
.controls { margin-bottom: .6rem; }
label { display: flex; flex-direction: column; gap: .25rem; font-size: .9rem; font-weight: 600; }
input[type=range] { width: 100%; max-width: 400px; cursor: pointer; }
.canvas-wrap { display: flex; flex-direction: column; gap: .25rem; margin: .5rem 0; }
.canvas-label { font-size: .78rem; font-weight: 700; color: #1d3557; text-transform: uppercase; letter-spacing: .04em; }
canvas { width: 100%; height: 100px; border-radius: 8px; background: #e8eef3; display: block; }
.status { font-size: .95rem; font-weight: 600; min-height: 1.4em; margin: .4rem 0; color: #1d3557; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem;
border: 1px solid #1d3557; background: #1d3557; color: #fff;
border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .45; cursor: not-allowed; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Phase vocoder parameters
const SR = 22050; // sample rate (Hz)
const DURATION = 1.0; // source duration (seconds)
const N = 1024; // DFT frame size (kept small so the browser does not freeze)
const HOP_A = N / 4; // analysis hop = N/4 (75% overlap)
// Synthesise a C-major chord: C4, E4, G4
// frequencies in Hz
const FREQS = [261.63, 329.63, 392.00];
function synthesise() {
// Mix three sine waves at equal amplitude
const len = Math.round(SR * DURATION);
const buf = new Float32Array(len);
for (let i = 0; i < len; i++) {
const t = i / SR;
for (const f of FREQS) buf[i] += (1 / FREQS.length) * Math.sin(2 * Math.PI * f * t);
}
return buf;
}
// Hann window (reduces spectral leakage at frame edges)
function hann(n) {
const w = new Float32Array(n);
for (let i = 0; i < n; i++) w[i] = 0.5 * (1 - Math.cos(2 * Math.PI * i / (n - 1)));
return w;
}
// Naive DFT — O(N^2) but fine for the small N=1024 used here
function dft(real, imag) {
const n = real.length;
const re = new Float32Array(n), im = new Float32Array(n);
for (let k = 0; k < n; k++) {
for (let t = 0; t < n; t++) {
const ang = -2 * Math.PI * k * t / n;
re[k] += real[t] * Math.cos(ang) - imag[t] * Math.sin(ang);
im[k] += real[t] * Math.sin(ang) + imag[t] * Math.cos(ang);
}
}
return { re, im };
}
// Inverse DFT
function idft(re, im) {
const n = re.length;
const real = new Float32Array(n), imag = new Float32Array(n);
for (let t = 0; t < n; t++) {
for (let k = 0; k < n; k++) {
const ang = 2 * Math.PI * k * t / n;
real[t] += re[k] * Math.cos(ang) - im[k] * Math.sin(ang);
imag[t] += re[k] * Math.sin(ang) + im[k] * Math.cos(ang);
}
real[t] /= n;
imag[t] /= n;
}
return real;
}
// Wrap angle to the interval (-pi, pi]
function principalAngle(a) {
return ((a % (2 * Math.PI)) + 3 * Math.PI) % (2 * Math.PI) - Math.PI;
}
// Phase vocoder time-scale modification
function phaseVocoder(input, alpha) {
const W = hann(N);
const hopA = HOP_A;
const hopS = Math.round(hopA * alpha);
const numFrames = Math.floor((input.length - N) / hopA);
const outLen = (numFrames * hopS) + N;
const output = new Float32Array(outLen);
const phaseAcc = new Float32Array(N);
let prevPhase = null;
for (let frame = 0; frame < numFrames; frame++) {
// Extract and window one analysis frame
const inOffset = frame * hopA;
const re = new Float32Array(N), im = new Float32Array(N);
for (let i = 0; i < N; i++) re[i] = (input[inOffset + i] || 0) * W[i];
// Transform to frequency domain
const spec = dft(re, im);
const mag = new Float32Array(N);
const phase = new Float32Array(N);
for (let k = 0; k < N; k++) {
mag[k] = Math.sqrt(spec.re[k] * spec.re[k] + spec.im[k] * spec.im[k]);
phase[k] = Math.atan2(spec.im[k], spec.re[k]);
}
if (frame === 0) {
// First frame — seed the phase accumulator directly
for (let k = 0; k < N; k++) phaseAcc[k] = phase[k];
prevPhase = phase.slice();
} else {
// Compute instantaneous frequency and advance synthesis phase
for (let k = 0; k < N; k++) {
const expectedAdv = 2 * Math.PI * k * hopA / N;
const delta = principalAngle(phase[k] - prevPhase[k] - expectedAdv);
const instFreq = 2 * Math.PI * k / N + delta / hopA;
phaseAcc[k] += instFreq * hopS;
}
prevPhase = phase.slice();
}
// Reconstruct from magnitudes + accumulated phase
const outRe = new Float32Array(N), outIm = new Float32Array(N);
for (let k = 0; k < N; k++) {
outRe[k] = mag[k] * Math.cos(phaseAcc[k]);
outIm[k] = mag[k] * Math.sin(phaseAcc[k]);
}
// Convert back to time domain and overlap-add
const rebuilt = idft(outRe, outIm);
const outOffset = frame * hopS;
const norm = 1.5;
for (let i = 0; i < N; i++) {
output[outOffset + i] += rebuilt[i] * W[i] / norm;
}
}
return output;
}
// Draw a waveform buffer onto a canvas element
function drawWave(canvas, buf, color) {
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = '#e8eef3';
ctx.fillRect(0, 0, W, H);
ctx.beginPath();
ctx.strokeStyle = color;
ctx.lineWidth = 1.2;
const step = buf.length / W;
let maxAmp = 0;
for (let i = 0; i < buf.length; i++) if (Math.abs(buf[i]) > maxAmp) maxAmp = Math.abs(buf[i]);
if (maxAmp < 0.001) maxAmp = 1;
for (let x = 0; x < W; x++) {
const idx = Math.floor(x * step);
const y = H / 2 - (buf[idx] / maxAmp) * (H / 2 - 4);
x === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.stroke();
}
let origBuf = null, outBuf = null;
// Play a Float32Array through the Web Audio API
function playBuffer(floatBuf) {
const ac = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: SR });
const ab = ac.createBuffer(1, floatBuf.length, SR);
ab.copyToChannel(floatBuf, 0);
const src = ac.createBufferSource();
src.buffer = ab;
src.connect(ac.destination);
src.start();
}
const stretchSlider = document.getElementById('stretchSlider');
const stretchValEl = document.getElementById('stretchVal');
const statusEl = document.getElementById('status');
const btnRun = document.getElementById('btnRun');
const btnPlay = document.getElementById('btnPlay');
const btnReset = document.getElementById('btnReset');
const origCanvas = document.getElementById('origCanvas');
const outCanvas = document.getElementById('outCanvas');
stretchSlider.addEventListener('input', () => {
stretchValEl.textContent = parseFloat(stretchSlider.value).toFixed(2) + '×';
});
btnRun.addEventListener('click', () => {
const alpha = parseFloat(stretchSlider.value);
statusEl.textContent = i18nFmt('Running phase vocoder at %alpha%× …', { alpha: alpha.toFixed(2) });
statusEl.className = 'status';
btnRun.disabled = true;
btnPlay.disabled = true;
// Defer heavy work so the browser can repaint the status first
setTimeout(() => {
try {
origBuf = synthesise();
drawWave(origCanvas, origBuf, '#1d3557');
outBuf = phaseVocoder(origBuf, alpha);
drawWave(outCanvas, outBuf, '#e63946');
const ratio = (outBuf.length / origBuf.length).toFixed(2);
statusEl.textContent = i18nFmt('Done — stretch %alpha%× · output length ratio: %ratio%', { alpha: alpha.toFixed(2), ratio });
statusEl.className = 'status ok';
btnPlay.disabled = false;
} catch (e) {
statusEl.textContent = 'Something went wrong. Try again.';
statusEl.className = 'status bad';
}
btnRun.disabled = false;
}, 30);
});
btnPlay.addEventListener('click', () => {
if (outBuf) playBuffer(outBuf);
});
btnReset.addEventListener('click', () => {
origBuf = null; outBuf = null;
const ctx1 = origCanvas.getContext('2d'); ctx1.clearRect(0, 0, origCanvas.width, origCanvas.height);
ctx1.fillStyle = '#e8eef3'; ctx1.fillRect(0, 0, origCanvas.width, origCanvas.height);
const ctx2 = outCanvas.getContext('2d'); ctx2.clearRect(0, 0, outCanvas.width, outCanvas.height);
ctx2.fillStyle = '#e8eef3'; ctx2.fillRect(0, 0, outCanvas.width, outCanvas.height);
statusEl.textContent = 'Adjust the slider and click Run.';
statusEl.className = 'status';
btnPlay.disabled = true;
stretchSlider.value = '1.0';
stretchValEl.textContent = '1.00×';
});
// Paint blank canvases on load
(function init() {
const ctx1 = origCanvas.getContext('2d');
ctx1.fillStyle = '#e8eef3'; ctx1.fillRect(0, 0, origCanvas.width, origCanvas.height);
const ctx2 = outCanvas.getContext('2d');
ctx2.fillStyle = '#e8eef3'; ctx2.fillRect(0, 0, outCanvas.width, outCanvas.height);
})();
Comments
Loading comments...