diff options
| author | skal <pascal.massimino@gmail.com> | 2026-02-17 19:21:28 +0100 |
|---|---|---|
| committer | skal <pascal.massimino@gmail.com> | 2026-02-17 19:21:28 +0100 |
| commit | 9416e4ed202d66b20649fb445b6a352f804efd8c (patch) | |
| tree | 40809485e64b6855cc534220169ec262ab8a601d /tools/mq_editor | |
| parent | 03579c4a33ab3955ff9924a6dcd882fe91dd9aaa (diff) | |
fix(mq_editor): Improve spectrogram visualization and navigation
- Fixed FFT to 1024 bins for 31.25 Hz resolution (better bass analysis)
- Refactored view state to zoom_factor + t_center for cleaner pan/zoom
- Mousewheel scrolls horizontally, shift+mousewheel zooms (respects deltaX/Y)
- Spectrogram bins now fill complete time/freq buckets at all zoom levels
- Extended dB range to -80→0 dB (80 dB) for better high-amplitude granularity
- Added real-time intensity tooltip in dB
- 50% alpha on spectrogram to reduce clutter over partial trajectories
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Diffstat (limited to 'tools/mq_editor')
| -rw-r--r-- | tools/mq_editor/index.html | 13 | ||||
| -rw-r--r-- | tools/mq_editor/viewer.js | 218 |
2 files changed, 153 insertions, 78 deletions
diff --git a/tools/mq_editor/index.html b/tools/mq_editor/index.html index d44f19b..c1d7bc9 100644 --- a/tools/mq_editor/index.html +++ b/tools/mq_editor/index.html @@ -76,15 +76,8 @@ <button id="extractBtn" disabled>Extract Partials</button> <div class="params"> - <label>FFT Size:</label> - <select id="fftSize"> - <option value="1024">1024</option> - <option value="2048" selected>2048</option> - <option value="4096">4096</option> - </select> - <label>Hop:</label> - <input type="number" id="hopSize" value="512" min="64" max="2048" step="64"> + <input type="number" id="hopSize" value="256" min="64" max="1024" step="64"> <label>Threshold (dB):</label> <input type="number" id="threshold" value="-60" min="-80" max="-20" step="5"> @@ -109,9 +102,9 @@ const canvas = document.getElementById('canvas'); const status = document.getElementById('status'); - const fftSize = document.getElementById('fftSize'); const hopSize = document.getElementById('hopSize'); const threshold = document.getElementById('threshold'); + const fftSize = 1024; // Fixed // Load WAV file wavFile.addEventListener('change', async (e) => { @@ -145,7 +138,7 @@ setTimeout(() => { try { const params = { - fftSize: parseInt(fftSize.value), + fftSize: fftSize, hopSize: parseInt(hopSize.value), threshold: parseFloat(threshold.value), sampleRate: audioBuffer.sampleRate diff --git a/tools/mq_editor/viewer.js b/tools/mq_editor/viewer.js index 1b2f5bf..6ebf7e0 100644 --- a/tools/mq_editor/viewer.js +++ b/tools/mq_editor/viewer.js @@ -8,11 +8,21 @@ class SpectrogramViewer { this.audioBuffer = audioBuffer; this.partials = []; - // View state (time only, frequency fixed) - this.timeStart = 0; - this.timeEnd = audioBuffer.duration; + // Fixed time bounds + this.t_min = 0; + this.t_max = audioBuffer.duration; + + // View state (zoom and center) + this.zoom_factor = 1.0; // 1.0 = full view + this.t_center = audioBuffer.duration / 2; + + // Computed view bounds (updated by updateViewBounds) + this.t_view_min = 0; + this.t_view_max = audioBuffer.duration; + + // Fixed frequency bounds this.freqStart = 0; - this.freqEnd = 16000; // Fixed + this.freqEnd = 16000; // Tooltip this.tooltip = document.getElementById('tooltip'); @@ -21,6 +31,7 @@ class SpectrogramViewer { this.setupMouseHandlers(); // Initial render + this.updateViewBounds(); this.render(); } @@ -30,11 +41,40 @@ class SpectrogramViewer { } reset() { - this.timeStart = 0; - this.timeEnd = this.audioBuffer.duration; + this.zoom_factor = 1.0; + this.t_center = this.audioBuffer.duration / 2; + this.updateViewBounds(); this.render(); } + updateViewBounds() { + const full_duration = this.t_max - this.t_min; + const view_duration = full_duration / this.zoom_factor; + + let t_view_min = this.t_center - view_duration / 2; + let t_view_max = this.t_center + view_duration / 2; + + // Clamp to [t_min, t_max] + if (t_view_min < this.t_min) { + t_view_min = this.t_min; + t_view_max = this.t_min + view_duration; + } + if (t_view_max > this.t_max) { + t_view_max = this.t_max; + t_view_min = this.t_max - view_duration; + } + if (t_view_min < this.t_min) t_view_min = this.t_min; + if (t_view_max > this.t_max) t_view_max = this.t_max; + + this.t_view_min = t_view_min; + this.t_view_max = t_view_max; + + // Recompute zoom_factor and t_center from clamped values + const actual_duration = this.t_view_max - this.t_view_min; + this.zoom_factor = full_duration / actual_duration; + this.t_center = (this.t_view_min + this.t_view_max) / 2; + } + render() { this.renderSpectrogram(); this.renderPartials(); @@ -51,25 +91,20 @@ class SpectrogramViewer { ctx.fillRect(0, 0, width, height); const signal = getMono(audioBuffer); - const fftSize = 2048; - const hopSize = 512; + const fftSize = 1024; + const hopSize = 256; const sampleRate = audioBuffer.sampleRate; const numFrames = Math.floor((signal.length - fftSize) / hopSize); - - // Compute one FFT per ~4 pixels for wider bars - const pixelsPerFrame = 4; - const numDisplayFrames = Math.floor(width / pixelsPerFrame); + const frameDuration = hopSize / sampleRate; + const viewDuration = this.t_view_max - this.t_view_min; // Map view bounds to frame indices - const startFrameIdx = Math.floor(this.timeStart * sampleRate / hopSize); - const endFrameIdx = Math.floor(this.timeEnd * sampleRate / hopSize); - const visibleFrames = endFrameIdx - startFrameIdx; - const frameStep = Math.max(1, Math.floor(visibleFrames / numDisplayFrames)); + const startFrameIdx = Math.floor(this.t_view_min * sampleRate / hopSize); + const endFrameIdx = Math.ceil(this.t_view_max * sampleRate / hopSize); - for (let displayIdx = 0; displayIdx < numDisplayFrames; ++displayIdx) { - const frameIdx = startFrameIdx + displayIdx * frameStep; - if (frameIdx >= numFrames) break; + for (let frameIdx = startFrameIdx; frameIdx < endFrameIdx; ++frameIdx) { + if (frameIdx < 0 || frameIdx >= numFrames) continue; const offset = frameIdx * hopSize; if (offset + fftSize > signal.length) break; @@ -86,31 +121,40 @@ class SpectrogramViewer { // FFT const spectrum = realFFT(windowed); - // Draw as vertical bar - const xStart = displayIdx * pixelsPerFrame; - const xEnd = Math.min(xStart + pixelsPerFrame, width); + // Compute frame time range + const frameTime = frameIdx * hopSize / sampleRate; + const frameTimeEnd = frameTime + frameDuration; + + const xStart = Math.floor((frameTime - this.t_view_min) / viewDuration * width); + const xEnd = Math.ceil((frameTimeEnd - this.t_view_min) / viewDuration * width); + const frameWidth = Math.max(1, xEnd - xStart); // Draw frequency bins const numBins = fftSize / 2; + const binFreqWidth = sampleRate / fftSize; + for (let bin = 0; bin < numBins; ++bin) { - const freq = bin * sampleRate / fftSize; - if (freq < this.freqStart || freq > this.freqEnd) continue; + const freq = bin * binFreqWidth; + const freqNext = (bin + 1) * binFreqWidth; + if (freqNext < this.freqStart || freq > this.freqEnd) continue; const re = spectrum[bin * 2]; const im = spectrum[bin * 2 + 1]; const mag = Math.sqrt(re * re + im * im); const magDB = 20 * Math.log10(Math.max(mag, 1e-10)); - const normalized = (magDB + 80) / 60; + const normalized = (magDB + 80) / 80; const intensity = Math.max(0, Math.min(1, normalized)); - const freqNorm = (freq - this.freqStart) / (this.freqEnd - this.freqStart); - const y = Math.floor(height - freqNorm * height); - if (y < 0 || y >= height) continue; + const freqNorm0 = (freq - this.freqStart) / (this.freqEnd - this.freqStart); + const freqNorm1 = (freqNext - this.freqStart) / (this.freqEnd - this.freqStart); + const y0 = Math.floor(height - freqNorm1 * height); + const y1 = Math.floor(height - freqNorm0 * height); + const binHeight = Math.max(1, y1 - y0); const color = this.getSpectrogramColor(intensity); - ctx.fillStyle = `rgb(${color.r},${color.g},${color.b})`; - ctx.fillRect(xStart, y, xEnd - xStart, 1); + ctx.fillStyle = `rgba(${color.r},${color.g},${color.b},0.5)`; + ctx.fillRect(xStart, y0, frameWidth, binHeight); } } } @@ -126,7 +170,7 @@ class SpectrogramViewer { '#fa4', '#4fa', '#a4f', '#af4', '#f4a', '#4af' ]; - const timeDuration = this.timeEnd - this.timeStart; + const timeDuration = this.t_view_max - this.t_view_min; const freqRange = this.freqEnd - this.freqStart; for (let p = 0; p < partials.length; ++p) { @@ -143,10 +187,10 @@ class SpectrogramViewer { const t = partial.times[i]; const f = partial.freqs[i]; - if (t < this.timeStart || t > this.timeEnd) continue; + if (t < this.t_view_min || t > this.t_view_max) continue; if (f < this.freqStart || f > this.freqEnd) continue; - const x = (t - this.timeStart) / timeDuration * width; + const x = (t - this.t_view_min) / timeDuration * width; const y = height - (f - this.freqStart) / freqRange * height; if (!started) { @@ -173,10 +217,10 @@ class SpectrogramViewer { const t = curve.t0 + (curve.t3 - curve.t0) * i / numSteps; const freq = evalBezier(curve, t); - if (t < this.timeStart || t > this.timeEnd) continue; + if (t < this.t_view_min || t > this.t_view_max) continue; if (freq < this.freqStart || freq > this.freqEnd) continue; - const x = (t - this.timeStart) / timeDuration * width; + const x = (t - this.t_view_min) / timeDuration * width; const y = height - (freq - this.freqStart) / freqRange * height; if (!started) { @@ -201,13 +245,13 @@ class SpectrogramViewer { // Draw control point drawControlPoint(t, v) { - if (t < this.timeStart || t > this.timeEnd) return; + if (t < this.t_view_min || t > this.t_view_max) return; if (v < this.freqStart || v > this.freqEnd) return; - const timeDuration = this.timeEnd - this.timeStart; + const timeDuration = this.t_view_max - this.t_view_min; const freqRange = this.freqEnd - this.freqStart; - const x = (t - this.timeStart) / timeDuration * this.canvas.width; + const x = (t - this.t_view_min) / timeDuration * this.canvas.width; const y = this.canvas.height - (v - this.freqStart) / freqRange * this.canvas.height; this.ctx.beginPath(); @@ -230,14 +274,14 @@ class SpectrogramViewer { ctx.font = '11px monospace'; ctx.lineWidth = 1; - const timeDuration = this.timeEnd - this.timeStart; + const timeDuration = this.t_view_max - this.t_view_min; const freqRange = this.freqEnd - this.freqStart; // Time axis const timeStep = this.getAxisStep(timeDuration); - let t = Math.ceil(this.timeStart / timeStep) * timeStep; - while (t <= this.timeEnd) { - const x = (t - this.timeStart) / timeDuration * width; + let t = Math.ceil(this.t_view_min / timeStep) * timeStep; + while (t <= this.t_view_max) { + const x = (t - this.t_view_min) / timeDuration * width; ctx.beginPath(); ctx.moveTo(x, 0); @@ -277,62 +321,100 @@ class SpectrogramViewer { const time = this.canvasToTime(x); const freq = this.canvasToFreq(y); + const intensity = this.getIntensityAt(time, freq); tooltip.style.left = (e.clientX + 10) + 'px'; tooltip.style.top = (e.clientY + 10) + 'px'; tooltip.style.display = 'block'; - tooltip.textContent = `${time.toFixed(3)}s, ${freq.toFixed(1)}Hz`; + tooltip.textContent = `${time.toFixed(3)}s, ${freq.toFixed(1)}Hz, ${intensity.toFixed(1)}dB`; }); canvas.addEventListener('mouseleave', () => { tooltip.style.display = 'none'; }); - // Mouse wheel (horizontal zoom only) + // Mouse wheel: scroll (default) or zoom (shift) canvas.addEventListener('wheel', (e) => { e.preventDefault(); - const rect = canvas.getBoundingClientRect(); - const x = e.clientX - rect.left; - - // Get mouse position in time space - const mouseTime = this.canvasToTime(x); - - // Zoom factor - const zoomFactor = e.deltaY > 0 ? 1.2 : 0.8; + const delta = e.deltaY !== 0 ? e.deltaY : e.deltaX; - // Zoom time around mouse position - const timeDuration = this.timeEnd - this.timeStart; - const newTimeDuration = timeDuration * zoomFactor; - const timeRatio = (mouseTime - this.timeStart) / timeDuration; + if (e.shiftKey) { + // Zoom in/out around mouse position + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const mouseTime = this.canvasToTime(x); - this.timeStart = mouseTime - newTimeDuration * timeRatio; - this.timeEnd = mouseTime + newTimeDuration * (1 - timeRatio); + const zoomDelta = delta > 0 ? 1.2 : 1 / 1.2; + const newZoomFactor = this.zoom_factor * zoomDelta; - // Clamp time bounds - if (this.timeStart < 0) { - this.timeEnd -= this.timeStart; - this.timeStart = 0; - } - if (this.timeEnd > this.audioBuffer.duration) { - this.timeStart -= (this.timeEnd - this.audioBuffer.duration); - this.timeEnd = this.audioBuffer.duration; + // Clamp zoom to [1.0, inf] + if (newZoomFactor < 1.0) { + this.zoom_factor = 1.0; + this.t_center = (this.t_max + this.t_min) / 2; + } else { + this.zoom_factor = newZoomFactor; + const newDuration = (this.t_max - this.t_min) / this.zoom_factor; + const oldDuration = this.t_view_max - this.t_view_min; + const mouseRatio = (mouseTime - this.t_view_min) / oldDuration; + this.t_center = mouseTime - newDuration * (mouseRatio - 0.5); + } + } else { + // Scroll left/right + const timeDuration = this.t_view_max - this.t_view_min; + const scrollAmount = (delta / 100) * timeDuration * 0.1; + this.t_center += scrollAmount; } - // Re-render + this.updateViewBounds(); this.render(); }); } // Coordinate conversion canvasToTime(x) { - return this.timeStart + (x / this.canvas.width) * (this.timeEnd - this.timeStart); + return this.t_view_min + (x / this.canvas.width) * (this.t_view_max - this.t_view_min); } canvasToFreq(y) { return this.freqEnd - (y / this.canvas.height) * (this.freqEnd - this.freqStart); } + getIntensityAt(time, freq) { + const signal = getMono(this.audioBuffer); + const fftSize = 1024; + const hopSize = 256; + const sampleRate = this.audioBuffer.sampleRate; + + const frameIdx = Math.floor(time * sampleRate / hopSize); + const offset = frameIdx * hopSize; + + if (offset < 0 || offset + fftSize > signal.length) return -80; + + const frame = signal.slice(offset, offset + fftSize); + + // Apply Hann window + const windowed = new Float32Array(fftSize); + for (let i = 0; i < fftSize; ++i) { + const w = 0.5 - 0.5 * Math.cos(2 * Math.PI * i / fftSize); + windowed[i] = frame[i] * w; + } + + // FFT + const spectrum = realFFT(windowed); + + // Find closest bin + const bin = Math.round(freq * fftSize / sampleRate); + if (bin < 0 || bin >= fftSize / 2) return -80; + + const re = spectrum[bin * 2]; + const im = spectrum[bin * 2 + 1]; + const mag = Math.sqrt(re * re + im * im); + const magDB = 20 * Math.log10(Math.max(mag, 1e-10)); + + return magDB; + } + // Utilities getAxisStep(range) { const steps = [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000]; |
