Adjust the string length (which sets the pitch) and the blend factor (which controls how quickly energy decays), then press Pluck. The waveform display shows the delay-line buffer evolving in real time — watch the initial noise smooth into a periodic wave.
<!-- Karplus-Strong demo: noise burst -> averaging delay loop -> plucked string sound -->
<div class="controls">
<label>
<span class="lbl">String length</span>
<input type="range" id="length" min="20" max="200" value="80" />
<span id="length-val" class="val">80</span>
</label>
<label>
<span class="lbl">Blend factor</span>
<input type="range" id="blend" min="90" max="99" value="96" />
<span id="blend-val" class="val">0.96</span>
</label>
</div>
<canvas id="wave" width="480" height="140" title="Delay-line buffer waveform"></canvas>
<div class="status" id="status">Ready. Press Pluck to synthesize a string.</div>
<div class="btns">
<button id="pluck" type="button">Pluck</button>
<button id="stop" type="button" class="ghost">Stop</button>
</div>
<p class="hint">Longer buffer = lower pitch. Blend closer to 1 = longer sustain. The waveform shows the delay-line buffer: noise at first, then a clean periodic wave.</p>
/* layout and theme */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; background: #fff; }
.controls { display: flex; flex-direction: column; gap: .6rem; margin-bottom: .8rem; }
label { display: flex; align-items: center; gap: .5rem; font-size: .9rem; }
.lbl { min-width: 7rem; font-weight: 600; color: #1d3557; }
.val { min-width: 2.8rem; font-variant-numeric: tabular-nums; color: #444; }
input[type=range] { flex: 1; accent-color: #1d3557; cursor: pointer; }
canvas { display: block; width: 100%; border-radius: 10px; background: #eef2f6; margin-bottom: .6rem; }
.status { font-size: 1rem; font-weight: 600; min-height: 1.4em; margin-bottom: .4rem; color: #555; }
.status.playing { color: #0a7d33; }
.status.stopped { color: #1d3557; }
.btns { display: flex; gap: .5rem; margin-bottom: .8rem; }
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; }
.hint { font-size: .85rem; color: #555; margin: 0; line-height: 1.5; }
/* Karplus-Strong algorithm: fill buffer with white noise, then each step replaces buffer[pos] with blend * average(buffer[pos], buffer[pos+1]) */
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* audio and animation state */
let audioCtx = null;
let scriptNode = null;
let buffer = null;
let bufPos = 0;
let blend = 0.96;
let bufLen = 80;
let animId = null;
let playing = false;
const canvas = document.getElementById('wave');
const ctx2d = canvas.getContext('2d');
const W = canvas.width;
const H = canvas.height;
/* sync slider labels */
const lenSlider = document.getElementById('length');
const blendSlider = document.getElementById('blend');
const lenVal = document.getElementById('length-val');
const blendVal = document.getElementById('blend-val');
lenSlider.addEventListener('input', () => {
bufLen = parseInt(lenSlider.value);
lenVal.textContent = bufLen;
});
blendSlider.addEventListener('input', () => {
blend = parseInt(blendSlider.value) / 100;
blendVal.textContent = blend.toFixed(2);
});
/* draw the delay-line buffer onto the canvas */
function drawBuffer(buf) {
ctx2d.clearRect(0, 0, W, H);
ctx2d.beginPath();
for (let i = 0; i < buf.length; i++) {
const x = (i / buf.length) * W;
const y = H / 2 - buf[i] * (H / 2 - 8);
if (i === 0) ctx2d.moveTo(x, y); else ctx2d.lineTo(x, y);
}
ctx2d.strokeStyle = '#1d3557';
ctx2d.lineWidth = 1.8;
ctx2d.stroke();
}
/* pluck the string — reset buffer with noise and start audio */
function pluck() {
stop();
bufLen = parseInt(lenSlider.value);
blend = parseInt(blendSlider.value) / 100;
/* fill buffer with white noise (the initial excitation) */
buffer = new Float32Array(bufLen);
for (let i = 0; i < bufLen; i++) buffer[i] = Math.random() * 2 - 1;
bufPos = 0;
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const SR = audioCtx.sampleRate;
scriptNode = audioCtx.createScriptProcessor(256, 0, 1);
scriptNode.onaudioprocess = (e) => {
const out = e.outputBuffer.getChannelData(0);
for (let i = 0; i < out.length; i++) {
/* Karplus-Strong averaging step: new sample = blend * 0.5 * (current + next) */
const cur = buffer[bufPos];
const next = buffer[(bufPos + 1) % bufLen];
const avg = blend * 0.5 * (cur + next);
buffer[bufPos] = avg;
out[i] = avg;
bufPos = (bufPos + 1) % bufLen;
}
};
scriptNode.connect(audioCtx.destination);
playing = true;
setStatus('Playing — watch the waveform evolve.', 'playing');
/* animation loop: redraw buffer every frame while playing */
function frame() {
if (!playing) return;
drawBuffer(buffer);
animId = requestAnimationFrame(frame);
}
animId = requestAnimationFrame(frame);
}
function stop() {
playing = false;
if (animId) { cancelAnimationFrame(animId); animId = null; }
if (scriptNode) { scriptNode.disconnect(); scriptNode = null; }
if (audioCtx) { audioCtx.close(); audioCtx = null; }
setStatus('Stopped.', 'stopped');
if (buffer) drawBuffer(buffer);
}
function setStatus(t, cls) {
const s = document.getElementById('status');
s.textContent = t;
s.className = 'status ' + cls;
}
document.getElementById('pluck').addEventListener('click', pluck);
document.getElementById('stop').addEventListener('click', stop);
/* draw the initial flat buffer on load */
buffer = new Float32Array(bufLen).fill(0);
drawBuffer(buffer);
<!-- Karplus-Strong demo: noise burst -> averaging delay loop -> plucked string sound -->
<div class="controls">
<label>
<span class="lbl">String length</span>
<input type="range" id="length" min="20" max="200" value="80" />
<span id="length-val" class="val">80</span>
</label>
<label>
<span class="lbl">Blend factor</span>
<input type="range" id="blend" min="90" max="99" value="96" />
<span id="blend-val" class="val">0.96</span>
</label>
</div>
<canvas id="wave" width="480" height="140" title="Delay-line buffer waveform"></canvas>
<div class="status" id="status">Ready. Press Pluck to synthesize a string.</div>
<div class="btns">
<button id="pluck" type="button">Pluck</button>
<button id="stop" type="button" class="ghost">Stop</button>
</div>
<p class="hint">Longer buffer = lower pitch. Blend closer to 1 = longer sustain. The waveform shows the delay-line buffer: noise at first, then a clean periodic wave.</p>
/* layout and theme */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; background: #fff; }
.controls { display: flex; flex-direction: column; gap: .6rem; margin-bottom: .8rem; }
label { display: flex; align-items: center; gap: .5rem; font-size: .9rem; }
.lbl { min-width: 7rem; font-weight: 600; color: #1d3557; }
.val { min-width: 2.8rem; font-variant-numeric: tabular-nums; color: #444; }
input[type=range] { flex: 1; accent-color: #1d3557; cursor: pointer; }
canvas { display: block; width: 100%; border-radius: 10px; background: #eef2f6; margin-bottom: .6rem; }
.status { font-size: 1rem; font-weight: 600; min-height: 1.4em; margin-bottom: .4rem; color: #555; }
.status.playing { color: #0a7d33; }
.status.stopped { color: #1d3557; }
.btns { display: flex; gap: .5rem; margin-bottom: .8rem; }
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; }
.hint { font-size: .85rem; color: #555; margin: 0; line-height: 1.5; }
/* Karplus-Strong algorithm: fill buffer with white noise, then each step replaces buffer[pos] with blend * average(buffer[pos], buffer[pos+1]) */
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* audio and animation state */
let audioCtx = null;
let scriptNode = null;
let buffer = null;
let bufPos = 0;
let blend = 0.96;
let bufLen = 80;
let animId = null;
let playing = false;
const canvas = document.getElementById('wave');
const ctx2d = canvas.getContext('2d');
const W = canvas.width;
const H = canvas.height;
/* sync slider labels */
const lenSlider = document.getElementById('length');
const blendSlider = document.getElementById('blend');
const lenVal = document.getElementById('length-val');
const blendVal = document.getElementById('blend-val');
lenSlider.addEventListener('input', () => {
bufLen = parseInt(lenSlider.value);
lenVal.textContent = bufLen;
});
blendSlider.addEventListener('input', () => {
blend = parseInt(blendSlider.value) / 100;
blendVal.textContent = blend.toFixed(2);
});
/* draw the delay-line buffer onto the canvas */
function drawBuffer(buf) {
ctx2d.clearRect(0, 0, W, H);
ctx2d.beginPath();
for (let i = 0; i < buf.length; i++) {
const x = (i / buf.length) * W;
const y = H / 2 - buf[i] * (H / 2 - 8);
if (i === 0) ctx2d.moveTo(x, y); else ctx2d.lineTo(x, y);
}
ctx2d.strokeStyle = '#1d3557';
ctx2d.lineWidth = 1.8;
ctx2d.stroke();
}
/* pluck the string — reset buffer with noise and start audio */
function pluck() {
stop();
bufLen = parseInt(lenSlider.value);
blend = parseInt(blendSlider.value) / 100;
/* fill buffer with white noise (the initial excitation) */
buffer = new Float32Array(bufLen);
for (let i = 0; i < bufLen; i++) buffer[i] = Math.random() * 2 - 1;
bufPos = 0;
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const SR = audioCtx.sampleRate;
scriptNode = audioCtx.createScriptProcessor(256, 0, 1);
scriptNode.onaudioprocess = (e) => {
const out = e.outputBuffer.getChannelData(0);
for (let i = 0; i < out.length; i++) {
/* Karplus-Strong averaging step: new sample = blend * 0.5 * (current + next) */
const cur = buffer[bufPos];
const next = buffer[(bufPos + 1) % bufLen];
const avg = blend * 0.5 * (cur + next);
buffer[bufPos] = avg;
out[i] = avg;
bufPos = (bufPos + 1) % bufLen;
}
};
scriptNode.connect(audioCtx.destination);
playing = true;
setStatus('Playing — watch the waveform evolve.', 'playing');
/* animation loop: redraw buffer every frame while playing */
function frame() {
if (!playing) return;
drawBuffer(buffer);
animId = requestAnimationFrame(frame);
}
animId = requestAnimationFrame(frame);
}
function stop() {
playing = false;
if (animId) { cancelAnimationFrame(animId); animId = null; }
if (scriptNode) { scriptNode.disconnect(); scriptNode = null; }
if (audioCtx) { audioCtx.close(); audioCtx = null; }
setStatus('Stopped.', 'stopped');
if (buffer) drawBuffer(buffer);
}
function setStatus(t, cls) {
const s = document.getElementById('status');
s.textContent = t;
s.className = 'status ' + cls;
}
document.getElementById('pluck').addEventListener('click', pluck);
document.getElementById('stop').addEventListener('click', stop);
/* draw the initial flat buffer on load */
buffer = new Float32Array(bufLen).fill(0);
drawBuffer(buffer);
Notice how a longer buffer gives a lower pitch (more samples per cycle = lower frequency), and a blend factor closer to 1 makes the string ring longer. The math behind both observations is captured in a single averaging formula: y[n]=b⋅2x[n]+x[n−1], where b is the blend factor and the buffer length N sets the fundamental frequency f=fs/N.
Comments
Loading comments...