Lab note · from Plectrum
Karplus-Strong with tanh saturation never decays: normalise by drive
If you put a tanh waveshaper inside a Karplus-Strong feedback loop to get a sitar-style buzz, write it as tanh(drive * x) / drive, not tanh(drive * x) / tanh(drive). The second form has a slope of drive / tanh(drive) at zero, which is greater than 1 for any positive drive. The loop then gains energy on every pass, and the string never decays. With Plectrum's settings that slope would run from 1.45 (koto) to 4.85 (sitar).
Symptoms
Plectrum synthesises every note as an extended Karplus-Strong string, with no samples. The buzz of the sitar's jawari and the shamisen's sawari comes from a tanh inside the loop, and the koto, pipa and bağlama use a lighter setting of it. With the wrong normaliser:
- Only buzz instruments go wrong. The waveshaper runs under
if (buzz > 0). Guitars, oud, kora and the rest decay normally, which points straight at it. - The affected strings don't fade. We rendered each buzz instrument's lowest and highest string at 48 kHz, ten times, with the normaliser swapped back in a copy of the renderer. A 100 ms window just before the fade-out came out 0.2 to 3.1 dB louder than the first 100 ms. With the fix, it is at least 65 dB down.
- The note still ends, but not because the string decayed. Buffers are capped at 6 s, and playback releases the gain envelope no later than 0.65 s after the written length. So the note holds full level until the envelope cuts it.
Why it happens
For each sample, renderString in synth.js reads the delay line with linear interpolation and runs it through a one-pole lowpass (damping) and an allpass (stiffness). It then multiplies by a feedback gain R = Math.pow(0.001, total / (decay * sr)), applies the buzz, and writes the result back.
R is the loss the loop is meant to have. One trip round the loop, one period of total = sr / freq samples, loses just enough for a 60 dB fall over decay seconds. On the buzz instruments' open strings, R is between 0.977 and 0.997. Anything else in the loop must have a gain of at most 1, or it cancels R out.
The small-signal gain of the waveshaper is its slope at zero. For small u, tanh(u) ≈ u, so tanh(drive * x) / N ≈ (drive / N) * x.
- With
N = drive, the slope is exactly 1. - With
N = tanh(drive), the slope isdrive / tanh(drive). Becausetanh(u) < ufor everyu > 0, that is always above 1.
tanh(drive) is the tempting normaliser because it pins the curve at (1, 1), so a full-scale sample comes out at full scale. But a decaying string spends almost all of its life at small amplitudes, and there the slope is what counts.
Plectrum computes drive = 1 + buzz * 7. For each buzz instrument, here is the wrong normaliser's slope and the loop gain it gives at the lowest string's fundamental at 48 kHz (R × lowpass × interpolation × slope).
- Sitar (buzz 0.55): drive 4.85, slope 4.851, loop gain 4.79
- Shamisen (0.30): drive 3.10, slope 3.113, loop gain 3.04
- Bağlama (0.07): drive 1.49, slope 1.649, loop gain 1.63
- Pipa (0.05): drive 1.35, slope 1.545, loop gain 1.52
- Koto (0.03): drive 1.21, slope 1.446, loop gain 1.43
With the fix, the loop gains are between 0.977 and 0.994 across the lowest and highest open strings.
A loop with gain above 1 can't decay. Each pass amplifies the one before until tanh saturates and the effective gain falls back to 1. It is a self-sustaining oscillator, hence the flat tails.
The fix
In synth.js:
// Jawari / sawari buzz. The normaliser must be `drive`, not tanh(drive), so
// the curve is unity-gain for small signals: anything above 1 makes the
// feedback loop self-oscillate and the string never decays.
const buzz = t.buzz || 0;
const drive = 1 + buzz * 7;
// ...
v *= R;
if (buzz > 0) v = Math.tanh(v * drive) / drive;
The broken line, reconstructed from the project's description (the first version isn't in git):
if (buzz > 0) v = Math.tanh(v * drive) / Math.tanh(drive); // slope drive/tanh(drive) > 1
Why the fix holds: |tanh(u)| ≤ |u| for every u, so |tanh(drive * x) / drive| ≤ |x| for every sample. The waveshaper can only take energy out, so the loop gain can never exceed R.
Near zero the curve is transparent. At high amplitude it compresses: a full-scale sample leaves the sitar's waveshaper at tanh(4.85) / 4.85 ≈ 0.21. That compression is the buzz, so the buzz is strongest just after the pluck and fades as the string decays.
How to check you've fixed it
Check the slope numerically:
const slope = (f, h = 1e-6) => (f(h) - f(-h)) / (2 * h);
const drive = 1 + 0.55 * 7; // sitar
slope((x) => Math.tanh(x * drive) / drive); // 1.000
slope((x) => Math.tanh(x * drive) / Math.tanh(drive)); // 4.851
Then render every instrument and compare the end level with the start level:
const sr = 48000, w = Math.floor(0.1 * sr);
const rms = (a, i0, i1) => Math.sqrt(a.slice(i0, i1).reduce((s, x) => s + x * x, 0) / (i1 - i0));
for (const [id, inst] of Object.entries(INSTRUMENTS)) {
const out = renderString(sr, midiToFreq(inst.strings[0] ?? inst.range[0]), inst.timbre);
const end = out.length - Math.floor(0.08 * sr);
const db = 20 * Math.log10(rms(out, end - w, end) / rms(out, 0, w));
if (db > -40) console.log(`${id} does not decay: ${db.toFixed(1)} dB`);
}
Against the committed renderer this prints nothing. With the tanh(drive) line, it flagged bağlama, sitar, pipa, koto and shamisen, at 0.5 to 2.7 dB in one run.
Notes
- Offline rendering. The loop runs in plain JavaScript into a
Float32Array.Synth.buffer()caches the result per pitch as anAudioBuffer, which aBufferSourceplays through a gain envelope and the body filters. No AudioWorklet or DelayNode is involved. - Porting to Web Audio nodes. The Web Audio spec says: "If DelayNode is part of a cycle, then the value of the delayTime attribute is clamped to a minimum of one render quantum." A render quantum is 128 frames by default. At 48 kHz that caps the loop's fundamental at 48000 / 128 = 375 Hz, and the koto's top string is at 784 Hz.
- Provenance. The fix predates the first commit, so the broken version survives only in the README and the comment above. The README says "a small-signal gain of about 1.5" and "four instruments". With the committed
buzzvalues, 1.5 fits the koto, pipa and bağlama (1.45 to 1.65). The sitar and shamisen come out at 4.85 and 3.11, and the table has five instruments withbuzz > 0, not four.
Fingering is scheduled, not looked up
The README's other claim holds up in the code. assignStrings in model.js sorts notes by start time and tracks a busyUntil time per string plus a running hand position, anchor. It gives each note the candidate with the lowest cost, Math.abs(fret - anchor) + fret * 0.14 + (occupied ? 8 : 0). That is distance from the hand, plus a small charge for climbing the neck, plus a large one for taking a string that is still ringing. It then updates anchor = anchor * 0.7 + fret * 0.3. It is one greedy pass, with no lookahead.
The choice changes the sound as well as the tab. The player passes the assigned string to synth.pluck, and a new note on a string that is still ringing damps the previous one.
More lab notes
- AudioContext was not allowed to start: make the click the UIChrome starts an AudioContext made before any user gesture suspended. Create or resume() it in a click or keydown handler, like Meridian 7's power button.
- Anthropic API key in an iOS app binary: move it to the KeychainA Swift string literal ships in the app binary, readable with strings. Store a user-supplied key in the Keychain with a ThisDeviceOnly class instead.
- AXIsProcessTrusted false after rebuild: ad-hoc signing and TCCAn ad-hoc signature's designated requirement is the build's cdhash, so changed code no longer matches its Accessibility grant. Sign with a stable identity.
- CGWindowListCreateImage unavailable in macOS 15: use SCStreamCGWindowListCreateImage is deprecated in macOS 14 and a compile error from a macOS 15 target. Anchor still uses it; here is the SCStream replacement.