From bc07ea00a9f2f418e6b460884c3925b72ff2a358 Mon Sep 17 00:00:00 2001 From: skal Date: Wed, 18 Feb 2026 16:22:54 +0100 Subject: refactor(mq_editor): unify freq+amp into single bezier curve freqCurve now carries a0-a3 (amplitude control values) alongside v0-v3 (frequency). Both components share the same t0-t3 time parameterization. evalBezierAmp() added to utils.js. ampCurve removed from partials and synth pipeline. Amp panel drag now changes only a_i; t is read-only (shared with freq). handoff(Claude): unified freq/amp bezier done --- tools/mq_editor/editor.js | 39 +++++++++++++++++++-------------------- tools/mq_editor/mq_extract.js | 4 +++- tools/mq_editor/mq_synth.js | 13 ++++++------- tools/mq_editor/utils.js | 13 +++++++++++++ 4 files changed, 41 insertions(+), 28 deletions(-) (limited to 'tools/mq_editor') diff --git a/tools/mq_editor/editor.js b/tools/mq_editor/editor.js index 868d3d5..3c07877 100644 --- a/tools/mq_editor/editor.js +++ b/tools/mq_editor/editor.js @@ -86,11 +86,11 @@ class PartialEditor { muteBtn.style.color = partial.muted ? '#fa4' : ''; this._buildCurveGrid(this._freqGrid, partial, 'freqCurve', 'f', index); - this._buildCurveGrid(this._ampGrid, partial, 'ampCurve', 'a', index); + this._buildCurveGrid(this._ampGrid, partial, 'freqCurve', 'a', index, 'a'); this._buildSynthGrid(partial, index); } - _buildCurveGrid(grid, partial, curveKey, valueLabel, partialIndex) { + _buildCurveGrid(grid, partial, curveKey, valueLabel, partialIndex, valueKey = 'v') { grid.innerHTML = ''; const curve = partial[curveKey]; if (!curve) { grid.style.color = '#444'; grid.textContent = 'none'; return; } @@ -108,10 +108,10 @@ class PartialEditor { const vInput = document.createElement('input'); vInput.type = 'number'; - vInput.value = curveKey === 'freqCurve' ? curve['v' + i].toFixed(2) : curve['v' + i].toFixed(4); - vInput.step = curveKey === 'freqCurve' ? '1' : '0.0001'; + vInput.value = valueKey === 'v' ? curve['v' + i].toFixed(2) : curve[valueKey + i].toFixed(4); + vInput.step = valueKey === 'v' ? '1' : '0.0001'; vInput.title = valueLabel + i; - vInput.addEventListener('change', this._makeCurveUpdater(partialIndex, curveKey, 'v', i)); + vInput.addEventListener('change', this._makeCurveUpdater(partialIndex, curveKey, valueKey, i)); grid.appendChild(lbl); grid.appendChild(tInput); @@ -444,7 +444,7 @@ class PartialEditor { } // Bezier curve - const curve = partial.ampCurve; + const curve = partial.freqCurve; if (!curve) return; const color = this.viewer ? this.viewer.partialColor(this._selectedIndex) : '#f44'; @@ -456,7 +456,7 @@ class PartialEditor { const t = curve.t0 + (curve.t3 - curve.t0) * i / 120; const x = this._tToX(t); if (x < -1 || x > W + 1) { started = false; continue; } - const y = this._ampToY(evalBezier(curve, t)); + const y = this._ampToY(evalBezierAmp(curve, t)); if (!started) { ctx.moveTo(x, y); started = true; } else ctx.lineTo(x, y); } ctx.stroke(); @@ -464,7 +464,7 @@ class PartialEditor { // Control points for (let i = 0; i < 4; ++i) { const x = this._tToX(curve['t' + i]); - const y = this._ampToY(curve['v' + i]); + const y = this._ampToY(curve['a' + i]); ctx.fillStyle = color; ctx.beginPath(); ctx.arc(x, y, 6, 0, 2 * Math.PI); @@ -485,17 +485,17 @@ class PartialEditor { canvas.addEventListener('mousedown', (e) => { if (this._selectedIndex < 0 || !this.partials) return; const partial = this.partials[this._selectedIndex]; - if (!partial || !partial.ampCurve) return; + if (!partial || !partial.freqCurve) return; const {x, y} = getCanvasCoords(e, canvas); - const curve = partial.ampCurve; + const curve = partial.freqCurve; for (let i = 0; i < 4; ++i) { - if (Math.hypot(this._tToX(curve['t' + i]) - x, this._ampToY(curve['v' + i]) - y) <= 8) { + if (Math.hypot(this._tToX(curve['t' + i]) - x, this._ampToY(curve['a' + i]) - y) <= 8) { this._dragPointIndex = i; this._dragCompanionOff = null; if (i === 0) - this._dragCompanionOff = { dt: curve.t1 - curve.t0, dv: curve.v1 - curve.v0 }; + this._dragCompanionOff = { da: curve.a1 - curve.a0 }; else if (i === 3) - this._dragCompanionOff = { dt: curve.t2 - curve.t3, dv: curve.v2 - curve.v3 }; + this._dragCompanionOff = { da: curve.a2 - curve.a3 }; canvas.style.cursor = 'grabbing'; e.preventDefault(); return; @@ -507,14 +507,13 @@ class PartialEditor { const {x, y} = getCanvasCoords(e, canvas); if (this._dragPointIndex >= 0) { - const curve = this.partials[this._selectedIndex].ampCurve; + const curve = this.partials[this._selectedIndex].freqCurve; const i = this._dragPointIndex; - curve['t' + i] = Math.max(0, Math.min(this.viewer ? this.viewer.t_max : 1e6, this._xToT(x))); - curve['v' + i] = Math.max(0, this._yToAmp(y)); + curve['a' + i] = Math.max(0, this._yToAmp(y)); if (this._dragCompanionOff) { const off = this._dragCompanionOff; - if (i === 0) { curve.t1 = curve.t0 + off.dt; curve.v1 = curve.v0 + off.dv; } - else { curve.t2 = curve.t3 + off.dt; curve.v2 = curve.v3 + off.dv; } + if (i === 0) { curve.a1 = curve.a0 + off.da; } + else { curve.a2 = curve.a3 + off.da; } } this._renderAmpEditor(); if (this.viewer) this.viewer.render(); @@ -524,11 +523,11 @@ class PartialEditor { // Cursor hint if (this._selectedIndex >= 0 && this.partials) { - const curve = this.partials[this._selectedIndex]?.ampCurve; + const curve = this.partials[this._selectedIndex]?.freqCurve; if (curve) { let near = false; for (let i = 0; i < 4; ++i) { - if (Math.hypot(this._tToX(curve['t' + i]) - x, this._ampToY(curve['v' + i]) - y) <= 8) { + if (Math.hypot(this._tToX(curve['t' + i]) - x, this._ampToY(curve['a' + i]) - y) <= 8) { near = true; break; } } diff --git a/tools/mq_editor/mq_extract.js b/tools/mq_editor/mq_extract.js index 107b2ac..97191e2 100644 --- a/tools/mq_editor/mq_extract.js +++ b/tools/mq_editor/mq_extract.js @@ -22,7 +22,9 @@ function extractPartials(params, stftCache) { for (const partial of partials) { partial.freqCurve = fitBezier(partial.times, partial.freqs); - partial.ampCurve = fitBezier(partial.times, partial.amps); + const ac = fitBezier(partial.times, partial.amps); + partial.freqCurve.a0 = ac.v0; partial.freqCurve.a1 = ac.v1; + partial.freqCurve.a2 = ac.v2; partial.freqCurve.a3 = ac.v3; } return {partials, frames}; diff --git a/tools/mq_editor/mq_synth.js b/tools/mq_editor/mq_synth.js index 4c68056..00867a9 100644 --- a/tools/mq_editor/mq_synth.js +++ b/tools/mq_editor/mq_synth.js @@ -8,7 +8,7 @@ function randFloat(seed, min, max) { } // Synthesize audio from MQ partials -// partials: array of {freqCurve, ampCurve, replicas?, resonator?} +// partials: array of {freqCurve (with a0-a3 for amp), replicas?, resonator?} // replicas: {offsets, decay_alpha, jitter, spread_above, spread_below} // resonator: {enabled, r, gainComp} — two-pole resonator mode per partial // integratePhase: true = accumulate 2π*f/SR per sample (correct for varying freq) @@ -35,7 +35,6 @@ function synthesizeMQ(partials, sampleRate, duration, integratePhase = true, opt for (let p = 0; p < partials.length; ++p) { const partial = partials[p]; const fc = partial.freqCurve; - const ac = partial.ampCurve; if ((partial.resonator && partial.resonator.enabled) || options.forceResonator) { // --- Two-pole resonator mode --- @@ -50,7 +49,7 @@ function synthesizeMQ(partials, sampleRate, duration, integratePhase = true, opt const gainNorm = Math.sqrt(Math.max(0, 1.0 - r * r)); configs.push({ mode: 'resonator', - fc, ac, + fc, r, gainComp, gainNorm, y1: 0.0, y2: 0.0, noiseSeed: ((p * 1664525 + 1013904223) & 0xFFFFFFFF) >>> 0 @@ -70,7 +69,7 @@ function synthesizeMQ(partials, sampleRate, duration, integratePhase = true, opt const initPhase = randFloat(p * 67890 + r, 0.0, 1.0) * (jitter * jitterMult) * 2.0 * Math.PI; replicaData.push({ratio: offsets[r], spread, phase: initPhase}); } - configs.push({ mode: 'sinusoid', fc, ac, decay_alpha, replicaData }); + configs.push({ mode: 'sinusoid', fc, decay_alpha, replicaData }); } } @@ -80,13 +79,13 @@ function synthesizeMQ(partials, sampleRate, duration, integratePhase = true, opt for (let p = 0; p < configs.length; ++p) { const cfg = configs[p]; - const {fc, ac} = cfg; + const {fc} = cfg; if (cfg.mode === 'resonator') { if (t < fc.t0 || t > fc.t3) { cfg.y1 = 0.0; cfg.y2 = 0.0; continue; } const f0 = evalBezier(fc, t); - const A = evalBezier(ac, t); + const A = evalBezierAmp(fc, t); const omega = 2.0 * Math.PI * f0 / sampleRate; const b1 = 2.0 * cfg.r * Math.cos(omega); @@ -104,7 +103,7 @@ function synthesizeMQ(partials, sampleRate, duration, integratePhase = true, opt if (t < fc.t0 || t > fc.t3) continue; const f0 = evalBezier(fc, t); - const A0 = evalBezier(ac, t); + const A0 = evalBezierAmp(fc, t); const {decay_alpha, replicaData} = cfg; for (let r = 0; r < replicaData.length; ++r) { diff --git a/tools/mq_editor/utils.js b/tools/mq_editor/utils.js index c38b1f5..96d807c 100644 --- a/tools/mq_editor/utils.js +++ b/tools/mq_editor/utils.js @@ -13,6 +13,19 @@ function evalBezier(curve, t) { u*u*u * curve.v3; } +// Evaluate amplitude component of unified bezier curve at time t +function evalBezierAmp(curve, t) { + const dt = curve.t3 - curve.t0; + if (dt <= 0) return curve.a0; + let u = (t - curve.t0) / dt; + u = Math.max(0, Math.min(1, u)); + const u1 = 1.0 - u; + return u1*u1*u1 * curve.a0 + + 3*u1*u1*u * curve.a1 + + 3*u1*u*u * curve.a2 + + u*u*u * curve.a3; +} + // Get canvas-relative {x, y} from a mouse event function getCanvasCoords(e, canvas) { const rect = canvas.getBoundingClientRect(); -- cgit v1.2.3