restructuring and layout improvements + save config locally
This commit is contained in:
+1
-1
@@ -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
@@ -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>
|
||||||
|
|||||||
+45
-48
@@ -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">♭?</span>
|
WhatTheFlat <span className="text-gray-600">♭?</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,31 +544,41 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Behind the scenes debug view ── */}
|
{/* ── Behind the scenes — collapsible ── */}
|
||||||
{showDebug && (
|
<div className="mb-3 bg-panel border border-border rounded-xl overflow-hidden">
|
||||||
<div className="mb-3">
|
<button
|
||||||
<DebugView
|
onClick={() => setShowDebug(v => !v)}
|
||||||
chroma={debugChroma}
|
className="w-full flex items-center justify-between px-4 py-2 text-sm text-gray-400 hover:text-gray-200 transition-all"
|
||||||
chordCandidates={debugCandidates}
|
>
|
||||||
noteAnalysis={debugNoteAnalysis}
|
<span>BEHIND THE SCENES</span>
|
||||||
waveform={debugWaveform}
|
<span>{showDebug ? '▲' : '▼'}</span>
|
||||||
keyInfo={effectiveKey}
|
</button>
|
||||||
currentChord={currentChord}
|
{showDebug && (
|
||||||
instrument={instrument}
|
<div className="border-t border-border p-4">
|
||||||
/>
|
<DebugView
|
||||||
</div>
|
chroma={debugChroma}
|
||||||
)}
|
chordCandidates={debugCandidates}
|
||||||
|
noteAnalysis={debugNoteAnalysis}
|
||||||
|
waveform={debugWaveform}
|
||||||
|
keyInfo={effectiveKey}
|
||||||
|
currentChord={currentChord}
|
||||||
|
instrument={instrument}
|
||||||
|
monoColor={monoColor}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
)
|
)
|
||||||
|
|||||||
+161
-48
@@ -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 (C3–B4) ───────────────────────────────────────────
|
// ─── 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']
|
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,17 +406,49 @@ 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
|
||||||
const pts = Array.from({ length: n }, (_, i) => {
|
const pts = Array.from({ length: n }, (_, i) => {
|
||||||
const x = ((i / (n - 1)) * W).toFixed(1)
|
const x = ((i / (n - 1)) * W).toFixed(1)
|
||||||
const y = (SPEC_H * (1 - spectrum[i])).toFixed(1)
|
const y = (SPEC_H * (1 - spectrum[i])).toFixed(1)
|
||||||
return `${i === 0 ? 'M' : 'L'}${x},${y}`
|
return `${i === 0 ? 'M' : 'L'}${x},${y}`
|
||||||
}).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
|
||||||
return (
|
|
||||||
<g>
|
|
||||||
<line x1={x} y1={0} x2={x} y2={SPEC_H - 14}
|
|
||||||
stroke="rgba(168,85,247,0.9)" strokeWidth={1.2} />
|
|
||||||
<text x={x} y={11} textAnchor="middle" fontSize={8} fontWeight="700"
|
|
||||||
fill="rgba(168,85,247,0.95)">f</text>
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* Harmonics 2f–5f */}
|
|
||||||
{detectedFreq && [2, 3, 4, 5].map(h => {
|
|
||||||
const hf = detectedFreq * h
|
|
||||||
if (hf > SPEC_F_MAX) return null
|
if (hf > SPEC_F_MAX) return null
|
||||||
const x = specX(hf, W).toFixed(1)
|
const xNum = specX(hf, W)
|
||||||
const op = (0.5 - (h - 2) * 0.1).toFixed(2)
|
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 (
|
||||||
|
<g key={h}>
|
||||||
|
<line x1={x} y1={0} x2={x} y2={SPEC_H - 14}
|
||||||
|
stroke={`rgba(168,85,247,${op})`} strokeWidth={1.2} />
|
||||||
|
<text x={labelX} y={10} textAnchor={anchor} fontSize={8} fontWeight="700"
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const op = ((0.5 - (h - 2) * 0.1) * ghostOpacity).toFixed(3)
|
||||||
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 */}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -108,17 +108,14 @@ 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>
|
<button
|
||||||
<div>
|
onClick={() => (isListening ? stopListening() : startListening())}
|
||||||
<button
|
className={`px-4 py-2 rounded-full text-sm font-semibold ${isListening ? 'bg-red-600' : 'bg-accent'}`}
|
||||||
onClick={() => (isListening ? stopListening() : startListening())}
|
>
|
||||||
className={`px-4 py-2 rounded-full text-sm font-semibold ${isListening ? 'bg-red-600' : 'bg-accent'}`}
|
{isListening ? 'Stop' : 'Start'}
|
||||||
>
|
</button>
|
||||||
{isListening ? 'Stop' : 'Start'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full flex flex-col items-center">
|
<div className="w-full flex flex-col items-center">
|
||||||
|
|||||||
+2
-3
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user