diff options
Diffstat (limited to 'tools/mq_editor/viewer.js')
| -rw-r--r-- | tools/mq_editor/viewer.js | 176 |
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) |
