When a style is active (rolled or detected), RelatedProgressions leads with same-style siblings reframed by data-derived character (minor version / shorter form / extended form / reharmonized), computed from mode/bars/qualities — no new KB content, no matcher change. Cross-style same-changes entries demoted to a secondary section. Critic PASS (role labels recomputed honest for blues + jazz; smoke-pin shift is label-only). Deliberately avoids verse/chorus/bridge labels the KB has no data for. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
12 KiB
Related progressions — same-style-first (D-72)
Concept doc for L-72. Design-only; no code here. Scope is one bounded edit to
src/components/RelatedProgressions.jsx (match.js untouched — see §6).
The user's ask (2026-07-13, verbatim intent)
"for the related progressions this is good also, but i would also want bridge/chorus/modifications in the same style … say i select jam roulette with blues, then i want for that progression other options and not necessarily go into other styles."
Chosen scope: reuse the KB's existing same-style progressions. No computed modifications, no new authored content, no new KB fields. When a style is active (rolled via Jam Roulette or live-detected), the panel leads with the OTHER progressions of that same style, reframed as variations/sections to try — instead of jumping to other styles.
The component today already computes a match, ranks every other KB progression by musical proximity, and prints a flat cross-style list. This doc changes only how the list is partitioned, floored, ordered, and labelled once a style is known. When no style is known, behaviour is unchanged.
1. How the active style is known — reuse the component's own match
rankRelatedProgressions(loop) already calls
matchLoopToProgression(loop, index), whose result is
{ matched, id, style, rotation, progression }. match.style IS the active
style — for both entry points:
- Jam Roulette:
rollJamseedsdetectedProgression = seedableLoop(prog, key)(the collapsed canonical form). The L-60 collapsed-form index makes the component re-match that loop back to the rolled progression →match.style == the rolled style,match.id == the rolled progression's id. That id is already excluded fromentries(theprog.id === match.idguard), so the surviving same-style progressions are exactly "other options for the style I rolled." - Live detection: the detected repeating loop is what App passes as
loop={detectedProgression}; the same match resolves the live style/id.
Recommendation — use the internally-computed match.style; do NOT add a prop.
Naming it explicitly: inside rankRelatedProgressions, after the existing
const match = matchLoopToProgression(...), take
const activeStyle = match.matched ? match.style : null
Rejected: threading a matchedStyle prop down from App (roulette knows it via
lastRolledStyleRef; live detection could expose its own match). It duplicates
state that the component already derives identically from the same loop, and
introduces a divergence risk (App's match vs the component's match drifting).
The single source of truth is the loop → its match. Keep it in one place.
No-match fallback. When match.matched === false (the loop matches no KB
progression — an off-book jam), activeStyle is null; the panel renders exactly
today's cross-style list (flat, floor RELATED_SCORE_FLOOR, existing
annotations). Nothing about the current behaviour changes when no style is locked.
2. Same-style-first presentation — two sections
When activeStyle != null, partition the scored candidates by
entry.style === activeStyle:
Primary — "Try these in {styleLabel}" (same-style siblings).
All same-style progressions except the one being played, in scorer order (§4),
capped at RELATED_MAX_ENTRIES (5). The score floor is relaxed to 0 for this
section — a sibling of your own style is never "junk"; it is exactly the "other
options" the user asked for. Styles hold ≤7 progressions, so this shows all of
them (blues → 4 siblings).
Secondary — "Same changes, other styles" (cross-style), demoted, small.
Only progressions whose canonical changes are identical to the loop
(sameChanges === true), capped at 2, floor kept. This preserves a genuinely
valuable, rare relative — "this exact turnaround also lives in jazz and gospel" —
without "going into other styles" for merely-similar material. If none qualify,
the section is omitted entirely.
Recommendation: ship both sections (option b). It honours "not necessarily go into other styles" (same-style leads and dominates the panel) while not hiding an exact-match cousin elsewhere. Dropping the secondary later is a one-line change (don't render it) if the user wants pure same-style — noted as the toggle.
Rejected: a hard same-style-only filter that never shows cross-style. It throws away the exact-match cousin (musically the most useful cross-style pointer we have) and would also have to special-case the no-match path. Kept only as the one-line fallback if the user insists on zero cross-style.
3. The reframe — "bridge/chorus/modifications" with NO new content
Same-style siblings must read as sections/variations to try, not a flat list.
We label each with a short role phrase derived only from data already in the
KB — comparing the sibling to the active (matched) progression. Fields used:
mode, bars (summed = the form length), qualities (the colour set),
name, level. No new fields.
siblingRole(sibling, active) → a short phrase or null, first rule that fires:
- mode differs →
"{mode} version"— minor→"minor version", major→"major version", else the mode name ("dorian version", …). - same mode, fewer total bars →
"shorter form". - same mode, more total bars →
"extended form". - same mode & length, a quality the active lacks →
"reharmonized". - otherwise →
null(honest: just the name + level badge, no role line).
The active progression's mode/bars come from looking the raw KB entry up by
match.id in kbRegistry[activeStyle].progressions (avoids any collapsed-
projection subtlety; the raw entry is authoritative).
Concrete — active = blues Standard 12-bar (major, 12 bars)
| sibling | mode | bars | role phrase | reads as |
|---|---|---|---|---|
| Quick-change 12-bar | major | 12 | null |
name + foundation (name already says "quick-change") |
| 8-bar blues | major | 8 | shorter form | "the compact take" |
| Minor blues | minor | 12 | minor version | "the minor cousin" |
| Turnaround cycle | major | 4 | shorter form | name already says "Turnaround cycle" |
Every phrase is honest and re-derivable from mode/bars/qualities. Where no
character is derivable (Quick-change: same mode, same length, same all-dom7 colour
set) we print nothing beyond the name and level badge — the name carries it.
The two "shorter form"s are fine: their names (8-bar blues, Turnaround cycle) already distinguish them, and the scorer orders them by proximity (§4).
This is where the "bridge/chorus/modification" feel comes from: the KB already holds the minor version, the short form, the turnaround, the quick-change — we are just reframing existing siblings with a one-line role, not synthesising sections.
4. Ranking within same-style — keep the scorer order
The existing scorer still runs over every candidate. Within the same-style group
the +40 SCORE_SAME_STYLE term is constant, so it cancels — ordering is driven by
same changes (+100) → shared transitions → Jaccard → − length, i.e.
musical proximity to the loop you're playing. That is more useful mid-jam than
alphabetical, so:
Recommendation: keep the scorer sort within same-style. Do NOT re-sort by level/name. The closest variation to what your hands are already doing surfaces first. The only change is relaxing the floor to 0 for this section (§2) so no sibling is silently dropped for being "only" a distant relative — the user explicitly wants all the style's options.
5. Edge / empty states
- Style with only 1 progression (0 same-style siblings after excluding the played one). No current style hits this (all have ≥5), but handle it: render an honest primary line — "You're on the only {styleLabel} loop in the songbook." — then fall through to the cross-style secondary (kept at floor). Never pad.
- Secondary empty (no exact cross-style cousin) → omit the secondary section silently; the primary stands alone.
- No match at all (
activeStyle == null) → today's single flat list, unchanged. - Instrument-agnostic — confirmed.
RelatedProgressionsreads onlyprogressions(degrees/qualities/rn/level/name/mode/bars), never theinstrumentscells. It behaves identically under guitar / piano / bass; the global instrument selector does not touch it.
6. L-72 change list (RelatedProgressions.jsx only)
match.js needs no change — matchLoopToProgression already returns
{ style, id, progression }. Reuse it.
In rankRelatedProgressions:
-
- After computing
match, derive `const activeStyle = match.matched ? match.style - null
and look up the raw active progression (kbRegistry[activeStyle] ?.progressions.find(p => p.id === match.id)) for itsmode+ summedbars`.
- After computing
- Keep the existing scoring loop. Change the floor test: skip the
score < RELATED_SCORE_FLOOR → continueonly whenstyle === activeStyle(same-style siblings bypass the floor); cross-style keeps the floor. - Add a
siblingRole(sibling, active)helper (§3) and attachroleto each same-style entry; leave cross-style entries' existingannotationintact. - Partition the sorted entries into
primary(style === activeStyle, cap 5) andsecondary(style !== activeStyle && sameChanges, cap 2). Return{ match, activeStyle, activeStyleLabel, primary, secondary }. Keepentries(=primary.concat(secondary)) on the return so any existingentries[0]/entries.lengthreads still resolve during the transition. WhenactiveStyle == null, return today's shape (primary = entries,secondary = []) so the render path collapses to the current flat list.
In the render:
5. Two <section>-internal blocks: primary headed "Try these in
{activeStyleLabel}", secondary headed "Same changes, elsewhere" (rendered
only when non-empty). Same-style rows swap the annotation line for the role
phrase (omit the line when role == null). Reuse the existing LevelBadge /
ChordChain — no new tokens, no new colours (all within bg-panel /
border-border / text-gray-* / text-amber, already in use).
Smoke pins (C-50, scripts/smoke.mjs §8) will shift — re-derive, coordinate
with C-70:
- Pin (a) collapsed 12-bar in A: top stays blues/blues-8bar, score 92
(blues is now the active style; 8-bar is a same-style sibling; the +40 is still
in its score, order unchanged). Its annotation label changes from
'shares I7→V7'to the role'shorter form'— the assertion must be re-pinned. - Pin (b) ii–V–I in C: top stays jazz/jazz-251-minor, score 156 (same-style,
same changes). Its label changes from
'same changes'to the role'minor version'(mode differs) — re-pin. - Scores and top ids are stable; only the annotation strings and the return shape move. Add coverage for the primary/secondary split and the no-match fallback.
Rejected alternatives (recap)
- Computed modifications (synthesise a bridge/chorus by transposing or reharmonising) — rejected per the user's explicit choice to reuse existing KB content; also risks inventing non-idiomatic changes we can't vouch for.
- Hard same-style-only, no cross-style ever — rejected: loses the exact-match cousin in another style and complicates the no-match path. Retained only as the one-line "drop the secondary" toggle if the user later wants zero cross-style.
- An explicit
matchedStyleprop from App — rejected as redundant (§1): the component derives the same style from the same loop; a prop only adds a divergence surface.