summaryrefslogtreecommitdiff
path: root/tools
diff options
context:
space:
mode:
authorskal <pascal.massimino@gmail.com>2026-02-18 07:15:39 +0100
committerskal <pascal.massimino@gmail.com>2026-02-18 07:15:39 +0100
commitc37c9459c78a03e2b69b29cbeb17075a19c0418f (patch)
treecc9a5db7cd89459927fbea94c6cefc75379bd22d /tools
parent105c817021a84bfacffa1553d6bcd536808b9f23 (diff)
refactor(mq_editor): clean coordinate API and remove UI duplication
viewer.js: - Add timeToX(), freqLogNorm(), normToFreq() coordinate primitives - freqToY/canvasToFreq now delegate to freqLogNorm/normToFreq - normalizeDB() replaces duplicated (magDB-(maxDB-80))/80 formula - partialColor(p) replaces repeated color array - All inline time/freq→pixel math replaced with API calls index.html: - getKeepCount() replaces 3 copies of the same calculation - playAudioBuffer() replaces duplicated playback setup + RAF loop handoff(Claude): refactor complete
Diffstat (limited to 'tools')
-rw-r--r--tools/mq_editor/index.html111
-rw-r--r--tools/mq_editor/viewer.js474
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;
}