diff options
| author | skal <pascal.massimino@gmail.com> | 2026-02-18 06:02:32 +0100 |
|---|---|---|
| committer | skal <pascal.massimino@gmail.com> | 2026-02-18 06:02:32 +0100 |
| commit | bf3929220be7eddf32cebe12573b870fc9b54997 (patch) | |
| tree | 27318902f41b0e4f5deb73831d9cfc03ca37b229 /tools/mq_editor | |
| parent | 14c3bfe09f906e9b80d6100b126e59c9a88ac976 (diff) | |
fix(mq_editor): mini-spectrum and spectrogram display improvements
- Fix mini-spectrum: log-scale frequency axis, per-pixel bin averaging,
red peak bars after extraction
- Fix key handlers swallowing digits in input fields
- Clamp hop size to min 64 to prevent hang
- Store maxDB in STFTCache, use it to normalize both mini-spectrum and
main spectrogram (fixes clipping at 0dB for pure tones)
- Add Test WAV console validation for 440/660Hz peaks
- Grayscale colormap for main spectrogram
handoff(Gemini): mq_editor display fixes complete, tests not affected
Diffstat (limited to 'tools/mq_editor')
| -rw-r--r-- | tools/mq_editor/fft.js | 5 | ||||
| -rw-r--r-- | tools/mq_editor/index.html | 74 | ||||
| -rw-r--r-- | tools/mq_editor/viewer.js | 73 |
3 files changed, 121 insertions, 31 deletions
diff --git a/tools/mq_editor/fft.js b/tools/mq_editor/fft.js index 9b9bc94..10a5b45 100644 --- a/tools/mq_editor/fft.js +++ b/tools/mq_editor/fft.js @@ -115,6 +115,7 @@ class STFTCache { compute() { this.frames = []; + this.maxDB = -Infinity; const numFrames = Math.floor((this.signal.length - this.fftSize) / this.hopSize); for (let frameIdx = 0; frameIdx < numFrames; ++frameIdx) { @@ -138,10 +139,14 @@ class STFTCache { const re = fftOut[i * 2]; const im = fftOut[i * 2 + 1]; squaredAmplitude[i] = re * re + im * im; + const db = 10 * Math.log10(Math.max(squaredAmplitude[i], 1e-20)); + if (db > this.maxDB) this.maxDB = db; } this.frames.push({time, offset, squaredAmplitude}); } + + if (!isFinite(this.maxDB)) this.maxDB = 0; } setHopSize(hopSize) { diff --git a/tools/mq_editor/index.html b/tools/mq_editor/index.html index 1ebc0d3..22f8ff9 100644 --- a/tools/mq_editor/index.html +++ b/tools/mq_editor/index.html @@ -153,9 +153,10 @@ setTimeout(() => { const signal = audioBuffer.getChannelData(0); - stftCache = new STFTCache(signal, audioBuffer.sampleRate, fftSize, parseInt(hopSize.value)); + stftCache = new STFTCache(signal, audioBuffer.sampleRate, fftSize, Math.max(64, parseInt(hopSize.value) || 64)); setStatus(`${label} — ${audioBuffer.duration.toFixed(2)}s, ${audioBuffer.sampleRate}Hz, ${audioBuffer.numberOfChannels}ch (${stftCache.getNumFrames()} frames cached)`, 'info'); viewer = new SpectrogramViewer(canvas, audioBuffer, stftCache); + if (label.startsWith('Test WAV')) validateTestWAVPeaks(stftCache); }, 10); } @@ -196,10 +197,12 @@ // Update cache when hop size changes hopSize.addEventListener('change', () => { + const val = Math.max(64, parseInt(hopSize.value) || 64); + hopSize.value = val; if (stftCache) { setStatus('Updating STFT cache...', 'info'); setTimeout(() => { - stftCache.setHopSize(parseInt(hopSize.value)); + stftCache.setHopSize(val); setStatus(`Cache updated (${stftCache.getNumFrames()} frames)`, 'info'); if (viewer) viewer.render(); }, 10); @@ -375,6 +378,7 @@ // Keyboard shortcuts document.addEventListener('keydown', (e) => { + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; if (e.code === 'Digit1') { e.preventDefault(); playSynthesized(); @@ -394,6 +398,72 @@ } } }); + + // --- Test WAV peak validation --- + function validateTestWAVPeaks(cache) { + const SR = cache.sampleRate; + const N = cache.fftSize; + const binWidth = SR / N; // Hz per bin + const numBins = N / 2; + const numBars = 100; // mini-spectrum bar count + + // Use a mid-signal frame (avoid edge effects) + const midFrame = cache.frames[Math.floor(cache.frames.length / 2)]; + if (!midFrame) { console.error('[TestWAV] No frames computed'); return; } + const sq = midFrame.squaredAmplitude; + const t = midFrame.time; + + console.group('[TestWAV] Peak validation @ t=' + t.toFixed(3) + 's'); + + // Top 5 bins by magnitude + const ranked = Array.from(sq) + .map((v, i) => ({ bin: i, freq: i * binWidth, db: 10 * Math.log10(Math.max(v, 1e-20)) })) + .sort((a, b) => b.db - a.db); + console.log('Top 5 FFT bins:'); + ranked.slice(0, 5).forEach(x => + console.log(` bin ${x.bin.toString().padStart(3)}: ${x.freq.toFixed(1).padStart(7)}Hz ${x.db.toFixed(1)}dB`)); + + // Expected bins for 440/660 Hz + const bin440 = Math.round(440 / binWidth); + const bin660 = Math.round(660 / binWidth); + const db440 = 10 * Math.log10(Math.max(sq[bin440], 1e-20)); + const db660 = 10 * Math.log10(Math.max(sq[bin660], 1e-20)); + console.log(`440Hz → bin ${bin440} (${(bin440 * binWidth).toFixed(1)}Hz): ${db440.toFixed(1)}dB`); + console.log(`660Hz → bin ${bin660} (${(bin660 * binWidth).toFixed(1)}Hz): ${db660.toFixed(1)}dB`); + + // Validate: 440/660 Hz must be in top-10 + const top10Freqs = ranked.slice(0, 10).map(x => x.freq); + const pass440 = top10Freqs.some(f => Math.abs(f - 440) < binWidth * 2); + const pass660 = top10Freqs.some(f => Math.abs(f - 660) < binWidth * 2); + console.log('Peak check: 440Hz ' + (pass440 ? 'PASS ✓' : 'FAIL ✗') + + ', 660Hz ' + (pass660 ? 'PASS ✓' : 'FAIL ✗')); + + // Mini-spectrum: which bar do these peaks land in? + const bar440 = Math.floor(bin440 * numBars / numBins); + const bar660 = Math.floor(bin660 * numBars / numBins); + const sampledBin440 = Math.floor(bar440 * numBins / numBars); + const sampledBin660 = Math.floor(bar660 * numBars / numBars); + console.log('Mini-spectrum (linear scale, 100 bars):'); + console.log(` 440Hz (bin ${bin440}) → bar ${bar440}/100 [bar samples bin ${sampledBin440} = ${(sampledBin440 * binWidth).toFixed(1)}Hz]`); + console.log(` 660Hz (bin ${bin660}) → bar ${bar660}/100 [bar samples bin ${Math.floor(bar660 * numBins / numBars)} = ${(Math.floor(bar660 * numBins / numBars) * binWidth).toFixed(1)}Hz]`); + if (bar440 < 5 || bar660 < 5) { + console.warn(' ⚠ BUG: peaks fall in bars ' + bar440 + ' and ' + bar660 + + ' (leftmost ~' + Math.max(bar440, bar660) * 2 + 'px of 200px canvas)' + + ' — linear scale hides low-frequency peaks. Need log-scale bar mapping.'); + } + + // Main spectrogram: confirm bins are in draw range + const mainFreqStart = 20, mainFreqEnd = 16000; + const inRange440 = 440 >= mainFreqStart && 440 <= mainFreqEnd; + const inRange660 = 660 >= mainFreqStart && 660 <= mainFreqEnd; + const norm440 = (Math.log2(440) - Math.log2(mainFreqStart)) / (Math.log2(mainFreqEnd) - Math.log2(mainFreqStart)); + const norm660 = (Math.log2(660) - Math.log2(mainFreqStart)) / (Math.log2(mainFreqEnd) - Math.log2(mainFreqStart)); + console.log('Main spectrogram (log Y-axis, 600px):'); + console.log(` 440Hz: in range=${inRange440}, y=${Math.round(600 * (1 - norm440))}px, db=${db440.toFixed(1)}dB → intensity=${Math.min(1, Math.pow(Math.max(0, (db440 + 80) / 80), 2)).toFixed(2)}`); + console.log(` 660Hz: in range=${inRange660}, y=${Math.round(600 * (1 - norm660))}px, db=${db660.toFixed(1)}dB → intensity=${Math.min(1, Math.pow(Math.max(0, (db660 + 80) / 80), 2)).toFixed(2)}`); + + console.groupEnd(); + } </script> </body> </html> diff --git a/tools/mq_editor/viewer.js b/tools/mq_editor/viewer.js index b267806..e19ec3a 100644 --- a/tools/mq_editor/viewer.js +++ b/tools/mq_editor/viewer.js @@ -187,7 +187,7 @@ class SpectrogramViewer { const magDB = 10 * Math.log10(Math.max(squaredAmp[bin], 1e-20)); - const normalized = (magDB + 80) / 80; + const normalized = (magDB - (stftCache.maxDB - 80)) / 80; const clamped = Math.max(0, Math.min(1, normalized)); // Power law for better peak visibility const intensity = Math.pow(clamped, 2.); @@ -493,24 +493,34 @@ class SpectrogramViewer { if (!squaredAmp) return; const fftSize = cache.fftSize; - - // Draw bars - const numBars = 100; - const barWidth = width / numBars; const numBins = fftSize / 2; + const binWidth = cache.sampleRate / fftSize; + + // Log-scale frequency mapping (matches main spectrogram) + const logMin = Math.log2(this.freqStart); + const logMax = Math.log2(this.freqEnd); + const freqToX = (freq) => { + const norm = (Math.log2(Math.max(freq, this.freqStart)) - logMin) / (logMax - logMin); + return norm * width; + }; + + // Draw histogram bars — one per pixel column + for (let px = 0; px < width; ++px) { + const fStart = Math.pow(2, logMin + (px / width) * (logMax - logMin)); + const fEnd = Math.pow(2, logMin + ((px + 1) / width) * (logMax - logMin)); - for (let i = 0; i < numBars; ++i) { - const binIdx = Math.floor(i * numBins / numBars); - const magDB = 10 * Math.log10(Math.max(squaredAmp[binIdx], 1e-20)); + const bStart = Math.max(0, Math.floor(fStart / binWidth)); + const bEnd = Math.min(numBins - 1, Math.ceil(fEnd / binWidth)); - // Normalize to [0, 1] - const normalized = (magDB + 80) / 80; - const intensity = Math.max(0, Math.min(1, normalized)); + let sum = 0, count = 0; + for (let b = bStart; b <= bEnd; ++b) { sum += squaredAmp[b]; ++count; } + if (count === 0) continue; - const barHeight = intensity * height; - const x = i * barWidth; + const magDB = 10 * Math.log10(Math.max(sum / count, 1e-20)); + const intensity = Math.max(0, Math.min(1, (magDB - (cache.maxDB - 80)) / 80)); + const barHeight = Math.round(intensity * height); + if (barHeight === 0) continue; - // Color: cyan/yellow for original, green/lime for synth const gradient = ctx.createLinearGradient(0, height - barHeight, 0, height); if (useSynth) { gradient.addColorStop(0, '#4f8'); @@ -519,9 +529,25 @@ class SpectrogramViewer { gradient.addColorStop(0, '#4af'); gradient.addColorStop(1, '#fa4'); } - ctx.fillStyle = gradient; - ctx.fillRect(x, height - barHeight, barWidth - 1, barHeight); + ctx.fillRect(px, height - barHeight, 1, barHeight); + } + + // Overlay extracted peaks as red histogram bars + if (this.frames && this.frames.length > 0) { + let bestFrame = this.frames[0]; + let bestDt = Math.abs(bestFrame.time - this.spectrumTime); + for (const f of this.frames) { + const dt = Math.abs(f.time - this.spectrumTime); + if (dt < bestDt) { bestDt = dt; bestFrame = f; } + } + ctx.fillStyle = '#f44'; + for (const peak of bestFrame.peaks) { + if (peak.freq < this.freqStart || peak.freq > this.freqEnd) continue; + const x0 = Math.floor(freqToX(peak.freq)); + const x1 = Math.max(x0 + 1, Math.floor(freqToX(peak.freq * Math.pow(2, 1 / width)))); + ctx.fillRect(x0, 0, x1 - x0, height); + } } // Label @@ -544,19 +570,8 @@ class SpectrogramViewer { } getSpectrogramColor(intensity) { - if (intensity < 0.25) { - const t = intensity / 0.25; - return {r: 0, g: 0, b: Math.floor(t * 128)}; - } else if (intensity < 0.5) { - const t = (intensity - 0.25) / 0.25; - return {r: 0, g: Math.floor(t * 128), b: 128}; - } else if (intensity < 0.75) { - const t = (intensity - 0.5) / 0.25; - return {r: Math.floor(t * 255), g: 128 + Math.floor(t * 127), b: 128 - Math.floor(t * 128)}; - } else { - const t = (intensity - 0.75) / 0.25; - return {r: 255, g: 255 - Math.floor(t * 128), b: 0}; - } + const v = Math.floor(intensity * 255); + return {r: v, g: v, b: v}; } } |
