// 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'
: ' SINE';
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 harmDefaults = { decay: 0.0, freq_mult: 2.0, jitter: 0.05, spread: 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 harm = partial.harmonics || {};
const sinParams = [
{ key: 'decay', label: 'h.decay', step: '0.01', max: '0.90' },
{ key: 'freq_mult', label: 'h.freq', step: '0.01' },
{ key: 'jitter', label: 'jitter', step: '0.001' },
{ key: 'spread', label: 'spread', step: '0.001' },
];
const sinInputs = {};
for (const p of sinParams) {
const val = harm[p.key] != null ? harm[p.key] : harmDefaults[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';
if (p.max) inp.max = p.max;
inp.addEventListener('change', (e) => {
if (!this.partials) return;
let v = parseFloat(e.target.value);
if (isNaN(v)) return;
if (p.max) v = Math.min(v, parseFloat(p.max));
if (!this.partials[index].harmonics) this.partials[index].harmonics = { ...harmDefaults };
this.partials[index].harmonics[p.key] = v;
if (this.viewer) this.viewer.render();
});
sinInputs[p.key] = inp;
const jog = this._makeJogSlider(inp, {
step: parseFloat(p.step),
max: p.max ? parseFloat(p.max) : undefined,
onUpdate: (newVal) => {
if (!this.partials || !this.partials[index]) return;
if (!this.partials[index].harmonics) this.partials[index].harmonics = { ...harmDefaults };
this.partials[index].harmonics[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 = '';
const autoBtn = document.createElement('button');
autoBtn.textContent = 'Auto spread';
autoBtn.title = 'Infer spread from half-power bandwidth 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} = autodetectSpread(p, sc, fs, sr);
if (!p.harmonics) p.harmonics = { ...harmDefaults };
p.harmonics.spread = spread;
sinInputs['spread'].value = spread.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);
if (this.viewer) this.viewer.render();
});
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);
if (this.viewer) this.viewer.render();
});
}
_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 = clamp(dx, -half, half);
thumb.style.transition = 'none';
thumb.style.left = `calc(50% - 3px + ${clamped}px)`;
const newVal = clamp(startVal + dx * sensitivity, min, max);
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 = (i === 1 || i === 2) ? 'move' : 'ns-resize';
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));
// 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();
return;
}
// Cursor hint
if (this._selectedIndex >= 0 && this.partials) {
const curve = this.partials[this._selectedIndex]?.freqCurve;
if (curve) {
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) {
cursor = (i === 1 || i === 2) ? 'move' : 'ns-resize';
break;
}
}
canvas.style.cursor = cursor;
}
}
});
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;
}
}