diff --git a/src/App.jsx b/src/App.jsx index d3c118a..cbccaae 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -55,11 +55,14 @@ export default function App() { const [debugChroma, setDebugChroma] = useState(null) const [debugCandidates, setDebugCandidates] = useState([]) const [debugNoteAnalysis, setDebugNoteAnalysis] = useState(null) + const [debugWaveform, setDebugWaveform] = useState(null) // ── Stable refs for values used inside callbacks ────────────────────────────── - const showDebugRef = useRef(showDebug) - const lockedKeyRef = useRef(null) + const showDebugRef = useRef(showDebug) + const lockedKeyRef = useRef(null) + const listenStartRef = useRef(null) useEffect(() => { showDebugRef.current = showDebug }, [showDebug]) + useEffect(() => { if (isListening) listenStartRef.current = Date.now() }, [isListening]) // ── BPM estimation from onset timestamps ───────────────────────────────────── const [bpm, setBpm] = useState(null) @@ -92,6 +95,7 @@ export default function App() { const chromaIdxRef = useRef(0) const chordVotesRef = useRef([]) const progressionVoteRef = useRef(null) + const progressionMissRef = useRef(0) const pendingKeyRef = useRef(null) // Keep refs in sync @@ -109,7 +113,16 @@ export default function App() { // ── Detect progression — require 2 consecutive identical results to commit ──── useEffect(() => { const detected = detectRepeatingProgression(chordHistory) - if (!detected) return + if (!detected) { + progressionMissRef.current++ + // Clear stale loop after 4 chord changes with no pattern found + if (progressionMissRef.current >= 4) { + setDetectedProgression(null) + progressionVoteRef.current = null + } + return + } + progressionMissRef.current = 0 const key = detected.join(',') if (progressionVoteRef.current === key) { setDetectedProgression(detected) @@ -125,11 +138,13 @@ export default function App() { keyVotesRef.current = [] chordVotesRef.current = [] progressionVoteRef.current = null + progressionMissRef.current = 0 pendingKeyRef.current = null chromaIdxRef.current = 0 chromaRingRef.current = Array.from({ length: cfg.chromaSmooth }, () => new Float32Array(12)) onsetTimestampsRef.current = [] bpmSmoothRef.current = null + listenStartRef.current = Date.now() setKeyInfo(null) setLockedKey(null) effectiveKeyRef.current = null @@ -141,6 +156,7 @@ export default function App() { setDebugChroma(null) setDebugCandidates([]) setDebugNoteAnalysis(null) + setDebugWaveform(null) } // ── Key lock handlers ───────────────────────────────────────────────────────── @@ -163,6 +179,11 @@ export default function App() { effectiveKeyRef.current = keyInfo } + // ── Waveform handler: feeds oscilloscope in debug view ─────────────────────── + const handleWaveform = useCallback((data) => { + if (showDebugRef.current) setDebugWaveform(data) + }, []) + // ── Note handler: drives key detection (pitch-based) ────────────────────────── const handleNote = useCallback(({ pitchClass }) => { const cfg = configRef.current @@ -174,7 +195,11 @@ export default function App() { const result = detectKey(history) setTopKeyCandidates(detectTopKeys(history)) - if (showDebugRef.current) setDebugNoteAnalysis(getNoteHistoryAnalysis(history)) + if (showDebugRef.current) { + const analysis = getNoteHistoryAnalysis(history) + analysis.sessionSecs = listenStartRef.current ? Math.floor((Date.now() - listenStartRef.current) / 1000) : 0 + setDebugNoteAnalysis(analysis) + } if (result.confidence < 0.5) return const votes = keyVotesRef.current @@ -226,7 +251,7 @@ export default function App() { if (showDebugRef.current) { setDebugChroma([...avg]) - setDebugCandidates(getChordCandidates(avg, key, bassPC)) + setDebugCandidates(getChordCandidates(avg, key, bassPC, 5)) } // Stability gate — if chroma is still changing across frames, we're mid-transition. @@ -256,7 +281,7 @@ export default function App() { const winner = votes[0] setChordHistory(prev => { if (prev[prev.length - 1] === winner) return prev - return [...prev.slice(-30), winner] + return [...prev.slice(-48), winner] }) // Inject chord tones into note history to anchor key detection @@ -492,6 +517,7 @@ export default function App() { onNote={handleNote} onChroma={handleChroma} onOnset={handleOnset} + onWaveform={handleWaveform} isListening={isListening} minClarity={config.minClarity} minVolume={config.minVolume} @@ -538,6 +564,7 @@ export default function App() { chroma={debugChroma} chordCandidates={debugCandidates} noteAnalysis={debugNoteAnalysis} + waveform={debugWaveform} keyInfo={effectiveKey} currentChord={currentChord} instrument={instrument} diff --git a/src/components/AudioCapture.jsx b/src/components/AudioCapture.jsx index 3ca6fdf..c3b67ae 100644 --- a/src/components/AudioCapture.jsx +++ b/src/components/AudioCapture.jsx @@ -81,7 +81,7 @@ function detectBassPC(freqData, sampleRate, fftSize) { return ((bestMidi % 12) + 12) % 12 } -export default function AudioCapture({ onNote, onChroma, onOnset, isListening, minClarity = 0.80, minVolume = 0.01, onPermissionError }) { +export default function AudioCapture({ onNote, onChroma, onOnset, onWaveform, isListening, minClarity = 0.80, minVolume = 0.01, onPermissionError }) { const audioCtxRef = useRef(null) const timeBufRef = useRef(null) const freqBufRef = useRef(null) @@ -94,20 +94,24 @@ export default function AudioCapture({ onNote, onChroma, onOnset, isListening, m const onNoteRef = useRef(onNote) const onChromaRef = useRef(onChroma) const onOnsetRef = useRef(onOnset) + const onWaveformRef = useRef(onWaveform) const onPermissionErrorRef = useRef(onPermissionError) const minClarityRef = useRef(minClarity) const minVolumeRef = useRef(minVolume) const smoothRmsRef = useRef(0) const lastOnsetRef = useRef(0) + const specPeakRef = useRef(null) // peak-hold spectrum for display lingering useEffect(() => { onNoteRef.current = onNote }, [onNote]) useEffect(() => { onChromaRef.current = onChroma }, [onChroma]) useEffect(() => { onOnsetRef.current = onOnset }, [onOnset]) + useEffect(() => { onWaveformRef.current = onWaveform }, [onWaveform]) useEffect(() => { onPermissionErrorRef.current = onPermissionError }, [onPermissionError]) useEffect(() => { minClarityRef.current = minClarity }, [minClarity]) useEffect(() => { minVolumeRef.current = minVolume }, [minVolume]) const stop = useCallback(() => { activeRef.current = false + specPeakRef.current = null if (rafRef.current) { cancelAnimationFrame(rafRef.current); rafRef.current = null } if (streamRef.current) { streamRef.current.getTracks().forEach(t => t.stop()); streamRef.current = null } if (audioCtxRef.current) { audioCtxRef.current.close(); audioCtxRef.current = null } @@ -163,6 +167,46 @@ export default function AudioCapture({ onNote, onChroma, onOnset, isListening, m onOnsetRef.current?.() } + // Always fire waveform callback — downsample 4096 → 512 points + log-binned spectrum + if (onWaveformRef.current) { + const stride = 8 // 4096 / 8 = 512 points + const wave = new Float32Array(PITCH_FFT / stride) + for (let i = 0; i < wave.length; i++) wave[i] = timeBuf[i * stride] + + // Log-binned frequency spectrum: 256 bins from 40 Hz → 4000 Hz + const LOG_BINS = 256 + const F_MIN = 40, F_MAX = 4000 + const binHz = ctx.sampleRate / ca.fftSize + const freqBuf = freqBufRef.current + ca.getFloatFrequencyData(freqBuf) + const spectrum = new Float32Array(LOG_BINS) + for (let b = 0; b < LOG_BINS; b++) { + const f = F_MIN * Math.pow(F_MAX / F_MIN, b / (LOG_BINS - 1)) + const bin = Math.round(f / binHz) + if (bin < freqBuf.length) { + const db = freqBuf[bin] + spectrum[b] = db < NOISE_FLOOR ? 0 : Math.max(0, (db - NOISE_FLOOR) / (-NOISE_FLOOR)) + } + } + + // Peak-hold with exponential decay — spectrum rises instantly, falls slowly + if (!specPeakRef.current) specPeakRef.current = new Float32Array(LOG_BINS) + const peak = specPeakRef.current + for (let b = 0; b < LOG_BINS; b++) { + peak[b] = spectrum[b] > peak[b] ? spectrum[b] : peak[b] * 0.92 + } + + let detectedFreq = null, detectedNote = null + if (rms >= minVolumeRef.current) { + const [f, c] = detectorRef.current.findPitch(timeBuf, ctx.sampleRate) + if (c >= minClarityRef.current && f > 60 && f < 4200) { + detectedFreq = f + detectedNote = NOTES[((Math.round(12 * Math.log2(f / 440) + 69) % 12) + 12) % 12] + } + } + onWaveformRef.current({ wave, rms, detectedFreq, detectedNote, spectrum: peak }) + } + 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/components/DebugView.jsx b/src/components/DebugView.jsx index 0c58167..b66479d 100644 --- a/src/components/DebugView.jsx +++ b/src/components/DebugView.jsx @@ -43,7 +43,7 @@ function PianoSVG({ values, keyNotes, chordNotes, keyH = KEY_H, showPct = false - {energy > 0.04 && ( + {energy > (inChord || inKey ? 0.12 : 0.35) && ( + {/* Base */} - + {/* Partial fill from bottom — same mechanic as white keys */} + {energy > (inChord || inKey ? 0.05 : 0.35) && ( + + )} + {/* % label near top of key (inside) */} + {showPct && pct > 0 && ( + + {pct}% + + )} + {/* Note name near bottom of key */} + {NOTES[pc]} @@ -170,7 +187,8 @@ function MiniFretboard({ values, keyNotes, chordNotes }) { const energy = values[pc] / max const inChord = chordNotes?.has(pc) const inKey = keyNotes?.has(pc) - if (!inChord && !inKey && energy < 0.12) return null + if (!inChord && !inKey && energy < 0.35) return null + if ((inChord || inKey) && energy < 0.08) return null const cx = fi === 0 ? MF_OPEN_X : mfFretX(fi) const cy = mfStringY(si) @@ -206,14 +224,213 @@ function MiniFretboard({ values, keyNotes, chordNotes }) { ) } +// ─── Oscilloscope strip ─────────────────────────────────────────────────────── +const OSC_W = 600 +const OSC_H = 110 + +function Oscilloscope({ waveform }) { + const { wave, rms, detectedFreq, detectedNote } = waveform || {} + const silent = !rms || rms < 0.005 + + let path = '', sinePath = '' + if (wave?.length) { + const mid = OSC_H / 2 + const waveAmp = Math.max(...wave.map(Math.abs), 0.001) + const gain = Math.min((OSC_H * 0.44) / waveAmp, OSC_H * 0.44) + + // Zero-crossing trigger: find first upward zero cross in first half → stable display + const lo = Math.floor(wave.length / 4) + const hi = Math.floor(wave.length / 2) + let offset = lo + for (let i = lo; i < hi - 1; i++) { + if (wave[i] <= 0 && wave[i + 1] > 0) { offset = i; break } + } + const drawLen = Math.min(wave.length - offset, Math.floor(wave.length * 0.85)) + const step = OSC_W / drawLen + + path = Array.from({ length: drawLen }, (_, i) => { + const v = wave[offset + i] + return `${i === 0 ? 'M' : 'L'}${(i * step).toFixed(1)},${(mid - v * gain).toFixed(1)}` + }).join(' ') + + // Sine overlay at the detected fundamental, phase-matched to trigger offset + if (detectedFreq) { + + const effectiveSR = 44100 / 8 + const sineAmp = Math.min(waveAmp * gain * 0.55, OSC_H * 0.38) + sinePath = Array.from({ length: 300 }, (_, i) => { + const t = i / 299 + const x = (t * OSC_W).toFixed(1) + const phase = ((offset + t * drawLen) / effectiveSR) * detectedFreq * Math.PI * 2 + const y = (mid - Math.sin(phase) * sineAmp).toFixed(1) + return `${i === 0 ? 'M' : 'L'}${x},${y}` + }).join(' ') + } + } + + const lineColor = detectedFreq + ? 'rgba(168,85,247,0.9)' + : silent ? 'rgba(50,50,60,0.8)' : 'rgba(100,200,140,0.75)' + + return ( +
+
+

Oscilloscope — raw mic input

+
+ {detectedFreq && detectedNote && ( + <> + {detectedNote} + {detectedFreq.toFixed(1)} Hz + {(1000 / detectedFreq).toFixed(2)} ms / cycle + + )} + {!detectedFreq && !silent && signal — no clear pitch} + {silent && silence} + rms {rms ? (rms * 100).toFixed(1) : '0.0'}% +
+
+ + {/* Zero line */} + + {/* Fill body — close path to the centre line for a filled silhouette */} + {path && ( + + )} + {/* Waveform line */} + {path && } + {/* Sine overlay */} + {sinePath && } + +
+ ) +} + +// ─── Frequency spectrum ─────────────────────────────────────────────────────── +const SPEC_H = 130 +const SPEC_F_MIN = 40 +const SPEC_F_MAX = 4000 +const SPEC_LOG = Math.log(SPEC_F_MAX / SPEC_F_MIN) + +// Map a frequency in Hz to an x pixel position (log scale) +function specX(f, w) { + if (f <= SPEC_F_MIN) return 0 + if (f >= SPEC_F_MAX) return w + return w * Math.log(f / SPEC_F_MIN) / SPEC_LOG +} + +const SPEC_GRID = [ + { label: 'E2', freq: 82.4 }, + { label: 'C3', freq: 130.8 }, + { label: 'E3', freq: 164.8 }, + { label: 'A3', freq: 220 }, + { label: 'C4', freq: 261.6 }, + { label: 'E4', freq: 329.6 }, + { label: 'A4', freq: 440 }, + { label: 'C5', freq: 523.3 }, + { label: 'C6', freq: 1046.5}, + { label: 'C7', freq: 2093 }, +] + +function SpectrumPanel({ spectrum, detectedFreq }) { + const W = OSC_W + let fillPath = '', strokePath = '' + + if (spectrum?.length) { + const n = spectrum.length + const pts = Array.from({ length: n }, (_, i) => { + const x = ((i / (n - 1)) * W).toFixed(1) + const y = (SPEC_H * (1 - spectrum[i])).toFixed(1) + return `${i === 0 ? 'M' : 'L'}${x},${y}` + }).join(' ') + strokePath = pts + fillPath = pts + ` L${W},${SPEC_H} L0,${SPEC_H} Z` + } + + return ( +
+

+ Frequency spectrum — 40 Hz → 4 kHz (log scale) +

+ + + {/* Note grid lines */} + {SPEC_GRID.map(({ label, freq }) => { + const x = specX(freq, W).toFixed(1) + return ( + + + {label} + + ) + })} + + {/* Spectrum fill + stroke */} + {fillPath && ( + <> + + + + )} + + {/* Fundamental frequency */} + {detectedFreq && (() => { + const x = specX(detectedFreq, W).toFixed(1) + return ( + + + f + + ) + })()} + + {/* Harmonics 2f–5f */} + {detectedFreq && [2, 3, 4, 5].map(h => { + const hf = detectedFreq * h + if (hf > SPEC_F_MAX) return null + const x = specX(hf, W).toFixed(1) + const op = (0.5 - (h - 2) * 0.1).toFixed(2) + return ( + + + {h}f + + ) + })} + +
+ ) +} + // ─── Main component ─────────────────────────────────────────────────────────── -export default function DebugView({ chroma, chordCandidates, noteAnalysis, keyInfo, currentChord, instrument = 'guitar' }) { +export default function DebugView({ chroma, chordCandidates, noteAnalysis, waveform, keyInfo, currentChord, instrument = 'guitar' }) { const keyPCs = new Set(keyInfo ? getScale(keyInfo.root, keyInfo.mode).map(n => NOTES.indexOf(n)) : []) const chordPCs = new Set(currentChord ? getChordTones(currentChord).map(n => NOTES.indexOf(n)) : []) - const chromaArr = chroma ? [...chroma] : new Array(12).fill(0) - const histFreq = noteAnalysis ? noteAnalysis.freq : new Array(12).fill(0) - const topKeys = noteAnalysis ? noteAnalysis.topKeys : [] + const chromaArr = chroma ? [...chroma] : new Array(12).fill(0) + const histFreq = noteAnalysis ? noteAnalysis.freq : new Array(12).fill(0) + const topKeys = noteAnalysis ? noteAnalysis.topKeys : [] + const totalNotes = noteAnalysis?.total ?? 0 + const sessionSecs = noteAnalysis?.sessionSecs ?? 0 + const sessionLabel = sessionSecs >= 60 + ? `${Math.floor(sessionSecs / 60)}m ${sessionSecs % 60}s` + : `${sessionSecs}s` const topScore = chordCandidates[0]?.score ?? 1 const topKeyScore = topKeys[0]?.score ?? 1 @@ -269,7 +486,14 @@ export default function DebugView({ chroma, chordCandidates, noteAnalysis, keyIn {/* Col 2: Note history piano with % labels */}
-

Note history — key evidence

+
+

Note history

+ {totalNotes > 0 && ( + + {totalNotes.toLocaleString()} notes · {sessionLabel} + + )} +
@@ -298,6 +522,12 @@ export default function DebugView({ chroma, chordCandidates, noteAnalysis, keyIn + + {/* ── Oscilloscope + spectrum ── */} +
+ + +
) } diff --git a/src/lib/theory.js b/src/lib/theory.js index 39cc2d0..0f46686 100644 --- a/src/lib/theory.js +++ b/src/lib/theory.js @@ -360,40 +360,73 @@ export function toRomanNumeral(chordName, keyRoot, keyMode) { // ─── Repeating progression detection ───────────────────────────────────────── +// Returns true if arr is made of a shorter repeating unit (e.g. [A,B,A,B] → true) +function isPeriodicPattern(arr) { + for (let p = 1; p <= Math.floor(arr.length / 2); p++) { + if (arr.length % p !== 0) continue + const unit = arr.slice(0, p) + if (arr.every((v, i) => v === unit[i % p])) return true + } + return false +} + +// Returns the lexicographically smallest rotation so the same loop always +// produces the same string regardless of where in the cycle we currently are. +function canonicalize(pattern) { + let best = pattern + for (let i = 1; i < pattern.length; i++) { + const rot = [...pattern.slice(i), ...pattern.slice(0, i)] + if (rot.join('\0') < best.join('\0')) best = rot + } + return best +} + /** * detectRepeatingProgression(history) → chord[] or null - * Returns the most-recently-completed repeating pattern (length 2–6). - * Uses non-overlapping match counting to avoid over-counting. + * + * Tests every unique subsequence of every length (not just the tail) so the + * result is stable regardless of where in the loop the musician currently is. + * Returns the canonical (rotation-normalised) form of the best pattern found. */ export function detectRepeatingProgression(history) { - if (!history || history.length < 4) return null + if (!history || history.length < 6) return null - const window = history.slice(-20) + const win = history.slice(-32) let best = null, bestScore = 0 for (let len = 2; len <= 6; len++) { - if (len * 2 > window.length) break + if (len * 2 > win.length) break - const candidate = window.slice(-len) - let reps = 0, i = 0 + const seen = new Set() - while (i <= window.length - len) { - if (candidate.every((c, j) => c === window[i + j])) { - reps++ - i += len // skip past match — non-overlapping - } else { - i++ + for (let start = 0; start <= win.length - len; start++) { + const candidate = win.slice(start, start + len) + const key = candidate.join('\0') + if (seen.has(key)) continue + seen.add(key) + + // A pattern that is itself a repetition of something shorter will be + // found at that shorter length — skip it here to avoid inflating scores. + if (len >= 4 && isPeriodicPattern(candidate)) continue + + let reps = 0, i = 0 + while (i <= win.length - len) { + if (candidate.every((c, j) => c === win[i + j])) { reps++; i += len } + else i++ } - } - const score = reps * len - if (reps >= 2 && score > bestScore) { - bestScore = score - best = candidate + if (reps < 2) continue + + const score = reps * len + // Prefer longer patterns on equal score — more descriptive loop wins + if (score > bestScore || (score === bestScore && len > (best?.length ?? 0))) { + bestScore = score + best = candidate + } } } - return best + return best ? canonicalize(best) : null } // ─── Debug / analysis helpers ───────────────────────────────────────────────── @@ -450,6 +483,7 @@ export function getNoteHistoryAnalysis(noteHistory) { } return { freq: normalized, + total, topKeys: candidates.sort((a, b) => b.score - a.score).slice(0, 5), } }