From scale detector to chord workshop
The piano roll shipped in March as a scale detector. You pressed notes, the page told you which scales contained them. Five iterations later it identifies chords by name, draws the keyboard shape, reads inversions as slash chords, lays out the full diatonic palette for any key, and toggles between triads and sevenths. Each layer reused the previous one. The total chord "engine" is one matcher and one helper.
What the page already had
A piano keyboard. A set of pressed pitch classes. A function that ranked scales by how many of those pitch classes they contained. That's it. The whole tool fit in one HTML file.
The data shape is the simplest thing in the world: an integer
set pcs ⊂ {0..11}. C is 0, C# is 1, B is 11. Press
C, E, G and the page sees {0, 4, 7}. Press the same
three notes one octave higher and the page still sees
{0, 4, 7} — pitch class is rotation-invariant, that
was always the trick.
Layer 1: name the chord
A scale detector is just "ranked subset matching against a dictionary." A chord detector is the same problem with a different dictionary. The matcher rotates the pressed set against each candidate root and computes Jaccard similarity:
for (let root = 0; root < 12; root++) {
const rotated = new Set([...pcSet].map((p) => (p - root + 12) % 12));
for (const chord of CHORDS) {
const cset = new Set(chord.intervals);
let inter = 0;
for (const iv of rotated) if (cset.has(iv)) inter++;
const j = inter / (rotated.size + cset.size - inter);
// higher j wins, lower rank breaks ties
}
}
CHORDS is 21 entries — major, minor, dim, aug, 7,
maj7, m7, m7b5, dim7, sus2, sus4, 6, m6, add9, 9, m9, 11, 13.
Press C-E-G → C major. Press C-E-G-Bb →
C7. Press C-Eb-G-Bb-D → Cm9.
The matcher took an afternoon. Then ambiguous cases bit us.
The bass tie-breaker
Csus4 and Fsus2 are the same three notes. C6 and Am7 are the same four notes. The matcher returned both with identical Jaccard scores. Musicians read them differently though — the chord whose root equals the lowest pressed note wins. That's one extra clause:
const isBassRoot = (root === bassPc);
// tie-break: equal score → bass-root wins → simpler chord wins
14 of 14 ambiguous cases pass after this. Round 101 shipped.
Layer 2: draw the shape
Naming the chord is half the job. Showing the player the
shape is the other half. A small SVG keyboard, one
octave wide, root highlighted in accent orange, chord tones in
cyan, non-chord keys dimmed. The matcher already returns
chord.intervals and chord.root, so the
diagram is one map call:
const pcOn = new Set(chord.intervals.map((iv) => (iv + chord.root) % 12));
// for each white/black key in the rendered octave:
// fill = pc === root ? accent : (pcOn.has(pc) ? cyan : dim)
Same data, new surface. We also append the chord-tone names — C · E · G for C major — beside the diagram so the player can read both the shape and the spelling at once.
Layer 3: read inversions
Inversion is what happens when the lowest note isn't the root.
C major with E in the bass is C/E. With G in the bass it's C/G.
The matcher already tracks bassPc for the
tie-breaker — the same field tells us whether we're looking at
an inversion:
const bassInterval = (chord.bassPc - chord.root + 12) % 12;
const sorted = chord.intervals.slice().sort();
const idx = sorted.indexOf(bassInterval);
// idx 0 → root position; idx 1 → 1st inversion; idx 2 → 2nd; ...
We label the ordinal in the chord-meta line ("1st inversion", "2nd inversion", "3rd inversion"). Foreign bass notes — bass pitch class outside the chord — keep the plain root reading, because slash notation there would mislead. Tested 7 of 7 cases. Round 122.
Layer 4: the diatonic palette
Identifying chords helps the writer who already pressed notes. The bigger UX win is helping the writer who hasn't pressed any yet — show them the chords they should reach for in their key. That's the diatonic palette: pick a key, get a row of seven buttons covering I-ii-iii-IV-V-vi-vii° in major (or the minor equivalents).
const MAJOR_TRIAD_TYPES = ['maj','min','min','maj','maj','min','dim'];
const MAJOR_ROMAN = ['I','ii','iii','IV','V','vi','vii°'];
function buildDiatonicChords(tonic, mode) {
const scalePcs = mode === 'major' ? MAJOR_SCALE_PCS : MINOR_SCALE_PCS;
return scalePcs.map((iv, i) => ({
roman: ROMAN[i],
chord: ch(PC_NAME[(tonic + iv) % 12], TYPES[i]),
}));
}
Click any button → applyChord() presses the right
keys → the chord identifier picks it up → the diagram updates →
the inversion logic runs → the URL hash syncs. We wrote no new
playback code; everything downstream just works because every
previous layer already accepted the same shape.
Each new feature was ~50 lines because the earlier features had already paid for the data structures.
Layer 5: triads or 7ths
A toggle in the palette header switches the row from triads to seventh chords. Same key, same row of buttons, but Imaj7 instead of I, ii7 instead of ii, V7 instead of V, viiø7 instead of vii°. For neo-soul or jazz writers this is the entire harmonic vocabulary on a single tap.
const MAJOR_SEVENTH_TYPES = ['maj7','min7','min7','maj7','dom7','min7','m7b5'];
// buildDiatonicChords now takes (tonic, mode, ext) where ext ∈ 'triad' | 'seventh'
The half-diminished chord (m7b5) was the only addition to the chord-builder map. The matcher already knew about it. Adding one entry to one table — and the entire 7th palette renders. Round 124.
The accidental architecture
None of this was planned. Round 91 shipped a scale detector. Round 101 added chord identification because the matching loop is the same shape as the scale matcher. Round 121 added a chord diagram because the matcher already returned the intervals. Round 122 added inversions because the matcher already tracked the bass. Round 123 added the diatonic palette because pressing a chord on the keyboard was already a single function call. Round 124 added 7ths because the diatonic palette already accepted a chord-type lookup table.
Each new feature was ~50 lines because the earlier features had already paid for the data structures. The chord matcher does one thing — turn a pitch-class set into a name with intervals. Everything else hangs off that single function: the diagram reads the intervals, the inversion logic reads the bass, the palette feeds notes back in, the 7th toggle picks a different type lookup. One engine, five UI surfaces.
Try the chord workshop
Press a few keys. Or pick a diatonic chord. Either path tells you what's there.
Open /tools/piano-roll →What's next
The drum sandbox just got swing. The metronome got BPM presets. The piano roll's inversion read works on every chord including the 7ths. The short list still has voice-leading hints (which tones moved between consecutive chords?) and a "borrowed chords" toggle (modal interchange — IV in major borrowed from the parallel minor). Both of those should also be ~50 lines.