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

659 lines
40 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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-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 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, 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 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):**
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) → **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:**
1. `src/lib/theory.js` — **additive 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.jsx` — `rollJam(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.jsx` — **four 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.