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
}