karplus-strong
Plucked-string synthesis: a noise burst circling a short delay line, filtered and damped each pass, until only the fundamental is left ringing.
click a string, or press 1–6.
the loop
The whole algorithm is a loop the length of one period of the note: fill it with random noise (the pluck), then repeat forever — output the next sample, replace it with the average of itself and its neighbor, scaled down slightly. As a recurrence that is
where is the delay length and the damping factor from the sustain slider. That averaging is a simple lowpass filter, so each trip around the loop smooths the noise a little more and bleeds off a little energy; a few hundred trips in, the noise has collapsed into a clean decaying tone at the loop's own frequency, with the high end going first — which is exactly what a plucked string does.
physical modelling
Karplus–Strong synthesis is usually counted as the earliest and simplest form of physical modelling: rather than describing the sound a string makes, it models the mechanism that produces it — a disturbance traveling along a medium, losing its high frequencies faster than its low ones on every reflection. That framing is what later grew into digital waveguide synthesis, and it is a genuinely different idea from the approach on the drum machine page, where sounds are assembled from oscillators and filtered noise shaped to resemble a target rather than simulated from a physical process.
equal temperament
Every string here is generated, not recorded — six standard guitar pitches (E2 to E4, the usual open tuning) computed from equal temperament rather than typed in as constants, each rendered into its own short buffer the instant you pluck it. Every pitch derives from a single reference, A2 at 110 Hz, multiplied by the twelfth root of two once per semitone:
for a note semitones from A2. Twelve semitones give , exactly an octave, which is the property the whole system is built to guarantee — at the cost of every other interval being slightly off, so every key sounds equally in tune and none perfectly so. The second expression is the delay length: sample rate divided by frequency, so pitch here is a buffer size rather than a parameter.
the sustain slider
The sustain slider is the one exposed parameter: it's the per-pass damping factor, so nudging it toward 1 lets the loop lose energy more slowly and ring out longer. The algorithm's correctness was checked standalone before it was wired up here — output stays finite and bounded across the whole frequency and damping range, energy genuinely decays over time, higher damping measurably sustains longer, and the pitch actually produced lands within about 1% of the pitch requested. That last check was initially done by counting zero crossings and appeared to fail badly; the test was the thing that was wrong, not the algorithm, since early in a pluck the signal is still dominated by the noise burst. Measuring with autocorrelation over a later window, once the filter has settled, is what actually answers the question.
view source
import { useEffect, useRef, useState } from 'react';
import { noteFrequency, pluckBuffer } from '../../lib/voices';
// Standard guitar tuning, low string to high: E A D G B E. The interval
// between each adjacent pair is a perfect fourth (5 semitones), except
// between G and B, which is a major third (4 semitones). Offsets below are
// each string's semitone distance from A2.
const STRINGS = [
{ id: 'E2', label: 'E', semitones: -5 },
{ id: 'A2', label: 'A', semitones: 0 },
{ id: 'D3', label: 'D', semitones: 5 },
{ id: 'G3', label: 'G', semitones: 10 },
{ id: 'B3', label: 'B', semitones: 14 },
{ id: 'E4', label: 'e', semitones: 19 },
] as const;
const KEY_TO_STRING: Record<string, (typeof STRINGS)[number]['id']> = {
'1': 'E2',
'2': 'A2',
'3': 'D3',
'4': 'G3',
'5': 'B3',
'6': 'E4',
};
export default function KarplusStrong() {
const [damping, setDamping] = useState(0.996);
const [plucks, setPlucks] = useState<Record<string, number>>({});
const dampingRef = useRef(damping);
const audioCtxRef = useRef<AudioContext | null>(null);
const masterGainRef = useRef<GainNode | null>(null);
useEffect(() => {
dampingRef.current = damping;
}, [damping]);
useEffect(() => {
return () => {
audioCtxRef.current?.close();
};
}, []);
const ensureAudio = (): { ctx: AudioContext; out: GainNode } => {
if (!audioCtxRef.current || !masterGainRef.current) {
const ctx = new AudioContext();
const gain = ctx.createGain();
gain.gain.value = 0.9;
gain.connect(ctx.destination);
audioCtxRef.current = ctx;
masterGainRef.current = gain;
}
const ctx = audioCtxRef.current;
if (ctx.state === 'suspended') ctx.resume();
return { ctx, out: masterGainRef.current };
};
const pluck = (str: (typeof STRINGS)[number]) => {
const { ctx, out } = ensureAudio();
const buffer = pluckBuffer(
ctx,
noteFrequency(str.semitones),
dampingRef.current,
);
const source = ctx.createBufferSource();
source.buffer = buffer;
source.connect(out);
source.start();
setPlucks((prev) => ({ ...prev, [str.id]: (prev[str.id] ?? 0) + 1 }));
};
useEffect(() => {
const onKeydown = (e: KeyboardEvent) => {
const id = KEY_TO_STRING[e.key];
if (!id) return;
const str = STRINGS.find((s) => s.id === id);
if (str) pluck(str);
};
window.addEventListener('keydown', onKeydown);
return () => window.removeEventListener('keydown', onKeydown);
}, []);
return (
<div className="karplus-strong">
<div className="strings">
{STRINGS.map((str, i) => (
<button
key={`${str.id}-${plucks[str.id] ?? 0}`}
type="button"
className="string-row"
onClick={() => pluck(str)}
aria-label={`pluck string ${str.label} (${i + 1})`}
>
<span className="string-key">{i + 1}</span>
<span className="string-line" />
<span className="string-note">{str.label}</span>
</button>
))}
</div>
<div className="controls">
<label>
sustain {damping.toFixed(3)}
<input
type="range"
min={0.9}
max={0.999}
step={0.001}
value={damping}
onChange={(e) => setDamping(Number(e.target.value))}
/>
</label>
</div>
<p className="hint">click a string, or press 1–6.</p>
<style>{`
.karplus-strong {
border: 1px solid #2a2d31;
background: #0e1113;
padding: 1.25rem 1rem;
}
.strings {
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.string-row {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
background: none;
border: none;
padding: 0.3rem 0;
cursor: pointer;
font-family: inherit;
}
.string-key {
color: var(--muted);
font-size: 0.75rem;
width: 1rem;
text-align: center;
flex-shrink: 0;
}
.string-note {
color: var(--muted);
font-size: 0.85rem;
width: 1rem;
text-align: center;
flex-shrink: 0;
}
.string-line {
flex: 1;
height: 2px;
background: var(--accent);
transform-origin: center;
animation: pluck-vibrate 0.6s ease-out;
}
.string-row:hover .string-line {
background: #e6e6e6;
}
@keyframes pluck-vibrate {
0% {
transform: scaleY(1);
}
10% {
transform: scaleY(7) translateY(-1px);
}
20% {
transform: scaleY(5) translateY(1px);
}
35% {
transform: scaleY(3) translateY(-1px);
}
55% {
transform: scaleY(1.6);
}
100% {
transform: scaleY(1);
}
}
.karplus-strong .controls {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid #2a2d31;
}
.karplus-strong .controls label {
display: flex;
align-items: center;
gap: 0.75rem;
color: var(--muted);
font-size: 0.8rem;
}
.karplus-strong .controls input[type='range'] {
flex: 1;
max-width: 20rem;
}
.karplus-strong .hint {
color: var(--muted);
font-size: 0.8rem;
margin: 0.75rem 0 0;
}
@media (prefers-reduced-motion: reduce) {
.string-line {
animation: none;
}
}
`}</style>
</div>
);
}