diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 43e1e4f..44d872b 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -15,8 +15,8 @@ const KEY_VOTE_WINDOW = 12 const KEY_VOTE_THRESHOLD = 9 // out of 12 — very stable // Chord detection tuning -const CHROMA_SMOOTH = 8 // frames to average (~130ms at 60fps) -const CHORD_VOTE_THRESHOLD = 3 // consecutive agreements before commit +const CHROMA_SMOOTH = 12 // frames to average (~200ms at 60fps) +const CHORD_VOTE_THRESHOLD = 4 // consecutive identical detections required export default function App() { // ── Listening state ────────────────────────────────────────────────────── @@ -33,7 +33,8 @@ export default function App() { // Effective key used by all components const effectiveKey = lockedKey ?? keyInfo - const isStrictMode = appMode === 'beginner' && lockedKey !== null + // Locked key = only match diatonic chords regardless of mode — far fewer candidates + const isStrictMode = lockedKey !== null // ── Chord state ─────────────────────────────────────────────────────────── const [chordHistory, setChordHistory] = useState([]) @@ -122,17 +123,19 @@ export default function App() { for (let i = 0; i < 12; i++) avg[i] /= CHROMA_SMOOTH const chord = matchChordFromChroma(avg, key, bassPC, isStrictMode) - if (!chord) return + if (!chord) { + // Ambiguous moment (transition, silence) — reset streak, history is untouched + chordVotesRef.current = [] + return + } const votes = chordVotesRef.current votes.push(chord) - if (votes.length > CHORD_VOTE_THRESHOLD * 2) votes.shift() + if (votes.length > CHORD_VOTE_THRESHOLD) votes.shift() - const counts = {} - for (const v of votes) counts[v] = (counts[v] || 0) + 1 - const [winner, winCount] = Object.entries(counts).sort((a, b) => b[1] - a[1])[0] - - if (winCount >= CHORD_VOTE_THRESHOLD) { + // All last N detections must agree — one wrong reading resets the streak + if (votes.length >= CHORD_VOTE_THRESHOLD && votes.every(v => v === votes[0])) { + const winner = votes[0] setChordHistory(prev => { if (prev[prev.length - 1] === winner) return prev return [...prev.slice(-30), winner] diff --git a/frontend/src/components/AudioCapture.jsx b/frontend/src/components/AudioCapture.jsx index 68005b5..bc8a950 100644 --- a/frontend/src/components/AudioCapture.jsx +++ b/frontend/src/components/AudioCapture.jsx @@ -44,7 +44,7 @@ function computeChroma(freqData, sampleRate, fftSize) { for (let bin = 2; bin < N; bin++) { const freq = bin * binHz - if (freq < 80 || freq > 6000) continue + if (freq < 80 || freq > 2000) 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 75f9f01..444fe4b 100644 --- a/frontend/src/lib/theory.js +++ b/frontend/src/lib/theory.js @@ -187,6 +187,7 @@ export function matchChordFromChroma(chroma, keyInfo, bassPC = null, strictDiato CHORD_TYPES.sus4, ] let best = { name: null, score: -Infinity } + let secondScore = -Infinity for (let r = 0; r < 12; r++) { for (const type of matchTypes) { @@ -210,15 +211,21 @@ export function matchChordFromChroma(chroma, keyInfo, bassPC = null, strictDiato const coverageScore = inEnergy / (inEnergy + outEnergy * 0.5) // Bass note matching chord root is a strong harmonic signal - const bassBonus = (bassPC !== null && r === bassPC) ? 0.35 : 0 + const bassBonus = (bassPC !== null && r === bassPC) ? 0.15 : 0 const diatonicBonus = diatonic.has(chordName) ? 0.15 : 0 const finalScore = coverageScore + bassBonus + diatonicBonus - if (finalScore > best.score) best = { name: chordName, score: finalScore } + if (finalScore > best.score) { + secondScore = best.score + best = { name: chordName, score: finalScore } + } else if (finalScore > secondScore) { + secondScore = finalScore + } } } - return best.score > 0.42 ? best.name : null + // 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 } // ─── Roman numeral notation ───────────────────────────────────────────────────