Try it: https://melissa-pereira-deel.github.io/harmonic-parser/
Type Am F C G into a box. The page tells you it is probably C major, puts a
Roman numeral under each chord, and shows you the readings it did not pick.
The interesting part is not the answer. It is that there are usually several, and that two very different things make a page unsure of which one to show.
Musical ambiguity is when the music genuinely supports more than one reading. A pivot chord belongs to two keys at once; that is a fact about the music and it is the most interesting thing on the screen. Model uncertainty is when nothing fits and the top answer is merely the least bad. Most interfaces collapse both into one number — "confidence: 62%" — which cannot tell you whether you are looking at two right answers or no right answer. Those want opposite things from an interface. The first wants both readings side by side. The second wants the page to stop asserting.
Everything runs in your browser. The model is 24 key profiles of 12 numbers each, about 1.2 KB, ported from music21. There is no network call, no download, no warm-up, and nothing to opt out of — which is worth saying out loud, because it is the strongest thing local inference buys and it is completely invisible unless somebody shows you.
⚠️ Status: early proof of concept, with five tests that pin claims known to be false.The page, the key ranking and the parity harness all run. The port agrees with music21 on every top reading and every score across 200 progressions.
npm testis green. Five tests are markedit.fails, and none should be made to pass by adjusting a number: each asserts that its claim does not hold today, so it goes red if somebody fixes it and if somebody breaks it further. An answered spike record inexperiments/says why the claims fail: the two confidence thresholds cannot be calibrated from the data that exists, so the page's claims about its own confidence are placeholders.There is no grammar, no trained model, no Roman-numeral analysis beyond a lookup from the inferred key, and no audio. The
'model'source inreadings.tsis a type, not a thing that exists.Treat every claim this page makes about its own confidence as a placeholder with a plausible shape.
Testing it? Start with the tester guide.
Small modules, one job each, and the seams between them are the point.
chords.ts "F#m7b5" -> { root, quality, bass } pure string work
keyprofiles.ts 24 profiles x 12 numbers the whole "model"
readings.ts rank() -> Reading[] ranked, with a source
confidence.ts ambiguity (a margin) vs uncertainty (an absolute)
suggest.ts what could come next a rule table, for now
pins.ts a pinned numeral is a filter over readings
announce.ts one spoken sentence once typing stops
ui.ts renders readings; shows their source, never branches on it
main.ts wiring: the box, the panel, the events
Reading carries a source field — 'key-profile' | 'grammar' | 'model' —
even though only the first producer exists. That is deliberate. A hand-written
harmonic grammar is meant to land here later as a second producer, and when
it does, the interface should not have to learn that anything changed.
'model' has no plan behind it; see Models.
fixtures/music21-rankings.json holds music21's full 24-key ranking for 200
progressions. test/parity.test.ts checks the TypeScript port against it and
prints the disagreement rate rather than asserting a tolerance, the way
examples/02-config-lexer in tiny-model-lab prints its torch-vs-ONNX and
fp32-vs-int8 rates. That rate is 0/200 on the top reading and 0/200 on
scores. Five rankings reorder within an exact tie, counted and printed
separately because it is not a disagreement: the fixture stores six decimal
places and those ties survive at seventeen, so a total order over 24 keys is
a question the oracle cannot answer.
music21 is pinned exactly rather than floored, which is a deliberate deviation from the sibling repo's policy. A floor says "at least this new"; here music21 is the oracle, and a floor lets the oracle move underneath you.
npm install
npm test # green. 5 are it.fails on purpose; read experiments/ first.
npm run devThe shipped model is the 24 Krumhansl-Schmuckler key profiles in
keyprofiles.ts, and nothing in it is learned. They are published numbers,
ported from music21 and checked against it by the parity test.
Next-chord prediction is the one planned learned model
(#17).
Its contract is experiments/next-chord.yaml
(#28),
which is not written yet. It would become a second source of suggestions in
suggest.ts, beside the rule table, and not a producer of readings. If it
ships, it loads after first paint:
experiments/next-chord-size.spike.md
found that a bundled table will not fit the page's first round trip.
The rule table is the baseline it has to beat, and the claim that it beats it has to survive tiny-model-lab's gates rather than a demo. That is what tiny-model-lab is for, and this repo is set up to be measured by it from outside:
pip install -r tools/requirements.txt
python -m harness init # see experiments/README.md
python -m harness spikesNothing else is a planned model:
Reading.source: 'model'is a type with no plan behind it. Nothing produces it, and no issue proposes a producer.- Ranking modes (Dorian, Mixolydian…), not only major and minor, is a proposed track waiting on a scoping spike (#29).
- A harmonic grammar, the
'grammar'source, is #16. It would be hand-written, not learned.
tiny-model-lab — a
harness for deciding whether a narrow task should be a model at all, and for
proving it beat the simpler thing it replaced. Two seams meet this repo. The
spike record
is where the answered questions in experiments/ came from — this repo
is the first thing to use it. And LATENCY_BANDS_MS in
harness/experiment.py
puts the instant boundary at 100 ms and the flow boundary at 1 s.
That second seam used to be an assertion. It is now a measurement: p95 from
keystroke to painted is 27.6 ms, so band_landed_in() returns instant
with 72 ms of headroom. Method in tools/bench-latency.md,
result in experiments/render-latency.spike.md.
Worth keeping the correction visible, because this README used to argue the other way. The original 200 ms target sits in flow, and the page was described here as needing a designed acknowledgement rather than direct manipulation. That was one unmeasured target disagreeing with another. The real number is seven times faster than the target, and the disagreement dissolves: the interaction is instant, and the honest thing is to say so and protect the headroom rather than spend it.
Protecting it is why this page has no render debounce. Adding one was the plan until the number arrived.
MIT. See LICENSE.
Created by Melissa de Britto.