diff options
Diffstat (limited to 'tools')
| -rw-r--r-- | tools/mq_editor/index.html | 111 | ||||
| -rw-r--r-- | tools/mq_editor/viewer.js | 474 |
2 files changed, 241 insertions, 344 deletions
diff --git a/tools/mq_editor/index.html b/tools/mq_editor/index.html index 345d2b9..60076b3 100644 --- a/tools/mq_editor/index.html +++ b/tools/mq_editor/index.html @@ -140,8 +140,8 @@ <canvas id="cursorCanvas" width="1400" height="600" style="position:absolute;top:0;left:0;pointer-events:none;"></canvas> <!-- Mini spectrum viewer (bottom-right overlay) --> - <div id="spectrumViewer" style="position: absolute; bottom: 10px; right: 10px; width: 200px; height: 100px; background: rgba(30, 30, 30, 0.9); border: 1px solid #555; border-radius: 3px; pointer-events: none;"> - <canvas id="spectrumCanvas" width="200" height="100"></canvas> + <div id="spectrumViewer" style="position: absolute; bottom: 10px; right: 10px; width: 400px; height: 100px; background: rgba(30, 30, 30, 0.9); border: 1px solid #555; border-radius: 3px; pointer-events: none;"> + <canvas id="spectrumCanvas" width="400" height="100"></canvas> </div> </div> @@ -184,11 +184,13 @@ const keepPctLabel = document.getElementById('keepPctLabel'); const fftSize = 1024; // Fixed + function getKeepCount() { + return Math.max(1, Math.ceil(extractedPartials.length * parseInt(keepPct.value) / 100)); + } + keepPct.addEventListener('input', () => { keepPctLabel.textContent = keepPct.value + '%'; - if (viewer && extractedPartials) { - viewer.setKeepCount(Math.max(1, Math.ceil(extractedPartials.length * parseInt(keepPct.value) / 100))); - } + if (viewer && extractedPartials) viewer.setKeepCount(getKeepCount()); }); // Initialize audio context @@ -297,7 +299,7 @@ viewer.setFrames(result.frames); setStatus(`Extracted ${result.partials.length} partials`, 'info'); viewer.setPartials(result.partials); - viewer.setKeepCount(Math.max(1, Math.ceil(result.partials.length * parseInt(keepPct.value) / 100))); + viewer.setKeepCount(getKeepCount()); } catch (err) { setStatus('Extraction error: ' + err.message, 'error'); @@ -317,18 +319,12 @@ if (stftCache) runExtraction(); }); - // Play audio - playBtn.addEventListener('click', () => { - if (!audioBuffer || !audioContext) return; - - stopAudio(); - + function playAudioBuffer(buffer, statusMsg) { const startTime = audioContext.currentTime; currentSource = audioContext.createBufferSource(); - currentSource.buffer = audioBuffer; + currentSource.buffer = buffer; currentSource.connect(audioContext.destination); currentSource.start(); - currentSource.onended = () => { currentSource = null; playBtn.disabled = false; @@ -336,43 +332,40 @@ viewer.setPlayheadTime(-1); setStatus('Stopped', 'info'); }; - playBtn.disabled = true; stopBtn.disabled = false; - setStatus('Playing...', 'info'); - - // Animate playhead - function updatePlayhead() { + setStatus(statusMsg, 'info'); + function tick() { if (!currentSource) return; - const elapsed = audioContext.currentTime - startTime; - viewer.setPlayheadTime(elapsed); - requestAnimationFrame(updatePlayhead); + viewer.setPlayheadTime(audioContext.currentTime - startTime); + requestAnimationFrame(tick); } - updatePlayhead(); - }); - - // Stop audio - stopBtn.addEventListener('click', () => { - stopAudio(); - }); + tick(); + } function stopAudio() { if (currentSource) { - try { - currentSource.stop(); - } catch (e) { - // Already stopped - } + try { currentSource.stop(); } catch (e) {} currentSource = null; } - if (viewer) { - viewer.setPlayheadTime(-1); - } + if (viewer) viewer.setPlayheadTime(-1); playBtn.disabled = false; stopBtn.disabled = true; setStatus('Stopped', 'info'); } + // Play audio + playBtn.addEventListener('click', () => { + if (!audioBuffer || !audioContext) return; + stopAudio(); + playAudioBuffer(audioBuffer, 'Playing...'); + }); + + // Stop audio + stopBtn.addEventListener('click', () => { + stopAudio(); + }); + function setStatus(msg, type = '') { status.innerHTML = msg; status.className = type; @@ -390,53 +383,23 @@ setStatus('Synthesizing...', 'info'); - // Synthesize PCM from top-N% partials by amplitude - const sampleRate = audioBuffer.sampleRate; - const duration = audioBuffer.duration; - const keepCount = Math.max(1, Math.ceil(extractedPartials.length * parseInt(keepPct.value) / 100)); + const keepCount = getKeepCount(); const partialsToUse = extractedPartials.slice(0, keepCount); setStatus(`Synthesizing ${keepCount}/${extractedPartials.length} partials (${keepPct.value}%)...`, 'info'); + const integratePhase = document.getElementById('integratePhase').checked; const disableJitter = document.getElementById('disableJitter').checked; const disableSpread = document.getElementById('disableSpread').checked; - const pcm = synthesizeMQ(partialsToUse, sampleRate, duration, integratePhase, {disableJitter, disableSpread}); + const pcm = synthesizeMQ(partialsToUse, audioBuffer.sampleRate, audioBuffer.duration, + integratePhase, {disableJitter, disableSpread}); - // Build STFT cache for synth signal (for FFT comparison via key 'a') if (viewer) { - const synthStft = new STFTCache(pcm, sampleRate, fftSize, parseInt(hopSize.value)); - viewer.setSynthStftCache(synthStft); + viewer.setSynthStftCache(new STFTCache(pcm, audioBuffer.sampleRate, fftSize, parseInt(hopSize.value))); } - // Create audio buffer - const synthBuffer = audioContext.createBuffer(1, pcm.length, sampleRate); + const synthBuffer = audioContext.createBuffer(1, pcm.length, audioBuffer.sampleRate); synthBuffer.getChannelData(0).set(pcm); - - const startTime = audioContext.currentTime; - currentSource = audioContext.createBufferSource(); - currentSource.buffer = synthBuffer; - currentSource.connect(audioContext.destination); - currentSource.start(); - - currentSource.onended = () => { - currentSource = null; - playBtn.disabled = false; - stopBtn.disabled = true; - viewer.setPlayheadTime(-1); - setStatus('Stopped', 'info'); - }; - - playBtn.disabled = true; - stopBtn.disabled = false; - setStatus(`Playing synthesized (${keepCount}/${extractedPartials.length} partials, ${keepPct.value}%)...`, 'info'); - - // Animate playhead - function updatePlayhead() { - if (!currentSource) return; - const elapsed = audioContext.currentTime - startTime; - viewer.setPlayheadTime(elapsed); - requestAnimationFrame(updatePlayhead); - } - updatePlayhead(); + playAudioBuffer(synthBuffer, `Playing synthesized (${keepCount}/${extractedPartials.length} partials, ${keepPct.value}%)...`); } // Keyboard shortcuts diff --git a/tools/mq_editor/viewer.js b/tools/mq_editor/viewer.js index ebf4fab..7f6e862 100644 --- a/tools/mq_editor/viewer.js +++ b/tools/mq_editor/viewer.js @@ -36,6 +36,7 @@ class SpectrogramViewer { // Mouse cursor overlay this.cursorCanvas = document.getElementById('cursorCanvas'); this.cursorCtx = this.cursorCanvas ? this.cursorCanvas.getContext('2d') : null; + this.mouseX = -1; // Playhead this.playheadTime = -1; // -1 = not playing @@ -55,10 +56,64 @@ class SpectrogramViewer { this.render(); } + // --- Coordinate API --- + + // time -> canvas X + timeToX(t) { + return (t - this.t_view_min) / (this.t_view_max - this.t_view_min) * this.canvas.width; + } + + // canvas X -> time + canvasToTime(x) { + return this.t_view_min + (x / this.canvas.width) * (this.t_view_max - this.t_view_min); + } + + // freq -> normalized log position [0..1] within [freqStart..freqEnd] + freqLogNorm(freq) { + const logMin = Math.log2(this.freqStart); + const logMax = Math.log2(this.freqEnd); + return (Math.log2(Math.max(freq, this.freqStart)) - logMin) / (logMax - logMin); + } + + // normalized log position [0..1] -> freq + normToFreq(norm) { + const logMin = Math.log2(this.freqStart); + const logMax = Math.log2(this.freqEnd); + return Math.pow(2, logMin + norm * (logMax - logMin)); + } + + // freq -> canvas Y (log scale) + freqToY(freq) { + return this.canvas.height * (1 - this.freqLogNorm(freq)); + } + + // canvas Y -> freq (log scale) + canvasToFreq(y) { + return this.normToFreq(1 - y / this.canvas.height); + } + + // DB value -> normalized intensity [0..1], relative to cache maxDB over 80dB range + normalizeDB(magDB, maxDB) { + return Math.max(0, Math.min(1, (magDB - (maxDB - 80)) / 80)); + } + + // Partial index -> display color + partialColor(p) { + const colors = [ + '#f44', '#4f4', '#44f', '#ff4', '#f4f', '#4ff', + '#fa4', '#4fa', '#a4f', '#af4', '#f4a', '#4af' + ]; + return colors[p % colors.length]; + } + + // --- Public API --- + setPlayheadTime(time) { this.playheadTime = time; if (time >= 0) { this.spectrumTime = time; + } else if (this.mouseX >= 0) { + this.spectrumTime = this.canvasToTime(this.mouseX); } this.render(); } @@ -77,6 +132,10 @@ class SpectrogramViewer { this.frames = frames; } + setSynthStftCache(cache) { + this.synthStftCache = cache; + } + togglePeaks() { this.showPeaks = !this.showPeaks; this.render(); @@ -89,34 +148,13 @@ class SpectrogramViewer { 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; + getIntensityAt(time, freq) { + if (!this.stftCache) return -80; + return this.stftCache.getMagnitudeDB(time, freq); } + // --- Render --- + render() { this.renderSpectrogram(); if (this.showPeaks) this.renderPeaks(); @@ -143,11 +181,8 @@ class SpectrogramViewer { drawPlayhead() { if (this.playheadTime < 0) return; if (this.playheadTime < this.t_view_min || this.playheadTime > this.t_view_max) return; - const {ctx, canvas} = this; - const timeDuration = this.t_view_max - this.t_view_min; - const x = (this.playheadTime - this.t_view_min) / timeDuration * canvas.width; - + const x = this.timeToX(this.playheadTime); ctx.strokeStyle = '#f00'; ctx.lineWidth = 2; ctx.beginPath(); @@ -156,7 +191,6 @@ class SpectrogramViewer { ctx.stroke(); } - // Render spectrogram background renderSpectrogram() { const {canvas, ctx, stftCache} = this; const width = canvas.width; @@ -171,12 +205,10 @@ class SpectrogramViewer { const hopSize = stftCache.hopSize; const fftSize = stftCache.fftSize; const frameDuration = hopSize / sampleRate; - const viewDuration = this.t_view_max - this.t_view_min; + const numFrames = stftCache.getNumFrames(); - // Map view bounds to frame indices const startFrameIdx = Math.floor(this.t_view_min * sampleRate / hopSize); const endFrameIdx = Math.ceil(this.t_view_max * sampleRate / hopSize); - const numFrames = stftCache.getNumFrames(); for (let frameIdx = startFrameIdx; frameIdx < endFrameIdx; ++frameIdx) { if (frameIdx < 0 || frameIdx >= numFrames) continue; @@ -185,117 +217,74 @@ class SpectrogramViewer { if (!frame) continue; const squaredAmp = frame.squaredAmplitude; - - // Compute frame time range - const frameTime = frame.time; - 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 xStart = Math.floor(this.timeToX(frame.time)); + const xEnd = Math.ceil(this.timeToX(frame.time + frameDuration)); 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 * binFreqWidth; + const freq = bin * binFreqWidth; const freqNext = (bin + 1) * binFreqWidth; if (freqNext < this.freqStart || freq > this.freqEnd) continue; - const magDB = 10 * Math.log10(Math.max(squaredAmp[bin], 1e-20)); - - 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.); + const magDB = 10 * Math.log10(Math.max(squaredAmp[bin], 1e-20)); + const intensity = Math.pow(this.normalizeDB(magDB, stftCache.maxDB), 2.0); const y0 = Math.floor(this.freqToY(freqNext)); const y1 = Math.floor(this.freqToY(Math.max(freq, this.freqStart))); const binHeight = Math.max(1, y1 - y0); - const color = this.getSpectrogramColor(intensity); - ctx.fillStyle = `rgba(${color.r},${color.g},${color.b}, 0.5)`; + const v = Math.floor(intensity * 255); + ctx.fillStyle = `rgba(${v},${v},${v}, 0.5)`; ctx.fillRect(xStart, y0, frameWidth, binHeight); } } } - // Render extracted partials renderPartials() { - const {ctx, canvas, partials} = this; - const width = canvas.width; - const height = canvas.height; - - const colors = [ - '#f44', '#4f4', '#44f', '#ff4', '#f4f', '#4ff', - '#fa4', '#4fa', '#a4f', '#af4', '#f4a', '#4af' - ]; - - const timeDuration = this.t_view_max - this.t_view_min; + const {ctx, partials} = this; for (let p = 0; p < partials.length; ++p) { const partial = partials[p]; - const color = colors[p % colors.length]; + const color = this.partialColor(p); ctx.globalAlpha = p < this.keepCount ? 1.0 : 0.5; - // Draw raw trajectory + // Raw trajectory ctx.strokeStyle = color + '44'; ctx.lineWidth = 1; ctx.beginPath(); - let started = false; for (let i = 0; i < partial.times.length; ++i) { const t = partial.times[i]; const f = partial.freqs[i]; - if (t < this.t_view_min || t > this.t_view_max) continue; if (f < this.freqStart || f > this.freqEnd) continue; - - const x = (t - this.t_view_min) / timeDuration * width; + const x = this.timeToX(t); const y = this.freqToY(f); - - if (!started) { - ctx.moveTo(x, y); - started = true; - } else { - ctx.lineTo(x, y); - } + if (!started) { ctx.moveTo(x, y); started = true; } else ctx.lineTo(x, y); } - if (started) ctx.stroke(); - // Draw bezier curve + // Bezier curve if (partial.freqCurve) { ctx.strokeStyle = color; ctx.lineWidth = 2; ctx.beginPath(); - const curve = partial.freqCurve; - const numSteps = 50; - started = false; - for (let i = 0; i <= numSteps; ++i) { - const t = curve.t0 + (curve.t3 - curve.t0) * i / numSteps; + for (let i = 0; i <= 50; ++i) { + const t = curve.t0 + (curve.t3 - curve.t0) * i / 50; const freq = evalBezier(curve, t); - if (t < this.t_view_min || t > this.t_view_max) continue; if (freq < this.freqStart || freq > this.freqEnd) continue; - - const x = (t - this.t_view_min) / timeDuration * width; + const x = this.timeToX(t); const y = this.freqToY(freq); - - if (!started) { - ctx.moveTo(x, y); - started = true; - } else { - ctx.lineTo(x, y); - } + if (!started) { ctx.moveTo(x, y); started = true; } else ctx.lineTo(x, y); } - if (started) ctx.stroke(); - // Draw control points ctx.fillStyle = color; this.drawControlPoint(curve.t0, curve.v0); this.drawControlPoint(curve.t1, curve.v1); @@ -307,50 +296,35 @@ class SpectrogramViewer { ctx.globalAlpha = 1.0; } - // Render raw peaks from mq_extract (before partial tracking) renderPeaks() { - const {ctx, canvas, frames} = this; + const {ctx, frames} = this; if (!frames || frames.length === 0) return; - const timeDuration = this.t_view_max - this.t_view_min; - ctx.fillStyle = '#fff'; - for (const frame of frames) { const t = frame.time; if (t < this.t_view_min || t > this.t_view_max) continue; - - const x = (t - this.t_view_min) / timeDuration * canvas.width; - + const x = this.timeToX(t); for (const peak of frame.peaks) { if (peak.freq < this.freqStart || peak.freq > this.freqEnd) continue; - - const y = this.freqToY(peak.freq); - ctx.fillRect(x - 1, y - 1, 3, 3); + ctx.fillRect(x - 1, this.freqToY(peak.freq) - 1, 3, 3); } } } - // Draw control point drawControlPoint(t, v) { if (t < this.t_view_min || t > this.t_view_max) return; if (v < this.freqStart || v > this.freqEnd) return; - - const timeDuration = this.t_view_max - this.t_view_min; - - const x = (t - this.t_view_min) / timeDuration * this.canvas.width; + const x = this.timeToX(t); const y = this.freqToY(v); - this.ctx.beginPath(); this.ctx.arc(x, y, 4, 0, 2 * Math.PI); this.ctx.fill(); - this.ctx.strokeStyle = '#fff'; this.ctx.lineWidth = 1; this.ctx.stroke(); } - // Draw axes with ticks and labels drawAxes() { const {ctx, canvas} = this; const width = canvas.width; @@ -361,140 +335,26 @@ class SpectrogramViewer { ctx.font = '11px monospace'; ctx.lineWidth = 1; - const timeDuration = this.t_view_max - this.t_view_min; - // Time axis + const timeDuration = this.t_view_max - this.t_view_min; const timeStep = this.getAxisStep(timeDuration); 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); - ctx.lineTo(x, height); - ctx.stroke(); - + const x = this.timeToX(t); + ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, height); ctx.stroke(); ctx.fillText(t.toFixed(2) + 's', x + 2, height - 4); t += timeStep; } // Frequency axis (log-spaced ticks) - const freqTicks = [20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 16000]; - for (const f of freqTicks) { + for (const f of [20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 16000]) { if (f < this.freqStart || f > this.freqEnd) continue; const y = this.freqToY(f); - - ctx.beginPath(); - ctx.moveTo(0, y); - ctx.lineTo(width, y); - ctx.stroke(); - - const label = f >= 1000 ? (f/1000).toFixed(0) + 'k' : f.toFixed(0); - ctx.fillText(label + 'Hz', 2, y - 2); + ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(width, y); ctx.stroke(); + ctx.fillText((f >= 1000 ? (f/1000).toFixed(0) + 'k' : f.toFixed(0)) + 'Hz', 2, y - 2); } } - // Setup mouse event handlers - setupMouseHandlers() { - const {canvas, tooltip} = this; - - // Mouse move (tooltip + cursor) - canvas.addEventListener('mousemove', (e) => { - const rect = canvas.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - - this.drawMouseCursor(x); - - const time = this.canvasToTime(x); - const freq = this.canvasToFreq(y); - const intensity = this.getIntensityAt(time, freq); - - // Update spectrum time when not playing - if (this.playheadTime < 0) { - this.spectrumTime = time; - this.renderSpectrum(); - } - - 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, ${intensity.toFixed(1)}dB`; - }); - - canvas.addEventListener('mouseleave', () => { - this.drawMouseCursor(-1); - tooltip.style.display = 'none'; - }); - - // Mouse wheel: scroll (default) or zoom (shift) - canvas.addEventListener('wheel', (e) => { - e.preventDefault(); - - const delta = e.deltaY !== 0 ? e.deltaY : e.deltaX; - - if (e.shiftKey) { - // Zoom in/out around mouse position - const rect = canvas.getBoundingClientRect(); - const x = e.clientX - rect.left; - const mouseTime = this.canvasToTime(x); - - const zoomDelta = delta > 0 ? 1.2 : 1 / 1.2; - const newZoomFactor = this.zoom_factor * zoomDelta; - - // 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; - } - - this.updateViewBounds(); - this.render(); - }); - } - - // Coordinate conversion - canvasToTime(x) { - return this.t_view_min + (x / this.canvas.width) * (this.t_view_max - this.t_view_min); - } - - // freq -> canvas Y (log scale) - freqToY(freq) { - const logMin = Math.log2(this.freqStart); - const logMax = Math.log2(this.freqEnd); - const norm = (Math.log2(Math.max(freq, this.freqStart)) - logMin) / (logMax - logMin); - return this.canvas.height * (1 - norm); - } - - // canvas Y -> freq (log scale, inverse of freqToY) - canvasToFreq(y) { - const logMin = Math.log2(this.freqStart); - const logMax = Math.log2(this.freqEnd); - const norm = 1 - (y / this.canvas.height); - return Math.pow(2, logMin + norm * (logMax - logMin)); - } - - getIntensityAt(time, freq) { - if (!this.stftCache) return -80; - return this.stftCache.getMagnitudeDB(time, freq); - } - - setSynthStftCache(cache) { - this.synthStftCache = cache; - } - renderSpectrum() { if (!this.spectrumCtx || !this.stftCache) return; @@ -506,29 +366,22 @@ class SpectrogramViewer { const width = canvas.width; const height = canvas.height; - // Clear ctx.fillStyle = '#1e1e1e'; ctx.fillRect(0, 0, width, height); const squaredAmp = cache.getSquaredAmplitude(this.spectrumTime); if (!squaredAmp) return; - const fftSize = cache.fftSize; - const numBins = fftSize / 2; - const binWidth = cache.sampleRate / fftSize; + const numBins = cache.fftSize / 2; + const binWidth = cache.sampleRate / cache.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; - }; + // freq -> mini-spectrum X using same log scale as main view + const freqToX = (freq) => this.freqLogNorm(freq) * 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)); + const fStart = this.normToFreq(px / width); + const fEnd = this.normToFreq((px + 1) / width); const bStart = Math.max(0, Math.floor(fStart / binWidth)); const bEnd = Math.min(numBins - 1, Math.ceil(fEnd / binWidth)); @@ -538,23 +391,20 @@ class SpectrogramViewer { if (count === 0) continue; 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); + const barHeight = Math.round(this.normalizeDB(magDB, cache.maxDB) * height); if (barHeight === 0) continue; const gradient = ctx.createLinearGradient(0, height - barHeight, 0, height); if (useSynth) { - gradient.addColorStop(0, '#4f8'); - gradient.addColorStop(1, '#af4'); + gradient.addColorStop(0, '#4f8'); gradient.addColorStop(1, '#af4'); } else { - gradient.addColorStop(0, '#4af'); - gradient.addColorStop(1, '#fa4'); + gradient.addColorStop(0, '#4af'); gradient.addColorStop(1, '#fa4'); } ctx.fillStyle = gradient; ctx.fillRect(px, height - barHeight, 1, barHeight); } - // Overlay extracted peaks as red histogram bars + // Overlay extracted peaks (green) if (this.frames && this.frames.length > 0) { let bestFrame = this.frames[0]; let bestDt = Math.abs(bestFrame.time - this.spectrumTime); @@ -562,37 +412,122 @@ class SpectrogramViewer { const dt = Math.abs(f.time - this.spectrumTime); if (dt < bestDt) { bestDt = dt; bestFrame = f; } } - ctx.fillStyle = '#f44'; + ctx.fillStyle = '#4f4'; 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)))); + const x1 = Math.max(x0 + 1, Math.floor(freqToX(this.normToFreq(this.freqLogNorm(peak.freq) + 1 / width)))); ctx.fillRect(x0, 0, x1 - x0, height); } } + // Overlay partial f0 markers (red, 2px) + ctx.strokeStyle = '#f44'; + ctx.lineWidth = 2; + for (let p = 0; p < this.partials.length && p < this.keepCount; ++p) { + const curve = this.partials[p].freqCurve; + if (!curve || this.spectrumTime < curve.t0 || this.spectrumTime > curve.t3) continue; + const freq = evalBezier(curve, this.spectrumTime); + if (freq < this.freqStart || freq > this.freqEnd) continue; + const x = Math.round(freqToX(freq)); + ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, height); ctx.stroke(); + } + // Label ctx.fillStyle = useSynth ? '#4f8' : '#4af'; ctx.font = '9px monospace'; ctx.fillText(useSynth ? 'SYNTH [a]' : 'ORIG [a]', 4, 10); } - // 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]; - const targetSteps = 8; - const targetStep = range / targetSteps; + // --- View management --- - for (const step of steps) { - if (step >= targetStep) return step; - } + updateViewBounds() { + const full_duration = this.t_max - this.t_min; + const view_duration = full_duration / this.zoom_factor; - return steps[steps.length - 1]; + let t_view_min = this.t_center - view_duration / 2; + let t_view_max = this.t_center + view_duration / 2; + + 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; + + 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; + } + + setupMouseHandlers() { + const {canvas, tooltip} = this; + + canvas.addEventListener('mousemove', (e) => { + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + this.mouseX = x; + this.drawMouseCursor(x); + + const time = this.canvasToTime(x); + const freq = this.canvasToFreq(y); + const intensity = this.getIntensityAt(time, freq); + + if (this.playheadTime < 0) { + this.spectrumTime = time; + this.renderSpectrum(); + } + + 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, ${intensity.toFixed(1)}dB`; + }); + + canvas.addEventListener('mouseleave', () => { + this.mouseX = -1; + this.drawMouseCursor(-1); + tooltip.style.display = 'none'; + }); + + canvas.addEventListener('wheel', (e) => { + e.preventDefault(); + const delta = e.deltaY !== 0 ? e.deltaY : e.deltaX; + + if (e.shiftKey) { + const rect = canvas.getBoundingClientRect(); + const mouseTime = this.canvasToTime(e.clientX - rect.left); + const zoomDelta = delta > 0 ? 1.2 : 1 / 1.2; + const newZoom = this.zoom_factor * zoomDelta; + if (newZoom < 1.0) { + this.zoom_factor = 1.0; + this.t_center = (this.t_max + this.t_min) / 2; + } else { + this.zoom_factor = newZoom; + const newDuration = (this.t_max - this.t_min) / this.zoom_factor; + const mouseRatio = (mouseTime - this.t_view_min) / (this.t_view_max - this.t_view_min); + this.t_center = mouseTime - newDuration * (mouseRatio - 0.5); + } + } else { + const timeDuration = this.t_view_max - this.t_view_min; + this.t_center += (delta / 100) * timeDuration * 0.1; + } + + this.updateViewBounds(); + this.render(); + }); } - getSpectrogramColor(intensity) { - const v = Math.floor(intensity * 255); - return {r: v, g: v, b: v}; + // --- 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]; + const targetStep = range / 8; + for (const step of steps) { if (step >= targetStep) return step; } + return steps[steps.length - 1]; } } @@ -600,10 +535,9 @@ class SpectrogramViewer { function evalBezier(curve, t) { let u = (t - curve.t0) / (curve.t3 - curve.t0); u = Math.max(0, Math.min(1, u)); - const u1 = 1 - u; return u1*u1*u1 * curve.v0 + 3*u1*u1*u * curve.v1 + - 3*u1*u*u * curve.v2 + - u*u*u * curve.v3; + 3*u1*u*u * curve.v2 + + u*u*u * curve.v3; } |
