debugview and loop improvements
This commit is contained in:
+33
-6
@@ -55,11 +55,14 @@ export default function App() {
|
|||||||
const [debugChroma, setDebugChroma] = useState(null)
|
const [debugChroma, setDebugChroma] = useState(null)
|
||||||
const [debugCandidates, setDebugCandidates] = useState([])
|
const [debugCandidates, setDebugCandidates] = useState([])
|
||||||
const [debugNoteAnalysis, setDebugNoteAnalysis] = useState(null)
|
const [debugNoteAnalysis, setDebugNoteAnalysis] = useState(null)
|
||||||
|
const [debugWaveform, setDebugWaveform] = useState(null)
|
||||||
|
|
||||||
// ── Stable refs for values used inside callbacks ──────────────────────────────
|
// ── Stable refs for values used inside callbacks ──────────────────────────────
|
||||||
const showDebugRef = useRef(showDebug)
|
const showDebugRef = useRef(showDebug)
|
||||||
const lockedKeyRef = useRef(null)
|
const lockedKeyRef = useRef(null)
|
||||||
|
const listenStartRef = useRef(null)
|
||||||
useEffect(() => { showDebugRef.current = showDebug }, [showDebug])
|
useEffect(() => { showDebugRef.current = showDebug }, [showDebug])
|
||||||
|
useEffect(() => { if (isListening) listenStartRef.current = Date.now() }, [isListening])
|
||||||
|
|
||||||
// ── BPM estimation from onset timestamps ─────────────────────────────────────
|
// ── BPM estimation from onset timestamps ─────────────────────────────────────
|
||||||
const [bpm, setBpm] = useState(null)
|
const [bpm, setBpm] = useState(null)
|
||||||
@@ -92,6 +95,7 @@ export default function App() {
|
|||||||
const chromaIdxRef = useRef(0)
|
const chromaIdxRef = useRef(0)
|
||||||
const chordVotesRef = useRef([])
|
const chordVotesRef = useRef([])
|
||||||
const progressionVoteRef = useRef(null)
|
const progressionVoteRef = useRef(null)
|
||||||
|
const progressionMissRef = useRef(0)
|
||||||
const pendingKeyRef = useRef(null)
|
const pendingKeyRef = useRef(null)
|
||||||
|
|
||||||
// Keep refs in sync
|
// Keep refs in sync
|
||||||
@@ -109,7 +113,16 @@ export default function App() {
|
|||||||
// ── Detect progression — require 2 consecutive identical results to commit ────
|
// ── Detect progression — require 2 consecutive identical results to commit ────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const detected = detectRepeatingProgression(chordHistory)
|
const detected = detectRepeatingProgression(chordHistory)
|
||||||
if (!detected) return
|
if (!detected) {
|
||||||
|
progressionMissRef.current++
|
||||||
|
// Clear stale loop after 4 chord changes with no pattern found
|
||||||
|
if (progressionMissRef.current >= 4) {
|
||||||
|
setDetectedProgression(null)
|
||||||
|
progressionVoteRef.current = null
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
progressionMissRef.current = 0
|
||||||
const key = detected.join(',')
|
const key = detected.join(',')
|
||||||
if (progressionVoteRef.current === key) {
|
if (progressionVoteRef.current === key) {
|
||||||
setDetectedProgression(detected)
|
setDetectedProgression(detected)
|
||||||
@@ -125,11 +138,13 @@ export default function App() {
|
|||||||
keyVotesRef.current = []
|
keyVotesRef.current = []
|
||||||
chordVotesRef.current = []
|
chordVotesRef.current = []
|
||||||
progressionVoteRef.current = null
|
progressionVoteRef.current = null
|
||||||
|
progressionMissRef.current = 0
|
||||||
pendingKeyRef.current = null
|
pendingKeyRef.current = null
|
||||||
chromaIdxRef.current = 0
|
chromaIdxRef.current = 0
|
||||||
chromaRingRef.current = Array.from({ length: cfg.chromaSmooth }, () => new Float32Array(12))
|
chromaRingRef.current = Array.from({ length: cfg.chromaSmooth }, () => new Float32Array(12))
|
||||||
onsetTimestampsRef.current = []
|
onsetTimestampsRef.current = []
|
||||||
bpmSmoothRef.current = null
|
bpmSmoothRef.current = null
|
||||||
|
listenStartRef.current = Date.now()
|
||||||
setKeyInfo(null)
|
setKeyInfo(null)
|
||||||
setLockedKey(null)
|
setLockedKey(null)
|
||||||
effectiveKeyRef.current = null
|
effectiveKeyRef.current = null
|
||||||
@@ -141,6 +156,7 @@ export default function App() {
|
|||||||
setDebugChroma(null)
|
setDebugChroma(null)
|
||||||
setDebugCandidates([])
|
setDebugCandidates([])
|
||||||
setDebugNoteAnalysis(null)
|
setDebugNoteAnalysis(null)
|
||||||
|
setDebugWaveform(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Key lock handlers ─────────────────────────────────────────────────────────
|
// ── Key lock handlers ─────────────────────────────────────────────────────────
|
||||||
@@ -163,6 +179,11 @@ export default function App() {
|
|||||||
effectiveKeyRef.current = keyInfo
|
effectiveKeyRef.current = keyInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Waveform handler: feeds oscilloscope in debug view ───────────────────────
|
||||||
|
const handleWaveform = useCallback((data) => {
|
||||||
|
if (showDebugRef.current) setDebugWaveform(data)
|
||||||
|
}, [])
|
||||||
|
|
||||||
// ── Note handler: drives key detection (pitch-based) ──────────────────────────
|
// ── Note handler: drives key detection (pitch-based) ──────────────────────────
|
||||||
const handleNote = useCallback(({ pitchClass }) => {
|
const handleNote = useCallback(({ pitchClass }) => {
|
||||||
const cfg = configRef.current
|
const cfg = configRef.current
|
||||||
@@ -174,7 +195,11 @@ export default function App() {
|
|||||||
|
|
||||||
const result = detectKey(history)
|
const result = detectKey(history)
|
||||||
setTopKeyCandidates(detectTopKeys(history))
|
setTopKeyCandidates(detectTopKeys(history))
|
||||||
if (showDebugRef.current) setDebugNoteAnalysis(getNoteHistoryAnalysis(history))
|
if (showDebugRef.current) {
|
||||||
|
const analysis = getNoteHistoryAnalysis(history)
|
||||||
|
analysis.sessionSecs = listenStartRef.current ? Math.floor((Date.now() - listenStartRef.current) / 1000) : 0
|
||||||
|
setDebugNoteAnalysis(analysis)
|
||||||
|
}
|
||||||
if (result.confidence < 0.5) return
|
if (result.confidence < 0.5) return
|
||||||
|
|
||||||
const votes = keyVotesRef.current
|
const votes = keyVotesRef.current
|
||||||
@@ -226,7 +251,7 @@ export default function App() {
|
|||||||
|
|
||||||
if (showDebugRef.current) {
|
if (showDebugRef.current) {
|
||||||
setDebugChroma([...avg])
|
setDebugChroma([...avg])
|
||||||
setDebugCandidates(getChordCandidates(avg, key, bassPC))
|
setDebugCandidates(getChordCandidates(avg, key, bassPC, 5))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stability gate — if chroma is still changing across frames, we're mid-transition.
|
// Stability gate — if chroma is still changing across frames, we're mid-transition.
|
||||||
@@ -256,7 +281,7 @@ export default function App() {
|
|||||||
const winner = votes[0]
|
const winner = votes[0]
|
||||||
setChordHistory(prev => {
|
setChordHistory(prev => {
|
||||||
if (prev[prev.length - 1] === winner) return prev
|
if (prev[prev.length - 1] === winner) return prev
|
||||||
return [...prev.slice(-30), winner]
|
return [...prev.slice(-48), winner]
|
||||||
})
|
})
|
||||||
|
|
||||||
// Inject chord tones into note history to anchor key detection
|
// Inject chord tones into note history to anchor key detection
|
||||||
@@ -492,6 +517,7 @@ export default function App() {
|
|||||||
onNote={handleNote}
|
onNote={handleNote}
|
||||||
onChroma={handleChroma}
|
onChroma={handleChroma}
|
||||||
onOnset={handleOnset}
|
onOnset={handleOnset}
|
||||||
|
onWaveform={handleWaveform}
|
||||||
isListening={isListening}
|
isListening={isListening}
|
||||||
minClarity={config.minClarity}
|
minClarity={config.minClarity}
|
||||||
minVolume={config.minVolume}
|
minVolume={config.minVolume}
|
||||||
@@ -538,6 +564,7 @@ export default function App() {
|
|||||||
chroma={debugChroma}
|
chroma={debugChroma}
|
||||||
chordCandidates={debugCandidates}
|
chordCandidates={debugCandidates}
|
||||||
noteAnalysis={debugNoteAnalysis}
|
noteAnalysis={debugNoteAnalysis}
|
||||||
|
waveform={debugWaveform}
|
||||||
keyInfo={effectiveKey}
|
keyInfo={effectiveKey}
|
||||||
currentChord={currentChord}
|
currentChord={currentChord}
|
||||||
instrument={instrument}
|
instrument={instrument}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ function detectBassPC(freqData, sampleRate, fftSize) {
|
|||||||
return ((bestMidi % 12) + 12) % 12
|
return ((bestMidi % 12) + 12) % 12
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AudioCapture({ onNote, onChroma, onOnset, isListening, minClarity = 0.80, minVolume = 0.01, onPermissionError }) {
|
export default function AudioCapture({ onNote, onChroma, onOnset, onWaveform, isListening, minClarity = 0.80, minVolume = 0.01, onPermissionError }) {
|
||||||
const audioCtxRef = useRef(null)
|
const audioCtxRef = useRef(null)
|
||||||
const timeBufRef = useRef(null)
|
const timeBufRef = useRef(null)
|
||||||
const freqBufRef = useRef(null)
|
const freqBufRef = useRef(null)
|
||||||
@@ -94,20 +94,24 @@ export default function AudioCapture({ onNote, onChroma, onOnset, isListening, m
|
|||||||
const onNoteRef = useRef(onNote)
|
const onNoteRef = useRef(onNote)
|
||||||
const onChromaRef = useRef(onChroma)
|
const onChromaRef = useRef(onChroma)
|
||||||
const onOnsetRef = useRef(onOnset)
|
const onOnsetRef = useRef(onOnset)
|
||||||
|
const onWaveformRef = useRef(onWaveform)
|
||||||
const onPermissionErrorRef = useRef(onPermissionError)
|
const onPermissionErrorRef = useRef(onPermissionError)
|
||||||
const minClarityRef = useRef(minClarity)
|
const minClarityRef = useRef(minClarity)
|
||||||
const minVolumeRef = useRef(minVolume)
|
const minVolumeRef = useRef(minVolume)
|
||||||
const smoothRmsRef = useRef(0)
|
const smoothRmsRef = useRef(0)
|
||||||
const lastOnsetRef = useRef(0)
|
const lastOnsetRef = useRef(0)
|
||||||
|
const specPeakRef = useRef(null) // peak-hold spectrum for display lingering
|
||||||
useEffect(() => { onNoteRef.current = onNote }, [onNote])
|
useEffect(() => { onNoteRef.current = onNote }, [onNote])
|
||||||
useEffect(() => { onChromaRef.current = onChroma }, [onChroma])
|
useEffect(() => { onChromaRef.current = onChroma }, [onChroma])
|
||||||
useEffect(() => { onOnsetRef.current = onOnset }, [onOnset])
|
useEffect(() => { onOnsetRef.current = onOnset }, [onOnset])
|
||||||
|
useEffect(() => { onWaveformRef.current = onWaveform }, [onWaveform])
|
||||||
useEffect(() => { onPermissionErrorRef.current = onPermissionError }, [onPermissionError])
|
useEffect(() => { onPermissionErrorRef.current = onPermissionError }, [onPermissionError])
|
||||||
useEffect(() => { minClarityRef.current = minClarity }, [minClarity])
|
useEffect(() => { minClarityRef.current = minClarity }, [minClarity])
|
||||||
useEffect(() => { minVolumeRef.current = minVolume }, [minVolume])
|
useEffect(() => { minVolumeRef.current = minVolume }, [minVolume])
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
const stop = useCallback(() => {
|
||||||
activeRef.current = false
|
activeRef.current = false
|
||||||
|
specPeakRef.current = null
|
||||||
if (rafRef.current) { cancelAnimationFrame(rafRef.current); rafRef.current = null }
|
if (rafRef.current) { cancelAnimationFrame(rafRef.current); rafRef.current = null }
|
||||||
if (streamRef.current) { streamRef.current.getTracks().forEach(t => t.stop()); streamRef.current = null }
|
if (streamRef.current) { streamRef.current.getTracks().forEach(t => t.stop()); streamRef.current = null }
|
||||||
if (audioCtxRef.current) { audioCtxRef.current.close(); audioCtxRef.current = null }
|
if (audioCtxRef.current) { audioCtxRef.current.close(); audioCtxRef.current = null }
|
||||||
@@ -163,6 +167,46 @@ export default function AudioCapture({ onNote, onChroma, onOnset, isListening, m
|
|||||||
onOnsetRef.current?.()
|
onOnsetRef.current?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Always fire waveform callback — downsample 4096 → 512 points + log-binned spectrum
|
||||||
|
if (onWaveformRef.current) {
|
||||||
|
const stride = 8 // 4096 / 8 = 512 points
|
||||||
|
const wave = new Float32Array(PITCH_FFT / stride)
|
||||||
|
for (let i = 0; i < wave.length; i++) wave[i] = timeBuf[i * stride]
|
||||||
|
|
||||||
|
// Log-binned frequency spectrum: 256 bins from 40 Hz → 4000 Hz
|
||||||
|
const LOG_BINS = 256
|
||||||
|
const F_MIN = 40, F_MAX = 4000
|
||||||
|
const binHz = ctx.sampleRate / ca.fftSize
|
||||||
|
const freqBuf = freqBufRef.current
|
||||||
|
ca.getFloatFrequencyData(freqBuf)
|
||||||
|
const spectrum = new Float32Array(LOG_BINS)
|
||||||
|
for (let b = 0; b < LOG_BINS; b++) {
|
||||||
|
const f = F_MIN * Math.pow(F_MAX / F_MIN, b / (LOG_BINS - 1))
|
||||||
|
const bin = Math.round(f / binHz)
|
||||||
|
if (bin < freqBuf.length) {
|
||||||
|
const db = freqBuf[bin]
|
||||||
|
spectrum[b] = db < NOISE_FLOOR ? 0 : Math.max(0, (db - NOISE_FLOOR) / (-NOISE_FLOOR))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Peak-hold with exponential decay — spectrum rises instantly, falls slowly
|
||||||
|
if (!specPeakRef.current) specPeakRef.current = new Float32Array(LOG_BINS)
|
||||||
|
const peak = specPeakRef.current
|
||||||
|
for (let b = 0; b < LOG_BINS; b++) {
|
||||||
|
peak[b] = spectrum[b] > peak[b] ? spectrum[b] : peak[b] * 0.92
|
||||||
|
}
|
||||||
|
|
||||||
|
let detectedFreq = null, detectedNote = null
|
||||||
|
if (rms >= minVolumeRef.current) {
|
||||||
|
const [f, c] = detectorRef.current.findPitch(timeBuf, ctx.sampleRate)
|
||||||
|
if (c >= minClarityRef.current && f > 60 && f < 4200) {
|
||||||
|
detectedFreq = f
|
||||||
|
detectedNote = NOTES[((Math.round(12 * Math.log2(f / 440) + 69) % 12) + 12) % 12]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onWaveformRef.current({ wave, rms, detectedFreq, detectedNote, spectrum: peak })
|
||||||
|
}
|
||||||
|
|
||||||
if (rms >= minVolumeRef.current) {
|
if (rms >= minVolumeRef.current) {
|
||||||
const [freq, clarity] = detectorRef.current.findPitch(timeBuf, ctx.sampleRate)
|
const [freq, clarity] = detectorRef.current.findPitch(timeBuf, ctx.sampleRate)
|
||||||
if (clarity >= minClarityRef.current && freq > 60 && freq < 4200) {
|
if (clarity >= minClarityRef.current && freq > 60 && freq < 4200) {
|
||||||
|
|||||||
+243
-13
@@ -43,7 +43,7 @@ function PianoSVG({ values, keyNotes, chordNotes, keyH = KEY_H, showPct = false
|
|||||||
<g key={`w${wi}`}>
|
<g key={`w${wi}`}>
|
||||||
<rect x={x+1} y={3} width={KEY_W-2} height={keyH}
|
<rect x={x+1} y={3} width={KEY_W-2} height={keyH}
|
||||||
rx={3} fill="rgb(20,20,26)" stroke="rgba(255,255,255,0.08)" strokeWidth={1} />
|
rx={3} fill="rgb(20,20,26)" stroke="rgba(255,255,255,0.08)" strokeWidth={1} />
|
||||||
{energy > 0.04 && (
|
{energy > (inChord || inKey ? 0.12 : 0.35) && (
|
||||||
<rect
|
<rect
|
||||||
x={x+1} y={3 + keyH * (1 - Math.min(energy, 1) * 0.85)}
|
x={x+1} y={3 + keyH * (1 - Math.min(energy, 1) * 0.85)}
|
||||||
width={KEY_W-2} height={keyH * Math.min(energy, 1) * 0.85}
|
width={KEY_W-2} height={keyH * Math.min(energy, 1) * 0.85}
|
||||||
@@ -69,17 +69,34 @@ function PianoSVG({ values, keyNotes, chordNotes, keyH = KEY_H, showPct = false
|
|||||||
const inChord = chordNotes?.has(pc)
|
const inChord = chordNotes?.has(pc)
|
||||||
const inKey = keyNotes?.has(pc)
|
const inKey = keyNotes?.has(pc)
|
||||||
const x = wi * KEY_W + KEY_W - BLACK_W / 2
|
const x = wi * KEY_W + KEY_W - BLACK_W / 2
|
||||||
const bg = inChord
|
const fillColor = inChord
|
||||||
? `rgba(139,92,246,${0.4 + energy * 0.6})`
|
? 'rgba(139,92,246,0.9)'
|
||||||
: inKey
|
: inKey
|
||||||
? `rgba(180,130,0,${0.35 + energy * 0.55})`
|
? 'rgba(180,130,0,0.85)'
|
||||||
: `rgba(12,12,16,0.95)`
|
: 'rgba(70,70,80,0.75)'
|
||||||
|
const pct = showPct ? Math.round(values[pc] * 100) : 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<g key={`b${i}`}>
|
<g key={`b${i}`}>
|
||||||
|
{/* Base */}
|
||||||
<rect x={x} y={3} width={BLACK_W} height={BLACK_H}
|
<rect x={x} y={3} width={BLACK_W} height={BLACK_H}
|
||||||
rx={2} fill={bg} stroke="rgba(255,255,255,0.06)" strokeWidth={1} />
|
rx={2} fill="rgb(14,14,18)" stroke="rgba(255,255,255,0.06)" strokeWidth={1} />
|
||||||
<text x={x + BLACK_W/2} y={BLACK_H - 5} textAnchor="middle" fontSize={7}
|
{/* Partial fill from bottom — same mechanic as white keys */}
|
||||||
|
{energy > (inChord || inKey ? 0.05 : 0.35) && (
|
||||||
|
<rect
|
||||||
|
x={x} y={3 + BLACK_H * (1 - Math.min(energy, 1) * 0.9)}
|
||||||
|
width={BLACK_W} height={BLACK_H * Math.min(energy, 1) * 0.9}
|
||||||
|
rx={1} fill={fillColor} />
|
||||||
|
)}
|
||||||
|
{/* % label near top of key (inside) */}
|
||||||
|
{showPct && pct > 0 && (
|
||||||
|
<text x={x + BLACK_W/2} y={3 + 10} textAnchor="middle" fontSize={7}
|
||||||
|
fill={inKey || inChord ? 'rgba(220,220,230,0.9)' : 'rgba(110,110,120,0.7)'}>
|
||||||
|
{pct}%
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
{/* Note name near bottom of key */}
|
||||||
|
<text x={x + BLACK_W/2} y={3 + BLACK_H - 5} textAnchor="middle" fontSize={7}
|
||||||
fill={inKey || inChord ? 'rgba(210,210,220,0.85)' : 'rgba(110,110,120,0.6)'}>
|
fill={inKey || inChord ? 'rgba(210,210,220,0.85)' : 'rgba(110,110,120,0.6)'}>
|
||||||
{NOTES[pc]}
|
{NOTES[pc]}
|
||||||
</text>
|
</text>
|
||||||
@@ -170,7 +187,8 @@ function MiniFretboard({ values, keyNotes, chordNotes }) {
|
|||||||
const energy = values[pc] / max
|
const energy = values[pc] / max
|
||||||
const inChord = chordNotes?.has(pc)
|
const inChord = chordNotes?.has(pc)
|
||||||
const inKey = keyNotes?.has(pc)
|
const inKey = keyNotes?.has(pc)
|
||||||
if (!inChord && !inKey && energy < 0.12) return null
|
if (!inChord && !inKey && energy < 0.35) return null
|
||||||
|
if ((inChord || inKey) && energy < 0.08) return null
|
||||||
|
|
||||||
const cx = fi === 0 ? MF_OPEN_X : mfFretX(fi)
|
const cx = fi === 0 ? MF_OPEN_X : mfFretX(fi)
|
||||||
const cy = mfStringY(si)
|
const cy = mfStringY(si)
|
||||||
@@ -206,14 +224,213 @@ function MiniFretboard({ values, keyNotes, chordNotes }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Oscilloscope strip ───────────────────────────────────────────────────────
|
||||||
|
const OSC_W = 600
|
||||||
|
const OSC_H = 110
|
||||||
|
|
||||||
|
function Oscilloscope({ waveform }) {
|
||||||
|
const { wave, rms, detectedFreq, detectedNote } = waveform || {}
|
||||||
|
const silent = !rms || rms < 0.005
|
||||||
|
|
||||||
|
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
|
||||||
|
for (let i = lo; i < hi - 1; i++) {
|
||||||
|
if (wave[i] <= 0 && wave[i + 1] > 0) { offset = i; break }
|
||||||
|
}
|
||||||
|
const drawLen = Math.min(wave.length - offset, Math.floor(wave.length * 0.85))
|
||||||
|
const step = OSC_W / drawLen
|
||||||
|
|
||||||
|
path = Array.from({ length: drawLen }, (_, i) => {
|
||||||
|
const v = wave[offset + i]
|
||||||
|
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
|
||||||
|
if (detectedFreq) {
|
||||||
|
|
||||||
|
const effectiveSR = 44100 / 8
|
||||||
|
const sineAmp = Math.min(waveAmp * gain * 0.55, OSC_H * 0.38)
|
||||||
|
sinePath = Array.from({ length: 300 }, (_, i) => {
|
||||||
|
const t = i / 299
|
||||||
|
const x = (t * OSC_W).toFixed(1)
|
||||||
|
const phase = ((offset + t * drawLen) / effectiveSR) * detectedFreq * Math.PI * 2
|
||||||
|
const y = (mid - Math.sin(phase) * sineAmp).toFixed(1)
|
||||||
|
return `${i === 0 ? 'M' : 'L'}${x},${y}`
|
||||||
|
}).join(' ')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lineColor = detectedFreq
|
||||||
|
? 'rgba(168,85,247,0.9)'
|
||||||
|
: silent ? 'rgba(50,50,60,0.8)' : 'rgba(100,200,140,0.75)'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<p className="text-xs text-gray-600 uppercase tracking-widest">Oscilloscope — raw mic input</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{detectedFreq && detectedNote && (
|
||||||
|
<>
|
||||||
|
<span className="text-xs font-bold text-accent">{detectedNote}</span>
|
||||||
|
<span className="text-xs text-gray-500 tabular-nums">{detectedFreq.toFixed(1)} Hz</span>
|
||||||
|
<span className="text-xs text-gray-600 tabular-nums">{(1000 / detectedFreq).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>}
|
||||||
|
<span className="text-xs text-gray-700 tabular-nums">rms {rms ? (rms * 100).toFixed(1) : '0.0'}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<svg viewBox={`0 0 ${OSC_W} ${OSC_H}`} width="100%" style={{ display: 'block' }}
|
||||||
|
className="rounded-lg bg-surface border border-border">
|
||||||
|
{/* Zero line */}
|
||||||
|
<line x1={0} y1={OSC_H / 2} x2={OSC_W} y2={OSC_H / 2}
|
||||||
|
stroke="rgba(255,255,255,0.05)" strokeWidth={0.5} />
|
||||||
|
{/* Fill body — close path to the centre line for a filled silhouette */}
|
||||||
|
{path && (
|
||||||
|
<path
|
||||||
|
d={`${path} L${OSC_W},${OSC_H / 2} L0,${OSC_H / 2} Z`}
|
||||||
|
fill={detectedFreq
|
||||||
|
? 'rgba(168,85,247,0.08)'
|
||||||
|
: silent ? 'none' : 'rgba(90,190,130,0.07)'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Waveform line */}
|
||||||
|
{path && <path d={path} fill="none" stroke={lineColor} strokeWidth={0.9}
|
||||||
|
strokeLinejoin="round" strokeLinecap="round" />}
|
||||||
|
{/* Sine overlay */}
|
||||||
|
{sinePath && <path d={sinePath} fill="none"
|
||||||
|
stroke="rgba(168,85,247,0.28)" strokeWidth={0.9}
|
||||||
|
strokeLinejoin="round" strokeDasharray="5 4" />}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Frequency spectrum ───────────────────────────────────────────────────────
|
||||||
|
const SPEC_H = 130
|
||||||
|
const SPEC_F_MIN = 40
|
||||||
|
const SPEC_F_MAX = 4000
|
||||||
|
const SPEC_LOG = Math.log(SPEC_F_MAX / SPEC_F_MIN)
|
||||||
|
|
||||||
|
// Map a frequency in Hz to an x pixel position (log scale)
|
||||||
|
function specX(f, w) {
|
||||||
|
if (f <= SPEC_F_MIN) return 0
|
||||||
|
if (f >= SPEC_F_MAX) return w
|
||||||
|
return w * Math.log(f / SPEC_F_MIN) / SPEC_LOG
|
||||||
|
}
|
||||||
|
|
||||||
|
const SPEC_GRID = [
|
||||||
|
{ label: 'E2', freq: 82.4 },
|
||||||
|
{ label: 'C3', freq: 130.8 },
|
||||||
|
{ label: 'E3', freq: 164.8 },
|
||||||
|
{ label: 'A3', freq: 220 },
|
||||||
|
{ label: 'C4', freq: 261.6 },
|
||||||
|
{ label: 'E4', freq: 329.6 },
|
||||||
|
{ label: 'A4', freq: 440 },
|
||||||
|
{ label: 'C5', freq: 523.3 },
|
||||||
|
{ label: 'C6', freq: 1046.5},
|
||||||
|
{ label: 'C7', freq: 2093 },
|
||||||
|
]
|
||||||
|
|
||||||
|
function SpectrumPanel({ spectrum, detectedFreq }) {
|
||||||
|
const W = OSC_W
|
||||||
|
let fillPath = '', strokePath = ''
|
||||||
|
|
||||||
|
if (spectrum?.length) {
|
||||||
|
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`
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-600 uppercase tracking-widest mb-1">
|
||||||
|
Frequency spectrum — 40 Hz → 4 kHz (log scale)
|
||||||
|
</p>
|
||||||
|
<svg viewBox={`0 0 ${W} ${SPEC_H}`} width="100%" style={{ display: 'block' }}
|
||||||
|
className="rounded-lg bg-surface border border-border">
|
||||||
|
|
||||||
|
{/* Note grid lines */}
|
||||||
|
{SPEC_GRID.map(({ label, freq }) => {
|
||||||
|
const x = specX(freq, W).toFixed(1)
|
||||||
|
return (
|
||||||
|
<g key={label}>
|
||||||
|
<line x1={x} y1={0} x2={x} y2={SPEC_H - 14}
|
||||||
|
stroke="rgba(255,255,255,0.06)" strokeWidth={1} />
|
||||||
|
<text x={x} y={SPEC_H - 3} textAnchor="middle" fontSize={7.5}
|
||||||
|
fill="rgba(80,80,95,0.9)">{label}</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Spectrum fill + stroke */}
|
||||||
|
{fillPath && (
|
||||||
|
<>
|
||||||
|
<path d={fillPath} fill="rgba(80,180,130,0.13)" />
|
||||||
|
<path d={strokePath} fill="none" stroke="rgba(90,200,145,0.55)" strokeWidth={0.8} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Fundamental frequency */}
|
||||||
|
{detectedFreq && (() => {
|
||||||
|
const x = specX(detectedFreq, W).toFixed(1)
|
||||||
|
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
|
||||||
|
const x = specX(hf, W).toFixed(1)
|
||||||
|
const op = (0.5 - (h - 2) * 0.1).toFixed(2)
|
||||||
|
return (
|
||||||
|
<g key={h}>
|
||||||
|
<line x1={x} y1={0} x2={x} y2={SPEC_H - 14}
|
||||||
|
stroke={`rgba(168,85,247,${op})`} strokeWidth={0.7} strokeDasharray="3 4" />
|
||||||
|
<text x={x} y={11} textAnchor="middle" fontSize={7.5}
|
||||||
|
fill={`rgba(168,85,247,${op})`}>{h}f</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Main component ───────────────────────────────────────────────────────────
|
// ─── Main component ───────────────────────────────────────────────────────────
|
||||||
export default function DebugView({ chroma, chordCandidates, noteAnalysis, keyInfo, currentChord, instrument = 'guitar' }) {
|
export default function DebugView({ chroma, chordCandidates, noteAnalysis, waveform, keyInfo, currentChord, instrument = 'guitar' }) {
|
||||||
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)) : [])
|
||||||
|
|
||||||
const chromaArr = chroma ? [...chroma] : new Array(12).fill(0)
|
const chromaArr = chroma ? [...chroma] : new Array(12).fill(0)
|
||||||
const histFreq = noteAnalysis ? noteAnalysis.freq : new Array(12).fill(0)
|
const histFreq = noteAnalysis ? noteAnalysis.freq : new Array(12).fill(0)
|
||||||
const topKeys = noteAnalysis ? noteAnalysis.topKeys : []
|
const topKeys = noteAnalysis ? noteAnalysis.topKeys : []
|
||||||
|
const totalNotes = noteAnalysis?.total ?? 0
|
||||||
|
const sessionSecs = noteAnalysis?.sessionSecs ?? 0
|
||||||
|
const sessionLabel = sessionSecs >= 60
|
||||||
|
? `${Math.floor(sessionSecs / 60)}m ${sessionSecs % 60}s`
|
||||||
|
: `${sessionSecs}s`
|
||||||
const topScore = chordCandidates[0]?.score ?? 1
|
const topScore = chordCandidates[0]?.score ?? 1
|
||||||
const topKeyScore = topKeys[0]?.score ?? 1
|
const topKeyScore = topKeys[0]?.score ?? 1
|
||||||
|
|
||||||
@@ -269,7 +486,14 @@ export default function DebugView({ chroma, chordCandidates, noteAnalysis, keyIn
|
|||||||
|
|
||||||
{/* Col 2: Note history piano with % labels */}
|
{/* Col 2: Note history piano with % labels */}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-gray-600 uppercase tracking-widest mb-2">Note history — key evidence</p>
|
<div className="flex items-baseline justify-between mb-2">
|
||||||
|
<p className="text-xs text-gray-600 uppercase tracking-widest">Note history</p>
|
||||||
|
{totalNotes > 0 && (
|
||||||
|
<span className="text-[10px] text-gray-600 tabular-nums">
|
||||||
|
{totalNotes.toLocaleString()} notes · {sessionLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<PianoSVG values={histFreq} keyNotes={keyPCs} chordNotes={chordPCs} keyH={70} showPct={true} />
|
<PianoSVG values={histFreq} keyNotes={keyPCs} chordNotes={chordPCs} keyH={70} showPct={true} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -298,6 +522,12 @@ export default function DebugView({ chroma, chordCandidates, noteAnalysis, keyIn
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Oscilloscope + spectrum ── */}
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Oscilloscope waveform={waveform} />
|
||||||
|
<SpectrumPanel spectrum={waveform?.spectrum} detectedFreq={waveform?.detectedFreq} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+53
-19
@@ -360,40 +360,73 @@ export function toRomanNumeral(chordName, keyRoot, keyMode) {
|
|||||||
|
|
||||||
// ─── Repeating progression detection ─────────────────────────────────────────
|
// ─── Repeating progression detection ─────────────────────────────────────────
|
||||||
|
|
||||||
|
// Returns true if arr is made of a shorter repeating unit (e.g. [A,B,A,B] → true)
|
||||||
|
function isPeriodicPattern(arr) {
|
||||||
|
for (let p = 1; p <= Math.floor(arr.length / 2); p++) {
|
||||||
|
if (arr.length % p !== 0) continue
|
||||||
|
const unit = arr.slice(0, p)
|
||||||
|
if (arr.every((v, i) => v === unit[i % p])) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the lexicographically smallest rotation so the same loop always
|
||||||
|
// produces the same string regardless of where in the cycle we currently are.
|
||||||
|
function canonicalize(pattern) {
|
||||||
|
let best = pattern
|
||||||
|
for (let i = 1; i < pattern.length; i++) {
|
||||||
|
const rot = [...pattern.slice(i), ...pattern.slice(0, i)]
|
||||||
|
if (rot.join('\0') < best.join('\0')) best = rot
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* detectRepeatingProgression(history) → chord[] or null
|
* detectRepeatingProgression(history) → chord[] or null
|
||||||
* Returns the most-recently-completed repeating pattern (length 2–6).
|
*
|
||||||
* Uses non-overlapping match counting to avoid over-counting.
|
* Tests every unique subsequence of every length (not just the tail) so the
|
||||||
|
* result is stable regardless of where in the loop the musician currently is.
|
||||||
|
* Returns the canonical (rotation-normalised) form of the best pattern found.
|
||||||
*/
|
*/
|
||||||
export function detectRepeatingProgression(history) {
|
export function detectRepeatingProgression(history) {
|
||||||
if (!history || history.length < 4) return null
|
if (!history || history.length < 6) return null
|
||||||
|
|
||||||
const window = history.slice(-20)
|
const win = history.slice(-32)
|
||||||
let best = null, bestScore = 0
|
let best = null, bestScore = 0
|
||||||
|
|
||||||
for (let len = 2; len <= 6; len++) {
|
for (let len = 2; len <= 6; len++) {
|
||||||
if (len * 2 > window.length) break
|
if (len * 2 > win.length) break
|
||||||
|
|
||||||
const candidate = window.slice(-len)
|
const seen = new Set()
|
||||||
let reps = 0, i = 0
|
|
||||||
|
|
||||||
while (i <= window.length - len) {
|
for (let start = 0; start <= win.length - len; start++) {
|
||||||
if (candidate.every((c, j) => c === window[i + j])) {
|
const candidate = win.slice(start, start + len)
|
||||||
reps++
|
const key = candidate.join('\0')
|
||||||
i += len // skip past match — non-overlapping
|
if (seen.has(key)) continue
|
||||||
} else {
|
seen.add(key)
|
||||||
i++
|
|
||||||
|
// A pattern that is itself a repetition of something shorter will be
|
||||||
|
// found at that shorter length — skip it here to avoid inflating scores.
|
||||||
|
if (len >= 4 && isPeriodicPattern(candidate)) continue
|
||||||
|
|
||||||
|
let reps = 0, i = 0
|
||||||
|
while (i <= win.length - len) {
|
||||||
|
if (candidate.every((c, j) => c === win[i + j])) { reps++; i += len }
|
||||||
|
else i++
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const score = reps * len
|
if (reps < 2) continue
|
||||||
if (reps >= 2 && score > bestScore) {
|
|
||||||
bestScore = score
|
const score = reps * len
|
||||||
best = candidate
|
// Prefer longer patterns on equal score — more descriptive loop wins
|
||||||
|
if (score > bestScore || (score === bestScore && len > (best?.length ?? 0))) {
|
||||||
|
bestScore = score
|
||||||
|
best = candidate
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return best
|
return best ? canonicalize(best) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Debug / analysis helpers ─────────────────────────────────────────────────
|
// ─── Debug / analysis helpers ─────────────────────────────────────────────────
|
||||||
@@ -450,6 +483,7 @@ export function getNoteHistoryAnalysis(noteHistory) {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
freq: normalized,
|
freq: normalized,
|
||||||
|
total,
|
||||||
topKeys: candidates.sort((a, b) => b.score - a.score).slice(0, 5),
|
topKeys: candidates.sort((a, b) => b.score - a.score).slice(0, 5),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user