Files
JamBuddy/src/lib/theory.js
T
vadimwit ce24d5490f fix(app): progression commit-layer tuning — survive fills, replace on evidence (task L-31)
COMMIT_VOTES=2 (unchanged, ~one bar), REPLACE_VOTES=3 (a transient or
flapping detection can never displace a committed loop; section change
replaces one commit later), NULL_CLEAR=6 (post-L-30, nulls only start
~28 commits after the loop last played — traced exactly). Vote ref now
{committedKey, candidateKey, candidateCount}; agreement resets rivals.
Diff = one hunk in the progression useEffect; audio-contract grep
clean. Ride-along: theory.js docstring overclaim softened (L-30 gate
finding b), comment-only. Critic PASS (independent re-trace of all
five scenarios + 3 hostile traces; REPLACE_VOTES=3 proven load-bearing).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 09:28:52 +01:00

747 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ─── Constants ───────────────────────────────────────────────────────────────
export const NOTES = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B']
export const NOTES_FLAT = ['C','Db','D','Eb','E','F','Gb','G','Ab','A','Bb','B']
// Semitone intervals for each scale mode
export const SCALES = {
major: [0, 2, 4, 5, 7, 9, 11],
minor: [0, 2, 3, 5, 7, 8, 10],
dorian: [0, 2, 3, 5, 7, 9, 10],
phrygian: [0, 1, 3, 5, 7, 8, 10],
lydian: [0, 2, 4, 6, 7, 9, 11],
mixolydian: [0, 2, 4, 5, 7, 9, 10],
pentatonic_major: [0, 2, 4, 7, 9],
pentatonic_minor: [0, 3, 5, 7, 10],
blues: [0, 3, 5, 6, 7, 10],
diminished: [0, 2, 3, 5, 6, 8, 9, 11],
whole_tone: [0, 2, 4, 6, 8, 10],
}
// Human-readable scale labels
export const SCALE_LABELS = {
major: 'Major',
minor: 'Natural Minor',
dorian: 'Dorian',
phrygian: 'Phrygian',
lydian: 'Lydian',
mixolydian: 'Mixolydian',
pentatonic_major: 'Major Pentatonic',
pentatonic_minor: 'Minor Pentatonic',
blues: 'Blues',
diminished: 'Diminished',
whole_tone: 'Whole Tone',
}
// Krumhansl-Schmuckler key profiles (major/minor only — used for key detection)
const KS_MAJOR = [6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88]
const KS_MINOR = [6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17]
// Chord type definitions: intervals (semitones from root) and display suffix
export const CHORD_TYPES = {
maj: { intervals: [0, 4, 7], suffix: '' },
min: { intervals: [0, 3, 7], suffix: 'm' },
dom7: { intervals: [0, 4, 7, 10], suffix: '7' },
maj7: { intervals: [0, 4, 7, 11], suffix: 'maj7' },
min7: { intervals: [0, 3, 7, 10], suffix: 'm7' },
dim: { intervals: [0, 3, 6], suffix: 'dim' },
dim7: { intervals: [0, 3, 6, 9], suffix: 'dim7' },
half_dim: { intervals: [0, 3, 6, 10], suffix: 'm7b5' },
aug: { intervals: [0, 4, 8], suffix: 'aug' },
sus4: { intervals: [0, 5, 7], suffix: 'sus4' },
sus2: { intervals: [0, 2, 7], suffix: 'sus2' },
maj6: { intervals: [0, 4, 7, 9], suffix: '6' },
min6: { intervals: [0, 3, 7, 9], suffix: 'm6' },
add9: { intervals: [0, 2, 4, 7], suffix: 'add9' },
}
// Chord types considered during real-time chroma matching
const MATCH_CHORD_TYPES = [
'maj', 'min', 'dom7', 'maj7', 'min7', 'dim', 'half_dim', 'aug', 'sus4', 'sus2', 'add9',
]
// Minimum score for a chord match to be reported
const CHORD_MATCH_MIN_SCORE = 0.42
// Minimum margin over second-best for a match to be considered unambiguous
const CHORD_MATCH_MIN_MARGIN = 0.07
// Chord quality for each scale degree, per mode
const DEGREE_QUALITIES = {
major: ['', 'm', 'm', '', '', 'm', 'dim'],
minor: ['m', 'dim', '', 'm', 'm', '', '' ],
dorian: ['m', 'm', '', '', 'm', 'dim', '' ],
phrygian: ['m', '', '', 'm', 'dim','', 'm' ],
lydian: ['', '', 'm', 'dim', '', 'm', 'm' ],
mixolydian: ['', 'm', 'dim', '', 'm', 'm', '' ],
}
const ROMAN_NUMERALS = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII']
// Common chord progressions by genre, expressed as semitone offsets from the root
const PROGRESSIONS = {
// Pop
pop: { name: 'Pop', rn: ['I', 'V', 'vi', 'IV'], degrees: [0, 7, 9, 5] },
pop2: { name: 'Pop', rn: ['I', 'IV', 'vi', 'V'], degrees: [0, 5, 9, 7] },
pop3: { name: 'Pop', rn: ['I', 'vi', 'ii', 'V'], degrees: [0, 9, 2, 7] },
// Blues
blues: { name: 'Blues', rn: ['I', 'IV', 'V'], degrees: [0, 5, 7] },
blues2: { name: 'Blues', rn: ['I', 'IV', 'V', 'IV'], degrees: [0, 5, 7, 5] },
blues3: { name: 'Blues', rn: ['I', 'I', 'IV', 'V'], degrees: [0, 0, 5, 7] },
// Folk
folk: { name: 'Folk', rn: ['I', 'IV', 'I', 'V'], degrees: [0, 5, 0, 7] },
folk2: { name: 'Folk', rn: ['I', 'V', 'IV', 'I'], degrees: [0, 7, 5, 0] },
folk3: { name: 'Folk', rn: ['I', 'ii', 'IV', 'V'], degrees: [0, 2, 5, 7] },
// Jazz
jazz: { name: 'Jazz', rn: ['ii', 'V', 'I'], degrees: [2, 7, 0] },
jazz2: { name: 'Jazz', rn: ['I', 'vi', 'ii', 'V'], degrees: [0, 9, 2, 7] },
jazz3: { name: 'Jazz', rn: ['iii', 'vi', 'ii', 'V'], degrees: [4, 9, 2, 7] },
// Rock
rock: { name: 'Rock', rn: ['I', 'bVII', 'IV', 'I'], degrees: [0, 10, 5, 0] },
rock2: { name: 'Rock', rn: ['I', 'IV', 'V', 'I'], degrees: [0, 5, 7, 0] },
rock3: { name: 'Rock', rn: ['I', 'bVII', 'bVI', 'bVII'], degrees: [0, 10, 8, 10] },
// '50s
'50s': { name: "'50s", rn: ['I', 'vi', 'IV', 'V'], degrees: [0, 9, 5, 7] },
'50s2': { name: "'50s", rn: ['I', 'V', 'vi', 'iii'], degrees: [0, 7, 9, 4] },
// Flamenco
flamen: { name: 'Flamenco', rn: ['i', 'bVII', 'bVI', 'V'], degrees: [0, 10, 8, 7] },
flamen2: { name: 'Flamenco', rn: ['i', 'bVI', 'bVII', 'i'], degrees: [0, 8, 10, 0] },
}
// ─── Internal helpers ────────────────────────────────────────────────────────
function noteName(semitone, preferFlat = false) {
const pc = ((semitone % 12) + 12) % 12
return preferFlat ? NOTES_FLAT[pc] : NOTES[pc]
}
function pearsonCorrelation(a, b) {
const n = a.length
const meanA = a.reduce((s, v) => s + v, 0) / n
const meanB = b.reduce((s, v) => s + v, 0) / n
let num = 0, denA = 0, denB = 0
for (let i = 0; i < n; i++) {
const da = a[i] - meanA
const db = b[i] - meanB
num += da * db
denA += da * da
denB += db * db
}
return num / Math.sqrt(denA * denB + 1e-10)
}
// Accepts both sharp (C#) and flat (Db) spellings
function noteIndex(note) {
const idx = NOTES.indexOf(note)
if (idx !== -1) return idx
return NOTES_FLAT.indexOf(note)
}
// ─── Key Detection ───────────────────────────────────────────────────────────
/**
* detectTopKeys(noteHistory, n) → top N key candidates sorted by confidence.
* Each entry: { root, mode, confidence }
*/
export function detectTopKeys(noteHistory, n = 3) {
if (!noteHistory || noteHistory.length < 8) return []
const freq = new Array(12).fill(0)
for (const note of noteHistory) freq[((note % 12) + 12) % 12]++
const candidates = []
for (let root = 0; root < 12; root++) {
const rotated = Array.from({ length: 12 }, (_, i) => freq[(i + root) % 12])
const scoreMaj = pearsonCorrelation(rotated, KS_MAJOR)
const scoreMin = pearsonCorrelation(rotated, KS_MINOR)
candidates.push({ root: noteName(root), mode: 'major', score: scoreMaj,
confidence: Math.max(0, Math.min(1, (scoreMaj + 1) / 2)) })
candidates.push({ root: noteName(root), mode: 'minor', score: scoreMin,
confidence: Math.max(0, Math.min(1, (scoreMin + 1) / 2)) })
}
return candidates.sort((a, b) => b.score - a.score).slice(0, n)
.map(({ root, mode, confidence }) => ({ root, mode, confidence }))
}
/**
* detectKey(noteHistory) → { root, mode, confidence }
* Uses Krumhansl-Schmuckler: correlates pitch-class histogram with key profiles.
* noteHistory: array of MIDI note numbers or pitch-class integers (011)
*/
export function detectKey(noteHistory) {
if (!noteHistory || noteHistory.length < 4) {
return { root: 'C', mode: 'major', confidence: 0 }
}
const freq = new Array(12).fill(0)
for (const note of noteHistory) {
freq[((note % 12) + 12) % 12]++
}
let best = { root: 0, mode: 'major', score: -Infinity }
for (let root = 0; root < 12; root++) {
const rotated = Array.from({ length: 12 }, (_, i) => freq[(i + root) % 12])
const scoreMaj = pearsonCorrelation(rotated, KS_MAJOR)
const scoreMin = pearsonCorrelation(rotated, KS_MINOR)
if (scoreMaj > best.score) best = { root, mode: 'major', score: scoreMaj }
if (scoreMin > best.score) best = { root, mode: 'minor', score: scoreMin }
}
const confidence = Math.max(0, Math.min(1, (best.score + 1) / 2))
return { root: noteName(best.root), mode: best.mode, confidence }
}
// ─── Scale helpers ───────────────────────────────────────────────────────────
export function getScale(root, mode) {
const rootIdx = noteIndex(root)
if (rootIdx === -1) return []
return (SCALES[mode] ?? SCALES.major).map(i => noteName(rootIdx + i))
}
// Legacy aliases
export const getFullScale = (root, mode) => getScale(root, mode)
export const getPentatonicScale = (root, mode) =>
getScale(root, mode === 'minor' ? 'pentatonic_minor' : 'pentatonic_major')
/**
* Returns all scale modes that contain every note in playedNotes.
* Useful for suggesting compatible scales from a detected chord or melody.
*/
export function getCompatibleScales(playedNotes, root) {
const played = new Set(playedNotes)
return Object.entries(SCALES)
.map(([mode]) => ({ mode, label: SCALE_LABELS[mode] ?? mode, notes: getScale(root, mode) }))
.filter(({ notes }) => [...played].every(n => notes.includes(n)))
}
// ─── Chord helpers ───────────────────────────────────────────────────────────
export function getChordTones(chordName) {
const match = chordName.match(/^([A-G][b#]?)(.*)$/)
if (!match) return []
const root = noteIndex(match[1])
const suffix = match[2] ?? ''
const type = Object.values(CHORD_TYPES).find(t => t.suffix === suffix) ?? CHORD_TYPES.maj
return type.intervals.map(i => noteName(root + i))
}
export function getChordsInKey(root, mode) {
const rootIdx = noteIndex(root)
if (rootIdx === -1) return []
const scale = SCALES[mode] ?? SCALES.major
const qualities = DEGREE_QUALITIES[mode] ?? DEGREE_QUALITIES.major
return scale.map((degree, i) => noteName(rootIdx + degree) + qualities[i])
}
// ─── Jam Guide: derived improv theory (L-01b) ─────────────────────────────────
//
// Three additive, pure helpers that feed the Roadmap Jam Guide. They are
// key-agnostic: chords are described as a pitch class (011) + a CHORD_TYPES
// quality key (e.g. 'min7', 'dom7', 'maj7', 'half_dim'), exactly the shape the
// KB progression data already uses (`degrees` + `qualities`). They reuse
// CHORD_TYPES / getChordTones rather than re-deriving intervals.
//
// Chord arg shape: { root, quality } where root is a pitch class 011 and
// quality is a CHORD_TYPES key. This matches the KB station model
// ({ degrees[i], qualities[i] }) so a caller maps a station to a chord with no
// string parsing.
// Internal: resolve a chord's pitch-class tone set from a CHORD_TYPES key.
// Returns the intervals mapped to absolute pitch classes, preserving the
// CHORD_TYPES interval order (index 0 = root, 1 = 3rd, last = 7th when present).
function chordTonePcs(rootPc, quality) {
const type = CHORD_TYPES[quality] ?? CHORD_TYPES.maj
const r = ((rootPc % 12) + 12) % 12
return type.intervals.map(i => (r + i) % 12)
}
/**
* guideTones(rootPc, quality) → { third, seventh, root }
*
* The guide tones a soloist targets: a chord's 3rd and 7th. Index 1 in every
* CHORD_TYPES interval set is the 3rd. A chord has a TRUE 7th only if its
* interval set contains 10 (m7) or 11 (M7) — NOT merely if it has 4 tones.
* When there is no real 7th (triads, and 4-tone non-7th chords like add9
* [0,2,4,7] or maj6/min6 [0,4,7,9]) we fall back to the 5th as the secondary
* anchor and flag `hasSeventh: false` so a caller labels it honestly ("5th",
* not "7th"). dim/dim7/aug have no perfect 5th, so they anchor on their ♭5/#5.
*
* Returns pitch classes (011) so the Roadmap TARGET lane can place dots in any
* key. `root` is included as the third anchor the design's badges reference.
*
* Sanity (C): guideTones(0,'maj7') → third 4 (E), seventh 11 (B), hasSeventh:true.
* guideTones(7,'dom7') → third 11 (B), seventh 5 (F), hasSeventh:true.
* guideTones(2,'min7') → third 5 (F), seventh 0 (C), hasSeventh:true.
* guideTones(0,'add9') → third 4 (E), seventh 7 (G=5th), hasSeventh:false.
* guideTones(0,'maj6') / (0,'min6') → seventh 7 (G=5th), hasSeventh:false.
*/
export function guideTones(rootPc, quality) {
const type = CHORD_TYPES[quality] ?? CHORD_TYPES.maj
const r = ((rootPc % 12) + 12) % 12
const ints = type.intervals
const third = (r + ints[1]) % 12 // index 1 is always the 3rd
// A chord has a TRUE 7th only if its interval set contains 10 (m7) or 11 (M7).
// `length >= 4` is wrong: add9 [0,2,4,7] and maj6/min6 [0,4,7,9] are 4-tone
// chords with NO seventh, so their secondary anchor must fall back to the 5th —
// never badge a 5th/6th as a "7". (add9 → hasSeventh:false, anchor=5th.)
const seventhInt = ints.find(i => i === 10 || i === 11) // m7 / M7
const hasSeventh = seventhInt !== undefined
// Secondary anchor: the true 7th when present; otherwise the perfect 5th (7).
// When no perfect 5th exists either (dim/dim7 carry a ♭5=6, aug carries a #5=8),
// anchor on whichever altered 5th the chord actually contains.
const fifthInt = ints.includes(7) ? 7 : ints.includes(6) ? 6 : ints.includes(8) ? 8 : 7
const seventh = (r + (hasSeventh ? seventhInt : fifthInt)) % 12
return { third, seventh, root: r, hasSeventh }
}
/**
* voiceLeadingPairs(chordA, chordB) → [{ from, to, semitones }]
*
* The voice-leading rails between two adjacent stations. For each guide tone of
* chordA (its 3rd and 7th) it finds the nearest tone of chordB (chordB's full
* tone set) and returns the smallest signed semitone move (negative = falls,
* positive = rises). Only rails moving ≤2 semitones are kept — that is the
* "smooth voice leading" band; bigger leaps are not rails. A 0-semitone rail
* (a held common tone) is kept so the design can draw "B holds → next loop".
*
* Each chord is { root, quality } (pitch class + CHORD_TYPES key).
*
* Sanity — iiVI in C (the gold-standard rails):
* Dm7 → G7 : 7th of Dm7 (C=0) → 3rd of G7 (B=11) ⇒ { from:0, to:11, semitones:-1 }
* G7 → Cmaj7: 7th of G7 (F=5) → 3rd of Cmaj7 (E=4) ⇒ { from:5, to:4, semitones:-1 }
* i.e. the classic 7→3 falls a half-step, proving C→B and F→E.
*/
export function voiceLeadingPairs(chordA, chordB) {
const a = guideTones(chordA.root, chordA.quality)
const targets = chordTonePcs(chordB.root, chordB.quality)
// smallest signed interval from pc x to pc y, in range (-6, 6]
const signedStep = (x, y) => {
let d = (((y - x) % 12) + 12) % 12
if (d > 6) d -= 12
return d
}
const rails = []
for (const from of [a.seventh, a.third]) { // 7th first (the headline 7→3 rail)
let best = null
for (const to of targets) {
const semitones = signedStep(from, to)
if (Math.abs(semitones) > 2) continue // only smooth moves are rails
if (best === null || Math.abs(semitones) < Math.abs(best.semitones)) {
best = { from, to, semitones }
}
}
if (best) rails.push(best)
}
return rails
}
// Default solo scale per chord quality (used when a KB pack didn't author an
// improv.scales entry for a degree). Maps a CHORD_TYPES key → a SCALES mode.
// 'locrian' is named here even though it isn't in SCALES (the KB references it
// for half-diminished); intervals are provided so a caller never has to look it
// up in SCALES for the half_dim case.
const SOLO_SCALE_BY_QUALITY = {
maj: 'major',
maj7: 'major', // Ionian; packs may upgrade to Lydian via improv.scales
maj6: 'major',
add9: 'major',
dom7: 'mixolydian',
min: 'dorian',
min7: 'dorian',
min6: 'dorian',
half_dim: 'locrian',
dim: 'diminished',
dim7: 'diminished',
aug: 'whole_tone',
sus4: 'mixolydian',
sus2: 'major',
}
// Locrian isn't in SCALES (no diatonic degree uses it); supply its intervals so
// soloScale can return a complete { name, intervals } for half-diminished.
const LOCRIAN_INTERVALS = [0, 1, 3, 5, 6, 8, 10]
/**
* soloScale(quality, mode) → { name, intervals }
*
* The computed default scale to solo over a chord of the given quality — the
* fallback for packs that didn't author an improv.scales entry. Returns the
* same shape callers already get from SCALES (a relative interval set) plus its
* mode `name`, so the Roadmap SCALE lane can label it ("G mixolydian") and the
* fretboard can offset the intervals against the chord root.
*
* `mode` (the song's key mode, e.g. 'major'/'minor') is an optional context
* hint: a dominant chord in a minor key implies the ♭9 colour, so we nudge
* dom7 → phrygian dominant there; otherwise it is ignored. This keeps the
* default sensible without needing per-chord KB data.
*
* Sanity: soloScale('dom7') → { name:'mixolydian', intervals:[0,2,4,5,7,9,10] }
* soloScale('min7') → { name:'dorian', intervals:[0,2,3,5,7,9,10] }
* soloScale('maj7') → { name:'major', intervals:[0,2,4,5,7,9,11] }
* soloScale('half_dim') → { name:'locrian', intervals:[0,1,3,5,6,8,10] }
* soloScale('dom7','minor') → phrygian-dominant intervals (♭9 over the V)
*/
export function soloScale(quality, mode) {
// Dominant in a minor key → Phrygian dominant (the ♭9/♭13 "V of i" sound).
if (quality === 'dom7' && mode === 'minor') {
return { name: 'phrygian_dominant', intervals: [0, 1, 4, 5, 7, 8, 10] }
}
const name = SOLO_SCALE_BY_QUALITY[quality] ?? 'major'
const intervals = name === 'locrian'
? LOCRIAN_INTERVALS
: (SCALES[name] ?? SCALES.major)
return { name, intervals }
}
// ─── Progression suggestions ─────────────────────────────────────────────────
export function getSuggestedProgressions(root, mode) {
const rootIdx = noteIndex(root)
if (rootIdx === -1) return []
const scale = SCALES[mode] ?? SCALES.major
const qualities = DEGREE_QUALITIES[mode] ?? DEGREE_QUALITIES.major
return Object.values(PROGRESSIONS).map(prog => {
const chords = prog.degrees.map(semitones => {
const noteIdx = (rootIdx + semitones) % 12
const degreeIdx = scale.indexOf(semitones)
// Chromatic degrees (e.g. bVII in rock) default to major triad
const quality = degreeIdx >= 0 ? qualities[degreeIdx] : ''
return noteName(noteIdx) + quality
})
return { genre: prog.name, rn: prog.rn, chords }
})
}
// ─── Chroma-based chord matching ─────────────────────────────────────────────
/**
* matchChordFromChroma(chroma, keyInfo, bassPC?, strictDiatonic?, minScore?, minMargin?)
* chroma: Float32Array[12], normalised 01 energy per pitch class.
* Returns null when no unambiguous winner is found (transition/silence).
*/
export function matchChordFromChroma(
chroma,
keyInfo,
bassPC = null,
strictDiatonic = false,
minScore = CHORD_MATCH_MIN_SCORE,
minMargin = CHORD_MATCH_MIN_MARGIN,
) {
if (!keyInfo?.root) return null
const diatonicSet = new Set(getChordsInKey(keyInfo.root, keyInfo.mode))
let best = { name: null, score: -Infinity }
let secondScore = -Infinity
for (let r = 0; r < 12; r++) {
for (const typeKey of MATCH_CHORD_TYPES) {
const type = CHORD_TYPES[typeKey]
const chordName = noteName(r) + type.suffix
if (strictDiatonic && !diatonicSet.has(chordName)) continue
const tones = new Set(type.intervals.map(i => (r + i) % 12))
// Skip if root has no meaningful energy — chord without its root is unreliable
if (chroma[r] < 0.08) continue
let inEnergy = 0, outEnergy = 0
for (let pc = 0; pc < 12; pc++) {
if (pc === r) {
inEnergy += chroma[pc] * 1.5 // root weight reduced: 2→1.5 (less root bias)
} else if (tones.has(pc)) {
inEnergy += chroma[pc]
} else {
outEnergy += chroma[pc]
}
}
if (inEnergy + outEnergy < 0.05) continue
// Stricter outEnergy penalty (0.7 vs 0.5) — wrong notes hurt more
const coverageScore = inEnergy / (inEnergy + outEnergy * 0.7)
const bassBonus = bassPC !== null && r === bassPC ? 0.15 : 0
const diatonicBonus = diatonicSet.has(chordName) ? 0.15 : 0
const finalScore = coverageScore + bassBonus + diatonicBonus
if (finalScore > best.score) {
secondScore = best.score
best = { name: chordName, score: finalScore }
} else if (finalScore > secondScore) {
secondScore = finalScore
}
}
}
return (best.score >= minScore && best.score - secondScore >= minMargin)
? best.name
: null
}
// ─── Roman numeral notation ───────────────────────────────────────────────────
/**
* Converts a chord name to its Roman numeral relative to a key.
* Chromatic (borrowed) chords get a flat prefix, e.g. Bb in C major → ♭VII.
*/
export function toRomanNumeral(chordName, keyRoot, keyMode) {
if (!chordName || !keyRoot) return '?'
const match = chordName.match(/^([A-G][b#]?)(.*)$/)
if (!match) return '?'
const [, root, quality] = match
const chordRootIdx = noteIndex(root)
const keyRootIdx = noteIndex(keyRoot)
if (chordRootIdx < 0 || keyRootIdx < 0) return '?'
const semitones = ((chordRootIdx - keyRootIdx) + 12) % 12
const scale = SCALES[keyMode] ?? SCALES.major
const degreeIdx = scale.indexOf(semitones)
let rn
if (degreeIdx >= 0) {
rn = ROMAN_NUMERALS[degreeIdx]
} else {
// Chromatic chord: flat the nearest diatonic degree above it
const nearestAbove = scale.findIndex(d => d > semitones)
const refDegree = nearestAbove >= 0 ? nearestAbove : 0
rn = '♭' + ROMAN_NUMERALS[refDegree]
}
const isMinorQuality = /^m(?!aj)/.test(quality) || quality === 'dim' || quality === 'm7b5'
return isMinorQuality ? rn.toLowerCase() : rn
}
// ─── Repeating progression detection ──────────────────────────────────────────
// True if arr has a "weak period" p < arr.length — i.e. arr[i] === arr[i-p] for
// every i ≥ p, meaning arr is a prefix of some p-periodic infinite sequence.
// This rejects not only exact repetitions ([A,B,A,B], p=2) but also self-overlap
// fragments/rotations of a shorter loop ([A,B,A], p=2; [C,G,Am,F,C], p=4) that
// would otherwise mint ghost candidates out of a short vamp. A genuine loop is
// never weak-periodic: a loop whose tail restates its head would produce an
// adjacent duplicate at the cycle seam, which the window collapse removes.
function hasShorterPeriod(arr) {
for (let p = 1; p < arr.length; p++) {
let periodic = true
for (let i = p; i < arr.length; i++) {
if (arr[i] !== arr[i - p]) { periodic = false; break }
}
if (periodic) return true
}
return false
}
// Match one occurrence of `cand` in `win` anchored at `start` (the first chord
// must match exactly), tolerating at most ONE edit per cycle: a substitution
// (one chord misdetected) or an insertion (one foreign chord slipped between two
// loop chords). The remainder after the edit must match exactly. Returns
// { end, matched, editPos } — `matched` = window indices that matched a loop
// chord, `editPos` = window index of the edit (-1 if the occurrence is exact) —
// or null if no match.
function matchLoopOccurrence(win, start, cand) {
if (win[start] !== cand[0]) return null
const matched = [start]
let i = start + 1
for (let j = 1; j < cand.length; j++) {
if (i >= win.length) return null
if (win[i] === cand[j]) { matched.push(i); i++; continue }
// First mismatch — the single allowed edit. Fork the two readings; each
// requires the rest of the candidate to match exactly from where it lands.
const exactFrom = (wi, cj) => {
const tail = []
for (; cj < cand.length; cj++, wi++) {
if (wi >= win.length || win[wi] !== cand[cj]) return null
tail.push(wi)
}
return { end: wi, tail }
}
const ins = exactFrom(i + 1, j) // win[i] is a foreign inserted chord
const sub = exactFrom(i + 1, j + 1) // win[i] is cand[j] misdetected
const hit = ins ?? sub // insertion keeps one more matched chord
if (!hit) return null
return { end: hit.end, matched: [...matched, ...hit.tail], editPos: i }
}
return { end: i, matched, editPos: -1 }
}
// Returns the lexicographically smallest rotation so the same loop always
// produces the same string regardless of where in the cycle we currently are.
function canonicalize(pattern) {
let best = pattern
for (let i = 1; i < pattern.length; i++) {
const rot = [...pattern.slice(i), ...pattern.slice(0, i)]
if (rot.join('\0') < best.join('\0')) best = rot
}
return best
}
/**
* detectRepeatingProgression(history) → chord[] or null (task L-30)
*
* Finds the loop the musician is playing NOW in the recent chord history.
* Candidates are contiguous slices (lengths 28) of the last-32 window with
* consecutive duplicate commits collapsed; candidates that are self-overlaps
* of a shorter period are rejected (see hasShorterPeriod). Each candidate is
* scored by recency-weighted COVERAGE: non-overlapping occurrences are counted
* with at most one substitution or insertion per cycle, every matched chord
* adds its recency weight, every edit subtracts the weight at the edit slot.
* Linear coverage (not reps × len²) means a ghost pattern straddling noise can
* never outscore the true loop, and exponential recency decay means the current
* section outscores a longer stale one. Requires ≥2 EXACT occurrences: an
* edit-tolerant occurrence corroborates a loop but cannot establish it — a
* loop means the sequence came back exactly, and a ghost slice that absorbs a
* noise chord into itself rarely recurs exactly (only phase-locked corruption
* of the same slot by the same chord can make one recur — and such data is
* genuinely periodic at that longer length). Returns the canonical
* (rotation-normalised) best pattern.
*/
export function detectRepeatingProgression(history) {
if (!history || history.length < 6) return null
// Collapse consecutive duplicate commits — a chord re-committed back-to-back
// is the same loop slot, not two. Non-adjacent repeats (e.g. Em … Em inside a
// 7-chord form) are meaningful and untouched.
const raw = history.slice(-32)
const win = raw.filter((c, i) => i === 0 || c !== raw[i - 1])
const n = win.length
if (n < 4) return null // shortest loop (2 chords) × 2 reps
// Recency weight per window slot: newest chord weighs 1, each step back
// decays by 0.9 (half-life ≈ 6.6 chords).
const RECENCY = 0.9
const weight = Array.from({ length: n }, (_, i) => RECENCY ** (n - 1 - i))
let best = null
let bestScore = 0
for (let len = 2; len <= 8; len++) {
if (len * 2 > n) break
const seen = new Set()
for (let start = 0; start <= n - len; start++) {
const candidate = win.slice(start, start + len)
const key = candidate.join('\0')
if (seen.has(key)) continue
seen.add(key)
if (hasShorterPeriod(candidate)) continue
let exactOccurrences = 0
let score = 0
let i = 0
while (i < n) {
const occ = matchLoopOccurrence(win, i, candidate)
// A 2-chord candidate may not take a substitution (1 matched chord is
// no evidence); insertions keep matched === len and stay allowed.
if (occ && occ.matched.length >= 2) {
if (occ.editPos < 0) exactOccurrences++
for (const p of occ.matched) score += weight[p]
if (occ.editPos >= 0) score -= weight[occ.editPos]
i = occ.end
} else {
i++
}
}
if (exactOccurrences < 2) continue // implies occurrences ≥ 2
if (score > bestScore) {
bestScore = score
best = candidate
}
}
}
return best ? canonicalize(best) : null
}
// ─── Debug / analysis helpers ─────────────────────────────────────────────────
/**
* Returns top N chord candidates with full score breakdown for the given chroma.
*/
export function getChordCandidates(chroma, keyInfo, bassPC = null, topN = 8) {
if (!keyInfo?.root || !chroma) return []
const diatonicSet = new Set(getChordsInKey(keyInfo.root, keyInfo.mode))
const candidates = []
for (let r = 0; r < 12; r++) {
for (const typeKey of MATCH_CHORD_TYPES) {
const type = CHORD_TYPES[typeKey]
const chordName = noteName(r) + type.suffix
const tones = new Set(type.intervals.map(i => (r + i) % 12))
let inEnergy = 0, outEnergy = 0
for (let pc = 0; pc < 12; pc++) {
if (pc === r) inEnergy += chroma[pc] * 2
else if (tones.has(pc)) inEnergy += chroma[pc]
else outEnergy += chroma[pc]
}
if (inEnergy + outEnergy < 0.05) continue
const coverage = inEnergy / (inEnergy + outEnergy * 0.5)
const bassBonus = bassPC !== null && r === bassPC ? 0.15 : 0
const diatBonus = diatonicSet.has(chordName) ? 0.15 : 0
const score = coverage + bassBonus + diatBonus
candidates.push({ name: chordName, score, coverage, bassBonus, diatBonus, diatonic: diatonicSet.has(chordName) })
}
}
return candidates.sort((a, b) => b.score - a.score).slice(0, topN)
}
/**
* Analyses note history: returns normalised pitch-class frequencies and
* top K-S key candidates with correlation scores.
*/
export function getNoteHistoryAnalysis(noteHistory) {
const freq = new Array(12).fill(0)
if (!noteHistory?.length) return { freq, topKeys: [] }
for (const note of noteHistory) freq[((note % 12) + 12) % 12]++
const total = noteHistory.length
const normalized = freq.map(f => f / total)
const candidates = []
for (let root = 0; root < 12; root++) {
const rotated = Array.from({ length: 12 }, (_, i) => normalized[(i + root) % 12])
candidates.push({ root: noteName(root), mode: 'major', score: pearsonCorrelation(rotated, KS_MAJOR) })
candidates.push({ root: noteName(root), mode: 'minor', score: pearsonCorrelation(rotated, KS_MINOR) })
}
return {
freq: normalized,
total,
topKeys: candidates.sort((a, b) => b.score - a.score).slice(0, 5),
}
}
// ─── Utilities ───────────────────────────────────────────────────────────────
export function intervalName(semitones) {
const names = [
'Unison', 'Minor 2nd', 'Major 2nd', 'Minor 3rd', 'Major 3rd',
'Perfect 4th', 'Tritone', 'Perfect 5th', 'Minor 6th',
'Major 6th', 'Minor 7th', 'Major 7th',
]
return names[((semitones % 12) + 12) % 12] ?? 'Unknown'
}
export function transposeChord(chordName, semitones) {
const match = chordName.match(/^([A-G][b#]?)(.*)$/)
if (!match) return chordName
return noteName(noteIndex(match[1]) + semitones) + match[2]
}
export function transposeProgression(chords, semitones) {
return chords.map(c => transposeChord(c, semitones))
}