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.js176
1 files changed, 172 insertions, 4 deletions
diff --git a/tools/mq_editor/viewer.js b/tools/mq_editor/viewer.js
index 677e5b5..1ac1afd 100644
--- a/tools/mq_editor/viewer.js
+++ b/tools/mq_editor/viewer.js
@@ -50,6 +50,14 @@ 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._partialRangeCache = null; // {partialIndex, dbMin, dbMax} — scanned across full partial duration
+ 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}
@@ -128,6 +136,7 @@ class SpectrogramViewer {
if (time >= 0) {
this.spectrumTime = time;
this.renderSpectrum();
+ this.renderPartialSpectrum(time);
} else if (this.mouseX >= 0) {
this.spectrumTime = this.canvasToTime(this.mouseX);
}
@@ -170,6 +179,8 @@ class SpectrogramViewer {
}
selectPartial(index) {
+ this._partialSpecCache = null;
+ this._partialRangeCache = null;
this.selectedPartial = index;
this.render();
if (this.onPartialSelect) this.onPartialSelect(index);
@@ -219,6 +230,7 @@ class SpectrogramViewer {
this.drawAxes();
this.drawPlayhead();
this.renderSpectrum();
+ this.renderPartialSpectrum(this.spectrumTime, true);
if (this.onRender) this.onRender();
}
@@ -295,12 +307,11 @@ class SpectrogramViewer {
const {ctx} = this;
const curve = partial.freqCurve;
const harm = partial.harmonics || {};
- const sa = harm.spread_above != null ? harm.spread_above : 0.02;
- const sb = harm.spread_below != null ? harm.spread_below : 0.02;
+ 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 {upper, lower} = buildBandPoints(this, curve, sa, sb);
+ const {upper, lower} = buildBandPoints(this, curve, spread, spread);
if (upper.length < 2) return;
const savedAlpha = ctx.globalAlpha;
@@ -370,7 +381,7 @@ class SpectrogramViewer {
}
// Spread band fill + boundary dashes
- const {upper: hu, lower: hl} = buildBandPoints(this, curve, sa, sb, hRatio);
+ 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]);
@@ -638,6 +649,162 @@ 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};
+
+ // dB range: scanned across full partial duration, cached per partial
+ if (!this._partialRangeCache || this._partialRangeCache.partialIndex !== p) {
+ this._partialRangeCache = this._computePartialRange(p, partial);
+ }
+ const {dbMin: DB_MIN, dbMax: DB_MAX} = this._partialRangeCache;
+
+ const {squaredAmp, 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(Math.max(0, Math.min(1, (magDB - DB_MIN) / (DB_MAX - DB_MIN))) * (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);
+
+ const amp = evalBezierAmp(curve, specTime);
+ ctx.fillStyle = '#f44';
+ ctx.textAlign = 'right';
+ ctx.fillText('A=' + amp.toFixed(3), width - 3, 10);
+ ctx.textAlign = 'left';
+ }
+
+ // 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};
+ }
+
+ // Scan the partial across its full duration to find the peak dB level, then derive
+ // [dbMin, dbMax] as [peak − 60, peak]. Cached per partialIndex; only called once on select.
+ _computePartialRange(partialIndex, partial) {
+ const fc = partial.freqCurve;
+ if (!fc) return {partialIndex, dbMin: -60, dbMax: 0};
+ const N = 8;
+ let globalMaxSq = 1e-20;
+ for (let i = 0; i < N; ++i) {
+ const t = fc.t0 + (fc.t3 - fc.t0) * (i + 0.5) / N;
+ const {squaredAmp} = this._computePartialSpectrum(partial, t);
+ for (let b = 0; b < squaredAmp.length; ++b) {
+ if (squaredAmp[b] > globalMaxSq) globalMaxSq = squaredAmp[b];
+ }
+ }
+ const dbMax = 10 * Math.log10(globalMaxSq);
+ return {partialIndex, dbMin: dbMax - 60, dbMax};
+ }
+
// --- View management ---
updateViewBounds() {
@@ -731,6 +898,7 @@ class SpectrogramViewer {
if (this.playheadTime < 0) {
this.spectrumTime = time;
this.renderSpectrum();
+ this.renderPartialSpectrum(time);
}
// Cursor hint for control points (skip in explore mode)