summaryrefslogtreecommitdiff
path: root/tools/mq_editor/viewer.js
diff options
context:
space:
mode:
Diffstat (limited to 'tools/mq_editor/viewer.js')
-rw-r--r--tools/mq_editor/viewer.js418
1 files changed, 302 insertions, 116 deletions
diff --git a/tools/mq_editor/viewer.js b/tools/mq_editor/viewer.js
index 7f6e862..76c57e2 100644
--- a/tools/mq_editor/viewer.js
+++ b/tools/mq_editor/viewer.js
@@ -48,6 +48,12 @@ class SpectrogramViewer {
this.showSynthFFT = false; // Toggle: false=original, true=synth
this.synthStftCache = null;
+ // Selection and editing
+ this.selectedPartial = -1;
+ this.dragState = null; // {pointIndex: 0-3}
+ this.onPartialSelect = null; // callback(index)
+ this.onRender = null; // callback() called after each render (for synced panels)
+
// Setup event handlers
this.setupMouseHandlers();
@@ -153,6 +159,47 @@ class SpectrogramViewer {
return this.stftCache.getMagnitudeDB(time, freq);
}
+ selectPartial(index) {
+ this.selectedPartial = index;
+ this.render();
+ if (this.onPartialSelect) this.onPartialSelect(index);
+ }
+
+ // Hit-test bezier curves: returns index of nearest partial within threshold
+ hitTestPartial(x, y) {
+ const THRESH = 10;
+ let bestIdx = -1, bestDist = THRESH;
+ for (let p = 0; p < this.partials.length; ++p) {
+ const curve = this.partials[p].freqCurve;
+ if (!curve) continue;
+ for (let i = 0; i <= 50; ++i) {
+ const t = curve.t0 + (curve.t3 - curve.t0) * i / 50;
+ if (t < this.t_view_min || t > this.t_view_max) continue;
+ const f = evalBezier(curve, t);
+ if (f < this.freqStart || f > this.freqEnd) continue;
+ const px = this.timeToX(t), py = this.freqToY(f);
+ const dist = Math.hypot(px - x, py - y);
+ if (dist < bestDist) { bestDist = dist; bestIdx = p; }
+ }
+ }
+ return bestIdx;
+ }
+
+ // Hit-test control points of a specific partial's freqCurve
+ hitTestControlPoint(x, y, partial) {
+ const curve = partial.freqCurve;
+ if (!curve) return -1;
+ const THRESH = 8;
+ for (let i = 0; i < 4; ++i) {
+ const t = curve['t' + i], v = curve['v' + i];
+ if (t < this.t_view_min || t > this.t_view_max) continue;
+ if (v < this.freqStart || v > this.freqEnd) continue;
+ const px = this.timeToX(t), py = this.freqToY(v);
+ if (Math.hypot(px - x, py - y) <= THRESH) return i;
+ }
+ return -1;
+ }
+
// --- Render ---
render() {
@@ -162,6 +209,178 @@ class SpectrogramViewer {
this.drawAxes();
this.drawPlayhead();
this.renderSpectrum();
+ if (this.onRender) this.onRender();
+ }
+
+ renderPartials() {
+ for (let p = 0; p < this.partials.length; ++p) {
+ if (p === this.selectedPartial) continue; // draw selected last (on top)
+ this._renderPartial(p, this.partials[p], false);
+ }
+ if (this.selectedPartial >= 0 && this.selectedPartial < this.partials.length) {
+ this._renderPartial(this.selectedPartial, this.partials[this.selectedPartial], true);
+ }
+ this.ctx.globalAlpha = 1.0;
+ this.ctx.shadowBlur = 0;
+ }
+
+ _renderPartial(p, partial, isSelected) {
+ const {ctx} = this;
+ const color = this.partialColor(p);
+ let alpha = isSelected ? 1.0 : (p < this.keepCount ? 1.0 : 0.12);
+ if (partial.muted && !isSelected) alpha = 0.15;
+ ctx.globalAlpha = alpha;
+
+ // 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 = this.timeToX(t);
+ const y = this.freqToY(f);
+ if (!started) { ctx.moveTo(x, y); started = true; } else ctx.lineTo(x, y);
+ }
+ if (started) ctx.stroke();
+
+ // Spread band (selected only)
+ if (isSelected && partial.freqCurve) {
+ this._renderSpreadBand(partial, color);
+ }
+
+ // Bezier curve
+ if (partial.freqCurve) {
+ const curve = partial.freqCurve;
+ if (isSelected) { ctx.shadowColor = color; ctx.shadowBlur = 8; }
+ ctx.strokeStyle = color;
+ ctx.lineWidth = isSelected ? 3 : 2;
+ ctx.beginPath();
+ started = false;
+ 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 = this.timeToX(t);
+ const y = this.freqToY(freq);
+ if (!started) { ctx.moveTo(x, y); started = true; } else ctx.lineTo(x, y);
+ }
+ if (started) ctx.stroke();
+ if (isSelected) ctx.shadowBlur = 0;
+
+ ctx.fillStyle = color;
+ const cpR = isSelected ? 6 : 4;
+ this.drawControlPoint(curve.t0, curve.v0, cpR);
+ this.drawControlPoint(curve.t1, curve.v1, cpR);
+ this.drawControlPoint(curve.t2, curve.v2, cpR);
+ this.drawControlPoint(curve.t3, curve.v3, cpR);
+ }
+ }
+
+ _renderSpreadBand(partial, color) {
+ const {ctx} = this;
+ const curve = partial.freqCurve;
+ const rep = partial.replicas || {};
+ const sa = rep.spread_above != null ? rep.spread_above : 0.02;
+ const sb = rep.spread_below != null ? rep.spread_below : 0.02;
+
+ const STEPS = 60;
+ const upper = [], lower = [];
+ for (let i = 0; i <= STEPS; ++i) {
+ const t = curve.t0 + (curve.t3 - curve.t0) * i / STEPS;
+ if (t < this.t_view_min - 0.01 || t > this.t_view_max + 0.01) continue;
+ const f = evalBezier(curve, t);
+ upper.push([this.timeToX(t), this.freqToY(f * (1 + sa))]);
+ lower.push([this.timeToX(t), this.freqToY(f * (1 - sb))]);
+ }
+ if (upper.length < 2) return;
+
+ const savedAlpha = ctx.globalAlpha;
+
+ // Outer soft fill
+ ctx.beginPath();
+ ctx.moveTo(upper[0][0], upper[0][1]);
+ for (let i = 1; i < upper.length; ++i) ctx.lineTo(upper[i][0], upper[i][1]);
+ for (let i = lower.length - 1; i >= 0; --i) ctx.lineTo(lower[i][0], lower[i][1]);
+ ctx.closePath();
+ ctx.fillStyle = color;
+ ctx.globalAlpha = 0.13;
+ ctx.fill();
+
+ // Dashed boundary lines
+ ctx.globalAlpha = 0.75;
+ ctx.strokeStyle = color;
+ ctx.lineWidth = 1.5;
+ ctx.setLineDash([4, 3]);
+ ctx.beginPath();
+ ctx.moveTo(upper[0][0], upper[0][1]);
+ for (let i = 1; i < upper.length; ++i) ctx.lineTo(upper[i][0], upper[i][1]);
+ ctx.stroke();
+ ctx.beginPath();
+ ctx.moveTo(lower[0][0], lower[0][1]);
+ for (let i = 1; i < lower.length; ++i) ctx.lineTo(lower[i][0], lower[i][1]);
+ ctx.stroke();
+ ctx.setLineDash([]);
+
+ // 50% drop-off reference lines (dotted, dimmer)
+ const p5upper = [], p5lower = [];
+ for (let i = 0; i <= STEPS; ++i) {
+ const t = curve.t0 + (curve.t3 - curve.t0) * i / STEPS;
+ if (t < this.t_view_min - 0.01 || t > this.t_view_max + 0.01) continue;
+ const f = evalBezier(curve, t);
+ p5upper.push([this.timeToX(t), this.freqToY(f * 1.50)]);
+ p5lower.push([this.timeToX(t), this.freqToY(f * 0.50)]);
+ }
+ if (p5upper.length >= 2) {
+ ctx.globalAlpha = 0.55;
+ ctx.strokeStyle = color;
+ ctx.lineWidth = 1;
+ ctx.setLineDash([1, 5]);
+ ctx.beginPath();
+ ctx.moveTo(p5upper[0][0], p5upper[0][1]);
+ for (let i = 1; i < p5upper.length; ++i) ctx.lineTo(p5upper[i][0], p5upper[i][1]);
+ ctx.stroke();
+ ctx.beginPath();
+ ctx.moveTo(p5lower[0][0], p5lower[0][1]);
+ for (let i = 1; i < p5lower.length; ++i) ctx.lineTo(p5lower[i][0], p5lower[i][1]);
+ ctx.stroke();
+ ctx.setLineDash([]);
+ }
+
+ ctx.globalAlpha = savedAlpha;
+ }
+
+ renderPeaks() {
+ const {ctx, frames} = this;
+ if (!frames || frames.length === 0) return;
+
+ 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 = this.timeToX(t);
+ for (const peak of frame.peaks) {
+ if (peak.freq < this.freqStart || peak.freq > this.freqEnd) continue;
+ ctx.fillRect(x - 1, this.freqToY(peak.freq) - 1, 3, 3);
+ }
+ }
+ }
+
+ drawControlPoint(t, v, radius = 4) {
+ if (t < this.t_view_min || t > this.t_view_max) return;
+ if (v < this.freqStart || v > this.freqEnd) return;
+ const x = this.timeToX(t);
+ const y = this.freqToY(v);
+ this.ctx.beginPath();
+ this.ctx.arc(x, y, radius, 0, 2 * Math.PI);
+ this.ctx.fill();
+ this.ctx.strokeStyle = '#fff';
+ this.ctx.lineWidth = 1;
+ this.ctx.stroke();
}
drawMouseCursor(x) {
@@ -243,118 +462,6 @@ class SpectrogramViewer {
}
}
- renderPartials() {
- const {ctx, partials} = this;
-
- for (let p = 0; p < partials.length; ++p) {
- const partial = partials[p];
- const color = this.partialColor(p);
- ctx.globalAlpha = p < this.keepCount ? 1.0 : 0.5;
-
- // 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 = this.timeToX(t);
- const y = this.freqToY(f);
- if (!started) { ctx.moveTo(x, y); started = true; } else ctx.lineTo(x, y);
- }
- if (started) ctx.stroke();
-
- // Bezier curve
- if (partial.freqCurve) {
- ctx.strokeStyle = color;
- ctx.lineWidth = 2;
- ctx.beginPath();
- const curve = partial.freqCurve;
- started = false;
- 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 = this.timeToX(t);
- const y = this.freqToY(freq);
- if (!started) { ctx.moveTo(x, y); started = true; } else ctx.lineTo(x, y);
- }
- if (started) ctx.stroke();
-
- ctx.fillStyle = color;
- this.drawControlPoint(curve.t0, curve.v0);
- this.drawControlPoint(curve.t1, curve.v1);
- this.drawControlPoint(curve.t2, curve.v2);
- this.drawControlPoint(curve.t3, curve.v3);
- }
- }
-
- ctx.globalAlpha = 1.0;
- }
-
- renderPeaks() {
- const {ctx, frames} = this;
- if (!frames || frames.length === 0) return;
-
- 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 = this.timeToX(t);
- for (const peak of frame.peaks) {
- if (peak.freq < this.freqStart || peak.freq > this.freqEnd) continue;
- ctx.fillRect(x - 1, this.freqToY(peak.freq) - 1, 3, 3);
- }
- }
- }
-
- drawControlPoint(t, v) {
- if (t < this.t_view_min || t > this.t_view_max) return;
- if (v < this.freqStart || v > this.freqEnd) return;
- 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();
- }
-
- drawAxes() {
- const {ctx, canvas} = this;
- const width = canvas.width;
- const height = canvas.height;
-
- ctx.strokeStyle = '#666';
- ctx.fillStyle = '#aaa';
- ctx.font = '11px monospace';
- ctx.lineWidth = 1;
-
- // 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 = 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)
- 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();
- ctx.fillText((f >= 1000 ? (f/1000).toFixed(0) + 'k' : f.toFixed(0)) + 'Hz', 2, y - 2);
- }
- }
-
renderSpectrum() {
if (!this.spectrumCtx || !this.stftCache) return;
@@ -386,11 +493,11 @@ class SpectrogramViewer {
const bStart = Math.max(0, Math.floor(fStart / binWidth));
const bEnd = Math.min(numBins - 1, Math.ceil(fEnd / binWidth));
- let sum = 0, count = 0;
- for (let b = bStart; b <= bEnd; ++b) { sum += squaredAmp[b]; ++count; }
- if (count === 0) continue;
+ let maxSq = 0;
+ for (let b = bStart; b <= bEnd; ++b) { if (squaredAmp[b] > maxSq) maxSq = squaredAmp[b]; }
+ if (bStart > bEnd) continue;
- const magDB = 10 * Math.log10(Math.max(sum / count, 1e-20));
+ const magDB = 10 * Math.log10(Math.max(maxSq, 1e-20));
const barHeight = Math.round(this.normalizeDB(magDB, cache.maxDB) * height);
if (barHeight === 0) continue;
@@ -464,11 +571,44 @@ class SpectrogramViewer {
setupMouseHandlers() {
const {canvas, tooltip} = this;
+ canvas.addEventListener('mousedown', (e) => {
+ const rect = canvas.getBoundingClientRect();
+ const x = e.clientX - rect.left;
+ const y = e.clientY - rect.top;
+
+ // Check control point drag on selected partial
+ if (this.selectedPartial >= 0 && this.selectedPartial < this.partials.length) {
+ const ptIdx = this.hitTestControlPoint(x, y, this.partials[this.selectedPartial]);
+ if (ptIdx >= 0) {
+ this.dragState = { pointIndex: ptIdx };
+ canvas.style.cursor = 'grabbing';
+ e.preventDefault();
+ return;
+ }
+ }
+
+ // Otherwise: select partial by click
+ const idx = this.hitTestPartial(x, y);
+ this.selectPartial(idx);
+ });
+
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
+ if (this.dragState) {
+ const t = Math.max(0, Math.min(this.t_max, this.canvasToTime(x)));
+ const v = Math.max(this.freqStart, Math.min(this.freqEnd, this.canvasToFreq(y)));
+ const partial = this.partials[this.selectedPartial];
+ const i = this.dragState.pointIndex;
+ partial.freqCurve['t' + i] = t;
+ partial.freqCurve['v' + i] = v;
+ this.render();
+ e.preventDefault();
+ return;
+ }
+
this.mouseX = x;
this.drawMouseCursor(x);
@@ -481,6 +621,14 @@ class SpectrogramViewer {
this.renderSpectrum();
}
+ // Cursor hint for control points
+ if (this.selectedPartial >= 0 && this.selectedPartial < this.partials.length) {
+ const ptIdx = this.hitTestControlPoint(x, y, this.partials[this.selectedPartial]);
+ canvas.style.cursor = ptIdx >= 0 ? 'grab' : 'crosshair';
+ } else {
+ canvas.style.cursor = 'crosshair';
+ }
+
tooltip.style.left = (e.clientX + 10) + 'px';
tooltip.style.top = (e.clientY + 10) + 'px';
tooltip.style.display = 'block';
@@ -493,6 +641,14 @@ class SpectrogramViewer {
tooltip.style.display = 'none';
});
+ canvas.addEventListener('mouseup', () => {
+ if (this.dragState) {
+ this.dragState = null;
+ canvas.style.cursor = 'crosshair';
+ if (this.onPartialSelect) this.onPartialSelect(this.selectedPartial);
+ }
+ });
+
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
const delta = e.deltaY !== 0 ? e.deltaY : e.deltaX;
@@ -529,6 +685,36 @@ class SpectrogramViewer {
for (const step of steps) { if (step >= targetStep) return step; }
return steps[steps.length - 1];
}
+
+ drawAxes() {
+ const {ctx, canvas} = this;
+ const width = canvas.width;
+ const height = canvas.height;
+
+ ctx.strokeStyle = '#666';
+ ctx.fillStyle = '#aaa';
+ ctx.font = '11px monospace';
+ ctx.lineWidth = 1;
+
+ // 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 = 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)
+ 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();
+ ctx.fillText((f >= 1000 ? (f/1000).toFixed(0) + 'k' : f.toFixed(0)) + 'Hz', 2, y - 2);
+ }
+ }
}
// Bezier evaluation (shared utility)