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