summaryrefslogtreecommitdiff
path: root/tools/mq_editor
diff options
context:
space:
mode:
authorskal <pascal.massimino@gmail.com>2026-02-18 21:36:07 +0100
committerskal <pascal.massimino@gmail.com>2026-02-18 21:36:07 +0100
commitf48397c58248ca338c258b1de762314926fe681f (patch)
tree219ddcc63f1600ae5604d655bb4c6faabd91ca33 /tools/mq_editor
parenteeecb76eef15f684b7909ff22fd054680c2a3498 (diff)
feat(mq_editor): movable inner bezier control points + clamp() refactor
- P1/P2 in amp editor now draggable horizontally; t0<t1<t2<t3 enforced - Add clamp() to utils.js; replace all Math.max/min clamping patterns - Cursor hints: move for P1/P2, ns-resize for P0/P3 - Remove test_fft.html - Docs: Delete key, style.css/utils.js in architecture, bezier editor section handoff(Claude): inner control points done, clamp() adopted everywhere Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'tools/mq_editor')
-rw-r--r--tools/mq_editor/README.md18
-rw-r--r--tools/mq_editor/app.js4
-rw-r--r--tools/mq_editor/editor.js19
-rw-r--r--tools/mq_editor/mq_extract.js2
-rw-r--r--tools/mq_editor/mq_synth.js8
-rw-r--r--tools/mq_editor/test_fft.html229
-rw-r--r--tools/mq_editor/utils.js6
-rw-r--r--tools/mq_editor/viewer.js6
8 files changed, 44 insertions, 248 deletions
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 @@
-<!DOCTYPE html>
-<!--
- test_fft.html — Isolated FFT correctness tests for fft.js
- Open directly in a browser (no server needed).
-
- Tests fftForward(), realFFT(), and STFTCache from fft.js.
-
- Test summary:
- 1 DC impulse — all-ones input → bin 0 must equal N
- 2 Single tone — 440 Hz pure sine → peak bin matches expected frequency
- 3 STFT magnitude — STFTCache.getMagnitudeDB() returns loud value at 440 Hz
- 4 Pair equal — 220 + 880 Hz, both peaks found
- 5 Pair equal — 440 + 1320 Hz, both peaks found
- 6 Pair wide — 300 + 3000 Hz (decade separation), both peaks found
- 7 Pair extreme — 100 + 8000 Hz (80× ratio), both peaks found
- 8 Pair unequal — 440 Hz + 2000 Hz at 10:1 amplitude, weak peak still found
- 9 Triplet chord — C4 (261.63) + E4 (329.63) + G4 (392) major chord
- 10 Triplet octaves — 110 + 220 + 440 Hz octave stack
- 11 Triplet harmonic — 500 + 1500 + 4500 Hz (1:3:9 ratio)
- 12 Triplet unequal — 440 + 880 + 1760 Hz at 1.0 / 0.5 / 0.25 (decaying harmonics)
-
- Pass criteria:
- - Each expected frequency bin is found within ±guard bins (guard ≈ 2 × bin_width).
- - Bin width = SR / N = 32000 / 4096 ≈ 7.8 Hz.
-
- All spectra are drawn as linear-magnitude plots (0..Nyquist on x-axis).
- Colored vertical markers show expected frequency positions.
--->
-<html>
-<head>
-<meta charset="utf-8">
-<title>FFT Test</title>
-<style>
- body { font-family: monospace; background: #111; color: #ccc; padding: 20px; }
- h2 { color: #fff; }
- canvas { border: 1px solid #444; display: block; margin: 10px 0; }
- .pass { color: #4f4; }
- .fail { color: #f44; }
- pre { background: #222; padding: 10px; }
-</style>
-</head>
-<body>
-<h2>FFT Isolation Test</h2>
-<pre id="log"></pre>
-<canvas id="spectrum" width="800" height="200"></canvas>
-<script src="fft.js"></script>
-<script>
-const log = document.getElementById('log');
-function print(msg, cls) {
- const span = document.createElement('span');
- if (cls) span.className = cls;
- span.textContent = msg + '\n';
- log.appendChild(span);
-}
-
-// --- Test 1: Single bin impulse (DC) ---
-{
- const N = 8;
- const real = new Float32Array([1,1,1,1,1,1,1,1]);
- const imag = new Float32Array(N);
- fftForward(real, imag, N);
- const ok = Math.abs(real[0] - 8) < 1e-4 && Math.abs(imag[0]) < 1e-4;
- print(`Test 1 DC impulse: real[0]=${real[0].toFixed(4)} ${ok?'PASS':'FAIL'}`, ok?'pass':'fail');
-}
-
-// --- Test 2: 440 Hz peak detection ---
-{
- const SR = 32000;
- const N = 2048;
- const signal = new Float32Array(N);
- for (let i = 0; i < N; ++i)
- signal[i] = Math.sin(2 * Math.PI * 440 * i / SR);
-
- const spectrum = realFFT(signal);
-
- let peakBin = 0, peakVal = 0;
- for (let i = 0; i < N / 2; ++i) {
- const re = spectrum[i * 2], im = spectrum[i * 2 + 1];
- const mag = re * re + im * im;
- if (mag > peakVal) { peakVal = mag; peakBin = i; }
- }
- const peakFreq = peakBin * SR / N;
- const ok = Math.abs(peakFreq - 440) < SR / N;
- print(`Test 2 440Hz peak: bin=${peakBin} freq=${peakFreq.toFixed(1)}Hz ${ok?'PASS':'FAIL'}`, ok?'pass':'fail');
-
- // Draw spectrum
- const canvas = document.getElementById('spectrum');
- const ctx = canvas.getContext('2d');
- ctx.fillStyle = '#111';
- ctx.fillRect(0, 0, canvas.width, canvas.height);
- ctx.strokeStyle = '#0af';
- ctx.beginPath();
- const halfN = N / 2;
- for (let i = 0; i < halfN; ++i) {
- const re = spectrum[i * 2], im = spectrum[i * 2 + 1];
- const mag = Math.sqrt(re * re + im * im) / (N / 2);
- const x = i / halfN * canvas.width;
- const y = canvas.height - mag * canvas.height;
- i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
- }
- ctx.stroke();
-
- // Mark 440Hz
- ctx.strokeStyle = '#f80';
- ctx.beginPath();
- const x440 = peakBin / halfN * canvas.width;
- ctx.moveTo(x440, 0); ctx.lineTo(x440, canvas.height);
- ctx.stroke();
- ctx.fillStyle = '#f80';
- ctx.fillText(`440Hz (bin ${peakBin})`, x440 + 4, 16);
-}
-
-// --- Test 3: STFT getMagnitudeDB at t=0 ---
-{
- const SR = 32000;
- const N = 44032; // ~1.375s
- const signal = new Float32Array(N);
- for (let i = 0; i < N; ++i)
- signal[i] = Math.sin(2 * Math.PI * 440 * i / SR);
-
- const stft = new STFTCache(signal, SR, 2048, 512);
- const db = stft.getMagnitudeDB(0.0, 440);
- const ok = db > -10;
- print(`Test 3 STFT getMagnitudeDB(0, 440Hz): ${db.toFixed(1)} dB ${ok?'PASS':'FAIL'}`, ok?'pass':'fail');
-}
-
-// Helper: find top-K peaks in spectrum (bin indices), ignoring neighbors within 'guard' bins
-function findPeaks(spectrum, halfN, k, guard) {
- const mags = new Float32Array(halfN);
- for (let i = 0; i < halfN; ++i) {
- const re = spectrum[i * 2], im = spectrum[i * 2 + 1];
- mags[i] = re * re + im * im;
- }
- const peaks = [];
- const used = new Uint8Array(halfN);
- for (let p = 0; p < k; ++p) {
- let best = -1, bestVal = 0;
- for (let i = 1; i < halfN; ++i) {
- if (!used[i] && mags[i] > bestVal) { bestVal = mags[i]; best = i; }
- }
- if (best < 0) break;
- peaks.push(best);
- for (let g = Math.max(0, best - guard); g <= Math.min(halfN - 1, best + guard); ++g)
- used[g] = 1;
- }
- return peaks;
-}
-
-// Helper: draw labeled spectrum on a new canvas
-function drawSpectrum(label, spectrum, N, SR, markerFreqs) {
- const halfN = N / 2;
- const colors = ['#f80', '#0f8', '#f0f', '#08f'];
- const canvas = document.createElement('canvas');
- canvas.width = 800; canvas.height = 160;
- const div = document.createElement('div');
- div.style.cssText = 'color:#888;font-family:monospace;font-size:12px;margin-top:8px';
- div.textContent = label;
- document.body.appendChild(div);
- document.body.appendChild(canvas);
-
- const ctx = canvas.getContext('2d');
- ctx.fillStyle = '#111';
- ctx.fillRect(0, 0, canvas.width, canvas.height);
- ctx.strokeStyle = '#0af';
- ctx.beginPath();
- for (let i = 0; i < halfN; ++i) {
- const re = spectrum[i * 2], im = spectrum[i * 2 + 1];
- const mag = Math.sqrt(re * re + im * im) / (N / 2);
- const x = i / halfN * canvas.width;
- const y = canvas.height - mag * canvas.height;
- i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
- }
- ctx.stroke();
-
- markerFreqs.forEach((f, idx) => {
- const bin = Math.round(f * N / SR);
- const x = bin / halfN * canvas.width;
- ctx.strokeStyle = colors[idx % colors.length];
- ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, canvas.height); ctx.stroke();
- ctx.fillStyle = colors[idx % colors.length];
- ctx.fillText(`${f}Hz`, x + 3, 14 + idx * 14);
- });
-}
-
-// Helper: test multi-frequency signal, return pass/fail
-function testMultiFreq(label, freqs, amplitudes, SR, N, testNum) {
- const signal = new Float32Array(N);
- for (let i = 0; i < N; ++i)
- for (let f = 0; f < freqs.length; ++f)
- signal[i] += (amplitudes ? amplitudes[f] : 1.0) * Math.sin(2 * Math.PI * freqs[f] * i / SR);
-
- const spectrum = realFFT(signal);
- const halfN = N / 2;
- const guard = Math.ceil(SR / N * 2); // ~2 bins tolerance
- const peaks = findPeaks(spectrum, halfN, freqs.length, guard);
-
- const detectedFreqs = peaks.map(b => (b * SR / N).toFixed(1));
- const allFound = freqs.every(f => {
- const expectedBin = Math.round(f * N / SR);
- return peaks.some(b => Math.abs(b - expectedBin) <= guard);
- });
-
- const freqStr = freqs.map((f, i) => amplitudes ? `${f}Hz@${amplitudes[i].toFixed(1)}` : `${f}Hz`).join(' + ');
- print(`Test ${testNum} ${label} [${freqStr}]: peaks=[${detectedFreqs.join(', ')}] ${allFound?'PASS':'FAIL'}`,
- allFound ? 'pass' : 'fail');
-
- drawSpectrum(`Test ${testNum}: ${label} — ${freqStr}`, spectrum, N, SR, freqs);
- return allFound;
-}
-
-const SR = 32000, N = 4096;
-
-// --- Pairs ---
-print('\n-- Pairs --');
-testMultiFreq('pair', [220, 880], null, SR, N, 4);
-testMultiFreq('pair', [440, 1320], null, SR, N, 5);
-testMultiFreq('pair', [300, 3000], null, SR, N, 6);
-testMultiFreq('pair', [100, 8000], null, SR, N, 7);
-testMultiFreq('pair unequal amp', [440, 2000], [1.0, 0.1], SR, N, 8);
-
-// --- Triplets ---
-print('\n-- Triplets --');
-testMultiFreq('triplet', [261.63, 329.63, 392.00], null, SR, N, 9); // C E G chord
-testMultiFreq('triplet', [110, 220, 440], null, SR, N, 10); // octave stack
-testMultiFreq('triplet', [500, 1500, 4500], null, SR, N, 11); // harmonic series
-testMultiFreq('triplet unequal', [440, 880, 1760], [1.0, 0.5, 0.25],SR, N, 12); // decaying harmonics
-</script>
-</body>
-</html>
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;