diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..179154b --- /dev/null +++ b/CLAUDE.md @@ -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. diff --git a/src/App.jsx b/src/App.jsx index 1c6b064..5ccf4fe 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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 (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() { { 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) { diff --git a/src/lib/theory.js b/src/lib/theory.js index d730187..39cc2d0 100644 --- a/src/lib/theory.js +++ b/src/lib/theory.js @@ -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