diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 44d872b..2eb99cc 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -16,7 +16,7 @@ const KEY_VOTE_THRESHOLD = 9 // out of 12 — very stable // Chord detection tuning const CHROMA_SMOOTH = 12 // frames to average (~200ms at 60fps) -const CHORD_VOTE_THRESHOLD = 4 // consecutive identical detections required +const CHORD_VOTE_THRESHOLD = 3 // consecutive identical detections required export default function App() { // ── Listening state ────────────────────────────────────────────────────── diff --git a/frontend/src/components/AudioCapture.jsx b/frontend/src/components/AudioCapture.jsx index bc8a950..a60a82c 100644 --- a/frontend/src/components/AudioCapture.jsx +++ b/frontend/src/components/AudioCapture.jsx @@ -26,7 +26,7 @@ import { NOTES } from '../lib/theory' const PITCH_FFT = 4096 // ~90ms window — good temporal resolution for pitch const CHORD_FFT = 16384 // ~370ms window — 2.7 Hz/bin, separates low semitones -const MIN_CLARITY = 0.85 +const MIN_CLARITY = 0.80 const MIN_VOLUME = 0.01 const NOISE_FLOOR = -65 // dB @@ -44,7 +44,7 @@ function computeChroma(freqData, sampleRate, fftSize) { for (let bin = 2; bin < N; bin++) { const freq = bin * binHz - if (freq < 80 || freq > 2000) continue + if (freq < 80 || freq > 4000) continue const db = freqData[bin] if (db < NOISE_FLOOR) continue diff --git a/frontend/src/lib/theory.js b/frontend/src/lib/theory.js index 444fe4b..d7c2133 100644 --- a/frontend/src/lib/theory.js +++ b/frontend/src/lib/theory.js @@ -1,61 +1,123 @@ // ─── 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'] +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'] -const SCALES = { +// 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], } -// Krumhansl-Schmuckler key profiles +// 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] -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' }, - sus4: { intervals: [0, 5, 7], suffix: 'sus4' }, - sus2: { intervals: [0, 2, 7], suffix: 'sus2' }, +// 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', 'min7', 'dim', 'half_dim', 'aug', 'sus4', '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.04 + +// Chord quality for each scale degree in major and minor +const DEGREE_QUALITIES = { + major: ['', 'm', 'm', '', '', 'm', 'dim'], + minor: ['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: { name: 'Pop', rn: ['I', 'V', 'vi', 'IV'], degrees: [0, 7, 9, 5] }, - blues: { name: 'Blues', rn: ['I', 'IV', 'V'], degrees: [0, 5, 7] }, - folk: { name: 'Folk', rn: ['I', 'IV', 'I', 'V'], degrees: [0, 5, 0, 7] }, - jazz: { name: 'Jazz', rn: ['ii', 'V', 'I'], degrees: [2, 7, 0] }, - rock: { name: 'Rock', rn: ['I', 'bVII', 'IV', 'I'], degrees: [0, 10, 5, 0] }, + pop: { name: 'Pop', rn: ['I', 'V', 'vi', 'IV'], degrees: [0, 7, 9, 5] }, + blues: { name: 'Blues', rn: ['I', 'IV', 'V'], degrees: [0, 5, 7] }, + folk: { name: 'Folk', rn: ['I', 'IV', 'I', 'V'], degrees: [0, 5, 0, 7] }, + jazz: { name: 'Jazz', rn: ['ii', 'V', 'I'], degrees: [2, 7, 0] }, + rock: { name: 'Rock', rn: ['I', 'bVII', 'IV', 'I'], degrees: [0, 10, 5, 0] }, + '50s': { name: "'50s", rn: ['I', 'vi', 'IV', 'V'], degrees: [0, 9, 5, 7] }, + flamen: { name: 'Flamenco', rn: ['i', 'bVII', 'bVI', 'V'], degrees: [0, 10, 8, 7] }, } -// ─── Helpers ───────────────────────────────────────────────────────────────── +// ─── Internal helpers ──────────────────────────────────────────────────────── -function noteName(semitone) { - return NOTES[((semitone % 12) + 12) % 12] +function noteName(semitone, preferFlat = false) { + const pc = ((semitone % 12) + 12) % 12 + return preferFlat ? NOTES_FLAT[pc] : NOTES[pc] } -function correlation(a, b) { - const meanA = a.reduce((s, v) => s + v, 0) / a.length - const meanB = b.reduce((s, v) => s + v, 0) / b.length +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 < a.length; i++) { - const da = a[i] - meanA, db = b[i] - meanB - num += da * db + 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 ─────────────────────────────────────────────────────────── /** * 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) { @@ -63,102 +125,83 @@ export function detectKey(noteHistory) { return { root: 'C', mode: 'major', confidence: 0 } } - // Build pitch class frequency vector const freq = new Array(12).fill(0) for (const note of noteHistory) { freq[((note % 12) + 12) % 12]++ } - let best = { root: 'C', mode: 'major', score: -Infinity } + let best = { root: 0, mode: 'major', score: -Infinity } for (let root = 0; root < 12; root++) { - // Rotate profile to match root - const rotated = [...Array(12)].map((_, i) => freq[(i + root) % 12]) - - const scoreMaj = correlation(rotated, KS_MAJOR) - const scoreMin = correlation(rotated, KS_MINOR) - + 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 } } - // Normalise confidence to 0–1 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 getFullScale(root, mode) { - const rootIdx = NOTES.indexOf(root) - const intervals = SCALES[mode] ?? SCALES.major - return intervals.map(i => noteName(rootIdx + i)) +export function getScale(root, mode) { + const rootIdx = noteIndex(root) + if (rootIdx === -1) return [] + return (SCALES[mode] ?? SCALES.major).map(i => noteName(rootIdx + i)) } -export function getPentatonicScale(root, mode) { - const key = mode === 'minor' ? 'pentatonic_minor' : 'pentatonic_major' - const rootIdx = NOTES.indexOf(root) - return SCALES[key].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) { - // Parse e.g. "Am", "G", "Fmaj7", "Bdim" - const match = chordName.match(/^([A-G]#?)(.*)$/) + const match = chordName.match(/^([A-G][b#]?)(.*)$/) if (!match) return [] - const root = NOTES.indexOf(match[1]) - const suffix = match[2] || '' - - for (const [, type] of Object.entries(CHORD_TYPES)) { - if (type.suffix === suffix) { - return type.intervals.map(i => noteName(root + i)) - } - } - // Default to major triad - return CHORD_TYPES.maj.intervals.map(i => noteName(root + i)) + 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 = NOTES.indexOf(root) - const scale = SCALES[mode] ?? SCALES.major - - return scale.map((degree, i) => { - const chordRoot = rootIdx + degree - // Determine chord quality from scale degree - let type - if (mode === 'major') { - type = [CHORD_TYPES.maj, CHORD_TYPES.min, CHORD_TYPES.min, - CHORD_TYPES.maj, CHORD_TYPES.maj, CHORD_TYPES.min, - CHORD_TYPES.dim][i] - } else { - type = [CHORD_TYPES.min, CHORD_TYPES.dim, CHORD_TYPES.maj, - CHORD_TYPES.min, CHORD_TYPES.min, CHORD_TYPES.maj, - CHORD_TYPES.maj][i] - } - return noteName(chordRoot) + type.suffix - }) + 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]) } // ─── Progression suggestions ───────────────────────────────────────────────── export function getSuggestedProgressions(root, mode) { - const rootIdx = NOTES.indexOf(root) - const scale = SCALES[mode] ?? SCALES.major - // Chord qualities for each scale degree - const qualities = mode === 'major' - ? ['', 'm', 'm', '', '', 'm', 'dim'] - : ['m', 'dim', '', 'm', 'm', '', ''] + const rootIdx = noteIndex(root) + if (rootIdx === -1) return [] + const scale = SCALES[mode] ?? SCALES.major + const qualities = DEGREE_QUALITIES[mode] ?? DEGREE_QUALITIES.major - return Object.entries(PROGRESSIONS).map(([genre, prog]) => { + return Object.values(PROGRESSIONS).map(prog => { const chords = prog.degrees.map(semitones => { - const noteIdx = (rootIdx + semitones) % 12 - const name = noteName(noteIdx) - // Find which scale degree this is to get quality + const noteIdx = (rootIdx + semitones) % 12 const degreeIdx = scale.indexOf(semitones) - const quality = degreeIdx >= 0 ? qualities[degreeIdx] : '' - return name + quality + // 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 } }) @@ -167,87 +210,100 @@ export function getSuggestedProgressions(root, mode) { // ─── Chroma-based chord matching ───────────────────────────────────────────── /** - * matchChordFromChroma(chroma, keyInfo, bassPC?, strictDiatonic?) - * chroma: Float32Array[12], normalised 0–1 energy per pitch class - * bassPC: pitch class of the detected bass/root note, or null - * strictDiatonic: if true, only considers chords in the key (beginner mode) + * 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) { +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 diatonic = new Set(getChordsInKey(keyInfo.root, keyInfo.mode)) + const diatonicSet = new Set(getChordsInKey(keyInfo.root, keyInfo.mode)) - // Match triads + dominant 7ths (blues/rock/band) + sus chords (rock guitar) - const matchTypes = [ - CHORD_TYPES.maj, - CHORD_TYPES.min, - CHORD_TYPES.dom7, - CHORD_TYPES.min7, - CHORD_TYPES.dim, - CHORD_TYPES.sus4, - ] - let best = { name: null, score: -Infinity } + let best = { name: null, score: -Infinity } let secondScore = -Infinity for (let r = 0; r < 12; r++) { - for (const type of matchTypes) { - const tones = new Set(type.intervals.map(i => (r + i) % 12)) + for (const typeKey of MATCH_CHORD_TYPES) { + const type = CHORD_TYPES[typeKey] const chordName = noteName(r) + type.suffix - if (strictDiatonic && !diatonic.has(chordName)) continue + if (strictDiatonic && !diatonicSet.has(chordName)) continue + + 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) { - // Root note is the strongest identity signal — weight it double - inEnergy += chroma[pc] * 2 + inEnergy += chroma[pc] * 2 // root carries strongest identity signal } else if (tones.has(pc)) { - inEnergy += chroma[pc] + inEnergy += chroma[pc] } else { outEnergy += chroma[pc] } } + if (inEnergy + outEnergy < 0.05) continue const coverageScore = inEnergy / (inEnergy + outEnergy * 0.5) - // Bass note matching chord root is a strong harmonic signal - const bassBonus = (bassPC !== null && r === bassPC) ? 0.15 : 0 - const diatonicBonus = diatonic.has(chordName) ? 0.15 : 0 + 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 } + best = { name: chordName, score: finalScore } } else if (finalScore > secondScore) { secondScore = finalScore } } } - // Require a clear winner — if two chords score too close it's a transition/ambiguity - return (best.score > 0.42 && best.score - secondScore > 0.08) ? best.name : null + 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]#?)(.*)$/) + + const match = chordName.match(/^([A-G][b#]?)(.*)$/) if (!match) return '?' const [, root, quality] = match - const chordRootIdx = NOTES.indexOf(root) - const keyRootIdx = NOTES.indexOf(keyRoot) + + 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 scale = SCALES[keyMode] ?? SCALES.major const degreeIdx = scale.indexOf(semitones) - if (degreeIdx < 0) return '♭' + ['I','II','III','IV','V','VI','VII'][0] // chromatic - const ROMAN = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII'] - const rn = ROMAN[degreeIdx] - const isMinor = /^m(?!aj)/.test(quality) || quality === 'dim' - return isMinor ? rn.toLowerCase() : rn + 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 ───────────────────────────────────────── @@ -255,27 +311,56 @@ export function toRomanNumeral(chordName, keyRoot, keyMode) { /** * detectRepeatingProgression(history) → chord[] or null * Returns the most-recently-completed repeating pattern (length 2–6). - * Requires the pattern to appear at least twice in the last 20 chords. + * Uses non-overlapping match counting to avoid over-counting. */ export function detectRepeatingProgression(history) { if (!history || history.length < 4) return null - const window = history.slice(-20) + const window = history.slice(-20) let best = null, bestScore = 0 for (let len = 2; len <= 6; len++) { if (len * 2 > window.length) break + const candidate = window.slice(-len) - let reps = 0 - for (let i = 0; i <= window.length - len; i++) { - if (window.slice(i, i + len).every((c, j) => c === candidate[j])) reps++ + let reps = 0, i = 0 + + while (i <= window.length - len) { + if (candidate.every((c, j) => c === window[i + j])) { + reps++ + i += len // skip past match — non-overlapping + } else { + i++ + } } - // Score favours longer patterns that repeat more + const score = reps * len if (reps >= 2 && score > bestScore) { bestScore = score - best = candidate + best = candidate } } + return best } + +// ─── 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)) +} diff --git a/frontend/src/lib/theory2.js b/frontend/src/lib/theory2.js new file mode 100644 index 0000000..e0e61fa --- /dev/null +++ b/frontend/src/lib/theory2.js @@ -0,0 +1,476 @@ +// ─── 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 (only major/minor 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', 'min7', 'dim', 'half_dim', 'aug', 'sus4', '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.08 + +// Chord quality arrays for each scale degree in major and minor +const DEGREE_QUALITIES = { + major: ['', 'm', 'm', '', '', 'm', 'dim'], + minor: ['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: { name: 'Pop', rn: ['I', 'V', 'vi', 'IV'], degrees: [0, 7, 9, 5] }, + blues: { name: 'Blues', rn: ['I', 'IV', 'V'], degrees: [0, 5, 7] }, + folk: { name: 'Folk', rn: ['I', 'IV', 'I', 'V'], degrees: [0, 5, 0, 7] }, + jazz: { name: 'Jazz', rn: ['ii', 'V', 'I'], degrees: [2, 7, 0] }, + rock: { name: 'Rock', rn: ['I', 'bVII', 'IV', 'I'], degrees: [0, 10, 5, 0] }, + '50s': { name: "'50s", rn: ['I', 'vi', 'IV', 'V'], degrees: [0, 9, 5, 7] }, + flamen: { name: 'Flamenco',rn: ['i', 'bVII', 'bVI', 'V'],degrees:[0, 10, 8, 7] }, +} + +// ─── Internal helpers ──────────────────────────────────────────────────────── + +/** + * Returns the note name for a given semitone value (0–11, wraps automatically). + * @param {number} semitone + * @param {boolean} [preferFlat=false] + * @returns {string} + */ +function noteName(semitone, preferFlat = false) { + const pc = ((semitone % 12) + 12) % 12 + return preferFlat ? NOTES_FLAT[pc] : NOTES[pc] +} + +/** + * Pearson correlation between two equal-length numeric arrays. + * Returns a value in [-1, 1]. A small epsilon avoids division by zero. + * @param {number[]} a + * @param {number[]} b + * @returns {number} + */ +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) +} + +/** + * Returns the semitone offset (0–11) of a note name, or -1 if not found. + * Accepts both sharp and flat spellings. + * @param {string} note + * @returns {number} + */ +function noteIndex(note) { + const idx = NOTES.indexOf(note) + if (idx !== -1) return idx + return NOTES_FLAT.indexOf(note) // handles Db, Eb, etc. +} + +// ─── Key Detection ─────────────────────────────────────────────────────────── + +/** + * Detects the most likely musical key from a recent history of played notes. + * + * Uses the Krumhansl-Schmuckler algorithm: builds a pitch-class frequency + * vector and correlates it against major and minor profiles for all 12 roots. + * + * @param {number[]} noteHistory MIDI note numbers or pitch-class integers (0–11) + * @returns {{ root: string, mode: 'major'|'minor', confidence: number }} + * confidence is normalised to [0, 1]; values below ~0.5 are unreliable. + */ +export function detectKey(noteHistory) { + if (!noteHistory || noteHistory.length < 4) { + return { root: 'C', mode: 'major', confidence: 0 } + } + + // Build pitch-class frequency vector + 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++) { + // Rotate the observed frequencies to align with the profile's C-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 } + } + + // Map correlation [-1, 1] to a rough confidence in [0, 1] + const confidence = Math.max(0, Math.min(1, (best.score + 1) / 2)) + + return { root: noteName(best.root), mode: best.mode, confidence } +} + +// ─── Scale helpers ─────────────────────────────────────────────────────────── + +/** + * Returns all note names in a given scale. + * @param {string} root e.g. 'G', 'F#' + * @param {keyof SCALES} mode + * @returns {string[]} + */ +export function getScale(root, mode) { + const rootIdx = noteIndex(root) + if (rootIdx === -1) return [] + const intervals = SCALES[mode] ?? SCALES.major + return intervals.map(i => noteName(rootIdx + i)) +} + +// Legacy aliases kept for backwards compatibility +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. + * @param {string[]} playedNotes e.g. ['C', 'E', 'G'] + * @param {string} root + * @returns {{ mode: string, label: string, notes: string[] }[]} + */ +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 ─────────────────────────────────────────────────────────── + +/** + * Parses a chord name and returns its component note names. + * @param {string} chordName e.g. 'Am', 'Gmaj7', 'Fdim', 'Baug' + * @returns {string[]} + */ +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 // default to major triad + + return type.intervals.map(i => noteName(root + i)) +} + +/** + * Returns the diatonic chords (triads) for every scale degree. + * @param {string} root + * @param {'major'|'minor'} mode + * @returns {string[]} e.g. ['C', 'Dm', 'Em', 'F', 'G', 'Am', 'Bdim'] + */ +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]) +} + +// ─── Progression suggestions ───────────────────────────────────────────────── + +/** + * Returns common chord progressions transposed to the given key. + * + * For non-diatonic degrees (e.g. bVII in rock), the quality falls back to a + * major triad rather than silently producing a wrong chord name. + * + * @param {string} root + * @param {'major'|'minor'} mode + * @returns {{ genre: string, rn: string[], chords: string[] }[]} + */ +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 name = noteName(noteIdx) + const degreeIdx = scale.indexOf(semitones) + // Diatonic degree → use proper quality; chromatic (e.g. bVII) → major triad + const quality = degreeIdx >= 0 ? qualities[degreeIdx] : '' + return name + quality + }) + return { genre: prog.name, rn: prog.rn, chords } + }) +} + +// ─── Chroma-based chord matching ───────────────────────────────────────────── + +/** + * Matches the most likely chord from a chroma energy vector. + * + * The algorithm scores each candidate chord by comparing in-chord vs + * out-of-chord energy, with bonuses for bass-note and diatonic alignment. + * Returns null when no unambiguous winner is found (e.g. during a transition). + * + * @param {Float32Array|number[]} chroma 12-element pitch-class energy (0–1) + * @param {{ root: string, mode: string }} keyInfo + * @param {number|null} [bassPC=null] Pitch class of the detected bass note + * @param {boolean} [strictDiatonic=false] Only consider diatonic chords + * @param {number} [minScore] Override default minimum match score + * @param {number} [minMargin] Override default ambiguity margin + * @returns {string|null} Chord name, e.g. 'Am7', or null if ambiguous + */ +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)) + + let inEnergy = 0, outEnergy = 0 + for (let pc = 0; pc < 12; pc++) { + if (pc === r) { + // Root carries the strongest identity signal — double weight + inEnergy += chroma[pc] * 2 + } else if (tones.has(pc)) { + inEnergy += chroma[pc] + } else { + outEnergy += chroma[pc] + } + } + + // Skip near-silence + if (inEnergy + outEnergy < 0.05) continue + + const coverageScore = inEnergy / (inEnergy + outEnergy * 0.5) + const bassBonus = bassPC !== null && r === bassPC ? 0.15 : 0 + const diatonicBonus = diatonicSet.has(chordName) ? 0.10 : 0 + + const finalScore = coverageScore + bassBonus + diatonicBonus + + if (finalScore > best.score) { + secondScore = best.score + best = { name: chordName, score: finalScore } + } else if (finalScore > secondScore) { + secondScore = finalScore + } + } + } + + const clearWinner = best.score >= minScore && (best.score - secondScore) >= minMargin + return clearWinner ? best.name : null +} + +// ─── Roman numeral notation ─────────────────────────────────────────────────── + +/** + * Converts a chord name to its Roman numeral relative to a key. + * + * Non-diatonic (borrowed/chromatic) chords are returned with a flat prefix, + * e.g. 'Bb' in C major → '♭VII'. Previously this always returned '♭I'. + * + * @param {string} chordName e.g. 'Am', 'G7' + * @param {string} keyRoot e.g. 'C' + * @param {string} keyMode e.g. 'major' + * @returns {string} + */ +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: find the nearest diatonic degree above and flat 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 ───────────────────────────────────────── + +/** + * Scans recent chord history for a repeating pattern of length 2–6. + * + * Returns the most recently completed pattern that appears at least twice + * within the last 20 chords. Longer patterns that repeat are preferred over + * shorter ones via a `repetitions × length` score. + * + * @param {string[]} history Ordered list of chord names + * @returns {string[]|null} Detected repeating pattern, or null + */ +export function detectRepeatingProgression(history) { + if (!history || history.length < 4) return null + + const window = history.slice(-20) + let best = null, bestScore = 0 + + for (let len = 2; len <= 6; len++) { + if (len * 2 > window.length) break + + const candidate = window.slice(-len) + let reps = 0 + + // Count non-overlapping matches from left to right + let i = 0 + while (i <= window.length - len) { + const matches = candidate.every((c, j) => c === window[i + j]) + if (matches) { + reps++ + i += len // skip past this match to avoid overlaps + } else { + i++ + } + } + + const score = reps * len + if (reps >= 2 && score > bestScore) { + bestScore = score + best = candidate + } + } + + return best +} + +// ─── Utility ───────────────────────────────────────────────────────────────── + +/** + * Returns the interval name for a semitone distance (0–11). + * @param {number} semitones + * @returns {string} + */ +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' +} + +/** + * Transposes a chord name by a given number of semitones. + * @param {string} chordName e.g. 'Am7' + * @param {number} semitones Positive = up, negative = down + * @returns {string} + */ +export function transposeChord(chordName, semitones) { + const match = chordName.match(/^([A-G][b#]?)(.*)$/) + if (!match) return chordName + const newRoot = noteName(noteIndex(match[1]) + semitones) + return newRoot + match[2] +} + +/** + * Transposes an entire progression by a given number of semitones. + * @param {string[]} chords + * @param {number} semitones + * @returns {string[]} + */ +export function transposeProgression(chords, semitones) { + return chords.map(c => transposeChord(c, semitones)) +} \ No newline at end of file