From 6e8d8f40fc277de99066f2810e437f226b8c8466 Mon Sep 17 00:00:00 2001 From: vadimwit Date: Sat, 11 Jul 2026 16:52:52 +0100 Subject: [PATCH] =?UTF-8?q?feat(related):=20RelatedProgressions=20?= =?UTF-8?q?=E2=80=94=20loop-relative=20suggestions=20replace=20the=20gener?= =?UTF-8?q?ic=20table=20(task=20L-51)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KB-sourced ranking per the one-screen spec: same canonical degree shape +100 (with the mandatory collapse of consecutive equal degree/quality pairs — a real detector-produced 12-bar fires its blues relatives despite the raw-index matcher bug), same style +40, shared transitions +12 capped 36, degree-set Jaccard, length penalty; floor 24, max 5, never padded; entries render name/style/level/annotation + the chord chain realized in the live key as real buttons -> ChordDetailModal. match.js: additive exports only. Ride-along: dead currentChord prop dropped from the banner mount. Critic PASS (both hand-verified scores recomputed independently; full entry lists byte-matched on three scenarios; collapse counterfactual = 39). Co-Authored-By: Claude Fable 5 --- src/App.jsx | 18 +- src/components/RelatedProgressions.jsx | 321 +++++++++++++++++++++++++ src/lib/match.js | 8 +- 3 files changed, 339 insertions(+), 8 deletions(-) create mode 100644 src/components/RelatedProgressions.jsx diff --git a/src/App.jsx b/src/App.jsx index 271f757..c831e9c 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -10,6 +10,7 @@ import DebugView from './components/DebugView' import DrumView from './components/DrumView' import { NOTES, detectKey, detectTopKeys, matchChordFromChroma, detectRepeatingProgression, getChordTones, getChordCandidates, getNoteHistoryAnalysis } from './lib/theory' import ChordDetailModal from './components/ChordDetailModal' +import RelatedProgressions from './components/RelatedProgressions' import LoopStation from './components/LoopStation' import JamGuide, { KnowledgeDock } from './components/JamGuide' import { useLoopEngine } from './services/loopEngine' @@ -653,17 +654,16 @@ export default function App() { chordHistory={chordHistory} keyInfo={effectiveKey} detectedProgression={detectedProgression} - currentChord={currentChord} onChordClick={setSelectedChord} /> {/* ── The jam dashboard grid (task L-50, one-screen.md §1/§6): JamGuide owns the two-column layout — LEFT: compact instrument view (chosen here, passed as the mainView slot) + licks strip + the related- - progressions slot (null until L-51); RIGHT: the suggested-voicings - rail. ProgressionSuggestions is unmounted (file kept) — its job - split into the rail + RelatedProgressions per the user directive. - Follows the one global instrument selector. ── */} + progressions slot (RelatedProgressions, task L-51); RIGHT: the + suggested-voicings rail. ProgressionSuggestions is unmounted (file + kept) — its job split into the rail + RelatedProgressions per the + user directive. Follows the one global instrument selector. ── */} } } - relatedSlot={null} + relatedSlot={ + + } fill={jamView} /> diff --git a/src/components/RelatedProgressions.jsx b/src/components/RelatedProgressions.jsx new file mode 100644 index 0000000..dbfaf6d --- /dev/null +++ b/src/components/RelatedProgressions.jsx @@ -0,0 +1,321 @@ +import { useMemo } from 'react' +import kb from '../data/kb/index.js' +import { NOTES, CHORD_TYPES } from '../lib/theory' +import { + buildLoopIndex, + matchLoopToProgression, + loopToDegrees, + canonicalDegrees, + chordRootPC, +} from '../lib/match' + +// ─── RelatedProgressions — loop-relative songbook relatives (task L-51) ─────── +// +// 3–5 KB progressions genuinely related to the DETECTED loop, replacing the +// retired generic ProgressionSuggestions table. KB-sourced only, loop-relative +// by construction. Spec: docs/design/one-screen.md §5 (ranking + render + empty +// states) and §6 (component boundary: pure/prop-driven, computes its own match +// so it stays file-disjoint from D-51's rail work). +// +// Props: +// loop : string[] | null — the detected repeating progression (chord names) +// keyInfo : { root, mode, confidence } | null — effective key (realizes chips) +// onChordClick : fn(chordName) — opens ChordDetailModal (App's setSelectedChord) +// +// The ranking (rankRelatedProgressions) is exported for smoke coverage. + +// Suffix → KB quality token, derived from CHORD_TYPES (the vocabulary +// authority — same inverse mapping as match.js's private suffixToQuality; all +// 14 suffixes are unique). Unknown suffixes stay unmapped → wildcard (§5: +// match on Δ alone). +const SUFFIX_TO_QUALITY = Object.fromEntries( + Object.entries(CHORD_TYPES).map(([quality, def]) => [def.suffix, quality]) +) + +// §5 scoring constants. +const SCORE_SAME_CHANGES = 100 // same canonical degree shape +const SCORE_SAME_STYLE = 40 // same style as the matched progression +const SCORE_PER_TRANSITION = 12 // per shared (Δ, quality→quality) transition… +const TRANSITION_CAP = 36 // …capped (= 3 distinct transitions) +const SCORE_JACCARD = 16 // × rebased degree-set Jaccard +export const RELATED_SCORE_FLOOR = 24 +export const RELATED_MAX_ENTRIES = 5 + +// The module-level index (§6: "matchLoopToProgression over a module-level +// index is trivially cheap") — built once, shared across renders. +const DEFAULT_INDEX = buildLoopIndex(kb) + +// ─── The MANDATORY collapse step (§5 step 2) ────────────────────────────────── +// The live detected loop is a COLLAPSED form (detection never commits the same +// chord twice in a row), while KB `degrees` are raw, bar-per-bar — blues-12bar +// is [0,0,0,0,5,5,0,0,7,5,0,7]. Compared raw, the +100 same-shape term would +// never fire for collapse-affected KB progressions. So before +// canonicalDegrees(p.degrees) AND before building T(p): collapse consecutive +// equal (degree, quality) pairs — including the wrap-around pair (last === first +// as a cycle). Each surviving unit keeps the rn of its first bar for the +// "shares {rn}→{rn}" annotation. +// (L-60 will land a shared collapsed-form index in match.js; this local +// collapse is deliberately component-scoped until then — one-screen.md §9.) +export function collapseChanges(progression) { + const degrees = Array.isArray(progression?.degrees) ? progression.degrees : [] + const qualities = Array.isArray(progression?.qualities) ? progression.qualities : [] + const rn = Array.isArray(progression?.rn) ? progression.rn : [] + const out = [] + for (let i = 0; i < degrees.length; i++) { + const q = qualities[i] ?? null + const prev = out[out.length - 1] + if (prev && prev.deg === degrees[i] && prev.quality === q) continue + out.push({ deg: degrees[i], quality: q, rn: rn[i] ?? '' }) + } + if (out.length > 1) { + const first = out[0] + const last = out[out.length - 1] + if (first.deg === last.deg && first.quality === last.quality) out.pop() + } + return out +} + +// Transition set over units [{deg, quality, rn?}], wrap-around included (§5): +// triples (Δ = (deg[i+1] − deg[i]) mod 12, q[i], q[i+1]); rn labels ride along +// for the annotation. +function transitionsOf(units) { + const n = units.length + if (n < 2) return [] + const out = [] + for (let i = 0; i < n; i++) { + const a = units[i] + const b = units[(i + 1) % n] + out.push({ + d: (((b.deg - a.deg) % 12) + 12) % 12, + qa: a.quality, + qb: b.quality, + rnFrom: a.rn ?? '', + rnTo: b.rn ?? '', + }) + } + return out +} + +// Live-loop units: degree from loopToDegrees, quality from the chord-name +// suffix (unmappable → null = wildcard, matches on Δ alone). +function loopUnitsOf(loop, loopDeg) { + return loop.map((name, i) => { + const m = typeof name === 'string' ? name.match(/^[A-G][b#]?(.*)$/) : null + const suffix = m ? m[1] : null + return { deg: loopDeg[i], quality: suffix != null ? SUFFIX_TO_QUALITY[suffix] ?? null : null } + }) +} + +// Distinct triples of T(p) matched by some loop transition (wildcard-aware), +// in p's canonical order — shared[0] names the annotation. +function sharedTransitions(loopT, pT) { + const seen = new Set() + const shared = [] + for (const t of pT) { + const key = `${t.d}|${t.qa}|${t.qb}` + if (seen.has(key)) continue + seen.add(key) + const hit = loopT.some( + l => + l.d === t.d && + (l.qa == null || t.qa == null || l.qa === t.qa) && + (l.qb == null || t.qb == null || l.qb === t.qb) + ) + if (hit) shared.push(t) + } + return shared +} + +// Rebased degree-set Jaccard over the canonical rotations (both sides rebased +// so their canonical first element is 0 — rotation-invariant by construction). +function degreeSetJaccard(aCanon, bCanon) { + const a = new Set(aCanon.split(',')) + const b = new Set(bCanon.split(',')) + let inter = 0 + for (const x of a) if (b.has(x)) inter++ + const union = a.size + b.size - inter + return union ? inter / union : 0 +} + +/** + * rankRelatedProgressions(loop, kbRegistry?) → { match, entries } | null + * + * The §5 ranking. Returns null when the loop yields no degrees (no loop / + * unparseable chord names) — the caller renders the idle state. `entries` is + * score-desc (stable by style, id), floored at RELATED_SCORE_FLOOR, at most + * RELATED_MAX_ENTRIES, never padded. Exported for smoke coverage. + */ +export function rankRelatedProgressions(loop, kbRegistry = kb) { + const loopDeg = loopToDegrees(loop) + if (!loopDeg || !loopDeg.length) return null + const loopCanon = canonicalDegrees(loopDeg) + const index = kbRegistry === kb ? DEFAULT_INDEX : buildLoopIndex(kbRegistry) + const match = matchLoopToProgression(loop, index) + const loopT = transitionsOf(loopUnitsOf(loop, loopDeg)) + + const entries = [] + for (const style of Object.keys(kbRegistry)) { + const styleLabel = kbRegistry[style]?.meta?.label ?? style + const progs = kbRegistry[style]?.progressions + if (!Array.isArray(progs)) continue + for (const prog of progs) { + if (!Array.isArray(prog.degrees) || !prog.degrees.length) continue + if (match.matched && prog.id === match.id) continue // §5: every p ≠ the matched one + const collapsed = collapseChanges(prog) + if (!collapsed.length) continue + const pCanon = canonicalDegrees(collapsed.map(u => u.deg)) + const shared = sharedTransitions(loopT, transitionsOf(collapsed)) + const sameChanges = pCanon === loopCanon + const sameStyle = match.matched && style === match.style + + let score = 0 + if (sameChanges) score += SCORE_SAME_CHANGES + if (sameStyle) score += SCORE_SAME_STYLE + score += Math.min(shared.length * SCORE_PER_TRANSITION, TRANSITION_CAP) + score += degreeSetJaccard(loopCanon, pCanon) * SCORE_JACCARD + score -= Math.abs(loop.length - collapsed.length) + if (score < RELATED_SCORE_FLOOR) continue // never pad with junk + + // §5 annotation: why this entry is here (survivors always have one — + // below the floor nothing scores on Jaccard − length alone). + const annotation = sameChanges + ? 'same changes' + : shared.length + ? `shares ${shared[0].rnFrom || '?'}→${shared[0].rnTo || '?'}` + : 'same style' + + entries.push({ + style, + styleLabel, + id: prog.id, + name: prog.name, + level: prog.level === 'intermediate' ? 'intermediate' : 'foundation', // untagged counts foundation + score, + annotation, + progression: prog, + }) + } + } + + entries.sort( + (a, b) => b.score - a.score || a.style.localeCompare(b.style) || a.id.localeCompare(b.id) + ) + return { match, entries: entries.slice(0, RELATED_MAX_ENTRIES) } +} + +// ─── Presentational bits ────────────────────────────────────────────────────── + +// Level chip — ExplorePanel's LevelBadge language (amber = the existing +// secondary-tone token; foundation stays quiet; untagged counts foundation). +function LevelBadge({ level }) { + if (level === 'intermediate') { + return ( + + intermediate + + ) + } + return ( + + foundation + + ) +} + +// The chord chain realized in the current key — the raw (bar-per-bar) KB form, +// truncated to the first 8 + "…" (§5); each chip taps through to +// ChordDetailModal via onChordClick (the banner-chip pattern, smaller). +const CHAIN_SHOWN = 8 + +function ChordChain({ progression, keyRoot, onChordClick }) { + const degrees = progression.degrees ?? [] + const qualities = progression.qualities ?? [] + const rn = progression.rn ?? [] + const shown = degrees.slice(0, CHAIN_SHOWN) + return ( +
+ {shown.map((deg, i) => { + const rootPc = (((keyRoot + deg) % 12) + 12) % 12 + const label = `${NOTES[rootPc]}${CHORD_TYPES[qualities[i]]?.suffix ?? ''}` + return ( + + ) + })} + {degrees.length > CHAIN_SHOWN && } +
+ ) +} + +export default function RelatedProgressions({ loop, keyInfo, onChordClick }) { + const loopKey = Array.isArray(loop) ? loop.join(',') : '' + const ranked = useMemo( + () => (loopKey ? rankRelatedProgressions(loop) : null), + [loopKey] // eslint-disable-line react-hooks/exhaustive-deps + ) + + // keyInfo.root is a note NAME; chips want a pitch class. Default to C (0) + // until a key is known, same fallback JamGuide's stations use. + const keyRoot = useMemo(() => { + const pc = chordRootPC(keyInfo?.root) + return pc >= 0 ? pc : 0 + }, [keyInfo?.root]) + + // Idle — no loop (or unparseable chord names, which rank as no loop). + if (!ranked) { + return ( +
+

+ Loop a progression — related changes from the songbook land here. +

+
+ ) + } + + return ( +
+

+ Related progressions · from the songbook + {keyInfo?.root ? ` · in ${keyInfo.root}` : ''} +

+ {ranked.entries.length === 0 ? ( + // Loop, but nothing clears the floor — honest, never padded (§5). +

+ Nothing in the songbook genuinely relates to this loop yet. +

+ ) : ( +
    + {ranked.entries.map(entry => ( +
  • +
    + {entry.name} + {entry.styleLabel} + + {entry.annotation} +
    + +
  • + ))} +
+ )} +
+ ) +} diff --git a/src/lib/match.js b/src/lib/match.js index 6d07004..5b0d1b3 100644 --- a/src/lib/match.js +++ b/src/lib/match.js @@ -91,7 +91,9 @@ export function findLoopPosition(chordHistory, loop) { // for each rotation, re-base so the first element is 0, then pick the // lexicographically smallest resulting sequence. Same loop → same string, // regardless of which chord it starts on. -function canonicalDegrees(degrees) { +// Exported additively for RelatedProgressions' ranking (task L-51, +// one-screen.md §5) — no behaviour change. +export function canonicalDegrees(degrees) { const n = degrees.length if (n === 0) return '' let best = null @@ -110,7 +112,9 @@ function canonicalDegrees(degrees) { // Converts a loop of chord-name strings into semitone offsets from the loop's // own first chord. Returns null if any chord root is unparseable. -function loopToDegrees(loop) { +// Exported additively for RelatedProgressions' ranking (task L-51, +// one-screen.md §5) — no behaviour change. +export function loopToDegrees(loop) { if (!loop?.length) return null const pcs = loop.map(chordRootPC) if (pcs.some(pc => pc < 0)) return null