The idea
A plucked string starts as a burst of energy that contains every frequency at once. The string itself then acts as a filter: high frequencies die away quickly while the fundamental keeps ringing. Karplus–Strong copies exactly that with two ingredients, a short loop of samples and an average.
- Fill a buffer of N samples with random noise. That's the pluck.
- Play the buffer in a loop. Each new sample is the average of two neighbouring samples from N steps back, multiplied by a decay factor just under 1.
- Averaging smooths out the harsh high frequencies a little more on every pass, so the noise turns into a tone that fades like a real string.
The length of the loop sets the pitch. One trip round the loop is one vibration of the string:
frequency = sample rate ÷ loop length
The code
This is the function that runs when you touch a string, with the audio routing trimmed down:
function pluck(freq) {
const sr = ctx.sampleRate; // 44,100 or 48,000 samples per second
const n = Math.round(sr / freq - 0.5); // loop length sets the pitch
const len = Math.floor(sr * 2.4); // 2.4 seconds of sound
const buf = ctx.createBuffer(1, len, sr);
const d = buf.getChannelData(0);
for (let i = 0; i < n; i++) d[i] = Math.random() * 2 - 1; // the pluck
for (let i = n; i < len; i++) d[i] = 0.4985 * (d[i - n] + d[i - n + 1]); // average + decay
const src = ctx.createBufferSource();
src.buffer = buf;
src.connect(ctx.destination);
src.start();
}
The constant 0.4985 is two steps in one: the ½ of the average and a decay of 0.997. Push the decay closer to 1 and the note sustains longer; lower it and the string sounds muted.
Why the −0.5?
My first version used Math.round(sr / freq). It sounded fine until I checked the tuning. Averaging two neighbouring samples adds half a sample of delay, so the real loop is N + ½ samples long and every string came out flat. Short loops suffer most, because half a sample is a bigger share of them. Tuning is measured in cents, hundredths of a semitone:
| String | Target (Hz) | N, naive | Error | N, −0.5 | Error |
|---|---|---|---|---|---|
| E2 · low E | 82.41 | 535 | −1.2 ¢ | 535 | −1.2 ¢ |
| A2 | 110.00 | 401 | −2.5 ¢ | 400 | +1.8 ¢ |
| D3 | 146.83 | 300 | −0.9 ¢ | 300 | −0.9 ¢ |
| G3 | 196.00 | 225 | −3.8 ¢ | 224 | +3.9 ¢ |
| B3 | 246.94 | 179 | −8.8 ¢ | 178 | +0.8 ¢ |
| E4 · high e | 329.63 | 134 | −9.2 ¢ | 133 | +3.7 ¢ |
Subtracting half a sample before rounding brings the worst string from 9.2 cents flat to within 4 cents. Getting to zero would need a fractional delay, usually an all-pass filter, because N can only be a whole number. For a portfolio, 4 cents is in tune.
Frets and chords
Each fret raises a note by one semitone, which multiplies the frequency by the twelfth root of 2. A chord is just six plucks, each on its own fret, started 35 milliseconds apart like a strum:
const G = [3, 0, 0, 0, 2, 3]; // frets, high e → low E
G.forEach((fret, i) => {
const open = STRINGS[i].hz;
pluck(open * Math.pow(2, fret / 12), (5 - i) * 0.035);
});
The same thing in Python
The algorithm is small enough to write anywhere. With NumPy, and SciPy to save the result:
import numpy as np
from scipy.io import wavfile
def pluck(freq, sr=44_100, seconds=2.4, decay=0.997):
n = round(sr / freq - 0.5)
out = np.zeros(int(sr * seconds))
out[:n] = np.random.uniform(-1, 1, n)
for i in range(n, len(out)):
out[i] = decay * 0.5 * (out[i - n] + out[i - n + 1])
return out
wavfile.write("low_e.wav", 44_100, (pluck(82.41) * 32767).astype(np.int16))
Go back to the home page, turn the sound on and sweep across the strings. Every note you hear was built this way, a few milliseconds before you heard it.