feat(match): rotation-invariant loop matcher + findLoopPosition (task L-01)

New src/lib/match.js: matchLoopToProgression (degree-relative, rotation-invariant,
quality tie-break, returns id/style/rotation for playhead alignment), findLoopPosition,
buildLoopIndex, shared numeral helper. ProgressionBanner refactored to import
findLoopPosition (behaviour-preserving). Critic PASS: build green, banner identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
vadimwit
2026-06-15 00:25:20 +01:00
parent fbaf5f4f91
commit 51358b2b56
2 changed files with 233 additions and 14 deletions
+1 -14
View File
@@ -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 ?? {}
+232
View File
@@ -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 011, 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 (011) 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<canonicalDegrees, entry[]> } 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 iiVI 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 iiV) 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))
}