From f48397c58248ca338c258b1de762314926fe681f Mon Sep 17 00:00:00 2001 From: skal Date: Wed, 18 Feb 2026 21:36:07 +0100 Subject: feat(mq_editor): movable inner bezier control points + clamp() refactor - P1/P2 in amp editor now draggable horizontally; t0 --- tools/mq_editor/README.md | 18 +++- tools/mq_editor/app.js | 4 +- tools/mq_editor/editor.js | 19 ++-- tools/mq_editor/mq_extract.js | 2 +- tools/mq_editor/mq_synth.js | 8 +- tools/mq_editor/test_fft.html | 229 ------------------------------------------ tools/mq_editor/utils.js | 6 +- tools/mq_editor/viewer.js | 6 +- 8 files changed, 44 insertions(+), 248 deletions(-) delete mode 100644 tools/mq_editor/test_fft.html (limited to 'tools/mq_editor') diff --git a/tools/mq_editor/README.md b/tools/mq_editor/README.md index 9435883..6f75a52 100644 --- a/tools/mq_editor/README.md +++ b/tools/mq_editor/README.md @@ -95,6 +95,7 @@ Committed partials are prepended to the partial list with full undo support. | `C` | Toggle ≋ Contour mode (iso-energy tracking) | | `P` | Toggle raw peak overlay | | `A` | Toggle mini-spectrum: original ↔ synthesized | +| `Delete` | Delete selected partial | | `Esc` | Exit explore/contour mode · deselect partial | | `Ctrl+Z` | Undo | | `Ctrl+Y` / `Ctrl+Shift+Z` | Redo | @@ -104,10 +105,12 @@ Committed partials are prepended to the partial list with full undo support. ## Architecture - `index.html` — UI, playback, extraction orchestration, keyboard shortcuts +- `style.css` — All UI styles (extracted from inline styles) - `editor.js` — Property panel and amplitude bezier editor for selected partials - `fft.js` — Cooley-Tukey radix-2 FFT + STFT cache - `mq_extract.js` — MQ algorithm: peak detection, forward tracking, backward expansion, bezier fitting - `mq_synth.js` — Oscillator bank + two-pole resonator synthesis from extracted partials +- `utils.js` — Shared helpers: `evalBezier`, `evalBezierAmp`, `clamp`, canvas coordinate utilities - `viewer.js` — Visualization: coordinate API, spectrogram, partials, mini-spectrum, mouse/zoom ### viewer.js coordinate API @@ -123,6 +126,19 @@ Committed partials are prepended to the partial list with full undo support. | `normalizeDB(db, maxDB)` | dB → intensity [0..1] over 80 dB range | | `partialColor(p)` | partial index → display color | +## Amplitude Bezier Editor + +The bottom panel shows the amplitude envelope for the selected partial as a **Lagrange interpolating curve** through four control points P0–P3. + +- **P0, P3** (endpoints): drag vertically to adjust amplitude at the start/end of the partial. Time positions are fixed to `t_start`/`t_end`. +- **P1, P2** (inner points): drag in **both axes** — vertically for amplitude, horizontally for time position. The ordering constraint `t0 < t1 < t2 < t3` is enforced automatically. + +Cursor hints: `ns-resize` for P0/P3, `move` for P1/P2. + +All four time and amplitude values are also editable as numbers in the property panel above. + +--- + ## Post-Synthesis Filters Global LP/HP filters applied after the oscillator bank, before normalization. @@ -200,7 +216,7 @@ For a partial at 440 Hz with `spread = 0.02`: `BW ≈ 8.8 Hz`, `r ≈ exp(−π - [x] Synthesized STFT cache for FFT comparison - [x] Phase 3: Editing UI - [x] Partial selection with property panel (freq/amp/synth tabs) - - [x] Amplitude bezier drag editor + - [x] Amplitude bezier drag editor (P1/P2 horizontally movable, ordering constrained) - [x] Synth params with jog sliders (decay, jitter, spread) - [x] Auto-spread detection per partial and global - [x] Mute / delete partials diff --git a/tools/mq_editor/app.js b/tools/mq_editor/app.js index 9b05789..dd0a24f 100644 --- a/tools/mq_editor/app.js +++ b/tools/mq_editor/app.js @@ -4,13 +4,13 @@ function k1ToHz(k, sr) { if (k >= 1.0) return sr / 2; const cosW = (2 - 2*k - k*k) / (2*(1 - k)); - return Math.acos(Math.max(-1, Math.min(1, cosW))) * sr / (2 * Math.PI); + return Math.acos(clamp(cosW, -1, 1)) * sr / (2 * Math.PI); } // HP: y[n] = k*(y[n-1]+x[n]-x[n-1]) => -3dB from peak at cos(w) = 2k/(1+k²) function k2ToHz(k, sr) { if (k >= 1.0) return 0; const cosW = 2*k / (1 + k*k); - return Math.acos(Math.max(-1, Math.min(1, cosW))) * sr / (2 * Math.PI); + return Math.acos(clamp(cosW, -1, 1)) * sr / (2 * Math.PI); } function fmtHz(f) { return f >= 1000 ? (f/1000).toFixed(1) + 'k' : Math.round(f) + 'Hz'; diff --git a/tools/mq_editor/editor.js b/tools/mq_editor/editor.js index a7d0879..6ea6d73 100644 --- a/tools/mq_editor/editor.js +++ b/tools/mq_editor/editor.js @@ -322,10 +322,10 @@ class PartialEditor { if (!dragging) return; const dx = e.clientX - startX; const half = slider.offsetWidth / 2; - const clamped = Math.max(-half, Math.min(half, dx)); + const clamped = clamp(dx, -half, half); thumb.style.transition = 'none'; thumb.style.left = `calc(50% - 3px + ${clamped}px)`; - const newVal = Math.max(min, Math.min(max, startVal + dx * sensitivity)); + const newVal = clamp(startVal + dx * sensitivity, min, max); inp.value = newVal.toFixed(decimals); onUpdate(newVal); }; @@ -496,7 +496,7 @@ class PartialEditor { if (Math.hypot(this._tToX(curve['t' + i]) - x, this._ampToY(curve['a' + i]) - y) <= 8) { if (this.onBeforeChange) this.onBeforeChange(); this._dragPointIndex = i; - canvas.style.cursor = 'grabbing'; + canvas.style.cursor = (i === 1 || i === 2) ? 'move' : 'ns-resize'; e.preventDefault(); return; } @@ -510,6 +510,12 @@ class PartialEditor { const curve = this.partials[this._selectedIndex].freqCurve; const i = this._dragPointIndex; curve['a' + i] = Math.max(0, this._yToAmp(y)); + // Inner control points are also horizontally draggable + if (i === 1) { + curve.t1 = clamp(this._xToT(x), curve.t0 + 1e-4, curve.t2 - 1e-4); + } else if (i === 2) { + curve.t2 = clamp(this._xToT(x), curve.t1 + 1e-4, curve.t3 - 1e-4); + } this._renderAmpEditor(); if (this.viewer) this.viewer.render(); e.preventDefault(); @@ -520,13 +526,14 @@ class PartialEditor { if (this._selectedIndex >= 0 && this.partials) { const curve = this.partials[this._selectedIndex]?.freqCurve; if (curve) { - let near = false; + let cursor = 'crosshair'; for (let i = 0; i < 4; ++i) { if (Math.hypot(this._tToX(curve['t' + i]) - x, this._ampToY(curve['a' + i]) - y) <= 8) { - near = true; break; + cursor = (i === 1 || i === 2) ? 'move' : 'ns-resize'; + break; } } - canvas.style.cursor = near ? 'grab' : 'crosshair'; + canvas.style.cursor = cursor; } } }); diff --git a/tools/mq_editor/mq_extract.js b/tools/mq_editor/mq_extract.js index 18897fb..42215d3 100644 --- a/tools/mq_editor/mq_extract.js +++ b/tools/mq_editor/mq_extract.js @@ -451,7 +451,7 @@ function trackIsoContour(stftCache, seedTime, seedFreq, params) { } const seedFrame = stftCache.getFrameAtIndex(seedFrameIdx); - const seedBin = Math.max(1, Math.min(halfBins - 2, Math.round(seedFreq / binHz))); + const seedBin = clamp(Math.round(seedFreq / binHz), 1, halfBins - 2); const targetSq = seedFrame.squaredAmplitude[seedBin]; if (targetSq <= 0) return null; const targetDB = 10 * Math.log10(targetSq); diff --git a/tools/mq_editor/mq_synth.js b/tools/mq_editor/mq_synth.js index 00867a9..a9f387c 100644 --- a/tools/mq_editor/mq_synth.js +++ b/tools/mq_editor/mq_synth.js @@ -42,8 +42,8 @@ function synthesizeMQ(partials, sampleRate, duration, integratePhase = true, opt // r controls pole radius (bandwidth): r→1 = narrow, r→0 = wide. // gainNorm = sqrt(1 - r²) normalises steady-state output power to ~A. const res = partial.resonator || {}; - const r = options.forceRGain ? Math.min(0.9999, Math.max(0, options.globalR)) - : (res.r != null ? Math.min(0.9999, Math.max(0, res.r)) : 0.995); + const r = options.forceRGain ? clamp(options.globalR, 0, 0.9999) + : (res.r != null ? clamp(res.r, 0, 0.9999) : 0.995); const gainComp = options.forceRGain ? options.globalGain : (res.gainComp != null ? res.gainComp : 1.0); const gainNorm = Math.sqrt(Math.max(0, 1.0 - r * r)); @@ -131,7 +131,7 @@ function synthesizeMQ(partials, sampleRate, duration, integratePhase = true, opt // LP: y[n] = k1*x[n] + (1-k1)*y[n-1] — options.k1 in (0,1], omit to bypass // HP: y[n] = k2*(y[n-1] + x[n] - x[n-1]) — options.k2 in (0,1], omit to bypass if (options.k1 != null) { - const k1 = Math.max(0, Math.min(1, options.k1)); + const k1 = clamp(options.k1, 0, 1); let y = 0.0; for (let i = 0; i < numSamples; ++i) { y = k1 * pcm[i] + (1.0 - k1) * y; @@ -139,7 +139,7 @@ function synthesizeMQ(partials, sampleRate, duration, integratePhase = true, opt } } if (options.k2 != null) { - const k2 = Math.max(0, Math.min(1, options.k2)); + const k2 = clamp(options.k2, 0, 1); let y = 0.0, xp = 0.0; for (let i = 0; i < numSamples; ++i) { const x = pcm[i]; diff --git a/tools/mq_editor/test_fft.html b/tools/mq_editor/test_fft.html deleted file mode 100644 index b4e7f48..0000000 --- a/tools/mq_editor/test_fft.html +++ /dev/null @@ -1,229 +0,0 @@ - - - - - -FFT Test - - - -

FFT Isolation Test

-

-
-
-
-
-
diff --git a/tools/mq_editor/utils.js b/tools/mq_editor/utils.js
index 2c6b2f5..ed34b8e 100644
--- a/tools/mq_editor/utils.js
+++ b/tools/mq_editor/utils.js
@@ -8,7 +8,7 @@ function evalBezier(curve, t) {
   const dt = curve.t3 - curve.t0;
   if (dt <= 0) return curve.v0;
   let u = (t - curve.t0) / dt;
-  u = Math.max(0, Math.min(1, u));
+  u = clamp(u, 0, 1);
   const u1 = (curve.t1 - curve.t0) / dt;
   const u2 = (curve.t2 - curve.t0) / dt;
   const d0 = (-u1) * (-u2) * (-1);
@@ -29,7 +29,7 @@ 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));
+  u = clamp(u, 0, 1);
   const u1 = (curve.t1 - curve.t0) / dt;
   const u2 = (curve.t2 - curve.t0) / dt;
   const d0 = (-u1) * (-u2) * (-1);
@@ -45,6 +45,8 @@ function evalBezierAmp(curve, t) {
   return l0 * curve.a0 + l1 * curve.a1 + l2 * curve.a2 + l3 * curve.a3;
 }
 
+function clamp(v, lo, hi) { return v < lo ? lo : v > hi ? hi : v; }
+
 // Get canvas-relative {x, y} from a mouse event
 function getCanvasCoords(e, canvas) {
   const rect = canvas.getBoundingClientRect();
diff --git a/tools/mq_editor/viewer.js b/tools/mq_editor/viewer.js
index d7b5ac1..1bc5fc9 100644
--- a/tools/mq_editor/viewer.js
+++ b/tools/mq_editor/viewer.js
@@ -109,7 +109,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
@@ -643,8 +643,8 @@ class SpectrogramViewer {
       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;
-- 
cgit v1.2.3