diff --git a/src/components/ProgressionBanner.jsx b/src/components/ProgressionBanner.jsx index 70c2a6a..a851faa 100644 --- a/src/components/ProgressionBanner.jsx +++ b/src/components/ProgressionBanner.jsx @@ -1,22 +1,9 @@ import { useRef, useEffect } from 'react' import { toRomanNumeral } from '../lib/theory' +import { findLoopPosition } from '../lib/match' const HISTORY_SHOWN = 8 -function findLoopPosition(chordHistory, progression) { - if (!progression?.length || !chordHistory.length) return -1 - const last = chordHistory[chordHistory.length - 1] - for (let p = progression.length - 1; p >= 0; p--) { - if (progression[p] !== last) continue - let match = true - for (let i = 1; i < Math.min(p + 1, chordHistory.length); i++) { - if (progression[p - i] !== chordHistory[chordHistory.length - 1 - i]) { match = false; break } - } - if (match) return p - } - return progression.indexOf(last) -} - export default function ProgressionBanner({ chordHistory, keyInfo, detectedProgression, currentChord, onChordClick }) { const { root, mode, confidence } = keyInfo ?? {} diff --git a/src/lib/match.js b/src/lib/match.js new file mode 100644 index 0000000..6d07004 --- /dev/null +++ b/src/lib/match.js @@ -0,0 +1,232 @@ +// ─── 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)) +}