8d74e54f5a
buildLoopIndex additionally indexes each progression's collapsed form (adjacent (degree,suffix) dedup + wrap), appended after raw entries so every prior matcher winner is preserved except the enumerated country-145 self-attribution. Repairs a pre-existing LIVE-detection bug: a real 12-bar or 8-bar blues stream collapses to fewer names than the raw KB degrees, so it matched NOTHING and the Jam Guide sat empty. JamGuide reads authored plays through the collapsed station's sourceIndex (all four guitar/piano/bass paths). Adds seedableLoop + the round-trip roulette pool (smoke sweep: 4 failures / pool 52). theory.js canonicalize exported. Critic PASS (fix-(a) additivity proven registry-wide; pool reproduced from the spec; bass sourceIndex station 5 -> raw 10 hand-checked). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
749 lines
32 KiB
JavaScript
749 lines
32 KiB
JavaScript
// ─── 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 (0–11)
|
||
*/
|
||
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 (0–11) + 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 0–11 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 (0–11) 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 — ii–V–I 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 0–1 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.
|
||
// Exported additively for match.js's seedableLoop / round-trip pool (task L-60,
|
||
// jam-roulette.md §3.2) — the seed must canonicalize identically to detection.
|
||
export 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 2–8) 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))
|
||
}
|