restructuring and layout improvements + save config locally

This commit is contained in:
vadimwit
2026-03-10 12:06:29 +00:00
parent b989238373
commit dec0fc1595
7 changed files with 258 additions and 114 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ function createWindow() {
height: 900, height: 900,
minWidth: 620, minWidth: 620,
minHeight: 600, minHeight: 600,
title: 'WhatTheFlat', title: 'WhatTheFlat ♭? - JamBuddy',
icon: path.join(__dirname, '../assets/whattheflat-logo.png'), icon: path.join(__dirname, '../assets/whattheflat-logo.png'),
webPreferences: { webPreferences: {
preload: path.join(__dirname, 'preload.cjs'), preload: path.join(__dirname, 'preload.cjs'),
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-eval'; connect-src 'self' http://localhost:5173 ws://localhost:5173; style-src 'self' 'unsafe-inline'; img-src 'self' data:;"> <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-eval'; connect-src 'self' http://localhost:5173 ws://localhost:5173; style-src 'self' 'unsafe-inline'; img-src 'self' data:;">
<title>WhatTheFlat</title> <title>WhatTheFlat ♭? - JamBuddy</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+33 -36
View File
@@ -10,7 +10,6 @@ import Settings from './components/Settings'
import DebugView from './components/DebugView' import DebugView from './components/DebugView'
import { NOTES, detectKey, detectTopKeys, matchChordFromChroma, detectRepeatingProgression, getChordTones, getChordCandidates, getNoteHistoryAnalysis } from './lib/theory' import { NOTES, detectKey, detectTopKeys, matchChordFromChroma, detectRepeatingProgression, getChordTones, getChordCandidates, getNoteHistoryAnalysis } from './lib/theory'
import settingIcon from './assets/setting-icon.png' import settingIcon from './assets/setting-icon.png'
import viewIcon from './assets/view.png'
const DEFAULTS = { const DEFAULTS = {
// Key detection // Key detection
@@ -27,11 +26,16 @@ const DEFAULTS = {
minVolume: 0.01, 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() { export default function App() {
// ── Config ─────────────────────────────────────────────────────────────────── // ── Config ───────────────────────────────────────────────────────────────────
const [config, setConfig] = useState(DEFAULTS) const [config, setConfig] = useState(() => ({ ...DEFAULTS, ...loadStored('wtf_config', {}) }))
const configRef = useRef(DEFAULTS) const configRef = useRef(config)
useEffect(() => { configRef.current = config }, [config]) useEffect(() => { configRef.current = config; localStorage.setItem('wtf_config', JSON.stringify(config)) }, [config])
const [showSettings, setShowSettings] = useState(false) const [showSettings, setShowSettings] = useState(false)
@@ -43,10 +47,10 @@ export default function App() {
const [isListening, setIsListening] = useState(false) const [isListening, setIsListening] = useState(false)
// ── Instrument view + tuner ─────────────────────────────────────────────────── // ── 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 [showTuner, setShowTuner] = useState(false)
const [showDebug, setShowDebug] = useState(false) const [showDebug, setShowDebug] = useState(false)
const [monoColor, setMonoColor] = useState(false) const [monoColor, setMonoColor] = useState(() => loadStored('wtf_monoColor', false))
// ── Mic permission error ────────────────────────────────────────────────────── // ── Mic permission error ──────────────────────────────────────────────────────
const [micError, setMicError] = useState(null) const [micError, setMicError] = useState(null)
@@ -62,6 +66,7 @@ export default function App() {
const lockedKeyRef = useRef(null) const lockedKeyRef = useRef(null)
const listenStartRef = useRef(null) const listenStartRef = useRef(null)
useEffect(() => { showDebugRef.current = showDebug }, [showDebug]) useEffect(() => { showDebugRef.current = showDebug }, [showDebug])
useEffect(() => { localStorage.setItem('wtf_monoColor', JSON.stringify(monoColor)) }, [monoColor])
useEffect(() => { if (isListening) listenStartRef.current = Date.now() }, [isListening]) useEffect(() => { if (isListening) listenStartRef.current = Date.now() }, [isListening])
// ── BPM estimation from onset timestamps ───────────────────────────────────── // ── 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 } for (const frame of ring) { const d = frame[i] - avg[i]; v += d * d }
if (v / cfg.chromaSmooth > maxVar) maxVar = v / cfg.chromaSmooth if (v / cfg.chromaSmooth > maxVar) maxVar = v / cfg.chromaSmooth
} }
if (maxVar > 0.05) { if (maxVar > 0.05) return
chordVotesRef.current = []
return
}
const chord = matchChordFromChroma(avg, key, bassPC, false, cfg.chordMinScore) const chord = matchChordFromChroma(avg, key, bassPC, false, cfg.chordMinScore)
if (!chord) { if (!chord) {
@@ -348,7 +350,9 @@ export default function App() {
config={config} config={config}
onChange={updateConfig} onChange={updateConfig}
onClose={() => setShowSettings(false)} onClose={() => setShowSettings(false)}
onReset={() => setConfig(DEFAULTS)} onReset={() => { setConfig(DEFAULTS); setMonoColor(false) }}
monoColor={monoColor}
onMonoColorChange={setMonoColor}
/> />
) )
} }
@@ -360,28 +364,11 @@ export default function App() {
<header className="mb-2 flex items-center justify-between"> <header className="mb-2 flex items-center justify-between">
<div> <div>
<h1 className="text-xl font-bold text-accent"> <h1 className="text-xl font-bold text-accent">
WhatTheFlat <span className="text-gray-600">&#9837;?</span> WhatTheFlat <span className="text-gray-600">&#9837;?</span> <span className="text-amber-400">- JamBuddy</span>
</h1> </h1>
<p className="text-xs text-gray-600">Real-time key detection for live jams</p> <p className="text-xs text-gray-600">Real-time key detection for live jams</p>
</div> </div>
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<button
onClick={() => setMonoColor(v => !v)}
className={`p-2 rounded-full border transition-all ${monoColor ? 'border-accent bg-accent/10' : 'border-border hover:border-gray-400'}`}
title="Mono color mode"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" style={{ opacity: 0.75 }}>
<circle cx="6" cy="10" r="4" fill={monoColor ? '#a855f7' : '#a855f7'} />
<circle cx="13" cy="10" r="4" fill={monoColor ? '#c084fc' : '#f59e0b'} />
</svg>
</button>
<button
onClick={() => setShowDebug(v => !v)}
className={`p-2 rounded-full border transition-all ${showDebug ? 'border-accent bg-accent/10' : 'border-border hover:border-gray-400'}`}
title="Behind the scenes"
>
<img src={viewIcon} alt="Debug view" className="w-5 h-5" style={{ filter: 'invert(1) opacity(0.75)' }} />
</button>
<button <button
onClick={() => setShowSettings(true)} onClick={() => setShowSettings(true)}
className="p-2 rounded-full border border-border hover:border-gray-400 transition-all" className="p-2 rounded-full border border-border hover:border-gray-400 transition-all"
@@ -419,9 +406,9 @@ export default function App() {
onChange={e => setInstrument(e.target.value)} onChange={e => setInstrument(e.target.value)}
className="appearance-none bg-surface border border-border hover:border-gray-500 focus:border-accent focus:outline-none rounded-lg pl-3 pr-7 py-1 text-sm text-gray-200 cursor-pointer transition-colors" className="appearance-none bg-surface border border-border hover:border-gray-500 focus:border-accent focus:outline-none rounded-lg pl-3 pr-7 py-1 text-sm text-gray-200 cursor-pointer transition-colors"
> >
<option value="piano">Piano</option>
<option value="guitar">Guitar</option> <option value="guitar">Guitar</option>
<option value="bass">Bass</option> <option value="bass">Bass</option>
<option value="piano">Piano</option>
</select> </select>
<span className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 text-xs"></span> <span className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 text-xs"></span>
</div> </div>
@@ -557,9 +544,17 @@ export default function App() {
</div> </div>
</div> </div>
{/* ── Behind the scenes debug view ── */} {/* ── Behind the scenes — collapsible ── */}
<div className="mb-3 bg-panel border border-border rounded-xl overflow-hidden">
<button
onClick={() => setShowDebug(v => !v)}
className="w-full flex items-center justify-between px-4 py-2 text-sm text-gray-400 hover:text-gray-200 transition-all"
>
<span>BEHIND THE SCENES</span>
<span>{showDebug ? '▲' : '▼'}</span>
</button>
{showDebug && ( {showDebug && (
<div className="mb-3"> <div className="border-t border-border p-4">
<DebugView <DebugView
chroma={debugChroma} chroma={debugChroma}
chordCandidates={debugCandidates} chordCandidates={debugCandidates}
@@ -568,20 +563,22 @@ export default function App() {
keyInfo={effectiveKey} keyInfo={effectiveKey}
currentChord={currentChord} currentChord={currentChord}
instrument={instrument} instrument={instrument}
monoColor={monoColor}
/> />
</div> </div>
)} )}
</div>
{/* ── Tuner — collapsible ── */} {/* ── Tuner — collapsible ── */}
<div> <div className="bg-panel border border-border rounded-xl overflow-hidden">
<button <button
onClick={() => setShowTuner(v => !v)} onClick={() => setShowTuner(v => !v)}
className="w-full flex items-center justify-between px-4 py-2 bg-panel border border-border rounded-xl text-sm text-gray-400 hover:text-gray-200 hover:border-gray-500 transition-all" className="w-full flex items-center justify-between px-4 py-2 text-sm text-gray-400 hover:text-gray-200 transition-all"
> >
<span>Tuner</span> <span>TUNER</span>
<span>{showTuner ? '▲' : '▼'}</span> <span>{showTuner ? '▲' : '▼'}</span>
</button> </button>
{showTuner && <div className="mt-2"><Tuner /></div>} {showTuner && <div className="border-t border-border"><Tuner /></div>}
</div> </div>
</div> </div>
) )
+154 -41
View File
@@ -1,3 +1,4 @@
import { useRef } from 'react'
import { getScale, getChordTones, NOTES } from '../lib/theory' import { getScale, getChordTones, NOTES } from '../lib/theory'
// ─── SVG Piano — 2 octaves (C3B4) ─────────────────────────────────────────── // ─── SVG Piano — 2 octaves (C3B4) ───────────────────────────────────────────
@@ -14,7 +15,7 @@ const BLACK_OCT = [
] ]
const WHITE_LABELS = ['C3','D3','E3','F3','G3','A3','B3','C4','D4','E4','F4','G4','A4','B4'] 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 max = Math.max(...values, 0.01)
const wKeys = [] const wKeys = []
const bKeys = [] const bKeys = []
@@ -35,7 +36,7 @@ function PianoSVG({ values, keyNotes, chordNotes, keyH = KEY_H, showPct = false
const fillColor = inChord const fillColor = inChord
? `rgba(167,139,250,${0.12 + energy * 0.88})` ? `rgba(167,139,250,${0.12 + energy * 0.88})`
: inKey : 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})` : `rgba(180,180,190,${0.05 + energy * 0.2})`
const pct = showPct ? Math.round(values[pc] * 100) : 0 const pct = showPct ? Math.round(values[pc] * 100) : 0
@@ -55,7 +56,7 @@ function PianoSVG({ values, keyNotes, chordNotes, keyH = KEY_H, showPct = false
</text> </text>
{showPct && pct > 0 && ( {showPct && pct > 0 && (
<text x={x + KEY_W/2} y={keyH + 14} textAnchor="middle" fontSize={8} <text x={x + KEY_W/2} y={keyH + 14} textAnchor="middle" fontSize={8}
fill={inKey ? 'rgb(251,191,36)' : 'rgba(100,100,110,0.8)'}> fill={inKey ? (monoColor ? 'rgb(192,132,252)' : 'rgb(251,191,36)') : 'rgba(100,100,110,0.8)'}>
{pct}% {pct}%
</text> </text>
)} )}
@@ -72,7 +73,7 @@ function PianoSVG({ values, keyNotes, chordNotes, keyH = KEY_H, showPct = false
const fillColor = inChord const fillColor = inChord
? 'rgba(139,92,246,0.9)' ? 'rgba(139,92,246,0.9)'
: inKey : 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)' : 'rgba(70,70,80,0.75)'
const pct = showPct ? Math.round(values[pc] * 100) : 0 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 mfFretX = f => MF_NUT_X + (f - 0.5) * MF_FRET_W
const mfStringY = si => MF_PAD_T + si * MF_STR_H 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) const max = Math.max(...values, 0.01)
return ( return (
@@ -198,8 +199,8 @@ function MiniFretboard({ values, keyNotes, chordNotes }) {
fill = `rgba(168,85,247,${0.3 + energy * 0.7})` fill = `rgba(168,85,247,${0.3 + energy * 0.7})`
textFill = '#fff' textFill = '#fff'
} else if (inKey) { } else if (inKey) {
fill = `rgba(245,158,11,${0.2 + energy * 0.75})` fill = monoColor ? `rgba(192,132,252,${0.2 + energy * 0.75})` : `rgba(245,158,11,${0.2 + energy * 0.75})`
textFill = 'rgba(0,0,0,0.85)' textFill = monoColor ? '#fff' : 'rgba(0,0,0,0.85)'
} else { } else {
fill = `rgba(100,100,120,${energy * 0.7})` fill = `rgba(100,100,120,${energy * 0.7})`
textFill = 'rgba(180,180,190,0.7)' textFill = 'rgba(180,180,190,0.7)'
@@ -209,7 +210,7 @@ function MiniFretboard({ values, keyNotes, chordNotes }) {
<g key={`${si}-${fi}`}> <g key={`${si}-${fi}`}>
{energy > 0.3 && (inChord || inKey) && ( {energy > 0.3 && (inChord || inKey) && (
<circle cx={cx} cy={cy} r={MF_DOT_R + 4} <circle cx={cx} cy={cy} r={MF_DOT_R + 4}
fill={inChord ? 'rgba(168,85,247,0.25)' : 'rgba(245,158,11,0.2)'} fill={inChord ? 'rgba(168,85,247,0.25)' : monoColor ? 'rgba(192,132,252,0.2)' : 'rgba(245,158,11,0.2)'}
style={{ filter: 'blur(4px)' }} /> style={{ filter: 'blur(4px)' }} />
)} )}
<circle cx={cx} cy={cy} r={MF_DOT_R} fill={fill} /> <circle cx={cx} cy={cy} r={MF_DOT_R} fill={fill} />
@@ -232,13 +233,34 @@ function Oscilloscope({ waveform }) {
const { wave, rms, detectedFreq, detectedNote } = waveform || {} const { wave, rms, detectedFreq, detectedNote } = waveform || {}
const silent = !rms || rms < 0.005 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 = '' let path = '', sinePath = ''
if (wave?.length) { if (wave?.length) {
const mid = OSC_H / 2 const mid = OSC_H / 2
const waveAmp = Math.max(...wave.map(Math.abs), 0.001) const waveAmp = Math.max(...wave.map(Math.abs), 0.001)
const gain = Math.min((OSC_H * 0.44) / waveAmp, OSC_H * 0.44) 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 lo = Math.floor(wave.length / 4)
const hi = Math.floor(wave.length / 2) const hi = Math.floor(wave.length / 2)
let offset = lo let offset = lo
@@ -253,9 +275,13 @@ function Oscilloscope({ waveform }) {
return `${i === 0 ? 'M' : 'L'}${(i * step).toFixed(1)},${(mid - v * gain).toFixed(1)}` return `${i === 0 ? 'M' : 'L'}${(i * step).toFixed(1)},${(mid - v * gain).toFixed(1)}`
}).join(' ') }).join(' ')
// Sine overlay at the detected fundamental, phase-matched to trigger offset // Capture ghost path when we have a clear pitch
if (detectedFreq) { if (detectedFreq) {
ghostRef.current.path = path
ghostRef.current.fill = path + ` L${OSC_W},${mid} L0,${mid} Z`
}
if (detectedFreq) {
const effectiveSR = 44100 / 8 const effectiveSR = 44100 / 8
const sineAmp = Math.min(waveAmp * gain * 0.55, OSC_H * 0.38) const sineAmp = Math.min(waveAmp * gain * 0.55, OSC_H * 0.38)
sinePath = Array.from({ length: 300 }, (_, i) => { sinePath = Array.from({ length: 300 }, (_, i) => {
@@ -272,19 +298,23 @@ function Oscilloscope({ waveform }) {
? 'rgba(168,85,247,0.9)' ? 'rgba(168,85,247,0.9)'
: silent ? 'rgba(50,50,60,0.8)' : 'rgba(100,200,140,0.75)' : 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 ( return (
<div> <div>
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<p className="text-xs text-gray-600 uppercase tracking-widest">Oscilloscope raw mic input</p> <p className="text-xs text-gray-600 uppercase tracking-widest">Oscilloscope raw mic input</p>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{detectedFreq && detectedNote && ( {lastDisplay && (
<> <>
<span className="text-xs font-bold text-accent">{detectedNote}</span> <span className={`text-xs font-bold ${detectedFreq ? 'text-accent' : 'text-gray-500'}`}>{lastDisplay.note}</span>
<span className="text-xs text-gray-500 tabular-nums">{detectedFreq.toFixed(1)} Hz</span> <span className="text-xs text-gray-500 tabular-nums">{lastDisplay.freq.toFixed(1)} Hz</span>
<span className="text-xs text-gray-600 tabular-nums">{(1000 / detectedFreq).toFixed(2)} ms / cycle</span> <span className="text-xs text-gray-600 tabular-nums">{(1000 / lastDisplay.freq).toFixed(2)} ms / cycle</span>
</> </>
)} )}
{!detectedFreq && !silent && <span className="text-xs text-gray-600">signal no clear pitch</span>}
{silent && <span className="text-xs text-gray-700">silence</span>} {silent && <span className="text-xs text-gray-700">silence</span>}
<span className="text-xs text-gray-700 tabular-nums">rms {rms ? (rms * 100).toFixed(1) : '0.0'}%</span> <span className="text-xs text-gray-700 tabular-nums">rms {rms ? (rms * 100).toFixed(1) : '0.0'}%</span>
</div> </div>
@@ -294,7 +324,18 @@ function Oscilloscope({ waveform }) {
{/* Zero line */} {/* Zero line */}
<line x1={0} y1={OSC_H / 2} x2={OSC_W} y2={OSC_H / 2} <line x1={0} y1={OSC_H / 2} x2={OSC_W} y2={OSC_H / 2}
stroke="rgba(255,255,255,0.05)" strokeWidth={0.5} /> stroke="rgba(255,255,255,0.05)" strokeWidth={0.5} />
{/* 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 && (
<>
<path d={ghost.fill} fill={`rgba(168,85,247,${(ghostOp * 0.06).toFixed(3)})`} />
<path d={ghost.path} fill="none"
stroke={`rgba(150,120,200,${(ghostOp * 0.35).toFixed(3)})`}
strokeWidth={0.8} strokeLinejoin="round" strokeLinecap="round" />
</>
)}
{/* Fill body */}
{path && ( {path && (
<path <path
d={`${path} L${OSC_W},${OSC_H / 2} L0,${OSC_H / 2} Z`} d={`${path} L${OSC_W},${OSC_H / 2} L0,${OSC_H / 2} Z`}
@@ -310,6 +351,28 @@ function Oscilloscope({ waveform }) {
{sinePath && <path d={sinePath} fill="none" {sinePath && <path d={sinePath} fill="none"
stroke="rgba(168,85,247,0.28)" strokeWidth={0.9} stroke="rgba(168,85,247,0.28)" strokeWidth={0.9}
strokeLinejoin="round" strokeDasharray="5 4" />} strokeLinejoin="round" strokeDasharray="5 4" />}
{/* 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 (
<g key={entry.id}
style={{ transform: `translateX(${x}px)`, transition: 'transform 0.45s cubic-bezier(0.4,0,0.2,1)' }}>
<text x={0} y={OSC_H - 18} textAnchor="middle"
fontSize={isNew ? 13 : 11} fontWeight={isNew ? '700' : '400'}
fill={isNew ? `rgba(168,85,247,${op})` : `rgba(160,130,210,${op})`}>
{entry.note}
</text>
<text x={0} y={OSC_H - 7} textAnchor="middle" fontSize={7}
fill={`rgba(120,100,160,${(parseFloat(op) * 0.7).toFixed(2)})`}>
{entry.freq ? entry.freq.toFixed(0) : ''}Hz
</text>
</g>
)
})}
</svg> </svg>
</div> </div>
) )
@@ -343,7 +406,28 @@ const SPEC_GRID = [
function SpectrumPanel({ spectrum, detectedFreq }) { function SpectrumPanel({ spectrum, detectedFreq }) {
const W = OSC_W 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) { if (spectrum?.length) {
const n = spectrum.length const n = spectrum.length
@@ -354,6 +438,17 @@ function SpectrumPanel({ spectrum, detectedFreq }) {
}).join(' ') }).join(' ')
strokePath = pts strokePath = pts
fillPath = pts + ` L${W},${SPEC_H} L0,${SPEC_H} Z` 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 ( return (
@@ -377,7 +472,15 @@ function SpectrumPanel({ spectrum, detectedFreq }) {
) )
})} })}
{/* Spectrum fill + stroke */} {/* Ghost — slow-decaying grey residue from previous peaks */}
{ghostFill && (
<>
<path d={ghostFill} fill="rgba(120,120,130,0.08)" />
<path d={ghostStroke} fill="none" stroke="rgba(130,130,145,0.30)" strokeWidth={0.7} />
</>
)}
{/* Live spectrum fill + stroke */}
{fillPath && ( {fillPath && (
<> <>
<path d={fillPath} fill="rgba(80,180,130,0.13)" /> <path d={fillPath} fill="rgba(80,180,130,0.13)" />
@@ -385,31 +488,43 @@ function SpectrumPanel({ spectrum, detectedFreq }) {
</> </>
)} )}
{/* Fundamental frequency */} {/* Fundamental + harmonics */}
{detectedFreq && (() => { {ghostFreq && [1, 2, 3, 4, 5].map(h => {
const x = specX(detectedFreq, W).toFixed(1) const hf = ghostFreq * h
if (hf > SPEC_F_MAX) return null
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 ( return (
<g> <g key={h}>
<line x1={x} y1={0} x2={x} y2={SPEC_H - 14} <line x1={x} y1={0} x2={x} y2={SPEC_H - 14}
stroke="rgba(168,85,247,0.9)" strokeWidth={1.2} /> stroke={`rgba(168,85,247,${op})`} strokeWidth={1.2} />
<text x={x} y={11} textAnchor="middle" fontSize={8} fontWeight="700" <text x={labelX} y={10} textAnchor={anchor} fontSize={8} fontWeight="700"
fill="rgba(168,85,247,0.95)">f</text> fill={`rgba(168,85,247,${textOp})`}>{note}{oct}</text>
<text x={labelX} y={20} textAnchor={anchor} fontSize={7}
fill={`rgba(168,85,247,${(ghostOpacity * 0.55).toFixed(3)})`}>f</text>
</g> </g>
) )
})()} }
{/* Harmonics 2f5f */} const op = ((0.5 - (h - 2) * 0.1) * ghostOpacity).toFixed(3)
{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 ( return (
<g key={h}> <g key={h}>
<line x1={x} y1={0} x2={x} y2={SPEC_H - 14} <line x1={x} y1={0} x2={x} y2={SPEC_H - 14}
stroke={`rgba(168,85,247,${op})`} strokeWidth={0.7} strokeDasharray="3 4" /> stroke={`rgba(168,85,247,${op})`} strokeWidth={0.7} strokeDasharray="3 4" />
<text x={x} y={11} textAnchor="middle" fontSize={7.5} <text x={labelX} y={10} textAnchor={anchor} fontSize={7.5}
fill={`rgba(168,85,247,${op})`}>{h}f</text> fill={`rgba(168,85,247,${op})`}>{note}{oct}</text>
<text x={labelX} y={19} textAnchor={anchor} fontSize={7}
fill={`rgba(168,85,247,${(parseFloat(op) * 0.7).toFixed(3)})`}>{h}f</text>
</g> </g>
) )
})} })}
@@ -419,7 +534,7 @@ function SpectrumPanel({ spectrum, detectedFreq }) {
} }
// ─── Main component ─────────────────────────────────────────────────────────── // ─── 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 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 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 const topKeyScore = topKeys[0]?.score ?? 1
return ( return (
<div className="bg-panel border border-border rounded-2xl p-4 flex flex-col gap-4"> <div className="flex flex-col gap-4">
<p className="text-xs text-gray-500 uppercase tracking-widest shrink-0">Behind the Scenes</p>
{/* ── Live chroma visualization (instrument-synced) ── */} {/* ── Live chroma visualization (instrument-synced) ── */}
<div> <div>
<p className="text-xs text-gray-600 uppercase tracking-widest mb-2">Live chroma what the engine hears right now</p> <p className="text-xs text-gray-600 uppercase tracking-widest mb-2">Live chroma what the engine hears right now</p>
{instrument === 'guitar' {instrument === 'guitar'
? <MiniFretboard values={chromaArr} keyNotes={keyPCs} chordNotes={chordPCs} /> ? <MiniFretboard values={chromaArr} keyNotes={keyPCs} chordNotes={chordPCs} monoColor={monoColor} />
: <PianoSVG values={chromaArr} keyNotes={keyPCs} chordNotes={chordPCs} keyH={90} /> : <PianoSVG values={chromaArr} keyNotes={keyPCs} chordNotes={chordPCs} keyH={90} monoColor={monoColor} />
} }
</div> </div>
@@ -494,7 +607,7 @@ export default function DebugView({ chroma, chordCandidates, noteAnalysis, wavef
</span> </span>
)} )}
</div> </div>
<PianoSVG values={histFreq} keyNotes={keyPCs} chordNotes={chordPCs} keyH={70} showPct={true} /> <PianoSVG values={histFreq} keyNotes={keyPCs} chordNotes={chordPCs} keyH={70} showPct={true} monoColor={monoColor} />
</div> </div>
{/* Col 3: Key candidates */} {/* Col 3: Key candidates */}
+40 -2
View File
@@ -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 ( return (
<div className="fixed inset-0 bg-surface z-50 overflow-y-auto"> <div className="fixed inset-0 bg-surface z-50 overflow-y-auto">
<div className="max-w-2xl mx-auto px-6 py-8"> <div className="max-w-2xl mx-auto px-6 py-8">
@@ -87,16 +99,42 @@ export default function Settings({ config, onChange, onClose, onReset }) {
> >
Reset defaults Reset defaults
</button> </button>
<button
onClick={handleCancel}
className="px-4 py-2 rounded-lg text-sm border border-border text-gray-500 hover:text-gray-300 hover:border-gray-400 transition-all"
>
Cancel
</button>
<button <button
onClick={onClose} onClick={onClose}
className="px-5 py-2 rounded-lg text-sm bg-accent hover:bg-purple-600 text-white font-semibold transition-all" className="px-5 py-2 rounded-lg text-sm bg-accent hover:bg-purple-600 text-white font-semibold transition-all"
> >
Done Save
</button> </button>
</div> </div>
</div> </div>
<div className="space-y-8"> <div className="space-y-8">
{/* ── Display ── */}
<div>
<h3 className="text-xs uppercase tracking-widest text-gray-500 mb-4 border-b border-border pb-2">
Display
</h3>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-gray-200">Mono Color Mode</p>
<p className="text-xs text-gray-600 mt-0.5">Use a single purple palette instead of purple + amber for note tiers.</p>
</div>
<button
onClick={() => onMonoColorChange(v => !v)}
className={`relative w-11 h-6 rounded-full transition-colors ${monoColor ? 'bg-accent' : 'bg-gray-700'}`}
>
<span className={`absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform ${monoColor ? 'translate-x-5' : 'translate-x-0'}`} />
</button>
</div>
</div>
{SETTINGS.map(section => ( {SETTINGS.map(section => (
<div key={section.section}> <div key={section.section}>
<h3 className="text-xs uppercase tracking-widest text-gray-500 mb-4 border-b border-border pb-2"> <h3 className="text-xs uppercase tracking-widest text-gray-500 mb-4 border-b border-border pb-2">
+2 -5
View File
@@ -108,10 +108,8 @@ export default function Tuner() {
return ( return (
<div className="p-6 bg-panel border border-border rounded-xl text-center"> <div className="p-6 text-center">
<div className="flex items-start justify-between mb-4"> <div className="flex items-start justify-end mb-4">
<h3 className="text-lg font-semibold">Tuner</h3>
<div>
<button <button
onClick={() => (isListening ? stopListening() : startListening())} onClick={() => (isListening ? stopListening() : startListening())}
className={`px-4 py-2 rounded-full text-sm font-semibold ${isListening ? 'bg-red-600' : 'bg-accent'}`} className={`px-4 py-2 rounded-full text-sm font-semibold ${isListening ? 'bg-red-600' : 'bg-accent'}`}
@@ -119,7 +117,6 @@ export default function Tuner() {
{isListening ? 'Stop' : 'Start'} {isListening ? 'Stop' : 'Start'}
</button> </button>
</div> </div>
</div>
<div className="w-full flex flex-col items-center"> <div className="w-full flex flex-col items-center">
<div className="w-full max-w-3xl"> <div className="w-full max-w-3xl">
+2 -3
View File
@@ -417,9 +417,8 @@ export function detectRepeatingProgression(history) {
if (reps < 2) continue if (reps < 2) continue
const score = reps * len const score = reps * len * len // square length — prevents sub-patterns from beating full loop
// Prefer longer patterns on equal score — more descriptive loop wins if (score > bestScore) {
if (score > bestScore || (score === bestScore && len > (best?.length ?? 0))) {
bestScore = score bestScore = score
best = candidate best = candidate
} }