chord recognition improvements
This commit is contained in:
@@ -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 55–220 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 2–6
|
||||
|
||||
**`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.
|
||||
+62
-29
@@ -60,10 +60,10 @@ export default function App() {
|
||||
const lockedKeyRef = useRef(null)
|
||||
useEffect(() => { showDebugRef.current = showDebug }, [showDebug])
|
||||
|
||||
// ── BPM estimation from chord-change intervals ────────────────────────────────
|
||||
// ── BPM estimation from onset timestamps ─────────────────────────────────────
|
||||
const [bpm, setBpm] = useState(null)
|
||||
const chordTimestampsRef = useRef([])
|
||||
const bpmSmoothRef = useRef(null) // exponentially smoothed BPM
|
||||
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 (55–220)
|
||||
|
||||
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}
|
||||
|
||||
@@ -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])
|
||||
@@ -120,6 +124,8 @@ export default function AudioCapture({ onNote, onChroma, isListening, minClarity
|
||||
}
|
||||
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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user