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.js370
1 files changed, 307 insertions, 63 deletions
diff --git a/tools/mq_editor/viewer.js b/tools/mq_editor/viewer.js
index 76c57e2..4744b96 100644
--- a/tools/mq_editor/viewer.js
+++ b/tools/mq_editor/viewer.js
@@ -38,7 +38,9 @@ class SpectrogramViewer {
this.cursorCtx = this.cursorCanvas ? this.cursorCanvas.getContext('2d') : null;
this.mouseX = -1;
- // Playhead
+ // Playhead overlay
+ this.playheadCanvas = document.getElementById('playheadCanvas');
+ this.playheadCtx = this.playheadCanvas ? this.playheadCanvas.getContext('2d') : null;
this.playheadTime = -1; // -1 = not playing
// Spectrum viewer
@@ -48,11 +50,25 @@ class SpectrogramViewer {
this.showSynthFFT = false; // Toggle: false=original, true=synth
this.synthStftCache = null;
+ // Partial spectrum viewer
+ this.partialSpectrumCanvas = document.getElementById('partialSpectrumCanvas');
+ this.partialSpectrumCtx = this.partialSpectrumCanvas ? this.partialSpectrumCanvas.getContext('2d') : null;
+ this._partialSpecCache = null; // {partialIndex, time, specData?} — see renderPartialSpectrum
+ this.synthOpts = {}; // synth options forwarded to synthesizeMQ (forceResonator, etc.)
+ this.onGetSynthOpts = null; // callback() → opts; called before each spectrum compute
+
// 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)
+ this.onBeforeChange = null; // callback() called before any mutation (for undo/redo)
+
+ // Explore mode
+ this.exploreMode = false;
+ this.previewPartial = null;
+ this.onExploreMove = null; // callback(time, freq)
+ this.onExploreCommit = null; // callback(partial)
// Setup event handlers
this.setupMouseHandlers();
@@ -100,7 +116,7 @@ class SpectrogramViewer {
// 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));
+ return clamp((magDB - (maxDB - 80)) / 80, 0, 1);
}
// Partial index -> display color
@@ -118,10 +134,12 @@ class SpectrogramViewer {
this.playheadTime = time;
if (time >= 0) {
this.spectrumTime = time;
+ this.renderSpectrum();
+ this.renderPartialSpectrum(time);
} else if (this.mouseX >= 0) {
this.spectrumTime = this.canvasToTime(this.mouseX);
}
- this.render();
+ this.drawPlayhead();
}
setPartials(partials) {
@@ -160,6 +178,7 @@ class SpectrogramViewer {
}
selectPartial(index) {
+ this._partialSpecCache = null;
this.selectedPartial = index;
this.render();
if (this.onPartialSelect) this.onPartialSelect(index);
@@ -169,7 +188,7 @@ class SpectrogramViewer {
hitTestPartial(x, y) {
const THRESH = 10;
let bestIdx = -1, bestDist = THRESH;
- for (let p = 0; p < this.partials.length; ++p) {
+ for (let p = 0; p < this.partials.length && p < this.keepCount; ++p) {
const curve = this.partials[p].freqCurve;
if (!curve) continue;
for (let i = 0; i <= 50; ++i) {
@@ -209,6 +228,7 @@ class SpectrogramViewer {
this.drawAxes();
this.drawPlayhead();
this.renderSpectrum();
+ this.renderPartialSpectrum(this.spectrumTime, true);
if (this.onRender) this.onRender();
}
@@ -283,20 +303,13 @@ class SpectrogramViewer {
_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 curve = partial.freqCurve;
+ const harm = partial.harmonics || {};
+ const spread = harm.spread != null ? harm.spread : 0.02;
+ const decay = harm.decay != null ? harm.decay : 0.0;
+ const freqMult = harm.freq_mult != null ? harm.freq_mult : 2.0;
- 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))]);
- }
+ const {upper, lower} = buildBandPoints(this, curve, spread, spread);
if (upper.length < 2) return;
const savedAlpha = ctx.globalAlpha;
@@ -327,14 +340,7 @@ class SpectrogramViewer {
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)]);
- }
+ const {upper: p5upper, lower: p5lower} = buildBandPoints(this, curve, 0.50, 0.50);
if (p5upper.length >= 2) {
ctx.globalAlpha = 0.55;
ctx.strokeStyle = color;
@@ -351,6 +357,56 @@ class SpectrogramViewer {
ctx.setLineDash([]);
}
+ // Harmonic bands (faint, fading with decay^n)
+ if (decay > 0) {
+ for (let n = 1; ; ++n) {
+ const ampMult = Math.pow(decay, n);
+ if (ampMult < 0.001) break;
+ const hRatio = n * freqMult;
+
+ // Center line
+ const cpts = buildCenterPoints(this, curve, hRatio);
+ if (cpts.length >= 2) {
+ ctx.globalAlpha = ampMult * 0.85;
+ ctx.strokeStyle = color;
+ ctx.lineWidth = 1.5;
+ ctx.setLineDash([3, 4]);
+ ctx.beginPath();
+ ctx.moveTo(cpts[0][0], cpts[0][1]);
+ for (let i = 1; i < cpts.length; ++i) ctx.lineTo(cpts[i][0], cpts[i][1]);
+ ctx.stroke();
+ ctx.setLineDash([]);
+ }
+
+ // Spread band fill + boundary dashes
+ const {upper: hu, lower: hl} = buildBandPoints(this, curve, spread, spread, hRatio);
+ if (hu.length >= 2) {
+ ctx.beginPath();
+ ctx.moveTo(hu[0][0], hu[0][1]);
+ for (let i = 1; i < hu.length; ++i) ctx.lineTo(hu[i][0], hu[i][1]);
+ for (let i = hl.length - 1; i >= 0; --i) ctx.lineTo(hl[i][0], hl[i][1]);
+ ctx.closePath();
+ ctx.fillStyle = color;
+ ctx.globalAlpha = ampMult * 0.12;
+ ctx.fill();
+
+ ctx.globalAlpha = ampMult * 0.55;
+ ctx.strokeStyle = color;
+ ctx.lineWidth = 1;
+ ctx.setLineDash([3, 5]);
+ ctx.beginPath();
+ ctx.moveTo(hu[0][0], hu[0][1]);
+ for (let i = 1; i < hu.length; ++i) ctx.lineTo(hu[i][0], hu[i][1]);
+ ctx.stroke();
+ ctx.beginPath();
+ ctx.moveTo(hl[0][0], hl[0][1]);
+ for (let i = 1; i < hl.length; ++i) ctx.lineTo(hl[i][0], hl[i][1]);
+ ctx.stroke();
+ ctx.setLineDash([]);
+ }
+ }
+ }
+
ctx.globalAlpha = savedAlpha;
}
@@ -389,24 +445,69 @@ class SpectrogramViewer {
const h = this.cursorCanvas.height;
ctx.clearRect(0, 0, this.cursorCanvas.width, h);
if (x < 0) return;
- ctx.strokeStyle = 'rgba(255, 60, 60, 0.7)';
+ ctx.strokeStyle = this.exploreMode === 'contour' ? 'rgba(0,220,220,0.8)'
+ : this.exploreMode ? 'rgba(255,160,0,0.8)'
+ : 'rgba(255,60,60,0.7)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, h);
ctx.stroke();
+ if (this.exploreMode && this.previewPartial) {
+ this._drawPreviewPartial(ctx, this.previewPartial);
+ }
+ }
+
+ setExploreMode(enabled) {
+ this.exploreMode = enabled;
+ if (!enabled) this.previewPartial = null;
+ this.drawMouseCursor(this.mouseX);
+ this.canvas.style.cursor = enabled ? 'cell' : 'crosshair';
+ }
+
+ setPreviewPartial(partial) {
+ this.previewPartial = partial;
+ this.drawMouseCursor(this.mouseX);
+ }
+
+ _drawPreviewPartial(ctx, partial) {
+ const curve = partial.freqCurve;
+ if (!curve) return;
+ const col = this.exploreMode === 'contour' ? '0,220,220' : '255,160,0';
+ ctx.save();
+ ctx.strokeStyle = `rgba(${col},0.9)`;
+ ctx.lineWidth = 2;
+ ctx.setLineDash([6, 3]);
+ ctx.shadowColor = `rgba(${col},0.5)`;
+ ctx.shadowBlur = 6;
+ ctx.beginPath();
+ let started = false;
+ for (let i = 0; i <= 80; ++i) {
+ const t = curve.t0 + (curve.t3 - curve.t0) * i / 80;
+ 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 px = this.timeToX(t);
+ const py = this.freqToY(freq);
+ if (!started) { ctx.moveTo(px, py); started = true; } else ctx.lineTo(px, py);
+ }
+ if (started) ctx.stroke();
+ ctx.restore();
}
drawPlayhead() {
+ if (!this.playheadCtx) return;
+ const ctx = this.playheadCtx;
+ const h = this.playheadCanvas.height;
+ ctx.clearRect(0, 0, this.playheadCanvas.width, h);
if (this.playheadTime < 0) return;
if (this.playheadTime < this.t_view_min || this.playheadTime > this.t_view_max) return;
- const {ctx, canvas} = this;
const x = this.timeToX(this.playheadTime);
- ctx.strokeStyle = '#f00';
+ ctx.strokeStyle = 'rgba(255, 80, 80, 0.9)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x, 0);
- ctx.lineTo(x, canvas.height);
+ ctx.lineTo(x, h);
ctx.stroke();
}
@@ -546,6 +647,132 @@ class SpectrogramViewer {
ctx.fillText(useSynth ? 'SYNTH [a]' : 'ORIG [a]', 4, 10);
}
+ // Draw synthesized power spectrum of the selected partial into partialSpectrumCanvas.
+ // X-axis: log frequency (same scale as main view). Y-axis: dB (normalised to peak).
+ // specTime = mouse time if inside partial's [t0,t3], else center of partial's interval.
+ // Cached on {partialIndex, specTime}; force=true bypasses cache (param changes, synth toggle).
+ renderPartialSpectrum(time, force = false) {
+ const ctx = this.partialSpectrumCtx;
+ if (!ctx) return;
+
+ const canvas = this.partialSpectrumCanvas;
+ const width = canvas.width;
+ const height = canvas.height;
+ const p = this.selectedPartial;
+
+ const showMsg = (msg) => {
+ ctx.fillStyle = '#1e1e1e'; ctx.fillRect(0, 0, width, height);
+ ctx.font = '9px monospace'; ctx.fillStyle = '#333';
+ ctx.fillText(msg, 4, height / 2 + 4);
+ this._partialSpecCache = null;
+ };
+
+ if (p < 0 || !this.partials || p >= this.partials.length) { showMsg('no partial'); return; }
+
+ const partial = this.partials[p];
+ const curve = partial.freqCurve;
+ if (!curve) { showMsg('no curve'); return; }
+
+ // Use mouse time if inside partial's window, else center of partial
+ const specTime = (time >= curve.t0 && time <= curve.t3)
+ ? time
+ : (curve.t0 + curve.t3) / 2;
+
+ // Cache check — must happen before clearing the canvas
+ if (!force && this._partialSpecCache &&
+ this._partialSpecCache.partialIndex === p &&
+ this._partialSpecCache.time === specTime) return;
+
+ ctx.fillStyle = '#1e1e1e';
+ ctx.fillRect(0, 0, width, height);
+ ctx.font = '9px monospace';
+
+ // Synthesize window → FFT → power spectrum
+ const specData = this._computePartialSpectrum(partial, specTime);
+ this._partialSpecCache = {partialIndex: p, time: specTime, specData};
+
+ const {squaredAmp, maxDB, sampleRate, fftSize} = specData;
+ const numBins = fftSize / 2;
+ const binWidth = sampleRate / fftSize;
+ const color = this.partialColor(p);
+ const cr = parseInt(color[1] + color[1], 16);
+ const cg = parseInt(color[2] + color[2], 16);
+ const cb = parseInt(color[3] + color[3], 16);
+
+ for (let px = 0; px < width; ++px) {
+ 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));
+ if (bStart > bEnd) continue;
+
+ let maxSq = 0;
+ for (let b = bStart; b <= bEnd; ++b) if (squaredAmp[b] > maxSq) maxSq = squaredAmp[b];
+
+ const magDB = 10 * Math.log10(Math.max(maxSq, 1e-20));
+ const barH = Math.round(this.normalizeDB(magDB, maxDB) * (height - 12));
+ if (barH <= 0) continue;
+
+ const grad = ctx.createLinearGradient(0, height - barH, 0, height);
+ grad.addColorStop(0, color);
+ grad.addColorStop(1, `rgba(${cr},${cg},${cb},0.53)`);
+ ctx.fillStyle = grad;
+ ctx.fillRect(px, height - barH, 1, barH);
+ }
+
+ ctx.fillStyle = color;
+ ctx.fillText('P#' + p + ' @' + specTime.toFixed(3) + 's', 4, 10);
+ }
+
+ // Synthesise a 2048-sample Hann-windowed frame of `partial` centred on `time`, run FFT,
+ // return {squaredAmp, maxDB, sampleRate, fftSize}. Uses this.synthOpts (forceResonator etc).
+ // freqCurve times are shifted so synthesizeMQ's t=0 aligns with tStart = time − window/2.
+ _computePartialSpectrum(partial, time) {
+ if (this.onGetSynthOpts) this.synthOpts = this.onGetSynthOpts();
+ const sampleRate = this.audioBuffer.sampleRate;
+ const FFT_SIZE = 2048;
+ const windowDuration = FFT_SIZE / sampleRate;
+ const tStart = time - windowDuration / 2;
+
+ // Shift curve times so synthesis window [0, windowDuration] maps to [tStart, tStart+windowDuration]
+ const fc = partial.freqCurve;
+ const shiftedPartial = {
+ ...partial,
+ freqCurve: {
+ t0: fc.t0 - tStart, t1: fc.t1 - tStart,
+ t2: fc.t2 - tStart, t3: fc.t3 - tStart,
+ v0: fc.v0, v1: fc.v1, v2: fc.v2, v3: fc.v3,
+ a0: fc.a0, a1: fc.a1, a2: fc.a2, a3: fc.a3,
+ },
+ };
+
+ const pcm = synthesizeMQ([shiftedPartial], sampleRate, windowDuration, true, this.synthOpts);
+
+ // Hann window
+ for (let i = 0; i < FFT_SIZE; ++i) {
+ pcm[i] *= 0.5 * (1 - Math.cos(2 * Math.PI * i / (FFT_SIZE - 1)));
+ }
+
+ // FFT
+ const real = new Float32Array(FFT_SIZE);
+ const imag = new Float32Array(FFT_SIZE);
+ for (let i = 0; i < FFT_SIZE; ++i) real[i] = pcm[i];
+ fftForward(real, imag, FFT_SIZE);
+
+ // Power spectrum
+ const squaredAmp = new Float32Array(FFT_SIZE / 2);
+ for (let i = 0; i < FFT_SIZE / 2; ++i) {
+ squaredAmp[i] = (real[i] * real[i] + imag[i] * imag[i]) / (FFT_SIZE * FFT_SIZE);
+ }
+
+ // maxDB for normalizing the display
+ let maxSq = 1e-20;
+ for (let i = 0; i < squaredAmp.length; ++i) if (squaredAmp[i] > maxSq) maxSq = squaredAmp[i];
+ const maxDB = 10 * Math.log10(maxSq);
+
+ return {squaredAmp, maxDB, sampleRate, fftSize: FFT_SIZE};
+ }
+
// --- View management ---
updateViewBounds() {
@@ -568,18 +795,34 @@ class SpectrogramViewer {
this.t_center = (this.t_view_min + this.t_view_max) / 2;
}
+ destroy() {
+ const {canvas} = this;
+ canvas.removeEventListener('mousedown', this._onMousedown);
+ canvas.removeEventListener('mousemove', this._onMousemove);
+ canvas.removeEventListener('mouseleave', this._onMouseleave);
+ canvas.removeEventListener('mouseup', this._onMouseup);
+ canvas.removeEventListener('wheel', this._onWheel);
+ }
+
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;
+ this._onMousedown = (e) => {
+ const {x, y} = getCanvasCoords(e, canvas);
+
+ // Explore mode: commit preview on click
+ if (this.exploreMode) {
+ if (this.previewPartial && this.onExploreCommit) {
+ this.onExploreCommit(this.previewPartial);
+ }
+ return;
+ }
// 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) {
+ if (this.onBeforeChange) this.onBeforeChange();
this.dragState = { pointIndex: ptIdx };
canvas.style.cursor = 'grabbing';
e.preventDefault();
@@ -590,16 +833,15 @@ class SpectrogramViewer {
// Otherwise: select partial by click
const idx = this.hitTestPartial(x, y);
this.selectPartial(idx);
- });
+ };
+ canvas.addEventListener('mousedown', this._onMousedown);
- canvas.addEventListener('mousemove', (e) => {
- const rect = canvas.getBoundingClientRect();
- const x = e.clientX - rect.left;
- const y = e.clientY - rect.top;
+ this._onMousemove = (e) => {
+ const {x, y} = getCanvasCoords(e, canvas);
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 t = clamp(this.canvasToTime(x), 0, this.t_max);
+ const v = clamp(this.canvasToFreq(y), this.freqStart, this.freqEnd);
const partial = this.partials[this.selectedPartial];
const i = this.dragState.pointIndex;
partial.freqCurve['t' + i] = t;
@@ -614,42 +856,53 @@ class SpectrogramViewer {
const time = this.canvasToTime(x);
const freq = this.canvasToFreq(y);
+
+ if (this.exploreMode && this.onExploreMove) {
+ this.onExploreMove(time, freq); // may call setPreviewPartial → redraws cursor canvas
+ }
+
const intensity = this.getIntensityAt(time, freq);
if (this.playheadTime < 0) {
this.spectrumTime = time;
this.renderSpectrum();
+ this.renderPartialSpectrum(time);
}
- // 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';
+ // Cursor hint for control points (skip in explore mode)
+ if (!this.exploreMode) {
+ 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';
tooltip.textContent = `${time.toFixed(3)}s, ${freq.toFixed(1)}Hz, ${intensity.toFixed(1)}dB`;
- });
+ };
+ canvas.addEventListener('mousemove', this._onMousemove);
- canvas.addEventListener('mouseleave', () => {
+ this._onMouseleave = () => {
this.mouseX = -1;
this.drawMouseCursor(-1);
tooltip.style.display = 'none';
- });
+ };
+ canvas.addEventListener('mouseleave', this._onMouseleave);
- canvas.addEventListener('mouseup', () => {
+ this._onMouseup = () => {
if (this.dragState) {
this.dragState = null;
canvas.style.cursor = 'crosshair';
if (this.onPartialSelect) this.onPartialSelect(this.selectedPartial);
}
- });
+ };
+ canvas.addEventListener('mouseup', this._onMouseup);
- canvas.addEventListener('wheel', (e) => {
+ this._onWheel = (e) => {
e.preventDefault();
const delta = e.deltaY !== 0 ? e.deltaY : e.deltaX;
@@ -674,7 +927,8 @@ class SpectrogramViewer {
this.updateViewBounds();
this.render();
- });
+ };
+ canvas.addEventListener('wheel', this._onWheel);
}
// --- Utilities ---
@@ -717,13 +971,3 @@ class SpectrogramViewer {
}
}
-// Bezier evaluation (shared utility)
-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;
-}