<!-- PSOLA pitch-correction demo -->
<p class="hint">Each red bar is one pitch grain from the input voice. The dashed lines are scale pitches. Press <b>Snap to Scale</b> to relocate every grain to the nearest in-tune note — that is PSOLA.</p>
<div class="controls">
<label>Scale:
<select id="scale-select">
<option value="major">C major</option>
<option value="minor">A minor</option>
<option value="pentatonic">C pentatonic</option>
</select>
</label>
<label>Correction speed:
<input id="speed-range" type="range" min="1" max="10" value="5">
</label>
</div>
<canvas id="pitch-canvas" width="560" height="200"></canvas>
<div class="status" id="status">Press Snap to Scale to correct pitch.</div>
<div class="btns">
<button id="btn-snap" type="button">Snap to Scale</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
/* Layout and colors for the PSOLA 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; gap: 1.2rem; flex-wrap: wrap; margin-bottom: .6rem; align-items: center; }
label { font-size: .85rem; color: #555; display: flex; align-items: center; gap: .4rem; }
select, input[type=range] { font-size: .85rem; }
canvas { display: block; width: 100%; max-width: 560px; border: 1px solid #cdd9e3;
border-radius: 8px; background: #f5f8fa; }
.status { font-size: 1rem; font-weight: 600; margin: .5rem 0; min-height: 1.4em; color: #1d3557; }
.status.done { color: #0a7d33; }
.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; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// PSOLA core: detect pitch marks, relocate grains to match target pitch,
// overlap-add with a smooth window. Here we simulate the grain-relocation
// step visually — each bar is one grain plotted at its MIDI pitch.
const canvas = document.getElementById('pitch-canvas');
const ctx = canvas.getContext('2d');
const statusEl = document.getElementById('status');
const scaleSelect = document.getElementById('scale-select');
const speedRange = document.getElementById('speed-range');
// Frequency helpers: MIDI note 69 = A4 = 440 Hz
const A4 = 440;
function midiToHz(m) { return A4 * Math.pow(2, (m - 69) / 12); }
function hzToMidi(f) { return 69 + 12 * Math.log2(f / A4); }
// Scale definitions as semitone intervals within an octave (root = 0)
const SCALES = {
major: [0, 2, 4, 5, 7, 9, 11],
minor: [0, 2, 3, 5, 7, 8, 10],
pentatonic: [0, 2, 4, 7, 9]
};
const NUM_GRAINS = 24;
const MIN_MIDI = 57; // A3 to A5, a comfortable vocal range
const MAX_MIDI = 81;
// State — input pitches, snapped output pitches, snapped flag
let inputPitches = [];
let outputPitches = [];
let snapped = false;
function randomPitches() {
// Generate a smoothly wandering pitch curve within the vocal range
const pitches = [];
let cur = 65 + (Math.random() - 0.5) * 4;
for (let i = 0; i < NUM_GRAINS; i++) {
cur += (Math.random() - 0.5) * 3;
cur = Math.max(MIN_MIDI, Math.min(MAX_MIDI, cur));
pitches.push(cur);
}
return pitches;
}
function nearestScalePitch(midi, scaleName) {
// Find the nearest scale note to the given MIDI pitch
const semitones = SCALES[scaleName];
const octave = Math.floor(midi / 12);
let best = Infinity, bestMidi = midi;
for (let o = octave - 1; o <= octave + 1; o++) {
for (const s of semitones) {
const candidate = o * 12 + s;
const dist = Math.abs(candidate - midi);
if (dist < best) { best = dist; bestMidi = candidate; }
}
}
return bestMidi;
}
function midiToY(midi) {
return canvas.height - ((midi - MIN_MIDI) / (MAX_MIDI - MIN_MIDI)) * (canvas.height - 20) - 10;
}
function drawScaleLines(scaleName) {
// Draw horizontal dashed lines at each scale pitch
ctx.save();
ctx.strokeStyle = '#b8d0e8';
ctx.lineWidth = 0.8;
ctx.setLineDash([4, 4]);
const semitones = SCALES[scaleName];
for (let midi = MIN_MIDI; midi <= MAX_MIDI; midi++) {
const rel = midi % 12;
if (semitones.includes(rel)) {
const y = midiToY(midi);
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(canvas.width, y);
ctx.stroke();
}
}
ctx.setLineDash([]);
ctx.restore();
}
function drawGrains(pitches, color, alpha) {
// Draw vertical grain bars at their respective pitches
const gw = (canvas.width - 20) / NUM_GRAINS;
ctx.save();
ctx.globalAlpha = alpha;
ctx.fillStyle = color;
for (let i = 0; i < pitches.length; i++) {
const x = 10 + i * gw + gw * 0.15;
const y = midiToY(pitches[i]);
const h = Math.max(3, gw * 0.7);
ctx.fillRect(x, y - 2, h, 4);
}
ctx.restore();
}
function drawCurve(pitches, color, alpha) {
// Draw a smooth curve connecting grain pitch positions
const gw = (canvas.width - 20) / NUM_GRAINS;
ctx.save();
ctx.globalAlpha = alpha;
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.beginPath();
for (let i = 0; i < pitches.length; i++) {
const x = 10 + i * gw + gw * 0.5;
const y = midiToY(pitches[i]);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
ctx.restore();
}
function render() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawScaleLines(scaleSelect.value);
// Draw the original (input) pitch curve in red
drawCurve(inputPitches, '#c92f3c', 0.55);
drawGrains(inputPitches, '#c92f3c', 0.7);
if (snapped && outputPitches.length) {
// Draw the snapped (output) pitch curve in green
drawCurve(outputPitches, '#0a7d33', 0.8);
drawGrains(outputPitches, '#0a7d33', 0.9);
}
}
function doSnap() {
// Snap each grain to the nearest scale pitch, blended by speed
const speed = parseInt(speedRange.value, 10);
const scaleName = scaleSelect.value;
outputPitches = inputPitches.map(p => {
const target = nearestScalePitch(p, scaleName);
// Speed 10 = full snap (robotic), speed 1 = gentle nudge (transparent)
const t = speed / 10;
return p + (target - p) * t;
});
snapped = true;
render();
const maxErr = Math.max(...inputPitches.map((p, i) => Math.abs(p - outputPitches[i])));
statusEl.textContent = i18nFmt('Snapped %n% grains — max shift %err% semitones.', { n: NUM_GRAINS, err: maxErr.toFixed(1) });
statusEl.className = 'status done';
}
function reset() {
inputPitches = randomPitches();
outputPitches = [];
snapped = false;
statusEl.textContent = 'Press Snap to Scale to correct pitch.';
statusEl.className = 'status';
render();
}
document.getElementById('btn-snap').addEventListener('click', doSnap);
document.getElementById('btn-reset').addEventListener('click', reset);
scaleSelect.addEventListener('change', () => { if (snapped) doSnap(); else render(); });
speedRange.addEventListener('input', () => { if (snapped) doSnap(); });
reset();
<!-- PSOLA pitch-correction demo -->
<p class="hint">Each red bar is one pitch grain from the input voice. The dashed lines are scale pitches. Press <b>Snap to Scale</b> to relocate every grain to the nearest in-tune note — that is PSOLA.</p>
<div class="controls">
<label>Scale:
<select id="scale-select">
<option value="major">C major</option>
<option value="minor">A minor</option>
<option value="pentatonic">C pentatonic</option>
</select>
</label>
<label>Correction speed:
<input id="speed-range" type="range" min="1" max="10" value="5">
</label>
</div>
<canvas id="pitch-canvas" width="560" height="200"></canvas>
<div class="status" id="status">Press Snap to Scale to correct pitch.</div>
<div class="btns">
<button id="btn-snap" type="button">Snap to Scale</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
/* Layout and colors for the PSOLA 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; gap: 1.2rem; flex-wrap: wrap; margin-bottom: .6rem; align-items: center; }
label { font-size: .85rem; color: #555; display: flex; align-items: center; gap: .4rem; }
select, input[type=range] { font-size: .85rem; }
canvas { display: block; width: 100%; max-width: 560px; border: 1px solid #cdd9e3;
border-radius: 8px; background: #f5f8fa; }
.status { font-size: 1rem; font-weight: 600; margin: .5rem 0; min-height: 1.4em; color: #1d3557; }
.status.done { color: #0a7d33; }
.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; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// PSOLA core: detect pitch marks, relocate grains to match target pitch,
// overlap-add with a smooth window. Here we simulate the grain-relocation
// step visually — each bar is one grain plotted at its MIDI pitch.
const canvas = document.getElementById('pitch-canvas');
const ctx = canvas.getContext('2d');
const statusEl = document.getElementById('status');
const scaleSelect = document.getElementById('scale-select');
const speedRange = document.getElementById('speed-range');
// Frequency helpers: MIDI note 69 = A4 = 440 Hz
const A4 = 440;
function midiToHz(m) { return A4 * Math.pow(2, (m - 69) / 12); }
function hzToMidi(f) { return 69 + 12 * Math.log2(f / A4); }
// Scale definitions as semitone intervals within an octave (root = 0)
const SCALES = {
major: [0, 2, 4, 5, 7, 9, 11],
minor: [0, 2, 3, 5, 7, 8, 10],
pentatonic: [0, 2, 4, 7, 9]
};
const NUM_GRAINS = 24;
const MIN_MIDI = 57; // A3 to A5, a comfortable vocal range
const MAX_MIDI = 81;
// State — input pitches, snapped output pitches, snapped flag
let inputPitches = [];
let outputPitches = [];
let snapped = false;
function randomPitches() {
// Generate a smoothly wandering pitch curve within the vocal range
const pitches = [];
let cur = 65 + (Math.random() - 0.5) * 4;
for (let i = 0; i < NUM_GRAINS; i++) {
cur += (Math.random() - 0.5) * 3;
cur = Math.max(MIN_MIDI, Math.min(MAX_MIDI, cur));
pitches.push(cur);
}
return pitches;
}
function nearestScalePitch(midi, scaleName) {
// Find the nearest scale note to the given MIDI pitch
const semitones = SCALES[scaleName];
const octave = Math.floor(midi / 12);
let best = Infinity, bestMidi = midi;
for (let o = octave - 1; o <= octave + 1; o++) {
for (const s of semitones) {
const candidate = o * 12 + s;
const dist = Math.abs(candidate - midi);
if (dist < best) { best = dist; bestMidi = candidate; }
}
}
return bestMidi;
}
function midiToY(midi) {
return canvas.height - ((midi - MIN_MIDI) / (MAX_MIDI - MIN_MIDI)) * (canvas.height - 20) - 10;
}
function drawScaleLines(scaleName) {
// Draw horizontal dashed lines at each scale pitch
ctx.save();
ctx.strokeStyle = '#b8d0e8';
ctx.lineWidth = 0.8;
ctx.setLineDash([4, 4]);
const semitones = SCALES[scaleName];
for (let midi = MIN_MIDI; midi <= MAX_MIDI; midi++) {
const rel = midi % 12;
if (semitones.includes(rel)) {
const y = midiToY(midi);
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(canvas.width, y);
ctx.stroke();
}
}
ctx.setLineDash([]);
ctx.restore();
}
function drawGrains(pitches, color, alpha) {
// Draw vertical grain bars at their respective pitches
const gw = (canvas.width - 20) / NUM_GRAINS;
ctx.save();
ctx.globalAlpha = alpha;
ctx.fillStyle = color;
for (let i = 0; i < pitches.length; i++) {
const x = 10 + i * gw + gw * 0.15;
const y = midiToY(pitches[i]);
const h = Math.max(3, gw * 0.7);
ctx.fillRect(x, y - 2, h, 4);
}
ctx.restore();
}
function drawCurve(pitches, color, alpha) {
// Draw a smooth curve connecting grain pitch positions
const gw = (canvas.width - 20) / NUM_GRAINS;
ctx.save();
ctx.globalAlpha = alpha;
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.beginPath();
for (let i = 0; i < pitches.length; i++) {
const x = 10 + i * gw + gw * 0.5;
const y = midiToY(pitches[i]);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
ctx.restore();
}
function render() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawScaleLines(scaleSelect.value);
// Draw the original (input) pitch curve in red
drawCurve(inputPitches, '#c92f3c', 0.55);
drawGrains(inputPitches, '#c92f3c', 0.7);
if (snapped && outputPitches.length) {
// Draw the snapped (output) pitch curve in green
drawCurve(outputPitches, '#0a7d33', 0.8);
drawGrains(outputPitches, '#0a7d33', 0.9);
}
}
function doSnap() {
// Snap each grain to the nearest scale pitch, blended by speed
const speed = parseInt(speedRange.value, 10);
const scaleName = scaleSelect.value;
outputPitches = inputPitches.map(p => {
const target = nearestScalePitch(p, scaleName);
// Speed 10 = full snap (robotic), speed 1 = gentle nudge (transparent)
const t = speed / 10;
return p + (target - p) * t;
});
snapped = true;
render();
const maxErr = Math.max(...inputPitches.map((p, i) => Math.abs(p - outputPitches[i])));
statusEl.textContent = i18nFmt('Snapped %n% grains — max shift %err% semitones.', { n: NUM_GRAINS, err: maxErr.toFixed(1) });
statusEl.className = 'status done';
}
function reset() {
inputPitches = randomPitches();
outputPitches = [];
snapped = false;
statusEl.textContent = 'Press Snap to Scale to correct pitch.';
statusEl.className = 'status';
render();
}
document.getElementById('btn-snap').addEventListener('click', doSnap);
document.getElementById('btn-reset').addEventListener('click', reset);
scaleSelect.addEventListener('change', () => { if (snapped) doSnap(); else render(); });
speedRange.addEventListener('input', () => { if (snapped) doSnap(); });
reset();
Notice that snapping does not flatten everything to one note. The algorithm finds the nearest scale pitch for each grain independently, so melodic contour is preserved while tuning errors vanish. Speed the correction up to feel the "robotic" Autotune sound; slow it down for the transparent correction pop producers prefer.
Comments
Loading comments...