The QWERTY keyboard already has the right shape: rows of keys, columns of keys. What if each column became a chord, and each row became a strum lane?
That's keystrum — a browser-based instrument that maps your keyboard to six chords. No external instrument, no samples, no install. Pure Web Audio.
The layout
Am C Em G Dm F
┌──────┬──────┬──────┬──────┬──────┬──────┐
E4 │ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ ← highest pitch
├──────┼──────┼──────┼──────┼──────┼──────┤
B3 │ Q │ W │ E │ R │ T │ Y │
├──────┼──────┼──────┼──────┼──────┼──────┤
G3 │ A │ S │ D │ F │ G │ H │
├──────┼──────┼──────┼──────┼──────┼──────┤
D3 │ Z │ X │ C │ V │ B │ N │ ← lowest pitch
└──────┴──────┴──────┴──────┴──────┴──────┘
Each column is one chord. Sweeping a column top-to-bottom — say 2 W S X for C — fires four notes in fast succession. That's a strum.
Strum detection
The trick is detecting intent. A user pressing 2 W S X simultaneously is one event; pressing them spread across 200ms is another. keystrum's rule:
Three or more keys in the same column within 90ms = a strum. Otherwise, individual notes.
// Simplified strum detector
const STRUM_WINDOW_MS = 90;
const buffer: KeyEvent[] = [];
function onKeyDown(key: string, time: number) {
buffer.push({ key, time });
buffer.splice(0, buffer.findIndex((e) => time - e.time < STRUM_WINDOW_MS));
const sameColumn = buffer.filter((e) => columnOf(e.key) === columnOf(key));
if (sameColumn.length >= 3) {
triggerStrum(columnOf(key), buffer);
} else {
triggerNote(key, time);
}
}
The 90ms window is the sweet spot from playtests — short enough that intentional taps stay individual, long enough that real strums get caught.
Karplus-Strong, in 30 lines
The sound comes from Karplus-Strong synthesis, a physical-modeling technique from 1983 that simulates plucked strings with a delay line and a low-pass filter. No samples. The whole thing runs in AudioWorklet.
// AudioWorklet processor — pluck a string at frequency f
class PluckProcessor extends AudioWorkletProcessor {
delay: Float32Array;
index = 0;
constructor(opts: AudioWorkletNodeOptions) {
super();
const len = Math.floor(sampleRate / opts.processorOptions.frequency);
this.delay = new Float32Array(len);
for (let i = 0; i < len; i++) this.delay[i] = Math.random() * 2 - 1; // noise burst
}
process(_inputs: Float32Array[][], outputs: Float32Array[][]) {
const out = outputs[0][0];
const len = this.delay.length;
for (let i = 0; i < out.length; i++) {
const next = (this.index + 1) % len;
this.delay[this.index] = (this.delay[this.index] + this.delay[next]) * 0.5 * 0.996;
out[i] = this.delay[this.index];
this.index = next;
}
return true;
}
}
The decay factor 0.996 controls sustain. The 0.5 averaging is the low-pass filter. That's the entire string model.
Why no samples
Samples are 5-50MB per instrument. They lock the timbre. They introduce attack-time latency from disk decode. Karplus-Strong is stateless math — sampleRate / frequency floats per voice, total latency under one buffer (about 6ms at 48kHz). The whole bundle is under 100KB.
What's next
- Mobile gestures (sweep on touchscreen)
- More chord sets (jazz voicings, modal scales)
- Sustain pedal mapping to spacebar
Try it live → — works in any modern browser. Source: github.com/kimhinton/keystrum.
Top comments (0)