// ─── Loop matching & positioning ────────────────────────────────────────────── // // Shared, rotation-invariant loop matcher used by both the live ProgressionBanner // and the upcoming Jam Guide (Roadmap) panel. // // Two concerns live here: // 1. POSITION — where in a known loop the player currently is (drives playheads). // 2. IDENTITY — which KB progression a detected loop *is*, independent of which // chord it happens to start on ([I IV V] == [IV V I] == [V I IV]). // // This file is matching/position logic only. All music-theory primitives // (note names, roman numerals) come read-only from theory.js. import { NOTES, NOTES_FLAT, toRomanNumeral } from './theory' // ─── Chord-name parsing (local — theory.js does not export a pitch-class helper) ─ // Maps a note name (sharp or flat spelling) to its pitch class 0–11, or -1. function pitchClassOf(note) { if (!note) return -1 const idx = NOTES.indexOf(note) if (idx !== -1) return idx return NOTES_FLAT.indexOf(note) } // Splits a chord name like "Cm7" / "F#maj7" / "Bb" into { root, quality }. function parseChord(chordName) { if (!chordName || typeof chordName !== 'string') return null const m = chordName.match(/^([A-G][b#]?)(.*)$/) if (!m) return null return { root: m[1], quality: m[2] } } // Pitch class (0–11) of a chord name's root, or -1 if unparseable. export function chordRootPC(chordName) { const parsed = parseChord(chordName) if (!parsed) return -1 return pitchClassOf(parsed.root) } // Maps a chord-name suffix to a KB quality token (the vocabulary used in // kb/**/progressions.js `qualities` arrays). Used as a soft tie-breaker only. function suffixToQuality(suffix) { switch (suffix) { case '': return 'maj' case 'm': return 'min' case '7': return 'dom7' case 'maj7': return 'maj7' case 'm7': return 'min7' case 'dim': return 'dim' case 'dim7': return 'dim7' case 'm7b5': return 'half_dim' case 'aug': return 'aug' case 'sus4': return 'sus4' case 'sus2': return 'sus2' case '6': return 'maj6' case 'm6': return 'min6' case 'add9': return 'add9' default: return null } } // ─── Position within a known loop ───────────────────────────────────────────── /** * findLoopPosition(chordHistory, loop) → index in `loop` the player is on, or -1. * * `loop` is an array of chord-name strings (e.g. the detected progression, or a * KB progression rendered into chord names for the current key). Robust to the * most recent chord: walks back from each occurrence of the last chord and keeps * the position whose preceding chords best continue the recent history; falls * back to the first plain occurrence of the last chord. */ export function findLoopPosition(chordHistory, loop) { if (!loop?.length || !chordHistory?.length) return -1 const last = chordHistory[chordHistory.length - 1] for (let p = loop.length - 1; p >= 0; p--) { if (loop[p] !== last) continue let match = true for (let i = 1; i < Math.min(p + 1, chordHistory.length); i++) { if (loop[p - i] !== chordHistory[chordHistory.length - 1 - i]) { match = false; break } } if (match) return p } return loop.indexOf(last) } // ─── Degree-relative, rotation-invariant identity ───────────────────────────── // Turns a degree sequence (semitone offsets) into the canonical rotation: // 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) { const n = degrees.length if (n === 0) return '' let best = null for (let r = 0; r < n; r++) { const base = degrees[r] const rot = [] for (let i = 0; i < n; i++) { const d = degrees[(r + i) % n] rot.push(((d - base) % 12 + 12) % 12) } const key = rot.join(',') if (best === null || key < best) best = key } return best } // 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) { if (!loop?.length) return null const pcs = loop.map(chordRootPC) if (pcs.some(pc => pc < 0)) return null const tonic = pcs[0] return pcs.map(pc => ((pc - tonic) % 12 + 12) % 12) } /** * Precompute a lookup table from a KB registry (kb/index.js default export). * Returns { byCanonical: Map } where each entry is * { style, id, progression }. Build once, reuse across matches. */ export function buildLoopIndex(kb) { const byCanonical = new Map() if (!kb) return { byCanonical } for (const style of Object.keys(kb)) { const progs = kb[style]?.progressions if (!Array.isArray(progs)) continue for (const progression of progs) { if (!Array.isArray(progression.degrees) || !progression.degrees.length) continue const canon = canonicalDegrees(progression.degrees) const entry = { style, id: progression.id, progression } if (!byCanonical.has(canon)) byCanonical.set(canon, []) byCanonical.get(canon).push(entry) } } return { byCanonical } } // A "no match" result, shared so callers can compare shape consistently. const NO_MATCH = { matched: false, id: null, style: null, rotation: 0, progression: null } /** * matchLoopToProgression(loop, kbOrIndex) → match result. * * `loop` is an array of chord-name strings (the detected repeating progression). * `kbOrIndex` is either the kb/index.js default export OR a prebuilt index from * buildLoopIndex() (preferred for repeated calls). * * Rotation-invariant: a detected [F7 G7 Cmaj7] (= IV V I) matches a KB * progression stored as ii–V–I etc. when their degree shapes coincide. * * Returns: * { matched: true, id, style, rotation, progression } * rotation = index into `loop` that aligns with the KB progression's first * degree (degrees[0]); callers rotate the loop by `rotation` to put it in * canonical KB order for the playhead. * { matched: false, id: null, style: null, rotation: 0, progression: null } */ export function matchLoopToProgression(loop, kbOrIndex) { const degrees = loopToDegrees(loop) if (!degrees) return NO_MATCH const index = kbOrIndex?.byCanonical instanceof Map ? kbOrIndex : buildLoopIndex(kbOrIndex) const canon = canonicalDegrees(degrees) const candidates = index.byCanonical.get(canon) if (!candidates || !candidates.length) return NO_MATCH // Disambiguate same-shape progressions (e.g. major vs minor ii–V) by chord // quality overlap with the loop, then by fewer chords (the tighter loop). const loopQualities = loop.map(c => { const p = parseChord(c) return p ? suffixToQuality(p.quality) : null }) let best = null let bestScore = -Infinity for (const cand of candidates) { const kbQ = cand.progression.qualities let qScore = 0 if (Array.isArray(kbQ) && kbQ.length) { const kbSet = new Set(kbQ) for (const q of loopQualities) if (q && kbSet.has(q)) qScore++ } // Prefer quality overlap; break ties toward shorter canonical progressions. const score = qScore * 100 - cand.progression.degrees.length if (score > bestScore) { bestScore = score; best = cand } } if (!best) return NO_MATCH return { matched: true, id: best.id, style: best.style, rotation: rotationToCanonicalOrder(degrees, best.progression.degrees), progression: best.progression, } } // Finds the rotation `r` of the loop's degree sequence that matches the KB // progression's degree shape (re-based to start at the KB's first degree). // Returns the index into the loop that lines up with KB degrees[0]. function rotationToCanonicalOrder(loopDegrees, kbDegrees) { const n = loopDegrees.length if (n === 0 || n !== kbDegrees.length) return 0 const kbBase = kbDegrees[0] const kbShape = kbDegrees.map(d => ((d - kbBase) % 12 + 12) % 12).join(',') for (let r = 0; r < n; r++) { const base = loopDegrees[r] const rot = [] for (let i = 0; i < n; i++) { const d = loopDegrees[(r + i) % n] rot.push(((d - base) % 12 + 12) % 12) } if (rot.join(',') === kbShape) return r } return 0 } // ─── Roman-numeral helpers (re-exported for callers that only need matching) ─── /** * loopRomanNumerals(loop, keyRoot, keyMode) → roman numeral per chord. * Thin wrapper over theory.toRomanNumeral so banner/roadmap share one path. */ export function loopRomanNumerals(loop, keyRoot, keyMode) { if (!loop?.length) return [] return loop.map(chord => (keyRoot ? toRomanNumeral(chord, keyRoot, keyMode) : chord)) }