Files
JamBuddy/docs/design/jam-roulette.md
T
vadimwit e87b2bbdcc docs(design): jam roulette spec — seed mechanism, matcher collapse fix, empirical roll pool (task D-62)
Two-click flow (button -> style -> instant seed) writing lockedKey +
detectedProgression + a seeded committed-vote so the whole dashboard
fills as if detected and live playing continues from it; three
commit-layer guards specced (every unguarded seed dies exactly one
commit before earliest confirmation); fix (a): match.js additionally
indexes collapsed forms — repairing the pre-existing live bug where
10 progressions (incl. blues-12bar) never match detection, plus
country-145 misattribution; roll pool = steady-state all-offsets
round-trip passers (52/56); sourceIndex remap covers guitar, piano
AND bass play lookups. Returned twice, PASS on round-3 re-gate (a
literal implementation of the sweep protocol reproduced the pinned
numbers with zero interpretation gaps).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 15:54:43 +01:00

40 KiB
Raw Blame History

Jam Roulette — design spec (task D-62)

Thesis: one button, two clicks, a jam. "Jam roulette" in the controls bar opens a genre menu (the 10 KB styles + Surprise me); picking one instantly rolls a random key + an interesting KB progression and seeds the exact state live detection already writeslockedKey, detectedProgression, and a committed-shape progressionVoteRef — so the loop strip, the voicing rail, the licks strip, and the related-progressions card all populate exactly as if the loop had been detected. Musicians read the screen and start playing; the L-31 commit layer then treats the seed like any committed loop: agreeing detections confirm it, a genuinely different loop replaces it after REPLACE_VOTES, and New Song clears it. No new parallel state — the seed is a writer into the existing machine, not a second machine.

User ask (verbatim, sprint goal 2026-07-11): "one button that would be a random chord progression selection. a button up top 'jam roulette' and then select a genre (like we have jazz, blues rock etc) and then have a random key chosen and an interesting progression show up as a loop in the screen. it would fill in the loop section and a 4 bar progression or something so that everyone can see the key and the chords to be played with different voicing so they can start playing… then musicians can start playing and it continues from it"

Dependency note: L-60 is dep-blocked on C-50 (one-screen close-out). This spec is written against docs/design/one-screen.md's target layout (controls bar per §1.1, slim loop strip per §1.2, the two-column dashboard), which L-50 is building concurrently. Everything here that names the controls bar or the strip means the post-L-50 versions; the seed mechanism (§3) is layout-independent and verified against App.jsx as it stands today (~b753b58).

Standing principles honoured: reuse, don't duplicate; nothing shown twice; scroll > click; tokens only, no raw hex; the 🚨 App.jsx audio contract (callbacks / ref-sync / AudioCapture untouched).


1. UX flow

1.1 The button

  • Where: right end of the controls bar, in the global-chrome cluster next to one-screen §1.1's ⛶ Jam view toggle ("a button up top" — the controls bar is the app's persistent top chrome; the header row stays reserved for Settings / New Song / Start).
  • What: 🎲 Jam roulette — same button language as the existing controls: px-3 py-1 rounded-lg border border-border text-sm text-gray-200 hover:border-gray-500 focus-visible:ring-2 focus-visible:ring-accent transition-colors. While a seed is active (unconfirmed, §1.3) the button carries the seeded accent: border-accent/40 text-accent bg-accent/10 + aria-pressed="true" — same active treatment as the locked-key pill.
  • Keyboard: real <button>, Tab-reachable, Enter/Space opens the menu.

1.2 The genre menu

Click → an anchored popover (not a page takeover; the jam never leaves the screen): absolute panel under the button, bg-panel border border-border rounded-xl shadow-lg p-1 z-20, one column of <button> rows (w-full text-left px-3 py-1.5 rounded-lg text-sm text-gray-200 hover:bg-accent/10 focus-visible:ring-2 focus-visible:ring-accent):

  1. ✨ Surprise me — first row, divider under it (border-b border-border) — uniform pick over the 10 styles, then §2 as normal.
  2. The 10 KB styles, straight from the registry: Object.entries(kb).map(([id, s]) => s.meta.label) — never a hard-coded list; an 11th style appears here for free.
  3. When a seed is active, row 0 becomes ⟳ Re-roll — {styleLabel} (same style, fresh key + progression, honouring §2's no-repeat memory) and ✨ Surprise me moves to row 1.

Escape / click-outside / picking a row closes it. Focus returns to the button. Picking a genre seeds instantly — two clicks from cold to a full jam dashboard. No confirmation step: re-rolling is cheaper than confirming.

1.3 The seeded indicator — honest provenance

The dashboard must not pretend the roulette loop was heard. Until live detection confirms it, the slim loop strip's loop row swaps its chrome:

  • The glyph becomes 🎲; the → loop tail becomes an amber provenance chip: rolled · {styleLabel} · {progression.name} · {Σbars} bars — play it! (text-amber-400 text-xs — amber is the app's established secondary accent, the rn colour; no new token). Σbars comes from the KB bars array — this is where "a 4 bar progression" is made visible.
  • The loop chips themselves render identically to a detected loop (same chip anatomy, chord + rn) — the chords are real instructions, only the provenance differs.
  • The moment the commit layer confirms the seed (§3.4 — first agreeing live detection), the indicator flips to the normal ♻ … → loop with no other visual change: the honest signal that "the band is now playing what was rolled". If a different loop replaces the seed, the indicator vanishes with the seed (the new loop is detection-owned).

Empty-state note: pre-first-note the history slot still shows "Start listening…" — correct and honest (nothing has been heard); the key chip shows the rolled key immediately (it's a real lockedKey, rendered with the 🔒 pill in the controls bar too, mode dropdown and unlock included — the rolled key is a first-class key lock, not a special display).

1.4 Flow summary

Cold start: 🎲 → genre → the dashboard fills (key chip + locked-key pill, loop chips with rn, voicing rail with every station's shapes/voicings, licks strip aimed at station 0, related progressions) → band reads, plays → playhead lights on the first committed loop chord → detection confirms after ~2 cycles → 🎲 indicator becomes ♻ → the jam continues under pure live detection. Mid-session: 🎲 acts as New Song + seed (§3.6) — one gesture, clean slate, new card.


2. Randomization rules

All randomness lives in the seed function (plain Math.random(); no audio contact). Inputs: the KB registry only.

2.1 Key

  • Root: uniform over the 12 pitch classes, spelled from NOTES (sharp spellings) — mandatory, not stylistic: matchChordFromChroma names every committed chord via noteName(r) with preferFlat=false (theory.js:112, 445 — the live naming path), so only sharp spellings string-match detection later (§3.3).
  • Mode: taken directly from the rolled progression's mode field — verified: every KB progression encodes mode, vocabulary major | minor | dorian | mixolydian (grep across all 10 progressions.js), all four already present in the key-lock mode dropdown and SCALES. No inference, no default needed.
  • Confidence: 1 (the applyLock convention).
  • Session memory: don't repeat the previous roll's root (re-draw once).

2.2 Progression pool ("interesting" AND seedable)

Per style, from kb[style].progressions, each entry realized + collapsed per §3.2 first. Pool membership = the hard bounds ∩ the round-trip invariant — a progression only rolls if the machine can genuinely confirm it:

  • Hard length bounds: collapsed length must be 28.
    • < 2 — unrepresentable as a detected loop (detectRepeatingProgression min pattern length 2). Today this excludes nothing: even funk-one-chord collapses to two names (X7, Xsus4 — its ['dom7','dom7','sus4','dom7'] qualities survive collapse because a quality change on the same root is a different name; wrap-dedupe then drops the trailing X7). The rule stays as future-proofing for a true single-name vamp, and the code comment must say so honestly.
    • > 8 — the detector's candidate loop only sweeps lengths 28 (theory.js:627). A longer seed is not merely unconfirmable: as the band plays it, in-window fragments of it become the only detectable structure and replace the seed after REPLACE_VOTES = 3 — the card self-destructs into a sub-loop. Excludes today: jazz-blues (collapsed 10), blues-quickchange (9), bossa-blue (10).
  • Round-trip invariant (the real gate) — the protocol is load-bearing: a progression passes iff, with the 32-commit window filled with repetitions of its seeded canonical form (steady state) and truncated at every partial-cycle offset (0…len1 commits past a cycle boundary), the real detectRepeatingProgression returns exactly that form at all offsets. Steady-state-plus-all-offsets is the honest protocol because a jam is sampled mid-cycle, not at cycle boundaries — and a naive "feed 2 clean cycles" protocol gets both failure modes wrong: it spuriously fails all 8 two-name vamps (2 cycles × 2 names = 4 commits, under theory.js:609's history ≥ 6 gate — a ramp artifact, not a steady-state failure), and it passes blues-8bar, whose 7-name form actually fails at exactly 1 of its 7 phase offsets (4 cycles + 4 extra commits → the detector prefers the [C7, G7] alternation). Round-trip is empirical, not constructional — the detector's recency×occurrence scoring can prefer an internal alternation over the full form at some phase. The invariant is precomputed over the whole registry (lazy module-level memo on first roulette open — key-independent per §3.3.3, so one sweep in C covers all keys; an 11th style or new progression joins the pool automatically) and pinned by the L-60 commit-1 smoke sweep (same protocol verbatim — §3.4 flag 2).
  • Honest numbers today (Critic gate ran the real matcher + detector under the protocol above): 4/56 fail — the 3 over-length forms plus blues-8bar's phase-offset failure — so the expected pool is 52/56, pinned exactly by the smoke sweep. (Pre-fix-(a), 10/56 additionally returned NO MATCH for their own collapsed form and country-145 matched a different progression — §3.3.1; all return to the pool with the fix, since matching post-fix is constructional, §3.3.4.)
  • Weight = levelW × lenW (over pool members only):
    • levelW: level === 'intermediate'2; foundation/untagged → 1 (the D-20 rule: untagged counts foundation). "Interesting" = intermediate leans in, foundations still roll.
    • lenW: collapsed length 37 → 2 (the "4-bar-ish" sweet spot — note a collapsed 12-bar blues is 7 names, so the genre's staple stays strong); 2 and 8 → 1.
  • No-repeat memory: rouletteMemoryRef (plain useRef([]) in App — UI-state ref, never read by audio code) keeps the last 6 rolled progression ids; they're excluded from the pool. If exclusion leaves the pool empty (small styles — several have 5 entries), fall back to excluding only the immediately previous id. The ref survives New Song deliberately (variety across songs is the point) and dies with the session.
  • Weighted draw; push id to memory.

3. The seed mechanism — spec'd against the real App state

3.1 What the seed writes (exhaustive)

rollJam(styleId) — a plain function in App.jsx, sibling of quickLock/newSong:

1. newSong()                                 // §3.6 — the clean-slate rule
2. const info = { root: NOTES[rolledPc], mode: prog.mode, confidence: 1 }
   setLockedKey(info)
   effectiveKeyRef.current = info            // the quickLock precedent —
   chordVotesRef.current = []                // detection uses the key NOW
3. const loop = seedableLoop(prog, rolledPc) // §3.2 — realize→collapse→canonicalize
4. setDetectedProgression(loop)
5. progressionVoteRef.current = {
     committedKey: loop.join(','),           // the committed shape, L-31's own
     candidateKey: null,
     candidateCount: 0,
     seeded: true,                           // §3.4 — the one flag L-60 adds
   }
   progressionMissRef.current = 0            // (newSong already did; explicit)
6. setSeedInfo({ styleLabel, name, bars })   // §3.5 — display provenance
7. rouletteMemoryRef bookkeeping (§2.2)

Nothing else. Never touched: handleNote / handleChroma / handleOnset / handleWaveform, AudioCapture and its props, noteHistoryRef, keyVotesRef, chromaRingRef, the loop engine. The seed is display state + the two progression refs the L-31 effect already owns — exactly the audio contract boundary the L-60 ledger row grep-gates.

Synergy worth stating: handleChroma bails while effectiveKeyRef.current is null — a cold session normally can't commit chords until key detection locks. The seed provides the key up front, so the very first chroma frames can commit chords, and the diatonic bonus in matchChordFromChroma is already biased toward the rolled key. The roulette makes detection faster, not just decorated.

3.2 Realizing the loop — seedableLoop(progression, keyRootPc)

The chord names must be byte-identical to what detection would commit when the band plays the progression, or the commit layer treats the seed as a rival forever. Three steps, each forced by a verified code path:

  1. Realize (the JamGuide stationVoicings formula, JamGuide.jsx:262-273): per station rootPc = (keyRootPc + degrees[i]) % 12, name = NOTES[rootPc] + (CHORD_TYPES[qualities[i]]?.suffix ?? ''). Sharp spellings only (§2.1).
  2. Collapse consecutive duplicate names, then if first === last drop the last — mirrors detectRepeatingProgression's collapse of back-to-back commits (theory.js:611-615) plus the cyclic wrap (in the live stream the loop's tail flows into its head; setChordHistory also dedupes consecutive identical commits, App.jsx:363). E.g. blues-12bar degrees [0,0,0,0,5,5,0,0,7,5,0,7] → 7 names [I,IV,I,V,IV,I,V] realized; funk-one-chord keeps its quality changes (same root, different suffix ≠ duplicate → [X7, Xsus4]). Name-collapse is provably key-independent (§3.3.3) — the collapsed shape is a property of the progression, which is what makes fix (a) (§3.3.2) computable once, key-free.
  3. Canonicalize rotation with theory.js's own rule — lexicographically smallest rotation compared via join('\0') (canonicalize, theory.js:579-586, currently private). detectRepeatingProgression returns its canonical rotation; the agreement branch compares exact join(',') strings, so the seed must store the same rotation or confirmation is unreachable (§3.4 flag 2).

Where it lives: seedableLoop exported from src/lib/match.js (loop-identity is that file's mandate; it already imports theory.js read-only; L-51 is adding adjacent exports there and L-60 runs after C-50, so no lock overlap). For step 3 it needs canonicalize: preferred — additive export keyword on theory.js's canonicalize (one-word diff; shared-file lock, flag to Maestro at L-60 promotion; smoke's loop-fixtures.mjs already byte-checks a replica of this function, so a second in-app replica would triple the drift surface). Fallback if Maestro won't open theory.js: replica in match.js with a drift comment + smoke guard, the loop-fixtures precedent.

Display note: the banner shows the seeded loop in this canonical rotation — identical to how any live-detected loop displays today; the JamGuide rail re-rotates to canonical KB order via match.rotation regardless, so the learning surface always reads in textbook order (ii first in a iiVI).

3.3 Matching + round-trip — constructional after fix (a)

3.3.1 The bug fix (a) exists for (Critic gate, real matcher run)

buildLoopIndex (match.js:126-141) indexes each progression's raw, uncollapsed degrees — but the seeded loop (and the live commit stream, which dedupes back-to-back chords) is the collapsed form, a different sequence length whenever a progression repeats a chord across adjacent slots or across the wrap. The gate ran the real matcher: 10/56 progressions return NO MATCH for their own collapsed formjazz-blues, blues-12bar, blues-quickchange, blues-8bar, blues-minor, bossa-blue, funk-one-chord, country-folk-axis, gospel-iv-passing-dim, gospel-tonicized-amen — and country-145 is worse: its raw 8-shape matches a different progression. Rolling blues would produce a populated banner and an empty JamGuide. This is also a pre-existing live-detection bug the roulette work surfaces: a band playing a real 12-bar blues today commits the collapsed stream and gets an empty JamGuide — fix (a) repairs both at once.

3.3.2 Fix (a) — collapsed-form indexing (Maestro-directed, mandatory)

buildLoopIndex additionally indexes each progression's collapsed form:

  • Collapse rule (key-free): dedupe adjacent stations whose (degree, suffix) pairs are equal, where suffix = CHORD_TYPES[quality]?.suffix ?? '' (compare suffixes, not raw quality tokens — two out-of-vocab qualities both fall back to '' and produce equal names, so a token comparison would under-collapse); then wrap-dedupe: if the last pair equals the first, drop the last (one check suffices post-collapse — only the boundary pair can merge).
  • What's indexed: canonicalDegrees(collapsedDegrees) → an entry whose progression is the collapsed projection of the authored one: { ...progression, degrees, qualities, rn (first-of-run), bars (summed per run), sourceIndex } where sourceIndex[i] = the first raw station index of collapsed station i. Same id/name/level/mode/songs/tip.
  • Only when it differs: entries are added only where the collapsed shape ≠ the raw shape — 45/56 index once, 11 twice. The full census (gate, raw → collapsed): jazz-blues 12→10, blues-12bar 12→7, blues-quickchange 12→9, blues-8bar 8→7, blues-minor 12→5, bossa-blue 16→10, funk-one-chord 4→2, country-145 8→4, country-folk-axis 8→7, gospel-iv-passing-dim 4→3, gospel-tonicized-amen 5→4.
  • The true compatibility invariant (stated exactly): collapsed entries are appended after all raw entries, so matchLoopToProgression's strict-> disambiguation keeps every previously-matching input's winner on ties — but append order never engages on a strict score win, and one real case exists: for input shape [0,5,0,7] with maj/maj/maj/dom7 qualities (e.g. [C, F, C, G7]), country-145's collapsed form scores 4×1004 and strictly beats the incumbent country-bluegrass-cycle (3×1004 — its quality set lacks dom7). Gate-verified registry-wide (raw + collapsed × 3 keys): this is the only winner change, and it is an improvement — the progression actually being played now self-attributes (pure-triad [C,F,C,G] input still ties and keeps bluegrass-cycle). So: behaviour-preserving except where a collapsed entry strictly dominates the disambiguation score, enumerated: country-145, desired direction. Previously-NO_MATCH collapsed streams now match; that is the point.
  • Rotation: for a collapsed-form hit, rotationToCanonicalOrder runs against the collapsed degrees — equal lengths by construction. (Today's length-mismatch guard, match.js:208, silently returns 0, which is exactly what would break the playhead if the raw progression were returned instead.)

Downstream, JamGuide renders the projection's stations naturally — a 12-bar renders 7 stations, one per distinct change (the right glance-rail answer: duplicate consecutive bars add no voicing information; the strip's loop chips carry position). The one alignment consumers need: every authored-play lookup must remap through sourceIndex — otherwise a collapsed match reads the first N raw play entries against the wrong stations. There are four raw-indexed lookups, not two:

  • guitar shape path, JamGuide.jsx:280, and piano recipe path, :288 — both inside the stationVoicings memo: play.chords[prog.sourceIndex?.[i] ?? i];
  • bass, inside BassGuideRows: play?.chords?.[i]?.pattern (:530) and …?.note (:533). Mechanism chosen: the stationVoicings memo attaches a per-station sourceIndex field (stations[i].sourceIndex = prog.sourceIndex?.[i] ?? i), and the two bass lines read play?.chords?.[st.sourceIndex ?? i] — BassGuideRows already receives the stations, so no new prop; the heard-live path's synthetic station has no sourceIndex and the ?? i fallback keeps it exact.

Blues ships guitar, piano and bass cells, and its bass pack covers four collapse-affected progressions (blues-12bar/quickchange/8bar/ minor), two of them pool members (blues-12bar 7, blues-minor 5) — so the bass remap is load-bearing for the roulette itself, not just for live detection. Four bounded lines + the one field attachment; JamGuide.jsx joins L-60's lock (§5).

3.3.3 Key-independence proof (why the index can be built key-free)

Realized names are Nᵢ = NOTES[(k + dᵢ) mod 12] + suffix(qᵢ) for key root k. Adjacent names are equal ⇔ their roots are equal AND their suffixes are equal. Roots: (k + dᵢ) ≡ (k + dⱼ) (mod 12) ⇔ dᵢ ≡ dⱼ (mod 12)k cancels, and KB degrees live in 011, so this is plain dᵢ = dⱼ. Suffixes: the 14 CHORD_TYPES suffixes are pairwise distinct (verified by inspection, theory.js:41-55), so equal suffixes ⇔ the same suffix class — identical quality for in-vocab tokens, the shared '' fallback for out-of-vocab ones (hence §3.3.2 compares suffixes). Therefore name-collapse ≡ (degree, suffix)-pair collapse in every key: buildLoopIndex computes the collapsed shape once with no key in hand, and seedableLoop's per-key name collapse always produces that same shape. The same argument makes the §2.2 round-trip invariant key-independent: the detector consumes only the name stream's equality structure, which is key-invariant; the lexicographic rotation choice may differ per key, but seed and detector canonicalize identically over identical names, so agreement of the two outputs is preserved — one sweep in C covers all 12 keys.

3.3.4 What is constructional vs what stays empirical

  • Matching is constructional post-fix-(a): every rolled loop is some progression's collapsed canonical shape, and fix (a) indexes exactly those — matchLoopToProgression cannot miss. (It may attribute to a same-shape sibling — the §4 quirk, now including within-style cases like country-145-collapsed ≡ country-bluegrass-cycle.)
  • Detector round-trip stays empirical — hence §2.2's precomputed registry-wide invariant (steady-state window, all partial-cycle offsets) as the pool gate: today 4/56 fail — 3 over-length + blues-8bar's 1-of-7-offsets self-competition; expected pool 52/56, pinned by smoke. Worked example of a passer: rolled jazz-251-major in A → seeded [Bm7, E7, Amaj7] (canonical rotation) → band plays it → detection commits the same sharp names from the same CHORD_TYPES suffixes → after 2 cycles (6 commits, clearing the history ≥ 6 gate) the detector returns the identical canonical form at every subsequent commit, whatever the phase → agreement branch fires. ✓

3.3.5 Honest limit — qualities detection can't name

MATCH_CHORD_TYPES (theory.js:59-61) is a subset — KB qualities dim7 and min6 (7 progression slots across the KB, counted) can never be committed by detection under those exact names (it will hear dim / m7-ish). For such rolls the seed displays the authored chord, and once the band plays, detection consistently returns the as-heard loop → the REPLACE_VOTES branch swaps in reality after 3 commits. That is the machine working as designed — the card yields to what's actually played, and the re-match still lands on the same KB progression (degree shape unchanged, quality is only a tie-breaker). Same story if the band plays triads where the KB says sevenths. Not a bug; documented behaviour.

3.4 The L-31 commit-layer interplay — branch by branch

The effect (App.jsx:181-213) runs once per chord commit ([chordHistory] deps — seeding itself triggers exactly one run via newSong's setChordHistory([]), landing in the null branch at miss 1). With a seeded vote {committedKey: S, candidateKey: null, candidateCount: 0, seeded: true}:

Branch Seeded behaviour Verdict
Agreement (vote.committedKey === key) Refreshes the loop, drops rivals — L-60 adds: vote.seeded = false + setSeedInfo(null). The seed is now a normal committed loop; every subsequent rule applies verbatim works, 2-line addition
Rival counting (candidateKey bookkeeping) A transient sub-cycle detection during ramp-up (real: a 7-name collapsed 12-bar contains [I,IV] twice before the full form recurs) starts a candidate; committedKey survives the rebuild — but the rebuild literal at line 207 drops seeded 🚩 flag 3 below
Replacement (candidateCount ≥ REPLACE_VOTES=3) A genuinely different, consistently-detected loop replaces the seed; new vote is detection-owned (no seeded) — L-60 adds setSeedInfo(null) here correct as-is: rolled cards must yield to a real band playing something else
Null branch / miss counter (NULL_CLEAR=6) Kills EVERY seed during ramp-up — no length survives. L-31's "fills never null" reasoning assumed the loop is in the history window — a seeded loop isn't yet. Exact timeline: the seed's own effect run (via newSong's history reset) is miss 1; commits 15 take the counter to 6 → the card is wiped at commit 5. But the earliest any detection can land is commit 6detectRepeatingProgression hard-gates raw history < 6 (theory.js:609), so even a 2-name loop cannot confirm before then, and a loop of length len needs ~2·len commits besides. Unguarded, every seed dies exactly one commit before the earliest possible confirmation — the guard is mandatory for every pool member, not a long-loop nicety 🚩 flag 1 — the critical fix

Flags for L-60 (each a 1-line change inside the effect — the one licensed edit in that block):

  1. Null-clear guard: if (progressionMissRef.current >= NULL_CLEAR && !progressionVoteRef.current?.seeded) { …clear… }. Semantics: a seeded card is an instruction, not an observation — it stays until confirmed (then normal rules resume), replaced by a consistently-detected different loop, re-rolled, or New Song. A band that noodles structurelessly under a seeded card keeps the card — intended: it's what they were asked to play.
  2. Rotation equality is constructional (§3.2/§3.3) — no branch change, but if seedableLoop skipped canonicalization the agreement branch would be unreachable and the seed would churn-replace with itself (rotated) at commit 3. L-60's smoke-visible invariant is §2.2's registry-wide sweep, protocol verbatim: for each of the 56 progressions, fill the 32-commit window with repetitions of the seeded canonical form (steady state) and truncate at every partial-cycle offset (0…len1 commits past a cycle boundary); the progression passes only if detectRepeatingProgression returns exactly the seeded canonical form at ALL offsets. Expected: 52 passers pinned; the 4 named failures (jazz-blues, blues-quickchange, bossa-blue, blues-8bar) asserted as excluded from the pool. (A naive 2-clean-cycles feed is wrong in both directions — §2.2.)
  3. Preserve seeded across candidate rebuilds: line 207 becomes { committedKey, candidateKey: key, candidateCount: 1, seeded: vote?.seeded ?? false } — otherwise one transient ghost sub-cycle strips the flag and flag 1's guard dies with it, resurrecting the ramp-up kill through the side door.

Nothing else in the effect treats a seed differently — COMMIT_VOTES never applies (a committed key exists), and progressionMissRef.current = 0 on any detection keeps the counter honest once structure appears.

3.5 The provenance flag — machine truth vs display echo

Two readers need "seeded, unconfirmed": the effect (guard, flag 1) and the banner/button (indicator, §1.3). The effect reads vote.seeded — its own ref, already in hand, mutation-safe. The display cannot read a ref reactively, so App gains one small UI-state: const [seedInfo, setSeedInfo] = useState(null){ styleLabel, name, bars } | null, passed to ProgressionBanner as a presentational prop (and read by the roulette button for its active state + re-roll row). This is provenance metadata that exists nowhere else (style label, progression name, bar count) — not a duplicate of detectedProgression. Invariant, enforced at every transition: seedInfo !== null ⇔ progressionVoteRef.current?.seeded === true. Sync points: set together in rollJam; cleared together in the agreement branch, the replacement branch, and newSong (one added line — setSeedInfo(null)).

3.6 New Song & mid-session rolls

newSong() (App.jsx:216-241) already clears the whole seed surface: progressionVoteRef.current = null, progressionMissRef.current = 0, setDetectedProgression(null), setLockedKey(null), setChordHistory([]) — verified line by line; L-60 adds only setSeedInfo(null). The seed clears naturally.

Mid-session rule: roulette = New Song + seed (rollJam step 1). Without it, a 32-commit history still containing the old loop keeps re-detecting it and would replace the fresh seed within 3 commits even after the band switches — the stale window poisons the handoff. newSong() is the already-tested full reset (history, votes, key, BPM); seeding onto that clean slate makes ramp-up §3.4's only story. The banner history emptying is honest ("new song" is literally what the button did), and the loop-station audio is untouched (newSong never touched it).


4. What fills in — every consumer, verified

The seed sets lockedKey (→ effectiveKey) + detectedProgression (+ the vote ref). Consumers and their honest pre-first-note state (chordHistory empty, currentChord undefined):

Surface Consumes Seeded behaviour
Slim loop strip — key chip keyInfo (= effectiveKey) Rolled key + mode render immediately
Slim loop strip — loop chips detectedProgression, findLoopPosition(chordHistory, loop) All chips render (chord + rn via the rolled key). findLoopPosition returns 1 on empty history (match.js:75 guard) → no chip highlighted — correct: nobody is anywhere yet. 🎲 indicator per §1.3
Strip — history row chordHistory "Start listening…" — honest
JamGuide match matchLoopToProgression(loop, kbIndex) Matched by construction — after §3.3.2's fix (a) (mandatory L-60 scope): the seeded loop is the progression's collapsed canonical shape, which fix (a) indexes. Without the fix, 10/56 rolls (all of blues among them) would show a populated banner and an empty JamGuide — and the same fix repairs today's live-detection miss for real 12-bar streams. Header shows the progression name + "in {key}"
Voicing rail (GlanceRail / BassGuideRows) stationVoicings (match + keyRoot), activeIndex = canonicalPos Every station's shapes/voicings render in canonical KB order — for collapse-affected matches, the collapsed projection's stations (a 12-bar renders 7, one per distinct change) with plays remapped via sourceIndex (§3.3.2). canonicalPos guard (JamGuide.jsx:220-225) → 1no "now" row — the rail shows the whole map unhighlighted
Licks strip activeStyle (= match.style), contextStation Populates; contextStation falls back to station 0 when canonicalPos is 1 (JamGuide.jsx:341-344, existing comment says exactly this) — licks aim at the progression's first chord until the playhead exists
Related progressions (L-51) { loop, keyInfo, onChordClick } Populates for free — it computes its own match from the loop
Instrument view keyInfo, currentChord Scale + pentatonic tiers light in the rolled key; no chord-tone tier until the first commit (currentChord undefined) — honest
ChordDetailModal tap any chip Works — chips pass real chord names
Chord detection itself effectiveKeyRef Enabled immediately by the seeded key (§3.1 synergy)

The pre-first-note playhead, stated plainly: there is none — no strip chip glows, no rail row is "now", and that is the design: a playhead claims knowledge of where the band is, and the app doesn't have it yet. It lights on the first committed chord whose name appears in the loop (findLoopPosition walks back from the last commit; a first chord outside the loop keeps it unlit). The 🎲 chip carries the "we're starting" affordance until then.

Style-attribution quirk (documented, accepted): same-shape progressions exist across styles (rock-axis / reggae-nwnc / pop-axis all [0,7,9,5]), and fix (a) adds within-style aliases (country-145-collapsed ≡ country-bluegrass-cycle). matchLoopToProgression disambiguates by quality overlap then length; ties fall to entry order (raw before collapsed, §3.3.2 — incumbents keep winning on ties), and the one strict-win exception is country-145's collapsed form over bluegrass-cycle for dom7-V input — the desired direction (§3.3.2's true invariant). So rolling pop → Axis may render the rail/licks attributed to rock. The chords, key, rn, and voicings are identical; only the style label and lick flavour differ. Re-judged against Maestro's belt-and-braces offer: with fix (a) mandatory, matching is constructional and a seeded styleHint would correct only the label/lick flavour — cosmetic, not load-bearing. Declined for v1 (it would plumb a new prop through JamGuide for no structural gain); noted for Maestro as a user-pulled follow-up if anyone notices.


5. L-60 scope — exact files, bounded commits

The audio-contract boundary (grep-gated, restated): the seed touches display state (lockedKey, detectedProgression, seedInfo, chordHistory via newSong) + the progression refs (progressionVoteRef, progressionMissRef) + UI refs (rouletteMemoryRef, effectiveKeyRef per the quickLock precedent) — never handleNote/handleChroma/handleOnset/handleWaveform, their ref-sync effects, or any AudioCapture prop.

Files:

  1. src/lib/theory.jsadditive only: export keyword on canonicalize (shared file — flag lock to Maestro at promotion; Professor+Critic co-review per PROTOCOL §3; fallback per §3.2).
  2. src/lib/match.js — the sprint's real engineering weight, all additive:
    • seedableLoop(progression, keyRootPc) → string[] | null (realize → collapse+wrap-dedupe → canonicalize; null for collapsed length < 2);
    • fix (a) per §3.3.2: buildLoopIndex collapsed-form entries (collapsed projection with sourceIndex, appended after raw entries)
      • matchLoopToProgression collapsed-hit rotation against collapsed degrees. Behaviour-preserving except the one enumerated strict win (§3.3.2 true invariant: country-145, desired direction); previously-NO_MATCH collapsed streams now match — this also fixes the pre-existing live 12-bar miss;
    • the round-trip pool sweep helper (lazy module-level memo, §2.2). After C-50, no lock overlap with L-51's exports.
  3. src/App.jsxrollJam(styleId) + seedInfo state + rouletteMemoryRef; the roulette button + popover menu in the controls bar; the three 1-line commit-layer edits (§3.4 flags 1 & 3 + the two setSeedInfo(null) clears); one line in newSong.
  4. src/components/ProgressionBanner.jsx — presentational seedInfo prop: 🎲 glyph + amber provenance chip on the loop row (§1.3).
  5. src/components/JamGuide.jsxfour bounded lines + one field (§3.3.2): guitar-shape and piano-recipe lookups remap through prog.sourceIndex?.[i] ?? i (JamGuide.jsx:280, :288); stationVoicings attaches stations[i].sourceIndex; BassGuideRows' two raw-indexed lookups (pattern :530, note :533) read play?.chords?.[st.sourceIndex ?? i] — no new prop, heard-live falls back via ?? i. Re-lock at promotion — JamGuide is one-screen-locked until C-50, which L-60 already depends on.

Suggested commits (green at each):

  1. theory.js export + match.js (seedableLoop + fix (a)) + the JamGuide sourceIndex remaps (all four paths incl. bass) + the smoke sweep: the §2.2 round-trip invariant over all 56 progressions under the steady-state/all-offsets protocol (§3.4 flag 2 verbatim; expected 52 passers pinned; the 4 named failures asserted excluded) plus a live-stream fixture proving a clean 12-bar history now matches (the pre-existing bug, pinned fixed).
  2. App.jsx commit-layer guard lines + rollJam + state/refs (mechanism live, invokable from a temporary plain button if needed).
  3. The controls-bar button + menu + banner indicator (the visible feature).

Out of L-60 scope: GlanceRail/VoicingBrowser (untouched — they render whatever stations JamGuide hands them), JamGuide beyond the four remap lines

  • field, KB content, theory beyond the one export, audio, and the styleHint disambiguation (declined, §4).

6. Rejected alternatives

Rejected A — fake chordHistory injection (seed by pushing 2 synthetic cycles into history so the detector "discovers" the roll naturally). Seductive — zero commit-layer changes — and dishonest at every surface: the banner history strip shows eight chords nobody played; findLoopPosition immediately lights a playhead asserting the band is mid-loop before a note sounds; the L-31 cadence reasoning (thresholds are per real chord commit) silently breaks; and the first real commits interleave with the fake tail, producing corrupted-rep flapping (the exact pathology C-30/L-31 spent a sprint taming). The seed must write conclusions, not forge evidence.

Rejected B — a separate seededProgression state rendered when detectedProgression is null. Duplicates the loop's identity across two states with a priority rule; every consumer (strip, JamGuide, RelatedProgressions, future ones) must learn the fallback or silently miss the seed; and the detection handoff becomes a hard visual swap between two objects instead of the vote ref's smooth confirm/replace. The standing principle exists for exactly this: one loop, one state, one machine.

Rejected C — display-only overlay ("ghost card") that never enters the state machine, cleared on first detection. Honest-looking, but the screen's surfaces don't populate from a ghost: the rail, licks, and related progressions all key off detectedProgression — an overlay would either leave them empty (failing "so that everyone can see the chords with different voicings") or need Rejected B's plumbing anyway. Also loses the §3.1 synergy (no lockedKey → chord detection stays gated on key detection).

Rejected D — neutralize the ramp-up with a miss-counter credit (progressionMissRef.current = -(2·len + 4) at seed time) instead of the seeded flag. One line, no flag — but the credit is a magic number that burns in real time: a band that chats for eight commits' worth of noodling before starting spends it on nothing and the card still dies mid-ramp-up; and it leaves no way for the agreement branch to know confirmation happened (the indicator can't flip). The flag has clean semantics; the credit has arithmetic luck.


7. Flags for Maestro

  • theory.js shared-file lock (additive export on canonicalize) — L-60 promotion must note it; Professor co-review per PROTOCOL §3.
  • L-60 lock list per §5 (App.jsx + ProgressionBanner.jsx + match.js + theory.js + JamGuide.jsx, four remap lines + one station field) — supersedes the ledger row's "App.jsx (+ per D-62)".
  • Fix (a) repairs a pre-existing LIVE bug (§3.3.1: real 12-bar/8-bar streams — 10 progressions match nothing in JamGuide today, and country-145 misattributes; 11 collapse-affected total, §3.3.2 census). Worth a line in the PR body / Herald's changelog: users get it even if they never touch the roulette.
  • styleHint disambiguation — re-judged and declined for v1 (§4: cosmetic post-fix-(a)); user-pulled follow-up only.
  • Smoke round-trip sweep (§5 commit 1, all 56 progressions + the live 12-bar fixture) — if Critic prefers it C-owned, it slots into C-61's sweep instead.
  • No new tokens, no new colours (amber + accent are established), no dependencies, no KB/audio changes anywhere in this design.