Engineering · April 29, 2026 · 9 min read

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.

21
chord types
8
progression presets
25
suffix aliases
1
html file

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:

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 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.

© 2026 StudioMode · One HTML file, four rounds, zero servers.