summaryrefslogtreecommitdiff
path: root/tools/mq_editor/editor.js
diff options
context:
space:
mode:
Diffstat (limited to 'tools/mq_editor/editor.js')
-rw-r--r--tools/mq_editor/editor.js578
1 files changed, 578 insertions, 0 deletions
diff --git a/tools/mq_editor/editor.js b/tools/mq_editor/editor.js
new file mode 100644
index 0000000..97d8a7a
--- /dev/null
+++ b/tools/mq_editor/editor.js
@@ -0,0 +1,578 @@
+// 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._synthGrid = document.getElementById('synthGrid');
+ 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';
+
+ const titleEl = document.getElementById('propTitle');
+ const badge = partial.resonator && partial.resonator.enabled
+ ? ' <span class="res-badge">RES</span>' : '';
+ titleEl.innerHTML = 'Partial #' + index + badge;
+ 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);
+ this._buildSynthGrid(partial, 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);
+ }
+ }
+
+ _buildSynthGrid(partial, index) {
+ const grid = this._synthGrid;
+ grid.innerHTML = '';
+
+ const repDefaults = { decay_alpha: 0.1, jitter: 0.05, spread_above: 0.02, spread_below: 0.02 };
+ const resDefaults = { r: 0.995, gainComp: 1.0 };
+
+ const isResonator = !!(partial.resonator && partial.resonator.enabled);
+
+ // --- Mode toggle ---
+ const modeLbl = document.createElement('span');
+ modeLbl.textContent = 'mode';
+
+ const modeWrap = document.createElement('div');
+ modeWrap.style.cssText = 'display:flex;gap:3px;';
+
+ const btnSin = document.createElement('button');
+ btnSin.textContent = 'Sinusoid';
+ btnSin.className = 'tab-btn' + (isResonator ? '' : ' active');
+ btnSin.style.cssText = 'flex:1;padding:2px 4px;font-size:11px;margin:0;';
+
+ const btnRes = document.createElement('button');
+ btnRes.textContent = 'Resonator';
+ btnRes.className = 'tab-btn' + (isResonator ? ' active' : '');
+ btnRes.style.cssText = 'flex:1;padding:2px 4px;font-size:11px;margin:0;';
+
+ modeWrap.appendChild(btnSin);
+ modeWrap.appendChild(btnRes);
+ grid.appendChild(modeLbl);
+ grid.appendChild(modeWrap);
+
+ // --- Sinusoid section ---
+ const sinSection = document.createElement('div');
+ sinSection.style.cssText = 'display:contents;';
+ sinSection.dataset.section = 'sinusoid';
+
+ const rep = partial.replicas || {};
+ const sinParams = [
+ { key: 'decay_alpha', label: 'decay', step: '0.001' },
+ { key: 'jitter', label: 'jitter', step: '0.001' },
+ { key: 'spread_above', label: 'spread ↑', step: '0.001' },
+ { key: 'spread_below', label: 'spread ↓', step: '0.001' },
+ ];
+ const sinInputs = {};
+ for (const p of sinParams) {
+ const val = rep[p.key] != null ? rep[p.key] : repDefaults[p.key];
+ const lbl = document.createElement('span');
+ lbl.textContent = p.label;
+ const inp = document.createElement('input');
+ inp.type = 'number';
+ inp.value = val.toFixed(3);
+ inp.step = p.step;
+ inp.min = '0';
+ inp.addEventListener('change', (e) => {
+ if (!this.partials) return;
+ const v = parseFloat(e.target.value);
+ if (isNaN(v)) return;
+ if (!this.partials[index].replicas) this.partials[index].replicas = { ...repDefaults };
+ this.partials[index].replicas[p.key] = v;
+ if (this.viewer) this.viewer.render();
+ });
+ sinInputs[p.key] = inp;
+
+ const jog = this._makeJogSlider(inp, partial, index, p, repDefaults);
+ const wrap = document.createElement('div');
+ wrap.className = 'synth-field-wrap';
+ wrap.appendChild(inp);
+ wrap.appendChild(jog);
+ sinSection.appendChild(lbl);
+ sinSection.appendChild(wrap);
+ }
+
+ // Auto-detect spread button
+ const autoLbl = document.createElement('span');
+ autoLbl.textContent = 'spread';
+ const autoBtn = document.createElement('button');
+ autoBtn.textContent = 'Auto';
+ autoBtn.title = 'Infer spread_above/below from frequency variance 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);
+ if (!p.replicas) p.replicas = { ...repDefaults };
+ p.replicas.spread_above = spread_above;
+ p.replicas.spread_below = spread_below;
+ sinInputs['spread_above'].value = spread_above.toFixed(4);
+ sinInputs['spread_below'].value = spread_below.toFixed(4);
+ });
+ sinSection.appendChild(autoLbl);
+ sinSection.appendChild(autoBtn);
+
+ // --- Resonator section ---
+ const resSection = document.createElement('div');
+ resSection.style.cssText = 'display:contents;';
+ resSection.dataset.section = 'resonator';
+
+ const resObj = partial.resonator || {};
+ const resParams = [
+ { key: 'r', label: 'r (pole)', step: '0.001', min: '0.75', max: '0.9999',
+ title: 'Pole radius. r→1 = narrow bandwidth / long ring. r→0 = wide / fast decay.' },
+ { key: 'gainComp', label: 'gain', step: '0.01', min: '0', max: '100',
+ title: 'Output gain multiplier (gainNorm=√(1-r²) normalises power; use this to trim level).' },
+ ];
+ for (const p of resParams) {
+ const val = resObj[p.key] != null ? resObj[p.key] : resDefaults[p.key];
+ const lbl = document.createElement('span');
+ lbl.textContent = p.label;
+ lbl.title = p.title || '';
+ const inp = document.createElement('input');
+ inp.type = 'number';
+ inp.value = val.toFixed(4);
+ inp.step = p.step;
+ inp.min = p.min || '0';
+ inp.max = p.max || '';
+ inp.title = p.title || '';
+ inp.addEventListener('change', (e) => {
+ if (!this.partials) return;
+ const v = parseFloat(e.target.value);
+ if (isNaN(v)) return;
+ if (!this.partials[index].resonator) this.partials[index].resonator = { ...resDefaults };
+ this.partials[index].resonator[p.key] = v;
+ if (this.viewer) this.viewer.render();
+ });
+
+ // Inline jog slider for resonator params
+ const step = parseFloat(p.step);
+ const sensitivity = step * 5;
+ const jog = document.createElement('div');
+ jog.className = 'jog-slider';
+ const thumb = document.createElement('div');
+ thumb.className = 'jog-thumb';
+ jog.appendChild(thumb);
+ let dragging = false, startX = 0, startVal = 0;
+ const onMove = (ev) => {
+ if (!dragging) return;
+ const dx = ev.clientX - startX;
+ const half = jog.offsetWidth / 2;
+ const clamped = Math.max(-half, Math.min(half, dx));
+ thumb.style.transition = 'none';
+ thumb.style.left = `calc(50% - 3px + ${clamped}px)`;
+ const newVal = Math.max(parseFloat(inp.min) || 0,
+ Math.min(parseFloat(inp.max) || 1e9, startVal + dx * sensitivity));
+ inp.value = newVal.toFixed(4);
+ if (!this.partials || !this.partials[index]) return;
+ if (!this.partials[index].resonator) this.partials[index].resonator = { ...resDefaults };
+ this.partials[index].resonator[p.key] = newVal;
+ if (this.viewer) this.viewer.render();
+ };
+ const onUp = () => {
+ if (!dragging) return;
+ dragging = false;
+ thumb.style.transition = '';
+ thumb.style.left = 'calc(50% - 3px)';
+ document.removeEventListener('mousemove', onMove);
+ document.removeEventListener('mouseup', onUp);
+ };
+ jog.addEventListener('mousedown', (ev) => {
+ dragging = true;
+ startX = ev.clientX;
+ startVal = parseFloat(inp.value) || 0;
+ document.addEventListener('mousemove', onMove);
+ document.addEventListener('mouseup', onUp);
+ ev.preventDefault();
+ });
+
+ const wrap = document.createElement('div');
+ wrap.className = 'synth-field-wrap';
+ wrap.appendChild(inp);
+ wrap.appendChild(jog);
+ resSection.appendChild(lbl);
+ resSection.appendChild(wrap);
+ }
+
+ // Show/hide helper
+ const applyMode = (resonator) => {
+ for (const el of sinSection.children) el.style.display = resonator ? 'none' : '';
+ for (const el of resSection.children) el.style.display = resonator ? '' : 'none';
+ btnSin.classList.toggle('active', !resonator);
+ btnRes.classList.toggle('active', resonator);
+ };
+
+ // Initial state
+ grid.appendChild(sinSection);
+ grid.appendChild(resSection);
+ applyMode(isResonator);
+
+ // Toggle handlers
+ btnSin.addEventListener('click', () => {
+ if (!this.partials) return;
+ if (!this.partials[index].resonator) this.partials[index].resonator = { ...resDefaults };
+ this.partials[index].resonator.enabled = false;
+ applyMode(false);
+ });
+ btnRes.addEventListener('click', () => {
+ if (!this.partials) return;
+ if (!this.partials[index].resonator) this.partials[index].resonator = { ...resDefaults };
+ this.partials[index].resonator.enabled = true;
+ applyMode(true);
+ });
+ }
+
+ _makeJogSlider(inp, partial, index, p, defaults) {
+ const slider = document.createElement('div');
+ slider.className = 'jog-slider';
+ const thumb = document.createElement('div');
+ thumb.className = 'jog-thumb';
+ slider.appendChild(thumb);
+
+ const sensitivity = parseFloat(p.step) * 5;
+ let startX = 0, startVal = 0, dragging = false;
+
+ const onMove = (e) => {
+ if (!dragging) return;
+ const dx = e.clientX - startX;
+ const half = slider.offsetWidth / 2;
+ const clamped = Math.max(-half, Math.min(half, dx));
+ thumb.style.transition = 'none';
+ thumb.style.left = `calc(50% - 3px + ${clamped}px)`;
+ const newVal = Math.max(0, startVal + dx * sensitivity);
+ inp.value = newVal.toFixed(3);
+ if (!this.partials || !this.partials[index]) return;
+ if (!this.partials[index].replicas) this.partials[index].replicas = { ...defaults };
+ this.partials[index].replicas[p.key] = newVal;
+ if (this.viewer) this.viewer.render();
+ };
+
+ const onUp = () => {
+ if (!dragging) return;
+ dragging = false;
+ thumb.style.transition = '';
+ thumb.style.left = 'calc(50% - 3px)';
+ document.removeEventListener('mousemove', onMove);
+ document.removeEventListener('mouseup', onUp);
+ };
+
+ slider.addEventListener('mousedown', (e) => {
+ dragging = true;
+ startX = e.clientX;
+ startVal = Math.max(0, parseFloat(inp.value) || 0);
+ document.addEventListener('mousemove', onMove);
+ document.addEventListener('mouseup', onUp);
+ e.preventDefault();
+ });
+
+ return slider;
+ }
+
+ _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;
+
+ // 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;
+ }
+}