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
+74
View File
@@ -0,0 +1,74 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
# Development (Vite dev server + Electron window with hot reload)
npm run electron:dev
# Browser-only dev (no Electron)
npm run dev
# Build installers
npm run electron:build:win # Windows NSIS installer → releases/
npm run electron:build:mac # macOS DMG → releases/
npm run electron:build:linux # Linux AppImage → releases/
```
No test suite exists. There is no lint script — no ESLint config is present.
## Architecture
**Electron shell** (`electron/main.cjs`) loads `dist/index.html` in production or `localhost:5173` in dev. The renderer process has full Web Audio API access (`sandbox: false`). `preload.cjs` uses `contextIsolation: true` with no exposed IPC — Electron is purely a window host; all logic lives in the renderer.
**Two audio pipelines run in parallel** inside `AudioCapture.jsx`:
| Path | FFT | Purpose |
|---|---|---|
| Pitch | 4096 samples (~90ms, `smoothingTimeConstant=0.0`) | McLeod pitch detection via `pitchy` → feeds key detection |
| Chord | 16384 samples (~370ms, `smoothingTimeConstant=0.5`) | Harmonic summation chroma → feeds chord detection |
The 16384 FFT gives 2.7 Hz/bin resolution, which is necessary to separate adjacent semitones on low guitar strings (~5-6 Hz apart). The chord analyser uses `computeChroma()` — a harmonic summation that folds each FFT bin back through 5 harmonics to cancel overtone contamination (prevents minor chords from reading as major).
**State and detection logic lives entirely in `App.jsx`:**
- `handleNote` (pitch callback) → accumulates `noteHistoryRef`, runs Krumhansl-Schmuckler key detection every 5 notes, votes in `keyVotesRef` (rolling window, requires strong consensus before committing)
- `handleChroma` (chord callback) → averages a ring buffer of `chromaSmooth` frames, runs a **chroma stability gate** (per-bin variance check — bails if still in transition), then matches against chord templates via `matchChordFromChroma`, votes in `chordVotesRef`
- `handleOnset` (onset callback from RMS spike detection) → builds a **tempo histogram** from pairwise inter-onset intervals, folding all intervals into 55220 BPM range; the histogram peak drives BPM display
Both `handleNote` and `handleChroma` use `useCallback(fn, [])` (empty deps). All values they need from render scope are kept in refs synced via `useEffect` — this prevents `AudioCapture`'s `start` from recreating on every render.
**All music theory is in `src/lib/theory.js`:**
- `detectKey` / `detectTopKeys` — Krumhansl-Schmuckler correlation against major/minor profiles only (K-S cannot distinguish modes — Dorian vs natural minor look the same; user manually picks mode)
- `matchChordFromChroma` — weighted coverage score (inEnergy / (inEnergy + outEnergy×0.7)), requires root presence (`chroma[r] >= 0.08`), margin over second-best, diatonic/bass bonuses
- `MATCH_CHORD_TYPES` — the subset of chord types used in real-time detection (not all of `CHORD_TYPES`)
- `detectRepeatingProgression` — non-overlapping pattern match over last 20 chords, length 26
**`src/services/audioService.js`** is a self-contained tuner hook (`useAudioTuner`) used only by `Tuner.jsx`. It uses its own separate `AudioContext` with simple autocorrelation — independent from the main pitch/chord pipeline.
## Design tokens (Tailwind)
Defined in `tailwind.config.js`: `bg-surface` (#0f0f0f), `bg-panel` (#1a1a1a), `border-border` (#2a2a2a), `text-accent` / `bg-accent` (#a855f7 purple). Use these rather than raw hex in components.
## Key configuration (`DEFAULTS` in `App.jsx`)
| Key | Purpose |
|---|---|
| `chromaSmooth` | Ring buffer size (frames averaged before chord check) |
| `chordVoteThreshold` | Consecutive matching chord frames required to commit |
| `chordMinScore` | Minimum coverage score from `matchChordFromChroma` |
| `keyVoteWindow` / `keyVoteThreshold` | Rolling window size and consensus count for key lock |
| `noteHistorySize` | Max pitch-class history kept for K-S key detection |
These are exposed in `Settings.jsx` as sliders. `configRef` keeps a ref in sync so stable callbacks can read current values.
## Instrument views
`Fretboard.jsx` and `Piano.jsx` are SVG-rendered visualisers. Both accept `keyInfo`, `currentChord`, and `monoColor`. They call `getPentatonicScale`, `getFullScale`, `getChordTones` from `theory.js` and colour notes by tier: chord tone (purple `#a855f7`) > pentatonic (amber or light purple in mono) > scale (dark gray or lightest purple in mono).
## GitHub Actions
`.github/workflows/release.yml` builds Windows and macOS installers on tagged pushes (`v*`) using `softprops/action-gh-release@v2`. Build scripts use `--publish never` to prevent electron-builder's own publish step.
+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