debugview and loop improvements

This commit is contained in:
vadimwit
2026-03-10 00:21:40 +00:00
parent 3aadf1afbb
commit b989238373
4 changed files with 374 additions and 39 deletions
+33 -6
View File
@@ -55,11 +55,14 @@ export default function App() {
const [debugChroma, setDebugChroma] = useState(null)
const [debugCandidates, setDebugCandidates] = useState([])
const [debugNoteAnalysis, setDebugNoteAnalysis] = useState(null)
const [debugWaveform, setDebugWaveform] = useState(null)
// ── Stable refs for values used inside callbacks ──────────────────────────────
const showDebugRef = useRef(showDebug)
const lockedKeyRef = useRef(null)
const showDebugRef = useRef(showDebug)
const lockedKeyRef = useRef(null)
const listenStartRef = useRef(null)
useEffect(() => { showDebugRef.current = showDebug }, [showDebug])
useEffect(() => { if (isListening) listenStartRef.current = Date.now() }, [isListening])
// ── BPM estimation from onset timestamps ─────────────────────────────────────
const [bpm, setBpm] = useState(null)
@@ -92,6 +95,7 @@ export default function App() {
const chromaIdxRef = useRef(0)
const chordVotesRef = useRef([])
const progressionVoteRef = useRef(null)
const progressionMissRef = useRef(0)
const pendingKeyRef = useRef(null)
// Keep refs in sync
@@ -109,7 +113,16 @@ export default function App() {
// ── Detect progression — require 2 consecutive identical results to commit ────
useEffect(() => {
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(',')
if (progressionVoteRef.current === key) {
setDetectedProgression(detected)
@@ -125,11 +138,13 @@ export default function App() {
keyVotesRef.current = []
chordVotesRef.current = []
progressionVoteRef.current = null
progressionMissRef.current = 0
pendingKeyRef.current = null
chromaIdxRef.current = 0
chromaRingRef.current = Array.from({ length: cfg.chromaSmooth }, () => new Float32Array(12))
onsetTimestampsRef.current = []
bpmSmoothRef.current = null
listenStartRef.current = Date.now()
setKeyInfo(null)
setLockedKey(null)
effectiveKeyRef.current = null
@@ -141,6 +156,7 @@ export default function App() {
setDebugChroma(null)
setDebugCandidates([])
setDebugNoteAnalysis(null)
setDebugWaveform(null)
}
// ── Key lock handlers ─────────────────────────────────────────────────────────
@@ -163,6 +179,11 @@ export default function App() {
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) ──────────────────────────
const handleNote = useCallback(({ pitchClass }) => {
const cfg = configRef.current
@@ -174,7 +195,11 @@ export default function App() {
const result = detectKey(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
const votes = keyVotesRef.current
@@ -226,7 +251,7 @@ export default function App() {
if (showDebugRef.current) {
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.
@@ -256,7 +281,7 @@ export default function App() {
const winner = votes[0]
setChordHistory(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
@@ -492,6 +517,7 @@ export default function App() {
onNote={handleNote}
onChroma={handleChroma}
onOnset={handleOnset}
onWaveform={handleWaveform}
isListening={isListening}
minClarity={config.minClarity}
minVolume={config.minVolume}
@@ -538,6 +564,7 @@ export default function App() {
chroma={debugChroma}
chordCandidates={debugCandidates}
noteAnalysis={debugNoteAnalysis}
waveform={debugWaveform}
keyInfo={effectiveKey}
currentChord={currentChord}
instrument={instrument}
+45 -1
View File
@@ -81,7 +81,7 @@ function detectBassPC(freqData, sampleRate, fftSize) {
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 timeBufRef = useRef(null)
const freqBufRef = useRef(null)
@@ -94,20 +94,24 @@ export default function AudioCapture({ onNote, onChroma, onOnset, isListening, m
const onNoteRef = useRef(onNote)
const onChromaRef = useRef(onChroma)
const onOnsetRef = useRef(onOnset)
const onWaveformRef = useRef(onWaveform)
const onPermissionErrorRef = useRef(onPermissionError)
const minClarityRef = useRef(minClarity)
const minVolumeRef = useRef(minVolume)
const smoothRmsRef = useRef(0)
const lastOnsetRef = useRef(0)
const specPeakRef = useRef(null) // peak-hold spectrum for display lingering
useEffect(() => { onNoteRef.current = onNote }, [onNote])
useEffect(() => { onChromaRef.current = onChroma }, [onChroma])
useEffect(() => { onOnsetRef.current = onOnset }, [onOnset])
useEffect(() => { onWaveformRef.current = onWaveform }, [onWaveform])
useEffect(() => { onPermissionErrorRef.current = onPermissionError }, [onPermissionError])
useEffect(() => { minClarityRef.current = minClarity }, [minClarity])
useEffect(() => { minVolumeRef.current = minVolume }, [minVolume])
const stop = useCallback(() => {
activeRef.current = false
specPeakRef.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 (audioCtxRef.current) { audioCtxRef.current.close(); audioCtxRef.current = null }
@@ -163,6 +167,46 @@ export default function AudioCapture({ onNote, onChroma, onOnset, isListening, m
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) {
const [freq, clarity] = detectorRef.current.findPitch(timeBuf, ctx.sampleRate)
if (clarity >= minClarityRef.current && freq > 60 && freq < 4200) {
+243 -13
View File
@@ -43,7 +43,7 @@ function PianoSVG({ values, keyNotes, chordNotes, keyH = KEY_H, showPct = false
<g key={`w${wi}`}>
<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} />
{energy > 0.04 && (
{energy > (inChord || inKey ? 0.12 : 0.35) && (
<rect
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}
@@ -69,17 +69,34 @@ function PianoSVG({ values, keyNotes, chordNotes, keyH = KEY_H, showPct = false
const inChord = chordNotes?.has(pc)
const inKey = keyNotes?.has(pc)
const x = wi * KEY_W + KEY_W - BLACK_W / 2
const bg = inChord
? `rgba(139,92,246,${0.4 + energy * 0.6})`
const fillColor = inChord
? 'rgba(139,92,246,0.9)'
: inKey
? `rgba(180,130,0,${0.35 + energy * 0.55})`
: `rgba(12,12,16,0.95)`
? 'rgba(180,130,0,0.85)'
: 'rgba(70,70,80,0.75)'
const pct = showPct ? Math.round(values[pc] * 100) : 0
return (
<g key={`b${i}`}>
{/* Base */}
<rect x={x} y={3} width={BLACK_W} height={BLACK_H}
rx={2} fill={bg} stroke="rgba(255,255,255,0.06)" strokeWidth={1} />
<text x={x + BLACK_W/2} y={BLACK_H - 5} textAnchor="middle" fontSize={7}
rx={2} fill="rgb(14,14,18)" stroke="rgba(255,255,255,0.06)" strokeWidth={1} />
{/* 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)'}>
{NOTES[pc]}
</text>
@@ -170,7 +187,8 @@ function MiniFretboard({ values, keyNotes, chordNotes }) {
const energy = values[pc] / max
const inChord = chordNotes?.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 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 2f5f */}
{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 ───────────────────────────────────────────────────────────
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 chordPCs = new Set(currentChord ? getChordTones(currentChord).map(n => NOTES.indexOf(n)) : [])
const chromaArr = chroma ? [...chroma] : new Array(12).fill(0)
const histFreq = noteAnalysis ? noteAnalysis.freq : new Array(12).fill(0)
const topKeys = noteAnalysis ? noteAnalysis.topKeys : []
const chromaArr = chroma ? [...chroma] : new Array(12).fill(0)
const histFreq = noteAnalysis ? noteAnalysis.freq : new Array(12).fill(0)
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 topKeyScore = topKeys[0]?.score ?? 1
@@ -269,7 +486,14 @@ export default function DebugView({ chroma, chordCandidates, noteAnalysis, keyIn
{/* Col 2: Note history piano with % labels */}
<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} />
</div>
@@ -298,6 +522,12 @@ export default function DebugView({ chroma, chordCandidates, noteAnalysis, keyIn
</div>
</div>
{/* ── Oscilloscope + spectrum ── */}
<div className="flex flex-col gap-3">
<Oscilloscope waveform={waveform} />
<SpectrumPanel spectrum={waveform?.spectrum} detectedFreq={waveform?.detectedFreq} />
</div>
</div>
)
}
+53 -19
View File
@@ -360,40 +360,73 @@ export function toRomanNumeral(chordName, keyRoot, keyMode) {
// ─── 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
* Returns the most-recently-completed repeating pattern (length 26).
* 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) {
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
for (let len = 2; len <= 6; len++) {
if (len * 2 > window.length) break
if (len * 2 > win.length) break
const candidate = window.slice(-len)
let reps = 0, i = 0
const seen = new Set()
while (i <= window.length - len) {
if (candidate.every((c, j) => c === window[i + j])) {
reps++
i += len // skip past match — non-overlapping
} else {
i++
for (let start = 0; start <= win.length - len; start++) {
const candidate = win.slice(start, start + len)
const key = candidate.join('\0')
if (seen.has(key)) continue
seen.add(key)
// 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 && score > bestScore) {
bestScore = score
best = candidate
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))) {
bestScore = score
best = candidate
}
}
}
return best
return best ? canonicalize(best) : null
}
// ─── Debug / analysis helpers ─────────────────────────────────────────────────
@@ -450,6 +483,7 @@ export function getNoteHistoryAnalysis(noteHistory) {
}
return {
freq: normalized,
total,
topKeys: candidates.sort((a, b) => b.score - a.score).slice(0, 5),
}
}