← Notes
Note · JavaScript & music · 5 min read

How this site plucks a string

The strings on the home page make real sound, and there isn't a single audio file on the site. Each note is computed the moment you touch it, with an algorithm published by Kevin Karplus and Alex Strong in 1983.

Try it

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.

  1. Fill a buffer of N samples with random noise. That's the pluck.
  2. 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.
  3. 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:

StringTarget (Hz)N, naiveErrorN, −0.5Error
E2 · low E82.41535−1.2 ¢535−1.2 ¢
A2110.00401−2.5 ¢400+1.8 ¢
D3146.83300−0.9 ¢300−0.9 ¢
G3196.00225−3.8 ¢224+3.9 ¢
B3246.94179−8.8 ¢178+0.8 ¢
E4 · high e329.63134−9.2 ¢133+3.7 ¢
At 44,100 samples per second. Actual pitch is sample rate ÷ (N + ½). Most ears notice around 5–10 cents.

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.