summaryrefslogtreecommitdiff
path: root/tools/mq_editor
diff options
context:
space:
mode:
Diffstat (limited to 'tools/mq_editor')
-rw-r--r--tools/mq_editor/index.html13
-rw-r--r--tools/mq_editor/viewer.js218
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];