summaryrefslogtreecommitdiff
path: root/tools/mq_editor/editor.js
diff options
context:
space:
mode:
authorskal <pascal.massimino@gmail.com>2026-02-18 07:53:35 +0100
committerskal <pascal.massimino@gmail.com>2026-02-18 07:53:35 +0100
commit6d2c3a9fa7ea3e7dc272d5622722f60d889612ce (patch)
tree14b61272b0d7e109dfda3cc0646ba5674fe4021a /tools/mq_editor/editor.js
parent8ba8135f92539d5df7694179f074f01b7087a505 (diff)
feat(mq_editor): partial selection, amp bezier editor, and editor.js refactor
- Click-to-select partials on canvas (proximity hit test on bezier) - Right panel: peak freq/amp, time range, freq/amp bezier text inputs, mute/delete - Selected partial renders on top with glow + larger control points - Draggable freq curve control points on main canvas (grab/grabbing cursor) - Amplitude bezier editor: 120px canvas below spectrogram, time-synced with main view zoom/scroll via viewer.onRender callback - Amp edits live-affect synthesis (mq_synth.js already uses ampCurve) - PartialEditor class in editor.js owns all editing logic; index.html wires it with 5 calls (setViewer, setPartials, onPartialSelect, onRender, onPartialDeleted) handoff(Gemini): partial editing MVP complete. Next: freq curve drag polish or export (.spec generation). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'tools/mq_editor/editor.js')
-rw-r--r--tools/mq_editor/editor.js324
1 files changed, 324 insertions, 0 deletions
diff --git a/tools/mq_editor/editor.js b/tools/mq_editor/editor.js
new file mode 100644
index 0000000..e1e70c6
--- /dev/null
+++ b/tools/mq_editor/editor.js
@@ -0,0 +1,324 @@
+// Partial Editor
+// Property panel (right) and amplitude bezier editor (bottom) for selected partials
+
+class PartialEditor {
+ constructor() {
+ // DOM refs
+ this._propPanel = document.getElementById('partialProps');
+ this._noSelMsg = document.getElementById('noSelMsg');
+ this._ampPanel = document.getElementById('ampEditPanel');
+ this._ampCanvas = document.getElementById('ampEditCanvas');
+ this._ampTitle = document.getElementById('ampEditTitle');
+ this._freqGrid = document.getElementById('freqCurveGrid');
+ this._ampGrid = document.getElementById('ampCurveGrid');
+ this._ampCtx = this._ampCanvas ? this._ampCanvas.getContext('2d') : null;
+
+ // References set by host
+ this.viewer = null;
+ this.partials = null;
+
+ // Callback: called after a partial is deleted so the host can update keepCount
+ this.onPartialDeleted = null;
+
+ // Private state
+ this._selectedIndex = -1;
+ this._dragPointIndex = -1;
+ this._amp = { tMin: 0, tMax: 1, ampTop: 1 };
+
+ this._setupButtons();
+ this._setupAmpDrag();
+ }
+
+ // --- Public API ---
+
+ setViewer(v) { this.viewer = v; }
+ setPartials(p) { this.partials = p; }
+
+ // Wire to viewer.onPartialSelect
+ onPartialSelect(index) {
+ this._selectedIndex = index;
+ this._updatePropPanel(index);
+ this._showAmpEditor(index);
+ }
+
+ // Wire to viewer.onRender — keeps amp editor in sync with zoom/scroll
+ onRender() {
+ this._renderAmpEditor();
+ }
+
+ // --- Property panel ---
+
+ _updatePropPanel(index) {
+ if (index < 0 || !this.partials || index >= this.partials.length) {
+ this._propPanel.style.display = 'none';
+ this._noSelMsg.style.display = 'block';
+ return;
+ }
+
+ this._propPanel.style.display = 'block';
+ this._noSelMsg.style.display = 'none';
+
+ const partial = this.partials[index];
+ const color = this.viewer ? this.viewer.partialColor(index) : '#888';
+
+ document.getElementById('propTitle').textContent = 'Partial #' + index;
+ document.getElementById('propSwatch').style.background = color;
+
+ let peakAmp = 0, peakIdx = 0;
+ for (let i = 0; i < partial.amps.length; ++i) {
+ if (partial.amps[i] > peakAmp) { peakAmp = partial.amps[i]; peakIdx = i; }
+ }
+ document.getElementById('propPeak').textContent =
+ partial.freqs[peakIdx].toFixed(1) + ' Hz ' + peakAmp.toFixed(3);
+
+ const t0 = partial.freqCurve ? partial.freqCurve.t0 : partial.times[0];
+ const t3 = partial.freqCurve ? partial.freqCurve.t3 : partial.times[partial.times.length - 1];
+ document.getElementById('propTime').textContent =
+ t0.toFixed(3) + 's\u2013' + t3.toFixed(3) + 's';
+
+ const muteBtn = document.getElementById('mutePartialBtn');
+ muteBtn.textContent = partial.muted ? 'Unmute' : 'Mute';
+ muteBtn.style.color = partial.muted ? '#fa4' : '';
+
+ this._buildCurveGrid(this._freqGrid, partial, 'freqCurve', 'f', index);
+ this._buildCurveGrid(this._ampGrid, partial, 'ampCurve', 'a', index);
+ }
+
+ _buildCurveGrid(grid, partial, curveKey, valueLabel, partialIndex) {
+ grid.innerHTML = '';
+ const curve = partial[curveKey];
+ if (!curve) { grid.style.color = '#444'; grid.textContent = 'none'; return; }
+
+ for (let i = 0; i < 4; ++i) {
+ const lbl = document.createElement('span');
+ lbl.textContent = 'P' + i;
+
+ const tInput = document.createElement('input');
+ tInput.type = 'number';
+ tInput.value = curve['t' + i].toFixed(4);
+ tInput.step = '0.001';
+ tInput.title = 't' + i;
+ tInput.addEventListener('change', this._makeCurveUpdater(partialIndex, curveKey, 't', i));
+
+ 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.title = valueLabel + i;
+ vInput.addEventListener('change', this._makeCurveUpdater(partialIndex, curveKey, 'v', i));
+
+ grid.appendChild(lbl);
+ grid.appendChild(tInput);
+ grid.appendChild(vInput);
+ }
+ }
+
+ _makeCurveUpdater(partialIndex, curveKey, field, pointIndex) {
+ return (e) => {
+ if (!this.partials) return;
+ const val = parseFloat(e.target.value);
+ if (isNaN(val)) return;
+ this.partials[partialIndex][curveKey][field + pointIndex] = val;
+ if (this.viewer) this.viewer.render();
+ };
+ }
+
+ _setupButtons() {
+ document.getElementById('mutePartialBtn').addEventListener('click', () => {
+ if (this._selectedIndex < 0 || !this.partials) return;
+ const p = this.partials[this._selectedIndex];
+ p.muted = !p.muted;
+ if (this.viewer) this.viewer.render();
+ this._updatePropPanel(this._selectedIndex);
+ });
+
+ document.getElementById('deletePartialBtn').addEventListener('click', () => {
+ if (this._selectedIndex < 0 || !this.partials || !this.viewer) return;
+ this.partials.splice(this._selectedIndex, 1);
+ this.viewer.selectPartial(-1);
+ if (this.onPartialDeleted) this.onPartialDeleted();
+ });
+ }
+
+ // --- Amplitude bezier editor ---
+
+ _showAmpEditor(index) {
+ if (index < 0 || !this.partials || index >= this.partials.length) {
+ this._ampPanel.style.display = 'none';
+ return;
+ }
+ this._ampPanel.style.display = 'block';
+ const color = this.viewer ? this.viewer.partialColor(index) : '#888';
+ this._ampTitle.textContent = 'Partial #' + index;
+ this._ampTitle.style.color = color;
+ this._renderAmpEditor();
+ }
+
+ _renderAmpEditor() {
+ if (this._selectedIndex < 0 || !this.partials || !this._ampCtx) return;
+ const partial = this.partials[this._selectedIndex];
+ if (!partial) return;
+
+ const canvas = this._ampCanvas;
+ const ctx = this._ampCtx;
+ const W = canvas.width, H = canvas.height;
+ const PADY = 10;
+
+ // Sync time range with viewer
+ const amp = this._amp;
+ amp.tMin = this.viewer ? this.viewer.t_view_min : 0;
+ amp.tMax = this.viewer ? this.viewer.t_view_max : 1;
+ amp.ampTop = Math.max(partial.amps.reduce((m, v) => Math.max(m, v), 0), 0.001) * 1.3;
+
+ ctx.fillStyle = '#0e0e0e';
+ ctx.fillRect(0, 0, W, H);
+
+ // Horizontal grid (0, 25, 50, 75, 100% of ampTop)
+ ctx.lineWidth = 1;
+ ctx.font = '9px monospace';
+ for (let k = 0; k <= 4; ++k) {
+ const a = amp.ampTop * k / 4;
+ const y = this._ampToY(a);
+ ctx.strokeStyle = k === 0 ? '#2a2a2a' : '#1a1a1a';
+ ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
+ ctx.fillStyle = '#383838';
+ ctx.fillText(a.toFixed(3), W - 40, y - 2);
+ }
+
+ // Vertical time grid (matching main view step)
+ if (this.viewer) {
+ const step = this.viewer.getAxisStep(amp.tMax - amp.tMin);
+ let t = Math.ceil(amp.tMin / step) * step;
+ ctx.strokeStyle = '#1a1a1a';
+ ctx.fillStyle = '#383838';
+ while (t <= amp.tMax) {
+ const x = this._tToX(t);
+ ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
+ ctx.fillText(t.toFixed(2) + 's', x + 2, H - 2);
+ t += step;
+ }
+ }
+
+ // Raw amp data (faint dots)
+ ctx.fillStyle = '#2e2e2e';
+ for (let i = 0; i < partial.times.length; ++i) {
+ const x = this._tToX(partial.times[i]);
+ if (x < -2 || x > W + 2) continue;
+ ctx.fillRect(x - 1, this._ampToY(partial.amps[i]) - 1, 2, 2);
+ }
+
+ // Bezier curve
+ const curve = partial.ampCurve;
+ if (!curve) return;
+
+ const color = this.viewer ? this.viewer.partialColor(this._selectedIndex) : '#f44';
+ ctx.strokeStyle = color;
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ let started = false;
+ for (let i = 0; i <= 120; ++i) {
+ 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));
+ if (!started) { ctx.moveTo(x, y); started = true; } else ctx.lineTo(x, y);
+ }
+ ctx.stroke();
+
+ // Control points
+ for (let i = 0; i < 4; ++i) {
+ const x = this._tToX(curve['t' + i]);
+ const y = this._ampToY(curve['v' + i]);
+ ctx.fillStyle = color;
+ ctx.beginPath();
+ ctx.arc(x, y, 6, 0, 2 * Math.PI);
+ ctx.fill();
+ ctx.strokeStyle = '#fff';
+ ctx.lineWidth = 1.5;
+ ctx.stroke();
+ ctx.fillStyle = '#888';
+ ctx.font = '9px monospace';
+ ctx.fillText('P' + i, x + 8, y - 4);
+ }
+ }
+
+ _setupAmpDrag() {
+ const canvas = this._ampCanvas;
+ if (!canvas) return;
+
+ canvas.addEventListener('mousedown', (e) => {
+ if (this._selectedIndex < 0 || !this.partials) return;
+ const partial = this.partials[this._selectedIndex];
+ if (!partial || !partial.ampCurve) return;
+ const rect = canvas.getBoundingClientRect();
+ const x = e.clientX - rect.left, y = e.clientY - rect.top;
+ const curve = partial.ampCurve;
+ for (let i = 0; i < 4; ++i) {
+ if (Math.hypot(this._tToX(curve['t' + i]) - x, this._ampToY(curve['v' + i]) - y) <= 8) {
+ this._dragPointIndex = i;
+ canvas.style.cursor = 'grabbing';
+ e.preventDefault();
+ return;
+ }
+ }
+ });
+
+ canvas.addEventListener('mousemove', (e) => {
+ const rect = canvas.getBoundingClientRect();
+ const x = e.clientX - rect.left, y = e.clientY - rect.top;
+
+ if (this._dragPointIndex >= 0) {
+ const curve = this.partials[this._selectedIndex].ampCurve;
+ 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));
+ this._renderAmpEditor();
+ if (this.viewer) this.viewer.render();
+ e.preventDefault();
+ return;
+ }
+
+ // Cursor hint
+ if (this._selectedIndex >= 0 && this.partials) {
+ const curve = this.partials[this._selectedIndex]?.ampCurve;
+ 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) {
+ near = true; break;
+ }
+ }
+ canvas.style.cursor = near ? 'grab' : 'crosshair';
+ }
+ }
+ });
+
+ canvas.addEventListener('mouseup', () => {
+ if (this._dragPointIndex >= 0) {
+ this._dragPointIndex = -1;
+ canvas.style.cursor = 'crosshair';
+ this._updatePropPanel(this._selectedIndex); // sync text inputs
+ }
+ });
+ }
+
+ // --- Coordinate helpers (amp canvas) ---
+
+ _tToX(t) {
+ return (t - this._amp.tMin) / (this._amp.tMax - this._amp.tMin) * this._ampCanvas.width;
+ }
+
+ _xToT(x) {
+ return this._amp.tMin + (x / this._ampCanvas.width) * (this._amp.tMax - this._amp.tMin);
+ }
+
+ _ampToY(a) {
+ const PADY = 10, H = this._ampCanvas.height;
+ return PADY + (1 - a / this._amp.ampTop) * (H - 2 * PADY);
+ }
+
+ _yToAmp(y) {
+ const PADY = 10, H = this._ampCanvas.height;
+ return (1 - (y - PADY) / (H - 2 * PADY)) * this._amp.ampTop;
+ }
+}