The chord workflow on a single HTML page
Four rounds after shipping the piano-roll scale detector, it now handles the full chord workflow: identify chords from notes, step through 8 famous progressions, auto-play them through the Web Audio scheduler at any tempo, and look up chords by name. Here's how each piece works — and why bass-priority turned out to be the magic tie-breaker.
The starting point
The piano roll launched as a scale detector. Tap notes on a 2-octave keyboard, and the page ranks every scale that contains them by fit — perfect matches first, smaller scales win ties (so pentatonic beats major when both work). One HTML file, no backend, ~500 lines of JS.
That covers melody. It doesn't help when you're sketching harmony — and harmony is where most beatmaking actually starts. Across four follow-up rounds, the same single HTML file grew to cover the whole chord workflow.
Round 101 — chord detection from notes
The first round added chord identification
alongside scale detection. Press 3-7 notes and a card pops up
with the chord name: Cmaj7, Dm9,
F sus4, etc.
The matcher works on pitch-class sets (notes mod 12, so middle C and high C are the same class). For each candidate root 0-11, we rotate the pressed pitch classes so the candidate root lands at 0, then compare against 21 chord shapes by Jaccard similarity:
J = |pressed ∩ chord| / |pressed ∪ chord|
An exact match scores 1.0; partial matches get the proportional score. Best fit wins, ties go to simpler chords by rank (a vanilla triad beats an obscure inversion).
The bass-priority tie-breaker
The naive matcher hits a wall on enharmonic chord pairs. C-F-G is identical to F-G-C — the same set of pitch classes. Is it C sus4 or F sus2? C-E-G-A is the same notes as A-C-E-G. Is it C6 or Am7?
Both interpretations are mathematically valid. The matcher needs one more signal to pick. The signal that musicians actually use is the bass note: whichever note sits lowest implies the root.
So the tie-breaker became: when two chords have the same Jaccard score, the chord whose root equals the lowest pressed MIDI note's pitch class wins. Now C-F-G with C in the bass reads as Csus4, but F-G-C with F in the bass reads as Fsus2. The same notes, different bass, different chord — exactly how a pianist would call it.
The chord ambiguity is real. The bass note resolves it the same way musicians do.
Verified the whole matcher with a 14-case test harness covering every triad family, the 7th set, 6ths, dim7, m9, and enharmonic-pair edge cases. 14/14 pass.
Round 102 — chord progressions
Pressing chords manually is awkward on a screen. So the next round added progression presets — buttons for eight famous chord progressions, each cycling through chords with a Next button:
- I-V-vi-IV in C — the pop progression (Don't Stop Believin', Take On Me)
- vi-IV-I-V — emotional pop variant
- I-vi-IV-V — 50s doo-wop
- ii-V-I in C with proper 7ths — the jazz cadence (Dm7 → G7 → Cmaj7)
- i-VI-III-VII in A minor — minor pop
- i-iv-V — minor cadence
- I-IV-V — the 12-bar blues backbone
- Andalusian (i-VII-VI-V)
Each chord is hand-baked as a (root pitch-class, intervals) pair. Hand-baking is overkill in theory — you could derive chords from Roman-numeral analysis given a key — but in practice we already have the chord interval bank and a renderer that takes pitch-class sets, so feeding it absolute notes is the path of least resistance.
The active progression renders as a sticky bar above the chord card: current chord highlighted in orange, every step clickable, prev / next / × nav. Arrow keys step through. Verified all 29 chord steps across 8 progressions resolve to the right chord names through the same matcher used for manual input.
Round 103 — auto-play through the Web Audio scheduler
Manual stepping is fine, but the real ask is "play me this progression at 90 BPM." So the next round wired in the same Web Audio look-ahead scheduler the metronome uses — a pattern from the canonical Web Audio scheduling doc:
- A
setIntervaltick every 25 ms checks whether the next chord falls within a 100 ms scheduling window. - If it does, all of the chord's oscillators get queued at a precise
audioCtx.currentTime. - The audio thread does the timing; JS just stays ahead.
A naive setInterval metronome drifts by 5-15 ms
per second. The look-ahead pattern fixes that — the audio
thread fires events at sample-accurate times no matter how busy
the JS thread is. The same code that works for the metronome's
single click works for stacking 3-5 oscillators per chord, just
with lower per-note gain so the peaks stay safe.
function scheduleChord(chord, when, durSec) {
const rootMidi = 60 + chord.root; // C4 base
for (const iv of chord.intervals) {
scheduleNote(rootMidi + iv, when, durSec, 0.10);
}
}
function progSchedulerTick() {
const secondsPerBeat = 60.0 / progPlayBpm;
const chordDur = secondsPerBeat * 2; // 2 beats per chord
while (progNextChordTime < audioCtx.currentTime + 0.10) {
const chord = activeProg.chords[progCursorIdx];
scheduleChord(chord, progNextChordTime, chordDur * 0.95);
progNextChordTime += chordDur;
progCursorIdx = (progCursorIdx + 1) % activeProg.chords.length;
}
}
A separate setTimeout per chord fires at the
chord's wall-clock start time to update the visual highlight —
the current step lights up, the keyboard re-presses to show
what's playing. Audio and visual stay in lock-step.
Round 104 — chord-name lookup
The detector goes notes → name. The progression presets go preset → notes + name. The missing direction was name → notes — type "Cmaj7" or "F#m7b5" or "Bb sus4" and watch the keys light up.
The parser handles every symbol variant musicians actually
type. Roots are A-G with optional
# / ♯ / b /
♭. Suffixes have ~25 aliases mapping to 21
canonical chord types: m = min =
-; ° = o =
dim; + = aug;
ø = m7♭5; m(maj7) =
mmaj7 = minMaj7.
Whitespace is tolerated (Bb dim7 works); suffixes
are case-insensitive (CMAJ7 == Cmaj7).
Inputs that don't parse cleanly turn the input red instead of
pressing wrong keys. 23/23 parser cases verified, including the
negatives — H, xyz, C#z
all correctly reject.
Successful lookup also plays the chord audibly via
scheduleChord() so the input feels responsive,
matching the per-keypress UX. The lookup stops any active
progression first so the two systems don't fight.
The shape of the page
Four rounds of features and the file is still a single HTML page. No build step, no React, no audio library, no server. About 1,200 lines of JS now (up from 500), all of which boots in one network round trip and runs entirely on the user's device.
The audio toolset is now: piano roll with scale + chord + progressions, BPM + key detector, guitar tuner with 5 tunings, and a tight metronome. All four share the same Web Audio context, the same look-ahead scheduler pattern, and the same constraint: zero server roundtrips, zero file uploads, zero signups.
Try the chord workflow
Tap notes for chords. Pick a progression. Hit play. Type any chord name.
Open piano roll →What's next
The piano roll has reached a stable plateau — scale detection, chord detection, progression presets, auto-play, and chord-name lookup cover most of what a songwriter needs from a chord sandbox. Future work is more likely to add adjacent tools (a drum-pattern preview to pair with the metronome, an audio trimmer) than to keep iterating on this one. But the principle stays: the cheapest correct architecture beats the most-features one.