import { useState, useCallback, useRef, useEffect } from 'react' import AudioCapture from './components/AudioCapture' import ProgressionBanner from './components/ProgressionBanner' import ProgressionSuggestions from './components/ProgressionSuggestions' import Fretboard from './components/Fretboard' import BassFretboard from './components/BassFretboard' import Tuner from './components/Tuner' import Piano from './components/Piano' import Settings from './components/Settings' import DebugView from './components/DebugView' import DrumView from './components/DrumView' import { NOTES, detectKey, detectTopKeys, matchChordFromChroma, detectRepeatingProgression, getChordTones, getChordCandidates, getNoteHistoryAnalysis } from './lib/theory' import ChordDetailModal from './components/ChordDetailModal' import CurrentJamPanel from './components/CurrentJamPanel' import LoopStation from './components/LoopStation' import JamGuide from './components/JamGuide' import { useLoopEngine } from './services/loopEngine' import settingIcon from './assets/setting-icon.png' const DEFAULTS = { // Key detection noteHistorySize: 2000, // ~60s of notes — stable across a song section keyVoteWindow: 30, // rolling window of key votes keyVoteThreshold: 20, // 67% consensus — locks in after a few bars chordNoteBoost: 3, // Chord detection chromaSmooth: 8, // 8 frames ≈ 130ms window, checks chord at ~7.5 Hz chordVoteThreshold: 2, // 2 consecutive matches ≈ 260ms — works at any BPM chordMinScore: 0.35, // lenient enough for live guitar signal // Audio input minClarity: 0.80, minVolume: 0.01, // Selected device (null = system default) audioDeviceId: null, } function loadStored(key, fallback) { try { const v = localStorage.getItem(key); return v !== null ? JSON.parse(v) : fallback } catch { return fallback } } export default function App() { // ── Config ─────────────────────────────────────────────────────────────────── const [config, setConfig] = useState(() => ({ ...DEFAULTS, ...loadStored('wtf_config', {}) })) const configRef = useRef(config) useEffect(() => { configRef.current = config; localStorage.setItem('wtf_config', JSON.stringify(config)) }, [config]) const [showSettings, setShowSettings] = useState(false) function updateConfig(key, val) { setConfig(prev => ({ ...prev, [key]: val })) } // ── Listening state ────────────────────────────────────────────────────────── const [isListening, setIsListening] = useState(false) // ── Instrument view + tuner ─────────────────────────────────────────────────── const [instrument, setInstrument] = useState('piano') // 'piano' | 'guitar' | 'bass' const [showTuner, setShowTuner] = useState(false) const [showDebug, setShowDebug] = useState(false) const [showDrumView, setShowDrumView] = useState(false) const [monoColor, setMonoColor] = useState(() => loadStored('wtf_monoColor', false)) // ── Jam Guide → Fretboard cross-link (D-03) ────────────────────────────────── // When a Roadmap station is tapped, JamGuide reports its {rootPc, quality} // here and the main Fretboard highlights that chord's guide tones (3rd/7th). // null = no station focused (Fretboard renders normally). Purely UI state — // NOT read by any audio callback, so it stays out of the ref-sync contract. const [jamFocusChord, setJamFocusChord] = useState(null) // { rootPc, quality } | null // ── Mic permission error ────────────────────────────────────────────────────── const [micError, setMicError] = useState(null) // ── Debug data ──────────────────────────────────────────────────────────────── 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 showDrumViewRef = useRef(showDrumView) const lockedKeyRef = useRef(null) const listenStartRef = useRef(null) useEffect(() => { showDebugRef.current = showDebug }, [showDebug]) useEffect(() => { showDrumViewRef.current = showDrumView }, [showDrumView]) useEffect(() => { localStorage.setItem('wtf_monoColor', JSON.stringify(monoColor)) }, [monoColor]) useEffect(() => { if (isListening) listenStartRef.current = Date.now() }, [isListening]) // ── BPM estimation from onset timestamps ───────────────────────────────────── const [bpm, setBpm] = useState(null) const onsetTimestampsRef = useRef([]) const bpmSmoothRef = useRef(null) // ── Loop station ───────────────────────────────────────────────────────────── const { slots, masterLen, setStream: loopSetStream, handleSlotClick, commitTrim, cancelRecord, retrimSlot, deleteSlot, setVolume: loopSetVolume, addSlot: loopAddSlot, audioCtxRef: loopAudioCtxRef, masterStartRef: loopMasterStartRef, masterLenRef: loopMasterLenRef, } = useLoopEngine(bpm) // ── Key: auto-detected + optional lock ─────────────────────────────────────── const [keyInfo, setKeyInfo] = useState(null) // auto-detected const [lockedKey, setLockedKey] = useState(null) // { root, mode } or null useEffect(() => { lockedKeyRef.current = lockedKey }, [lockedKey]) const [lockRoot, setLockRoot] = useState('A') const [lockMode, setLockMode] = useState('minor') const effectiveKey = lockedKey ?? keyInfo // ── Chord state ─────────────────────────────────────────────────────────────── const [chordHistory, setChordHistory] = useState([]) const [detectedProgression, setDetectedProgression] = useState(null) const [selectedChord, setSelectedChord] = useState(null) // ── Top key candidates (shown as quick-lock chips) ──────────────────────────── const [topKeyCandidates, setTopKeyCandidates] = useState([]) // ── Internal refs ───────────────────────────────────────────────────────────── const noteHistoryRef = useRef([]) const keyVotesRef = useRef([]) const effectiveKeyRef = useRef(null) const chromaRingRef = useRef( Array.from({ length: DEFAULTS.chromaSmooth }, () => new Float32Array(12)) ) const chromaIdxRef = useRef(0) const chordVotesRef = useRef([]) const progressionVoteRef = useRef(null) const progressionMissRef = useRef(0) const pendingKeyRef = useRef(null) // Keep refs in sync useEffect(() => { effectiveKeyRef.current = effectiveKey }, [effectiveKey]) // Re-init chroma ring when chromaSmooth changes useEffect(() => { chromaRingRef.current = Array.from( { length: config.chromaSmooth }, () => new Float32Array(12) ) chromaIdxRef.current = 0 }, [config.chromaSmooth]) // ── Detect progression — require 2 consecutive identical results to commit ──── useEffect(() => { const detected = detectRepeatingProgression(chordHistory) 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) } else { progressionVoteRef.current = key } }, [chordHistory]) // ── New song — full reset ───────────────────────────────────────────────────── function newSong() { const cfg = configRef.current noteHistoryRef.current = [] 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 setChordHistory([]) setDetectedProgression(null) setTopKeyCandidates([]) setBpm(null) setMicError(null) setDebugChroma(null) setDebugCandidates([]) setDebugNoteAnalysis(null) setDebugWaveform(null) } // ── Key lock handlers ───────────────────────────────────────────────────────── function applyLock() { const info = { root: lockRoot, mode: lockMode, confidence: 1 } setLockedKey(info) effectiveKeyRef.current = info chordVotesRef.current = [] } function quickLock({ root, mode, confidence }) { const info = { root, mode, confidence } setLockedKey(info) effectiveKeyRef.current = info chordVotesRef.current = [] } function removeLock() { setLockedKey(null) effectiveKeyRef.current = keyInfo } // ── Waveform handler: feeds oscilloscope / drum view ───────────────────────── const handleWaveform = useCallback((data) => { if (showDebugRef.current || showDrumViewRef.current) { setDebugWaveform({ ...data, onsets: [...onsetTimestampsRef.current] }) } }, []) // ── Note handler: drives key detection (pitch-based) ────────────────────────── const handleNote = useCallback(({ pitchClass }) => { const cfg = configRef.current const history = noteHistoryRef.current history.push(pitchClass) if (history.length > cfg.noteHistorySize) history.shift() if (history.length < 10) return if (history.length % 5 !== 0) return const result = detectKey(history) setTopKeyCandidates(detectTopKeys(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 votes.push(`${result.root}_${result.mode}`) if (votes.length > cfg.keyVoteWindow) votes.shift() const counts = {} for (const v of votes) counts[v] = (counts[v] || 0) + 1 const [winner, count] = Object.entries(counts).sort((a, b) => b[1] - a[1])[0] if (count >= cfg.keyVoteThreshold) { const [root, mode] = winner.split('_') const candidateKey = `${root}_${mode}` setKeyInfo(prev => { const currentKey = prev ? `${prev.root}_${prev.mode}` : null if (currentKey === candidateKey) { pendingKeyRef.current = null return { root, mode, confidence: result.confidence } } if (pendingKeyRef.current === candidateKey) { pendingKeyRef.current = null if (!lockedKeyRef.current) chordVotesRef.current = [] return { root, mode, confidence: result.confidence } } pendingKeyRef.current = candidateKey return prev }) } }, []) // ── Chroma handler: drives chord detection ──────────────────────────────────── const handleChroma = useCallback((chroma, bassPC) => { const cfg = configRef.current const ring = chromaRingRef.current ring[chromaIdxRef.current % cfg.chromaSmooth] = chroma chromaIdxRef.current++ if (chromaIdxRef.current % cfg.chromaSmooth !== 0) return const key = effectiveKeyRef.current if (!key) return const avg = new Float32Array(12) for (const frame of ring) for (let i = 0; i < 12; i++) avg[i] += frame[i] for (let i = 0; i < 12; i++) avg[i] /= cfg.chromaSmooth if (showDebugRef.current) { setDebugChroma([...avg]) setDebugCandidates(getChordCandidates(avg, key, bassPC, 5)) } // 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) return const chord = matchChordFromChroma(avg, key, bassPC, false, cfg.chordMinScore) if (!chord) { chordVotesRef.current = [] return } const votes = chordVotesRef.current votes.push(chord) if (votes.length > cfg.chordVoteThreshold) votes.shift() if (votes.length >= cfg.chordVoteThreshold && votes.every(v => v === votes[0])) { const winner = votes[0] setChordHistory(prev => { if (prev[prev.length - 1] === winner) return prev return [...prev.slice(-48), winner] }) // Inject chord tones into note history to anchor key detection const chordPCs = getChordTones(winner) .map(n => NOTES.indexOf(n)) .filter(i => i >= 0) const history = noteHistoryRef.current for (let j = 0; j < cfg.chordNoteBoost; j++) { for (const pc of chordPCs) history.push(pc) } while (history.length > cfg.noteHistorySize) history.shift() } }, []) // ── 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) { return ( setShowSettings(false)} onReset={() => { setConfig(DEFAULTS); setMonoColor(false) }} monoColor={monoColor} onMonoColorChange={setMonoColor} /> ) } return (
{/* ── Header ── */}

WhatTheFlat ♭? - JamBuddy

Real-time key detection for live jams

{/* ── Controls bar ── */}
{/* Instrument select */}
{/* BPM badge */} {bpm && ( {Math.round(bpm)} BPM )}
{lockedKey ? (
🔒 {lockedKey.root}
) : (
{topKeyCandidates.map((k, i) => ( ))} {topKeyCandidates.length > 0 && or}
)}
{ setMicError(true) setIsListening(false) }} onStreamReady={loopSetStream} /> {micError && (
Microphone permission denied. Please allow microphone access in your browser or OS settings and try again.
)} {/* ── Chord detail modal ── */} setSelectedChord(null)} onChordClick={setSelectedChord} keyInfo={effectiveKey} chordHistory={chordHistory} /> {/* ── Progression banner ── */} {/* ── Instrument + progressions row ── */}
{instrument === 'guitar' && } {instrument === 'bass' && } {instrument === 'piano' && }
{/* ── Current jam — collapsible ── */} {/* ── Loop station ── */} {/* ── Behind the scenes — collapsible ── */}
{showDebug && (
)}
{/* ── Rhythm / drum analyser — collapsible ── */}
{showDrumView && (
)}
{/* ── Tuner — collapsible ── */}
{showTuner &&
}
{/* ── Jam Guide — bottom dock (Roadmap) ── */}
) }