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>
This commit is contained in:
vadimwit
2026-07-11 15:54:43 +01:00
parent a7dbd6a8b1
commit e87b2bbdcc
+658
View File
@@ -0,0 +1,658 @@
# 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.