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>
40 KiB
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 writes —
lockedKey,detectedProgression, and a committed-shapeprogressionVoteRef— 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 afterREPLACE_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 viewtoggle ("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):
✨ Surprise me— first row, divider under it (border-b border-border) — uniform pick over the 10 styles, then §2 as normal.- 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. - When a seed is active, row 0 becomes
⟳ Re-roll — {styleLabel}(same style, fresh key + progression, honouring §2's no-repeat memory) and✨ Surprise memoves 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→ looptail 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 KBbarsarray — 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
♻ … → loopwith 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:matchChordFromChromanames every committed chord vianoteName(r)withpreferFlat=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
modefield — verified: every KB progression encodesmode, vocabularymajor | minor | dorian | mixolydian(grep across all 10progressions.js), all four already present in the key-lock mode dropdown andSCALES. No inference, no default needed. - Confidence:
1(theapplyLockconvention). - 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 2–8.
< 2— unrepresentable as a detected loop (detectRepeatingProgressionmin pattern length 2). Today this excludes nothing: evenfunk-one-chordcollapses 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 trailingX7). 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 2–8 (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 afterREPLACE_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…len−1 commits past a cycle boundary),
the real
detectRepeatingProgressionreturns 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 passesblues-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 andcountry-145matched 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 3–7 → 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(plainuseRef([])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:
- Realize (the JamGuide
stationVoicingsformula, JamGuide.jsx:262-273): per stationrootPc = (keyRootPc + degrees[i]) % 12,name = NOTES[rootPc] + (CHORD_TYPES[qualities[i]]?.suffix ?? ''). Sharp spellings only (§2.1). - Collapse consecutive duplicate names, then if
first === lastdrop the last — mirrorsdetectRepeatingProgression'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;setChordHistoryalso dedupes consecutive identical commits, App.jsx:363). E.g.blues-12bardegrees[0,0,0,0,5,5,0,0,7,5,0,7]→ 7 names[I,IV,I,V,IV,I,V]realized;funk-one-chordkeeps 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. - Canonicalize rotation with theory.js's own rule — lexicographically
smallest rotation compared via
join('\0')(canonicalize, theory.js:579-586, currently private).detectRepeatingProgressionreturns its canonical rotation; the agreement branch compares exactjoin(',')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 ii–V–I).
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 form — jazz-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, wheresuffix = 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 whoseprogressionis the collapsed projection of the authored one:{ ...progression, degrees, qualities, rn (first-of-run), bars (summed per run), sourceIndex }wheresourceIndex[i]= the first raw station index of collapsed stationi. Sameid/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-blues12→10,blues-12bar12→7,blues-quickchange12→9,blues-8bar8→7,blues-minor12→5,bossa-blue16→10,funk-one-chord4→2,country-1458→4,country-folk-axis8→7,gospel-iv-passing-dim4→3,gospel-tonicized-amen5→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]withmaj/maj/maj/dom7qualities (e.g.[C, F, C, G7]),country-145's collapsed form scores 4×100−4 and strictly beats the incumbentcountry-bluegrass-cycle(3×100−4 — its quality set lacksdom7). 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 keepsbluegrass-cycle). So: behaviour-preserving except where a collapsed entry strictly dominates the disambiguation score, enumerated:country-145, desired direction. Previously-NO_MATCHcollapsed streams now match; that is the point. - Rotation: for a collapsed-form hit,
rotationToCanonicalOrderruns 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
stationVoicingsmemo:play.chords[prog.sourceIndex?.[i] ?? i]; - bass, inside
BassGuideRows:play?.chords?.[i]?.pattern(:530) and…?.note(:533). Mechanism chosen: thestationVoicingsmemo attaches a per-stationsourceIndexfield (stations[i].sourceIndex = prog.sourceIndex?.[i] ?? i), and the two bass lines readplay?.chords?.[st.sourceIndex ?? i]— BassGuideRows already receives the stations, so no new prop; the heard-live path's synthetic station has nosourceIndexand the?? ifallback 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 0–11, 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
—
matchLoopToProgressioncannot miss. (It may attribute to a same-shape sibling — the §4 quirk, now including within-style cases likecountry-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: rolledjazz-251-majorin A → seeded[Bm7, E7, Amaj7](canonical rotation) → band plays it → detection commits the same sharp names from the sameCHORD_TYPESsuffixes → 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 1–5 take the counter to 6 → the card is wiped at commit 5. But the earliest any detection can land is commit 6 — detectRepeatingProgression 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):
- 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. - Rotation equality is constructional (§3.2/§3.3) — no branch change,
but if
seedableLoopskipped 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…len−1 commits past a cycle boundary); the progression passes only ifdetectRepeatingProgressionreturns 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.) - Preserve
seededacross 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) → −1 → no "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:
src/lib/theory.js— additive only:exportkeyword oncanonicalize(shared file — flag lock to Maestro at promotion; Professor+Critic co-review per PROTOCOL §3; fallback per §3.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:
buildLoopIndexcollapsed-form entries (collapsed projection withsourceIndex, appended after raw entries)matchLoopToProgressioncollapsed-hit rotation against collapsed degrees. Behaviour-preserving except the one enumerated strict win (§3.3.2 true invariant:country-145, desired direction); previously-NO_MATCHcollapsed 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.
src/App.jsx—rollJam(styleId)+seedInfostate +rouletteMemoryRef; the roulette button + popover menu in the controls bar; the three 1-line commit-layer edits (§3.4 flags 1 & 3 + the twosetSeedInfo(null)clears); one line innewSong.src/components/ProgressionBanner.jsx— presentationalseedInfoprop: 🎲 glyph + amber provenance chip on the loop row (§1.3).src/components/JamGuide.jsx— four bounded lines + one field (§3.3.2): guitar-shape and piano-recipe lookups remap throughprog.sourceIndex?.[i] ?? i(JamGuide.jsx:280, :288);stationVoicingsattachesstations[i].sourceIndex; BassGuideRows' two raw-indexed lookups (pattern:530,note:533) readplay?.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):
- theory.js export + match.js (
seedableLoop+ fix (a)) + the JamGuidesourceIndexremaps (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). - App.jsx commit-layer guard lines +
rollJam+ state/refs (mechanism live, invokable from a temporary plain button if needed). - 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
styleHintdisambiguation (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
exportoncanonicalize) — 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-145misattributes; 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. styleHintdisambiguation — 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.