// 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; // Callback: called before any mutation (for undo/redo) this.onBeforeChange = 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 ? ' RES' : ''; 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, 'freqCurve', 'a', index, 'a'); this._buildSynthGrid(partial, index); } _buildCurveGrid(grid, partial, curveKey, valueLabel, partialIndex, valueKey = 'v') { 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 = 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, valueKey, 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, { step: parseFloat(p.step), onUpdate: (newVal) => { if (!this.partials || !this.partials[index]) return; if (!this.partials[index].replicas) this.partials[index].replicas = { ...repDefaults }; this.partials[index].replicas[p.key] = newVal; if (this.viewer) this.viewer.render(); } }); 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(); }); const jog = this._makeJogSlider(inp, { step: parseFloat(p.step), decimals: 4, onUpdate: (newVal) => { 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 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, options) { const {step, onUpdate, decimals = 3} = options; const min = options.min != null ? options.min : (inp.min !== '' && !isNaN(parseFloat(inp.min)) ? parseFloat(inp.min) : 0); const max = options.max != null ? options.max : (inp.max !== '' && !isNaN(parseFloat(inp.max)) ? parseFloat(inp.max) : Infinity); const sensitivity = step * 5; const slider = document.createElement('div'); slider.className = 'jog-slider'; const thumb = document.createElement('div'); thumb.className = 'jog-thumb'; slider.appendChild(thumb); 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(min, Math.min(max, startVal + dx * sensitivity)); inp.value = newVal.toFixed(decimals); onUpdate(newVal); }; 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(min, 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; if (this.onBeforeChange) this.onBeforeChange(); 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; if (this.onBeforeChange) this.onBeforeChange(); 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; if (this.onBeforeChange) this.onBeforeChange(); 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.freqCurve; 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(evalBezierAmp(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['a' + 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.freqCurve) return; const {x, y} = getCanvasCoords(e, canvas); const curve = partial.freqCurve; for (let i = 0; i < 4; ++i) { 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'; e.preventDefault(); return; } } }); canvas.addEventListener('mousemove', (e) => { const {x, y} = getCanvasCoords(e, canvas); if (this._dragPointIndex >= 0) { const curve = this.partials[this._selectedIndex].freqCurve; const i = this._dragPointIndex; curve['a' + 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]?.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['a' + 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; } }