From dec0fc159535b69cfef7de461ce43956782cccb9 Mon Sep 17 00:00:00 2001 From: vadimwit Date: Tue, 10 Mar 2026 12:06:29 +0000 Subject: [PATCH] restructuring and layout improvements + save config locally --- electron/main.cjs | 2 +- index.html | 2 +- src/App.jsx | 93 ++++++++-------- src/components/DebugView.jsx | 209 +++++++++++++++++++++++++++-------- src/components/Settings.jsx | 42 ++++++- src/components/Tuner.jsx | 19 ++-- src/lib/theory.js | 5 +- 7 files changed, 258 insertions(+), 114 deletions(-) diff --git a/electron/main.cjs b/electron/main.cjs index 4dbad19..d7c0f05 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -23,7 +23,7 @@ function createWindow() { height: 900, minWidth: 620, minHeight: 600, - title: 'WhatTheFlat', + title: 'WhatTheFlat ♭? - JamBuddy', icon: path.join(__dirname, '../assets/whattheflat-logo.png'), webPreferences: { preload: path.join(__dirname, 'preload.cjs'), diff --git a/index.html b/index.html index a921dd9..5506c79 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - WhatTheFlat + WhatTheFlat ♭? - JamBuddy
diff --git a/src/App.jsx b/src/App.jsx index cbccaae..87d7ea8 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -10,7 +10,6 @@ import Settings from './components/Settings' import DebugView from './components/DebugView' import { NOTES, detectKey, detectTopKeys, matchChordFromChroma, detectRepeatingProgression, getChordTones, getChordCandidates, getNoteHistoryAnalysis } from './lib/theory' import settingIcon from './assets/setting-icon.png' -import viewIcon from './assets/view.png' const DEFAULTS = { // Key detection @@ -27,11 +26,16 @@ const DEFAULTS = { minVolume: 0.01, } +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) - const configRef = useRef(DEFAULTS) - useEffect(() => { configRef.current = config }, [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) @@ -43,10 +47,10 @@ export default function App() { const [isListening, setIsListening] = useState(false) // ── Instrument view + tuner ─────────────────────────────────────────────────── - const [instrument, setInstrument] = useState('guitar') // 'guitar' | 'bass' | 'piano' + const [instrument, setInstrument] = useState('piano') // 'piano' | 'guitar' | 'bass' const [showTuner, setShowTuner] = useState(false) const [showDebug, setShowDebug] = useState(false) - const [monoColor, setMonoColor] = useState(false) + const [monoColor, setMonoColor] = useState(() => loadStored('wtf_monoColor', false)) // ── Mic permission error ────────────────────────────────────────────────────── const [micError, setMicError] = useState(null) @@ -62,6 +66,7 @@ export default function App() { const lockedKeyRef = useRef(null) const listenStartRef = useRef(null) useEffect(() => { showDebugRef.current = showDebug }, [showDebug]) + useEffect(() => { localStorage.setItem('wtf_monoColor', JSON.stringify(monoColor)) }, [monoColor]) useEffect(() => { if (isListening) listenStartRef.current = Date.now() }, [isListening]) // ── BPM estimation from onset timestamps ───────────────────────────────────── @@ -262,10 +267,7 @@ export default function App() { 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 - } + if (maxVar > 0.05) return const chord = matchChordFromChroma(avg, key, bassPC, false, cfg.chordMinScore) if (!chord) { @@ -348,7 +350,9 @@ export default function App() { config={config} onChange={updateConfig} onClose={() => setShowSettings(false)} - onReset={() => setConfig(DEFAULTS)} + onReset={() => { setConfig(DEFAULTS); setMonoColor(false) }} + monoColor={monoColor} + onMonoColorChange={setMonoColor} /> ) } @@ -360,28 +364,11 @@ export default function App() {

- WhatTheFlat ♭? + WhatTheFlat ♭? - JamBuddy

Real-time key detection for live jams

- -
@@ -557,31 +544,41 @@ export default function App() { - {/* ── Behind the scenes debug view ── */} - {showDebug && ( -
- -
- )} + {/* ── Behind the scenes — collapsible ── */} +
+ + {showDebug && ( +
+ +
+ )} +
{/* ── Tuner — collapsible ── */} -
+
- {showTuner &&
} + {showTuner &&
}
) diff --git a/src/components/DebugView.jsx b/src/components/DebugView.jsx index b66479d..00cf86e 100644 --- a/src/components/DebugView.jsx +++ b/src/components/DebugView.jsx @@ -1,3 +1,4 @@ +import { useRef } from 'react' import { getScale, getChordTones, NOTES } from '../lib/theory' // ─── SVG Piano — 2 octaves (C3–B4) ─────────────────────────────────────────── @@ -14,7 +15,7 @@ const BLACK_OCT = [ ] const WHITE_LABELS = ['C3','D3','E3','F3','G3','A3','B3','C4','D4','E4','F4','G4','A4','B4'] -function PianoSVG({ values, keyNotes, chordNotes, keyH = KEY_H, showPct = false }) { +function PianoSVG({ values, keyNotes, chordNotes, keyH = KEY_H, showPct = false, monoColor = false }) { const max = Math.max(...values, 0.01) const wKeys = [] const bKeys = [] @@ -35,7 +36,7 @@ function PianoSVG({ values, keyNotes, chordNotes, keyH = KEY_H, showPct = false const fillColor = inChord ? `rgba(167,139,250,${0.12 + energy * 0.88})` : inKey - ? `rgba(251,191,36,${0.1 + energy * 0.7})` + ? monoColor ? `rgba(192,132,252,${0.1 + energy * 0.7})` : `rgba(251,191,36,${0.1 + energy * 0.7})` : `rgba(180,180,190,${0.05 + energy * 0.2})` const pct = showPct ? Math.round(values[pc] * 100) : 0 @@ -55,7 +56,7 @@ function PianoSVG({ values, keyNotes, chordNotes, keyH = KEY_H, showPct = false {showPct && pct > 0 && ( + fill={inKey ? (monoColor ? 'rgb(192,132,252)' : 'rgb(251,191,36)') : 'rgba(100,100,110,0.8)'}> {pct}% )} @@ -72,7 +73,7 @@ function PianoSVG({ values, keyNotes, chordNotes, keyH = KEY_H, showPct = false const fillColor = inChord ? 'rgba(139,92,246,0.9)' : inKey - ? 'rgba(180,130,0,0.85)' + ? monoColor ? 'rgba(192,132,252,0.85)' : 'rgba(180,130,0,0.85)' : 'rgba(70,70,80,0.75)' const pct = showPct ? Math.round(values[pc] * 100) : 0 @@ -130,7 +131,7 @@ const MF_H = MF_PAD_T + 5 * MF_STR_H + MF_PAD_B const mfFretX = f => MF_NUT_X + (f - 0.5) * MF_FRET_W const mfStringY = si => MF_PAD_T + si * MF_STR_H -function MiniFretboard({ values, keyNotes, chordNotes }) { +function MiniFretboard({ values, keyNotes, chordNotes, monoColor = false }) { const max = Math.max(...values, 0.01) return ( @@ -198,8 +199,8 @@ function MiniFretboard({ values, keyNotes, chordNotes }) { fill = `rgba(168,85,247,${0.3 + energy * 0.7})` textFill = '#fff' } else if (inKey) { - fill = `rgba(245,158,11,${0.2 + energy * 0.75})` - textFill = 'rgba(0,0,0,0.85)' + fill = monoColor ? `rgba(192,132,252,${0.2 + energy * 0.75})` : `rgba(245,158,11,${0.2 + energy * 0.75})` + textFill = monoColor ? '#fff' : 'rgba(0,0,0,0.85)' } else { fill = `rgba(100,100,120,${energy * 0.7})` textFill = 'rgba(180,180,190,0.7)' @@ -209,7 +210,7 @@ function MiniFretboard({ values, keyNotes, chordNotes }) { {energy > 0.3 && (inChord || inKey) && ( )} @@ -232,13 +233,34 @@ function Oscilloscope({ waveform }) { const { wave, rms, detectedFreq, detectedNote } = waveform || {} const silent = !rms || rms < 0.005 + // ── Note scroll history — last 5 distinct notes ─────────────────────────── + const noteHistoryRef = useRef([]) // [{ note, freq, id }, ...] oldest first + const lastNoteRef = useRef(null) + const noteIdRef = useRef(0) + const lastDisplayRef = useRef(null) // last detected note shown in header — never flickers + if (detectedNote && detectedNote !== lastNoteRef.current) { + lastNoteRef.current = detectedNote + lastDisplayRef.current = { note: detectedNote, freq: detectedFreq } + noteHistoryRef.current.push({ note: detectedNote, freq: detectedFreq, id: noteIdRef.current++ }) + if (noteHistoryRef.current.length > 5) noteHistoryRef.current.shift() + } else if (detectedFreq && detectedNote) { + lastDisplayRef.current = { note: detectedNote, freq: detectedFreq } + } + + // ── Ghost waveform — holds the last clear-pitch shape, fades slowly ─────── + const ghostRef = useRef({ path: '', fill: '', opacity: 0 }) + if (detectedFreq) { + ghostRef.current = { path: '', fill: '', opacity: 1 } // will be filled below + } else { + ghostRef.current = { ...ghostRef.current, opacity: ghostRef.current.opacity * 0.97 } + } + 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 @@ -253,9 +275,13 @@ function Oscilloscope({ waveform }) { 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 + // Capture ghost path when we have a clear pitch if (detectedFreq) { + ghostRef.current.path = path + ghostRef.current.fill = path + ` L${OSC_W},${mid} L0,${mid} Z` + } + if (detectedFreq) { const effectiveSR = 44100 / 8 const sineAmp = Math.min(waveAmp * gain * 0.55, OSC_H * 0.38) sinePath = Array.from({ length: 300 }, (_, i) => { @@ -272,19 +298,23 @@ function Oscilloscope({ waveform }) { ? 'rgba(168,85,247,0.9)' : silent ? 'rgba(50,50,60,0.8)' : 'rgba(100,200,140,0.75)' + const ghost = ghostRef.current + const ghostOp = ghost.opacity + const noteHistory = noteHistoryRef.current + const lastDisplay = lastDisplayRef.current + return (

Oscilloscope — raw mic input

- {detectedFreq && detectedNote && ( + {lastDisplay && ( <> - {detectedNote} - {detectedFreq.toFixed(1)} Hz - {(1000 / detectedFreq).toFixed(2)} ms / cycle + {lastDisplay.note} + {lastDisplay.freq.toFixed(1)} Hz + {(1000 / lastDisplay.freq).toFixed(2)} ms / cycle )} - {!detectedFreq && !silent && signal — no clear pitch} {silent && silence} rms {rms ? (rms * 100).toFixed(1) : '0.0'}%
@@ -294,7 +324,18 @@ function Oscilloscope({ waveform }) { {/* Zero line */} - {/* Fill body — close path to the centre line for a filled silhouette */} + + {/* Ghost waveform — previous clear-pitch shape fading out */} + {ghost.path && ghostOp > 0.04 && !detectedFreq && ( + <> + + + + )} + + {/* Fill body */} {path && ( } + + {/* Scrolling note history — newest on right, slides left on each new note */} + {noteHistory.map((entry, i) => { + const age = noteHistory.length - 1 - i // 0 = newest + const x = OSC_W - 28 - age * 100 + const op = (1 - age * 0.18).toFixed(2) + const isNew = age === 0 + return ( + + + {entry.note} + + + {entry.freq ? entry.freq.toFixed(0) : ''}Hz + + + ) + })}
) @@ -343,17 +406,49 @@ const SPEC_GRID = [ function SpectrumPanel({ spectrum, detectedFreq }) { const W = OSC_W - let fillPath = '', strokePath = '' + const ghostRef = useRef(null) + const ghostFreqRef = useRef(null) // { freq, opacity } + + // Ghost frequency lines — lock on detection, decay slowly when gone + if (detectedFreq) { + ghostFreqRef.current = { freq: detectedFreq, opacity: 1 } + } else if (ghostFreqRef.current) { + ghostFreqRef.current = { freq: ghostFreqRef.current.freq, opacity: ghostFreqRef.current.opacity * 0.97 } + } + const ghostFreq = ghostFreqRef.current?.opacity > 0.04 ? ghostFreqRef.current.freq : null + const ghostOpacity = ghostFreqRef.current?.opacity ?? 0 + + // Ghost: rises instantly with signal, decays very slowly — lingers as grey + if (spectrum?.length) { + if (!ghostRef.current) ghostRef.current = new Float32Array(spectrum.length) + const ghost = ghostRef.current + for (let i = 0; i < spectrum.length; i++) { + ghost[i] = spectrum[i] > ghost[i] ? spectrum[i] : ghost[i] * 0.988 + } + } + + let fillPath = '', strokePath = '', ghostFill = '', ghostStroke = '' if (spectrum?.length) { - const n = spectrum.length - const pts = Array.from({ length: n }, (_, i) => { + 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` + + const ghost = ghostRef.current + if (ghost) { + const gpts = Array.from({ length: n }, (_, i) => { + const x = ((i / (n - 1)) * W).toFixed(1) + const y = (SPEC_H * (1 - ghost[i])).toFixed(1) + return `${i === 0 ? 'M' : 'L'}${x},${y}` + }).join(' ') + ghostStroke = gpts + ghostFill = gpts + ` L${W},${SPEC_H} L0,${SPEC_H} Z` + } } return ( @@ -377,7 +472,15 @@ function SpectrumPanel({ spectrum, detectedFreq }) { ) })} - {/* Spectrum fill + stroke */} + {/* Ghost — slow-decaying grey residue from previous peaks */} + {ghostFill && ( + <> + + + + )} + + {/* Live spectrum fill + stroke */} {fillPath && ( <> @@ -385,31 +488,43 @@ function SpectrumPanel({ spectrum, detectedFreq }) { )} - {/* 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 + {/* Fundamental + harmonics */} + {ghostFreq && [1, 2, 3, 4, 5].map(h => { + const hf = ghostFreq * 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) + const xNum = specX(hf, W) + const x = xNum.toFixed(1) + const midi = Math.round(12 * Math.log2(hf / 440) + 69) + const note = NOTES[((midi % 12) + 12) % 12] + const oct = Math.floor(midi / 12) - 1 + // Place label left of line near the right edge, right of line elsewhere + const labelX = xNum > W - 40 ? xNum - 3 : xNum + 3 + const anchor = xNum > W - 40 ? 'end' : 'start' + + if (h === 1) { + const op = (0.9 * ghostOpacity).toFixed(3) + const textOp = (ghostOpacity * 0.95).toFixed(3) + return ( + + + {note}{oct} + f + + ) + } + + const op = ((0.5 - (h - 2) * 0.1) * ghostOpacity).toFixed(3) return ( - {h}f + {note}{oct} + {h}f ) })} @@ -419,7 +534,7 @@ function SpectrumPanel({ spectrum, detectedFreq }) { } // ─── Main component ─────────────────────────────────────────────────────────── -export default function DebugView({ chroma, chordCandidates, noteAnalysis, waveform, keyInfo, currentChord, instrument = 'guitar' }) { +export default function DebugView({ chroma, chordCandidates, noteAnalysis, waveform, keyInfo, currentChord, instrument = 'guitar', monoColor = false }) { 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)) : []) @@ -435,15 +550,13 @@ export default function DebugView({ chroma, chordCandidates, noteAnalysis, wavef const topKeyScore = topKeys[0]?.score ?? 1 return ( -
-

Behind the Scenes

- - {/* ── Live chroma visualization (instrument-synced) ── */} +
+{/* ── Live chroma visualization (instrument-synced) ── */}

Live chroma — what the engine hears right now

{instrument === 'guitar' - ? - : + ? + : }
@@ -494,7 +607,7 @@ export default function DebugView({ chroma, chordCandidates, noteAnalysis, wavef )}
- +
{/* Col 3: Key candidates */} diff --git a/src/components/Settings.jsx b/src/components/Settings.jsx index 0ea6e49..34f714f 100644 --- a/src/components/Settings.jsx +++ b/src/components/Settings.jsx @@ -70,7 +70,19 @@ const SETTINGS = [ }, ] -export default function Settings({ config, onChange, onClose, onReset }) { +import { useRef } from 'react' + +export default function Settings({ config, onChange, onClose, onReset, monoColor, onMonoColorChange }) { + // Snapshot on mount so Cancel can restore + const savedConfig = useRef(config) + const savedMono = useRef(monoColor) + + function handleCancel() { + Object.entries(savedConfig.current).forEach(([k, v]) => onChange(k, v)) + onMonoColorChange(savedMono.current) + onClose() + } + return (
@@ -87,16 +99,42 @@ export default function Settings({ config, onChange, onClose, onReset }) { > Reset defaults +
+ + {/* ── Display ── */} +
+

+ Display +

+
+
+

Mono Color Mode

+

Use a single purple palette instead of purple + amber for note tiers.

+
+ +
+
+ {SETTINGS.map(section => (

diff --git a/src/components/Tuner.jsx b/src/components/Tuner.jsx index 19b1e5f..ceeeab9 100644 --- a/src/components/Tuner.jsx +++ b/src/components/Tuner.jsx @@ -108,17 +108,14 @@ export default function Tuner() { return ( -
-
-

Tuner

-
- -
+
+
+
diff --git a/src/lib/theory.js b/src/lib/theory.js index 0f46686..5c1721f 100644 --- a/src/lib/theory.js +++ b/src/lib/theory.js @@ -417,9 +417,8 @@ export function detectRepeatingProgression(history) { 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))) { + const score = reps * len * len // square length — prevents sub-patterns from beating full loop + if (score > bestScore) { bestScore = score best = candidate }