melodic fine tuning
This commit is contained in:
+13
-10
@@ -15,8 +15,8 @@ const KEY_VOTE_WINDOW = 12
|
|||||||
const KEY_VOTE_THRESHOLD = 9 // out of 12 — very stable
|
const KEY_VOTE_THRESHOLD = 9 // out of 12 — very stable
|
||||||
|
|
||||||
// Chord detection tuning
|
// Chord detection tuning
|
||||||
const CHROMA_SMOOTH = 8 // frames to average (~130ms at 60fps)
|
const CHROMA_SMOOTH = 12 // frames to average (~200ms at 60fps)
|
||||||
const CHORD_VOTE_THRESHOLD = 3 // consecutive agreements before commit
|
const CHORD_VOTE_THRESHOLD = 4 // consecutive identical detections required
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
// ── Listening state ──────────────────────────────────────────────────────
|
// ── Listening state ──────────────────────────────────────────────────────
|
||||||
@@ -33,7 +33,8 @@ export default function App() {
|
|||||||
|
|
||||||
// Effective key used by all components
|
// Effective key used by all components
|
||||||
const effectiveKey = lockedKey ?? keyInfo
|
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 ───────────────────────────────────────────────────────────
|
// ── Chord state ───────────────────────────────────────────────────────────
|
||||||
const [chordHistory, setChordHistory] = useState([])
|
const [chordHistory, setChordHistory] = useState([])
|
||||||
@@ -122,17 +123,19 @@ export default function App() {
|
|||||||
for (let i = 0; i < 12; i++) avg[i] /= CHROMA_SMOOTH
|
for (let i = 0; i < 12; i++) avg[i] /= CHROMA_SMOOTH
|
||||||
|
|
||||||
const chord = matchChordFromChroma(avg, key, bassPC, isStrictMode)
|
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
|
const votes = chordVotesRef.current
|
||||||
votes.push(chord)
|
votes.push(chord)
|
||||||
if (votes.length > CHORD_VOTE_THRESHOLD * 2) votes.shift()
|
if (votes.length > CHORD_VOTE_THRESHOLD) votes.shift()
|
||||||
|
|
||||||
const counts = {}
|
// All last N detections must agree — one wrong reading resets the streak
|
||||||
for (const v of votes) counts[v] = (counts[v] || 0) + 1
|
if (votes.length >= CHORD_VOTE_THRESHOLD && votes.every(v => v === votes[0])) {
|
||||||
const [winner, winCount] = Object.entries(counts).sort((a, b) => b[1] - a[1])[0]
|
const winner = votes[0]
|
||||||
|
|
||||||
if (winCount >= CHORD_VOTE_THRESHOLD) {
|
|
||||||
setChordHistory(prev => {
|
setChordHistory(prev => {
|
||||||
if (prev[prev.length - 1] === winner) return prev
|
if (prev[prev.length - 1] === winner) return prev
|
||||||
return [...prev.slice(-30), winner]
|
return [...prev.slice(-30), winner]
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ function computeChroma(freqData, sampleRate, fftSize) {
|
|||||||
|
|
||||||
for (let bin = 2; bin < N; bin++) {
|
for (let bin = 2; bin < N; bin++) {
|
||||||
const freq = bin * binHz
|
const freq = bin * binHz
|
||||||
if (freq < 80 || freq > 6000) continue
|
if (freq < 80 || freq > 2000) continue
|
||||||
const db = freqData[bin]
|
const db = freqData[bin]
|
||||||
if (db < NOISE_FLOOR) continue
|
if (db < NOISE_FLOOR) continue
|
||||||
|
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ export function matchChordFromChroma(chroma, keyInfo, bassPC = null, strictDiato
|
|||||||
CHORD_TYPES.sus4,
|
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 (let r = 0; r < 12; r++) {
|
||||||
for (const type of matchTypes) {
|
for (const type of matchTypes) {
|
||||||
@@ -210,15 +211,21 @@ export function matchChordFromChroma(chroma, keyInfo, bassPC = null, strictDiato
|
|||||||
|
|
||||||
const coverageScore = inEnergy / (inEnergy + outEnergy * 0.5)
|
const coverageScore = inEnergy / (inEnergy + outEnergy * 0.5)
|
||||||
// Bass note matching chord root is a strong harmonic signal
|
// 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 diatonicBonus = diatonic.has(chordName) ? 0.15 : 0
|
||||||
|
|
||||||
const finalScore = coverageScore + bassBonus + diatonicBonus
|
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 ───────────────────────────────────────────────────
|
// ─── Roman numeral notation ───────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user