diff options
Diffstat (limited to 'tools/mq_editor')
| -rw-r--r-- | tools/mq_editor/README.md | 2 | ||||
| -rw-r--r-- | tools/mq_editor/app.js | 24 | ||||
| -rw-r--r-- | tools/mq_editor/editor.js | 25 | ||||
| -rw-r--r-- | tools/mq_editor/index.html | 4 | ||||
| -rw-r--r-- | tools/mq_editor/mq_extract.js | 19 | ||||
| -rw-r--r-- | tools/mq_editor/mq_synth.js | 47 | ||||
| -rw-r--r-- | tools/mq_editor/style.css | 1 | ||||
| -rw-r--r-- | tools/mq_editor/viewer.js | 144 |
8 files changed, 209 insertions, 57 deletions
diff --git a/tools/mq_editor/README.md b/tools/mq_editor/README.md index 7d7d06b..3827d4f 100644 --- a/tools/mq_editor/README.md +++ b/tools/mq_editor/README.md @@ -180,7 +180,7 @@ y[n] = 2r·cos(ω₀)·y[n-1] − r²·y[n-2] + A(t)·√(1−r²)·noise[n] | `gain` | 1.0 | [0, ∞) | Output multiplier on top of power normalization. | **Coefficient translation from spread:** -`r = exp(−π · BW / SR)` where `BW = f₀ · (spread_above + spread_below) / 2`. +`r = exp(−π · BW / SR)` where `BW = f₀ · spread`. For a partial at 440 Hz with `spread = 0.02`: `BW ≈ 8.8 Hz`, `r ≈ exp(−π·8.8/32000) ≈ 0.9991`. **When to use:** diff --git a/tools/mq_editor/app.js b/tools/mq_editor/app.js index 9df00fb..1c6d548 100644 --- a/tools/mq_editor/app.js +++ b/tools/mq_editor/app.js @@ -49,13 +49,18 @@ document.getElementById('hpK2').addEventListener('input', function() { // Show/hide global resonator params when forceResonator toggled document.getElementById('forceResonator').addEventListener('change', function() { document.getElementById('globalResParams').style.display = this.checked ? '' : 'none'; + if (viewer) viewer.render(); }); document.getElementById('globalR').addEventListener('input', function() { document.getElementById('globalRVal').textContent = parseFloat(this.value).toFixed(4); + if (viewer) viewer.render(); }); document.getElementById('globalGain').addEventListener('input', function() { document.getElementById('globalGainVal').textContent = parseFloat(this.value).toFixed(2); + if (viewer) viewer.render(); }); +document.getElementById('forceRGain').addEventListener('change', () => { if (viewer) viewer.render(); }); + let audioBuffer = null; let viewer = null; let audioContext = null; @@ -195,7 +200,8 @@ function loadAudioBuffer(buffer, label) { document.getElementById('exploreBtn').disabled = false; document.getElementById('contourBtn').disabled = false; editor.setViewer(viewer); - viewer.onPartialSelect = (i) => editor.onPartialSelect(i); + viewer.onGetSynthOpts = () => getSynthParams().opts; + viewer.onPartialSelect = (i) => editor.onPartialSelect(i); viewer.onRender = () => editor.onRender(); viewer.onBeforeChange = pushUndo; viewer.onExploreMove = (time, freq) => { @@ -219,10 +225,9 @@ function loadAudioBuffer(buffer, label) { viewer.onExploreCommit = (partial) => { if (!extractedPartials) extractedPartials = []; pushUndo(); - const {spread_above, spread_below} = autodetectSpread(partial, stftCache, fftSize, audioBuffer.sampleRate); - if (!partial.harmonics) partial.harmonics = { decay: 0.0, freq_mult: 2.0, jitter: 0.05, spread_above: 0.02, spread_below: 0.02 }; - partial.harmonics.spread_above = spread_above; - partial.harmonics.spread_below = spread_below; + const {spread} = autodetectSpread(partial, stftCache, fftSize, audioBuffer.sampleRate); + if (!partial.harmonics) partial.harmonics = { decay: 0.0, freq_mult: 2.0, jitter: 0.05, spread: 0.02 }; + partial.harmonics.spread = spread; extractedPartials.unshift(partial); refreshPartialsView(0); setStatus(`${exploreMode}: added partial (${extractedPartials.length} total)`, 'info'); @@ -333,7 +338,7 @@ function createNewPartial() { v0: 440, v1: 440, v2: 440, v3: 440, a0: 1.0, a1: 1.0, a2: 1.0, a3: 1.0, }, - harmonics: { decay: 0.0, freq_mult: 2.0, jitter: 0.05, spread_above: 0.02, spread_below: 0.02 }, + harmonics: { decay: 0.0, freq_mult: 2.0, jitter: 0.05, spread: 0.02 }, }; extractedPartials.unshift(newPartial); refreshPartialsView(0); @@ -357,12 +362,11 @@ function autoSpreadAll() { if (!extractedPartials || !stftCache) return; const fs = stftCache.fftSize; const sr = audioBuffer.sampleRate; - const defaults = { decay: 0.0, freq_mult: 2.0, jitter: 0.05, spread_above: 0.02, spread_below: 0.02 }; + const defaults = { decay: 0.0, freq_mult: 2.0, jitter: 0.05, spread: 0.02 }; for (const p of extractedPartials) { - const {spread_above, spread_below} = autodetectSpread(p, stftCache, fs, sr); + const {spread} = autodetectSpread(p, stftCache, fs, sr); if (!p.harmonics) p.harmonics = { ...defaults }; - p.harmonics.spread_above = spread_above; - p.harmonics.spread_below = spread_below; + p.harmonics.spread = spread; } if (viewer) viewer.render(); const sel = viewer ? viewer.selectedPartial : -1; diff --git a/tools/mq_editor/editor.js b/tools/mq_editor/editor.js index b07664e..98c92e5 100644 --- a/tools/mq_editor/editor.js +++ b/tools/mq_editor/editor.js @@ -124,7 +124,7 @@ class PartialEditor { const grid = this._synthGrid; grid.innerHTML = ''; - const harmDefaults = { decay: 0.0, freq_mult: 2.0, jitter: 0.05, spread_above: 0.02, spread_below: 0.02 }; + const harmDefaults = { decay: 0.0, freq_mult: 2.0, jitter: 0.05, spread: 0.02 }; const resDefaults = { r: 0.995, gainComp: 1.0 }; const isResonator = !!(partial.resonator && partial.resonator.enabled); @@ -158,11 +158,10 @@ class PartialEditor { const harm = partial.harmonics || {}; const sinParams = [ - { key: 'decay', label: 'h.decay', step: '0.01', max: '0.90' }, - { key: 'freq_mult', label: 'h.freq', step: '0.01' }, - { key: 'jitter', label: 'jitter', step: '0.001' }, - { key: 'spread_above', label: 'spread ↑', step: '0.001' }, - { key: 'spread_below', label: 'spread ↓', step: '0.001' }, + { key: 'decay', label: 'h.decay', step: '0.01', max: '0.90' }, + { key: 'freq_mult', label: 'h.freq', step: '0.01' }, + { key: 'jitter', label: 'jitter', step: '0.001' }, + { key: 'spread', label: 'spread', step: '0.001' }, ]; const sinInputs = {}; for (const p of sinParams) { @@ -206,22 +205,20 @@ class PartialEditor { // Auto-detect spread button const autoLbl = document.createElement('span'); - autoLbl.textContent = 'spread'; + autoLbl.textContent = ''; const autoBtn = document.createElement('button'); - autoBtn.textContent = 'Auto'; - autoBtn.title = 'Infer spread_above/below from frequency variance around the bezier curve'; + autoBtn.textContent = 'Auto spread'; + autoBtn.title = 'Infer spread from half-power bandwidth around the bezier curve'; autoBtn.addEventListener('click', () => { if (!this.partials) return; const p = this.partials[index]; const sc = this.viewer ? this.viewer.stftCache : null; const sr = this.viewer ? this.viewer.audioBuffer.sampleRate : 44100; const fs = sc ? sc.fftSize : 2048; - const {spread_above, spread_below} = autodetectSpread(p, sc, fs, sr); + const {spread} = autodetectSpread(p, sc, fs, sr); if (!p.harmonics) p.harmonics = { ...harmDefaults }; - p.harmonics.spread_above = spread_above; - p.harmonics.spread_below = spread_below; - sinInputs['spread_above'].value = spread_above.toFixed(4); - sinInputs['spread_below'].value = spread_below.toFixed(4); + p.harmonics.spread = spread; + sinInputs['spread'].value = spread.toFixed(4); }); sinSection.appendChild(autoLbl); sinSection.appendChild(autoBtn); diff --git a/tools/mq_editor/index.html b/tools/mq_editor/index.html index 605d91a..efbd73d 100644 --- a/tools/mq_editor/index.html +++ b/tools/mq_editor/index.html @@ -82,6 +82,10 @@ <canvas id="cursorCanvas" width="1400" height="600"></canvas> <canvas id="playheadCanvas" width="1400" height="600"></canvas> + <!-- Partial spectrum viewer (bottom-right overlay, left of main spectrum) --> + <div id="partialSpectrumViewer"> + <canvas id="partialSpectrumCanvas" width="200" height="100"></canvas> + </div> <!-- Mini spectrum viewer (bottom-right overlay) --> <div id="spectrumViewer"> <canvas id="spectrumCanvas" width="400" height="100"></canvas> diff --git a/tools/mq_editor/mq_extract.js b/tools/mq_editor/mq_extract.js index 47c21b9..0940cf1 100644 --- a/tools/mq_editor/mq_extract.js +++ b/tools/mq_editor/mq_extract.js @@ -270,13 +270,12 @@ function expandPartialsLeft(partials, frames) { } } -// Autodetect spread_above / spread_below from the spectrogram. -// For each (subsampled) STFT frame within the partial, measures the -// half-power (-3dB) width of the spectral peak above and below the center. -// spread = half_bandwidth / f0 (fractional). +// Autodetect spread from the spectrogram. +// Measures -3dB half-bandwidth above and below the peak in each STFT frame, +// returns spread = max(above, below) / f0 as a fractional frequency offset. function autodetectSpread(partial, stftCache, fftSize, sampleRate) { const curve = partial.freqCurve; - if (!curve || !stftCache) return {spread_above: 0.02, spread_below: 0.02}; + if (!curve || !stftCache) return {spread: 0.02}; const numFrames = stftCache.getNumFrames(); const binHz = sampleRate / fftSize; @@ -331,9 +330,9 @@ function autodetectSpread(partial, stftCache, fftSize, sampleRate) { ++count; } - const spread_above = count > 0 ? Math.sqrt(sumAbove / count) : 0.01; - const spread_below = count > 0 ? Math.sqrt(sumBelow / count) : 0.01; - return {spread_above, spread_below}; + const sa = count > 0 ? Math.sqrt(sumAbove / count) : 0.01; + const sb = count > 0 ? Math.sqrt(sumBelow / count) : 0.01; + return {spread: Math.max(sa, sb)}; } // Track a single partial starting from a (time, freq) seed position. @@ -428,7 +427,7 @@ function trackFromSeed(frames, seedTime, seedFreq, params) { return { times: allTimes, freqs: allFreqs, amps: allAmps, phases: allPhases, muted: false, freqCurve, - harmonics: { decay: 0.0, freq_mult: 2.0, jitter: 0.05, spread_above: 0.02, spread_below: 0.02 }, + harmonics: { decay: 0.0, freq_mult: 2.0, jitter: 0.05, spread: 0.02 }, }; } @@ -522,7 +521,7 @@ function trackIsoContour(stftCache, seedTime, seedFreq, params) { times: allTimes, freqs: allFreqs, amps: allAmps, phases: new Array(allTimes.length).fill(0), muted: false, freqCurve, - harmonics: { decay: 0.0, freq_mult: 2.0, jitter: 0.05, spread_above: 0.15, spread_below: 0.15 }, + harmonics: { decay: 0.0, freq_mult: 2.0, jitter: 0.05, spread: 0.15 }, }; } diff --git a/tools/mq_editor/mq_synth.js b/tools/mq_editor/mq_synth.js index e5f7e1a..1029626 100644 --- a/tools/mq_editor/mq_synth.js +++ b/tools/mq_editor/mq_synth.js @@ -26,22 +26,22 @@ function buildHarmonics(harmonics) { // Synthesize audio from MQ partials // partials: array of {freqCurve (with a0-a3 for amp), harmonics?, resonator?} -// harmonics: {decay, freq_mult, jitter, spread_above, spread_below} +// harmonics: {decay, freq_mult, jitter, spread} // resonator: {enabled, r, gainComp} — two-pole resonator mode per partial // integratePhase: true = accumulate 2π*f/SR per sample (correct for varying freq) // false = 2π*f*t (simpler, only correct for constant freq) // options.k1: LP coefficient in (0,1] — omit to bypass // options.k2: HP coefficient in (0,1] — omit to bypass +// options.disableJitter: true = suppress per-sample frequency jitter function synthesizeMQ(partials, sampleRate, duration, integratePhase = true, options = {}) { const numSamples = Math.floor(sampleRate * duration); const pcm = new Float32Array(numSamples); const defaultHarmonics = { - decay: 0.0, - freq_mult: 1.0, - jitter: 0.05, - spread_above: 0.02, - spread_below: 0.02 + decay: 0.0, + freq_mult: 1.0, + jitter: 0.05, + spread: 0.02 }; // Pre-build per-partial configs with fixed spread/jitter and phase accumulators @@ -62,35 +62,38 @@ function synthesizeMQ(partials, sampleRate, duration, integratePhase = true, opt : (res.gainComp != null ? res.gainComp : 1.0); const gainNorm = Math.sqrt(Math.max(0, 1.0 - r * r)); - // Build harmonic list (jitter/spread not applied to resonator) + // Build harmonic list (spread not applied to resonator; jitter modulates center freq) const harm = partial.harmonics || defaultHarmonics; const harmonicList = buildHarmonics(harm); + const jitter = options.disableJitter ? 0.0 : (harm.jitter ?? 0.0); configs.push({ mode: 'resonator', fc, r, gainComp, gainNorm, harmonicList, + jitter, y1: new Float64Array(harmonicList.length), y2: new Float64Array(harmonicList.length), - noiseSeed: ((p * 1664525 + 1013904223) & 0xFFFFFFFF) >>> 0 + noiseSeed: ((p * 1664525 + 1013904223) & 0xFFFFFFFF) >>> 0, + jitterSeed: ((p * 6364136223 + 1442695040) & 0xFFFFFFFF) >>> 0 }); } else { // --- Sinusoidal (harmonic) mode --- const harm = partial.harmonics || defaultHarmonics; - const spread_above = harm.spread_above ?? 0.0; - const spread_below = harm.spread_below ?? 0.0; - const jitter = harm.jitter ?? 0.0; + const spread = harm.spread ?? 0.0; + const jitter = options.disableJitter ? 0.0 : (harm.jitter ?? 0.0); const harmonicList = buildHarmonics(harm); const replicaData = []; for (let h = 0; h < harmonicList.length; ++h) { - const hc = harmonicList[h]; - const spread = randFloat(p * 67890 + h * 999, -spread_below, spread_above); - const initPhase = randFloat(p * 67890 + h, 0.0, 1.0) * jitter * 2.0 * Math.PI; - replicaData.push({ ratio: hc.ratio, ampMult: hc.ampMult, spread, phase: initPhase }); + const hc = harmonicList[h]; + const spreadVal = randFloat(p * 67890 + h * 999, -spread, spread); + const initPhase = randFloat(p * 67890 + h, 0.0, 1.0) * 2.0 * Math.PI; + const jitterSeed = ((p * 12345 + h * 67890 + 999) & 0xFFFFFFFF) >>> 0; + replicaData.push({ ratio: hc.ratio, ampMult: hc.ampMult, spread: spreadVal, phase: initPhase, jitterSeed }); } - configs.push({ mode: 'sinusoid', fc, replicaData }); + configs.push({ mode: 'sinusoid', fc, replicaData, jitter }); } } @@ -114,9 +117,14 @@ function synthesizeMQ(partials, sampleRate, duration, integratePhase = true, opt cfg.noiseSeed = (Math.imul(1664525, cfg.noiseSeed) + 1013904223) >>> 0; const noise = cfg.noiseSeed / 0x100000000 * 2.0 - 1.0; + // Per-sample frequency jitter on resonator center freq + cfg.jitterSeed = (Math.imul(1664525, cfg.jitterSeed) + 1013904223) >>> 0; + const jNoise = cfg.jitterSeed / 0x100000000 * 2.0 - 1.0; + const f0j = f0 * (1.0 + jNoise * cfg.jitter); + for (let h = 0; h < cfg.harmonicList.length; ++h) { const hc = cfg.harmonicList[h]; - const fh = f0 * hc.ratio; + const fh = f0j * hc.ratio; const omega = 2.0 * Math.PI * fh / sampleRate; const b1 = 2.0 * cfg.r * Math.cos(omega); @@ -140,7 +148,10 @@ function synthesizeMQ(partials, sampleRate, duration, integratePhase = true, opt let phase; if (integratePhase) { - rep.phase += 2.0 * Math.PI * f / sampleRate; + // Per-sample frequency jitter: ±jitter fraction of instantaneous freq + rep.jitterSeed = (Math.imul(1664525, rep.jitterSeed) + 1013904223) >>> 0; + const jNoise = rep.jitterSeed / 0x100000000 * 2.0 - 1.0; + rep.phase += 2.0 * Math.PI * f / sampleRate * (1.0 + jNoise * cfg.jitter); phase = rep.phase; } else { phase = 2.0 * Math.PI * f * t + rep.phase; diff --git a/tools/mq_editor/style.css b/tools/mq_editor/style.css index ed518f5..07a404a 100644 --- a/tools/mq_editor/style.css +++ b/tools/mq_editor/style.css @@ -39,6 +39,7 @@ button.contour-active { background: #145; border-color: #0cc; color: #aff; } /* === Canvas & overlays === */ #canvas { border: 1px solid #555; background: #000; cursor: crosshair; display: block; flex-shrink: 0; } #cursorCanvas, #playheadCanvas { position: absolute; top: 0; left: 0; pointer-events: none; } +#partialSpectrumViewer { position: absolute; bottom: 10px; right: 420px; width: 200px; height: 100px; background: rgba(30,30,30,.9); border: 1px solid #555; border-radius: 3px; pointer-events: none; } #spectrumViewer { position: absolute; bottom: 10px; right: 10px; width: 400px; height: 100px; background: rgba(30,30,30,.9); border: 1px solid #555; border-radius: 3px; pointer-events: none; } #keepOverlay { position: absolute; bottom: 10px; left: 10px; background: rgba(30,30,30,.88); border: 1px solid #555; border-radius: 3px; padding: 4px 8px; display: flex; align-items: center; gap: 6px; font-size: 12px; color: #aaa; user-select: none; } #keepOverlay input[type="range"] { width: 90px; } diff --git a/tools/mq_editor/viewer.js b/tools/mq_editor/viewer.js index 677e5b5..4744b96 100644 --- a/tools/mq_editor/viewer.js +++ b/tools/mq_editor/viewer.js @@ -50,6 +50,13 @@ 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} @@ -128,6 +135,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 +178,7 @@ class SpectrogramViewer { } selectPartial(index) { + this._partialSpecCache = null; this.selectedPartial = index; this.render(); if (this.onPartialSelect) this.onPartialSelect(index); @@ -219,6 +228,7 @@ class SpectrogramViewer { this.drawAxes(); this.drawPlayhead(); this.renderSpectrum(); + this.renderPartialSpectrum(this.spectrumTime, true); if (this.onRender) this.onRender(); } @@ -295,12 +305,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 +379,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 +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() { @@ -731,6 +866,7 @@ class SpectrogramViewer { if (this.playheadTime < 0) { this.spectrumTime = time; this.renderSpectrum(); + this.renderPartialSpectrum(time); } // Cursor hint for control points (skip in explore mode) |
