chord recognition improvements

This commit is contained in:
vadimwit
2026-03-08 01:38:09 +00:00
parent e3e4554d1f
commit 22b8f1fe04
4 changed files with 165 additions and 38 deletions
+63 -30
View File
@@ -60,10 +60,10 @@ export default function App() {
const lockedKeyRef = useRef(null)
useEffect(() => { showDebugRef.current = showDebug }, [showDebug])
// ── BPM estimation from chord-change intervals ────────────────────────────────
const [bpm, setBpm] = useState(null)
const chordTimestampsRef = useRef([])
const bpmSmoothRef = useRef(null) // exponentially smoothed BPM
// ── BPM estimation from onset timestamps ─────────────────────────────────────
const [bpm, setBpm] = useState(null)
const onsetTimestampsRef = useRef([])
const bpmSmoothRef = useRef(null)
// ── Key: auto-detected + optional lock ───────────────────────────────────────
const [keyInfo, setKeyInfo] = useState(null) // auto-detected
@@ -127,7 +127,7 @@ export default function App() {
pendingKeyRef.current = null
chromaIdxRef.current = 0
chromaRingRef.current = Array.from({ length: cfg.chromaSmooth }, () => new Float32Array(12))
chordTimestampsRef.current = []
onsetTimestampsRef.current = []
bpmSmoothRef.current = null
setKeyInfo(null)
setLockedKey(null)
@@ -228,6 +228,19 @@ export default function App() {
setDebugCandidates(getChordCandidates(avg, key, bassPC))
}
// Stability gate — if chroma is still changing across frames, we're mid-transition.
// Compute per-bin variance across the ring; bail if any bin is fluctuating heavily.
let maxVar = 0
for (let i = 0; i < 12; i++) {
let v = 0
for (const frame of ring) { const d = frame[i] - avg[i]; v += d * d }
if (v / cfg.chromaSmooth > maxVar) maxVar = v / cfg.chromaSmooth
}
if (maxVar > 0.05) {
chordVotesRef.current = []
return
}
const chord = matchChordFromChroma(avg, key, bassPC, false, cfg.chordMinScore)
if (!chord) {
chordVotesRef.current = []
@@ -245,31 +258,6 @@ export default function App() {
return [...prev.slice(-30), winner]
})
// BPM: track chord commit timestamps, trim outliers, smooth result
const now = performance.now()
const ts = chordTimestampsRef.current
ts.push(now)
if (ts.length > 32) ts.shift()
if (ts.length >= 4) {
const intervals = []
for (let i = 1; i < ts.length; i++) intervals.push(ts[i] - ts[i - 1])
// Trim the most extreme 25% on each side to remove held/rushed chords
const sorted = [...intervals].sort((a, b) => a - b)
const trim = Math.max(1, Math.floor(sorted.length * 0.25))
const trimmed = sorted.slice(trim, sorted.length - trim)
const avgMs = trimmed.reduce((a, b) => a + b) / trimmed.length
let raw = 60000 / avgMs
while (raw < 55) raw *= 2
while (raw > 220) raw /= 2
// Exponential smoothing — blend toward new estimate gradually
const prev = bpmSmoothRef.current
bpmSmoothRef.current = prev === null ? raw : 0.25 * raw + 0.75 * prev
setBpm(Math.round(bpmSmoothRef.current))
}
// Inject chord tones into note history to anchor key detection
const chordPCs = getChordTones(winner)
.map(n => NOTES.indexOf(n))
@@ -282,6 +270,50 @@ export default function App() {
}
}, [])
// ── Onset handler: drives BPM estimation via tempo histogram ────────────────
// Pairwise inter-onset intervals are folded into 55-220 BPM and vote in a
// histogram. Works with drums, guitar, piano, or mixed — whatever fires most
// consistently wins. Only updates when there's a clear peak (≥20% of votes).
const handleOnset = useCallback(() => {
const ts = onsetTimestampsRef.current
ts.push(performance.now())
if (ts.length > 64) ts.shift()
if (ts.length < 4) return
const recent = ts.slice(-24)
const bins = new Float32Array(221) // index = BPM (55220)
for (let i = 0; i < recent.length - 1; i++) {
for (let j = i + 1; j < recent.length && j < i + 8; j++) {
const ms = recent[j] - recent[i]
if (ms < 140 || ms > 6000) continue
// Fold interval into 55-220 BPM range (handles subdivisions & half-time)
let beatMs = ms
while (beatMs > 1091) beatMs /= 2
while (beatMs < 273) beatMs *= 2
if (beatMs < 273 || beatMs > 1091) continue
const bpm = Math.round(60000 / beatMs)
if (bpm >= 55 && bpm <= 220) bins[bpm] += 1 / (j - i) // weight closer pairs more
}
}
// Find peak with ±1 BPM smoothing
let best = 0, bestBpm = 0
for (let b = 56; b <= 219; b++) {
const s = bins[b - 1] + bins[b] + bins[b + 1]
if (s > best) { best = s; bestBpm = b }
}
const total = bins.reduce((a, v) => a + v, 0)
if (total < 1 || best / total < 0.2) return // no clear consensus yet
const prev = bpmSmoothRef.current
bpmSmoothRef.current = prev === null ? bestBpm : 0.25 * bestBpm + 0.75 * prev
setBpm(Math.round(bpmSmoothRef.current))
}, [])
const currentChord = chordHistory[chordHistory.length - 1]
if (showSettings) {
@@ -457,6 +489,7 @@ export default function App() {
<AudioCapture
onNote={handleNote}
onChroma={handleChroma}
onOnset={handleOnset}
isListening={isListening}
minClarity={config.minClarity}
minVolume={config.minVolume}
+20 -4
View File
@@ -81,7 +81,7 @@ function detectBassPC(freqData, sampleRate, fftSize) {
return ((bestMidi % 12) + 12) % 12
}
export default function AudioCapture({ onNote, onChroma, isListening, minClarity = 0.80, minVolume = 0.01, onPermissionError }) {
export default function AudioCapture({ onNote, onChroma, onOnset, isListening, minClarity = 0.80, minVolume = 0.01, onPermissionError }) {
const audioCtxRef = useRef(null)
const timeBufRef = useRef(null)
const freqBufRef = useRef(null)
@@ -93,11 +93,15 @@ export default function AudioCapture({ onNote, onChroma, isListening, minClarity
// All callbacks and thresholds read via refs — so start/stop never need to recreate
const onNoteRef = useRef(onNote)
const onChromaRef = useRef(onChroma)
const onOnsetRef = useRef(onOnset)
const onPermissionErrorRef = useRef(onPermissionError)
const minClarityRef = useRef(minClarity)
const minVolumeRef = useRef(minVolume)
const smoothRmsRef = useRef(0)
const lastOnsetRef = useRef(0)
useEffect(() => { onNoteRef.current = onNote }, [onNote])
useEffect(() => { onChromaRef.current = onChroma }, [onChroma])
useEffect(() => { onOnsetRef.current = onOnset }, [onOnset])
useEffect(() => { onPermissionErrorRef.current = onPermissionError }, [onPermissionError])
useEffect(() => { minClarityRef.current = minClarity }, [minClarity])
useEffect(() => { minVolumeRef.current = minVolume }, [minVolume])
@@ -118,8 +122,10 @@ export default function AudioCapture({ onNote, onChroma, isListening, minClarity
onPermissionErrorRef.current?.(err)
return
}
streamRef.current = stream
activeRef.current = true
streamRef.current = stream
activeRef.current = true
smoothRmsRef.current = 0
lastOnsetRef.current = 0
const ctx = new AudioContext()
audioCtxRef.current = ctx
@@ -136,7 +142,7 @@ export default function AudioCapture({ onNote, onChroma, isListening, minClarity
// Large analyser — chord detection needs fine frequency resolution
const ca = ctx.createAnalyser()
ca.fftSize = CHORD_FFT
ca.smoothingTimeConstant = 0.65
ca.smoothingTimeConstant = 0.5 // reduced from 0.65 — clears faster between chords
source.connect(ca)
freqBufRef.current = new Float32Array(ca.frequencyBinCount)
@@ -147,6 +153,16 @@ export default function AudioCapture({ onNote, onChroma, isListening, minClarity
pa.getFloatTimeDomainData(timeBuf)
const rms = Math.sqrt(timeBuf.reduce((s, v) => s + v * v, 0) / timeBuf.length)
// Onset detection — RMS spike significantly above smoothed baseline
const sr = smoothRmsRef.current
smoothRmsRef.current = 0.85 * sr + 0.15 * rms
const nowMs = performance.now()
if (rms > sr * 2.2 && rms > minVolumeRef.current * 1.5 && nowMs - lastOnsetRef.current > 120) {
lastOnsetRef.current = nowMs
onOnsetRef.current?.()
}
if (rms >= minVolumeRef.current) {
const [freq, clarity] = detectorRef.current.findPitch(timeBuf, ctx.sampleRate)
if (clarity >= minClarityRef.current && freq > 60 && freq < 4200) {
+8 -4
View File
@@ -57,13 +57,13 @@ export const CHORD_TYPES = {
// Chord types considered during real-time chroma matching
const MATCH_CHORD_TYPES = [
'maj', 'min', 'dom7', 'min7', 'dim', 'half_dim', 'aug', 'sus4', 'add9',
'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.04
const CHORD_MATCH_MIN_MARGIN = 0.07
// Chord quality for each scale degree, per mode
const DEGREE_QUALITIES = {
@@ -286,10 +286,13 @@ export function matchChordFromChroma(
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] * 2 // root carries strongest identity signal
inEnergy += chroma[pc] * 1.5 // root weight reduced: 2→1.5 (less root bias)
} else if (tones.has(pc)) {
inEnergy += chroma[pc]
} else {
@@ -299,7 +302,8 @@ export function matchChordFromChroma(
if (inEnergy + outEnergy < 0.05) continue
const coverageScore = inEnergy / (inEnergy + outEnergy * 0.5)
// 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