diff options
| author | skal <pascal.massimino@gmail.com> | 2026-02-18 07:53:35 +0100 |
|---|---|---|
| committer | skal <pascal.massimino@gmail.com> | 2026-02-18 07:53:35 +0100 |
| commit | 6d2c3a9fa7ea3e7dc272d5622722f60d889612ce (patch) | |
| tree | 14b61272b0d7e109dfda3cc0646ba5674fe4021a /tools | |
| parent | 8ba8135f92539d5df7694179f074f01b7087a505 (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')
| -rw-r--r-- | tools/mq_editor/editor.js | 324 | ||||
| -rw-r--r-- | tools/mq_editor/index.html | 150 | ||||
| -rw-r--r-- | tools/mq_editor/viewer.js | 332 |
3 files changed, 678 insertions, 128 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; + } +} diff --git a/tools/mq_editor/index.html b/tools/mq_editor/index.html index 60076b3..c5902a7 100644 --- a/tools/mq_editor/index.html +++ b/tools/mq_editor/index.html @@ -73,12 +73,16 @@ border: 1px solid #555; border-radius: 4px; padding: 12px; - min-width: 160px; + min-width: 220px; + max-width: 220px; + max-height: 600px; + overflow-y: auto; display: flex; flex-direction: column; - gap: 10px; + gap: 6px; + box-sizing: border-box; } - .right-panel .panel-title { + .panel-title { font-size: 11px; color: #888; text-transform: uppercase; @@ -86,6 +90,9 @@ border-bottom: 1px solid #444; padding-bottom: 6px; margin-bottom: 2px; + display: flex; + align-items: center; + gap: 6px; } .right-panel label { display: flex; @@ -95,6 +102,61 @@ cursor: pointer; font-size: 13px; } + /* Partial properties */ + .prop-row { + display: flex; + justify-content: space-between; + align-items: baseline; + font-size: 12px; + padding: 1px 0; + } + .prop-label { color: #777; font-size: 11px; } + .prop-section { + font-size: 10px; + color: #666; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-top: 6px; + margin-bottom: 2px; + } + .curve-grid { + display: grid; + grid-template-columns: 18px 1fr 1fr; + gap: 2px 3px; + align-items: center; + } + .curve-grid span { color: #666; font-size: 10px; } + .curve-grid input[type="number"] { + width: 100%; + background: #333; + color: #ccc; + border: 1px solid #444; + padding: 1px 3px; + border-radius: 2px; + font-size: 10px; + font-family: monospace; + box-sizing: border-box; + } + .curve-grid input[type="number"]:focus { + border-color: #666; + outline: none; + } + .partial-actions { + display: flex; + gap: 4px; + margin-top: 6px; + } + .partial-actions button { + flex: 1; + padding: 3px 6px; + font-size: 11px; + margin: 0; + } + .synth-section { + border-top: 1px solid #444; + padding-top: 8px; + margin-top: auto; + } #status { margin-top: 10px; padding: 8px; @@ -135,21 +197,62 @@ </div> <div class="main-area"> - <div style="position: relative; flex-shrink: 0;"> - <canvas id="canvas" width="1400" height="600"></canvas> - <canvas id="cursorCanvas" width="1400" height="600" style="position:absolute;top:0;left:0;pointer-events:none;"></canvas> + <div style="display:flex; flex-direction:column; gap:6px; flex-shrink:0;"> + <div style="position: relative;"> + <canvas id="canvas" width="1400" height="600"></canvas> + <canvas id="cursorCanvas" width="1400" height="600" style="position:absolute;top:0;left:0;pointer-events:none;"></canvas> - <!-- Mini spectrum viewer (bottom-right overlay) --> - <div id="spectrumViewer" style="position: absolute; bottom: 10px; right: 10px; width: 400px; height: 100px; background: rgba(30, 30, 30, 0.9); border: 1px solid #555; border-radius: 3px; pointer-events: none;"> - <canvas id="spectrumCanvas" width="400" height="100"></canvas> + <!-- Mini spectrum viewer (bottom-right overlay) --> + <div id="spectrumViewer" style="position: absolute; bottom: 10px; right: 10px; width: 400px; height: 100px; background: rgba(30, 30, 30, 0.9); border: 1px solid #555; border-radius: 3px; pointer-events: none;"> + <canvas id="spectrumCanvas" width="400" height="100"></canvas> + </div> + </div> + + <!-- Amplitude bezier editor (shown when partial selected) --> + <div id="ampEditPanel" style="display:none;"> + <div style="font-size:10px; color:#555; padding:2px 0 3px 1px; display:flex; align-items:center; gap:10px; text-transform:uppercase; letter-spacing:0.5px;"> + <span>Amplitude</span> + <span id="ampEditTitle" style="color:#777; text-transform:none; letter-spacing:0;"></span> + <span style="color:#333; text-transform:none; letter-spacing:0;">drag control points · Esc to deselect</span> + </div> + <canvas id="ampEditCanvas" width="1400" height="120" style="border:1px solid #2a2a2a; background:#0e0e0e; cursor:crosshair; display:block;"></canvas> </div> </div> <div class="right-panel"> - <div class="panel-title">Synthesis</div> - <label><input type="checkbox" id="integratePhase" checked> Integrate phase</label> - <label><input type="checkbox" id="disableJitter"> Disable jitter</label> - <label><input type="checkbox" id="disableSpread"> Disable spread</label> + <!-- Partial properties (visible when a partial is selected) --> + <div id="partialProps" style="display:none;"> + <div class="panel-title"> + <span id="propTitle">Partial #—</span> + <span id="propSwatch" style="display:inline-block;width:10px;height:10px;border-radius:2px;flex-shrink:0;"></span> + </div> + <div class="prop-row"> + <span class="prop-label">Peak</span> + <span id="propPeak">—</span> + </div> + <div class="prop-row"> + <span class="prop-label">Time</span> + <span id="propTime">—</span> + </div> + <div class="prop-section">Freq Curve</div> + <div class="curve-grid" id="freqCurveGrid"></div> + <div class="prop-section">Amp Curve</div> + <div class="curve-grid" id="ampCurveGrid"></div> + <div class="partial-actions"> + <button id="mutePartialBtn">Mute</button> + <button id="deletePartialBtn">Delete</button> + </div> + </div> + + <div id="noSelMsg" style="color:#555;font-size:11px;padding:2px 0;">Click a partial to select</div> + + <!-- Synthesis options (always at bottom) --> + <div class="synth-section"> + <div class="panel-title">Synthesis</div> + <label><input type="checkbox" id="integratePhase" checked> Integrate phase</label> + <label><input type="checkbox" id="disableJitter"> Disable jitter</label> + <label><input type="checkbox" id="disableSpread"> Disable spread</label> + </div> </div> </div> @@ -161,6 +264,7 @@ <script src="mq_extract.js"></script> <script src="mq_synth.js"></script> <script src="viewer.js"></script> + <script src="editor.js"></script> <script> let audioBuffer = null; let viewer = null; @@ -193,6 +297,13 @@ if (viewer && extractedPartials) viewer.setKeepCount(getKeepCount()); }); + // --- Editor --- + const editor = new PartialEditor(); + editor.onPartialDeleted = () => { + if (viewer && extractedPartials) + viewer.setKeepCount(extractedPartials.length > 0 ? getKeepCount() : 0); + }; + // Initialize audio context function initAudioContext() { if (!audioContext) { @@ -213,6 +324,9 @@ stftCache = new STFTCache(signal, audioBuffer.sampleRate, fftSize, Math.max(64, parseInt(hopSize.value) || 64)); setStatus(`${label} — ${audioBuffer.duration.toFixed(2)}s, ${audioBuffer.sampleRate}Hz, ${audioBuffer.numberOfChannels}ch (${stftCache.getNumFrames()} frames cached)`, 'info'); viewer = new SpectrogramViewer(canvas, audioBuffer, stftCache); + editor.setViewer(viewer); + viewer.onPartialSelect = (i) => editor.onPartialSelect(i); + viewer.onRender = () => editor.onRender(); if (label.startsWith('Test WAV')) validateTestWAVPeaks(stftCache); }, 10); } @@ -296,10 +410,12 @@ }); extractedPartials = result.partials; + editor.setPartials(result.partials); viewer.setFrames(result.frames); setStatus(`Extracted ${result.partials.length} partials`, 'info'); viewer.setPartials(result.partials); viewer.setKeepCount(getKeepCount()); + viewer.selectPartial(-1); } catch (err) { setStatus('Extraction error: ' + err.message, 'error'); @@ -384,8 +500,8 @@ setStatus('Synthesizing...', 'info'); const keepCount = getKeepCount(); - const partialsToUse = extractedPartials.slice(0, keepCount); - setStatus(`Synthesizing ${keepCount}/${extractedPartials.length} partials (${keepPct.value}%)...`, 'info'); + const partialsToUse = extractedPartials.slice(0, keepCount).filter(p => !p.muted); + setStatus(`Synthesizing ${partialsToUse.length}/${extractedPartials.length} partials (${keepPct.value}%)...`, 'info'); const integratePhase = document.getElementById('integratePhase').checked; const disableJitter = document.getElementById('disableJitter').checked; @@ -399,7 +515,7 @@ const synthBuffer = audioContext.createBuffer(1, pcm.length, audioBuffer.sampleRate); synthBuffer.getChannelData(0).set(pcm); - playAudioBuffer(synthBuffer, `Playing synthesized (${keepCount}/${extractedPartials.length} partials, ${keepPct.value}%)...`); + playAudioBuffer(synthBuffer, `Playing synthesized (${partialsToUse.length}/${extractedPartials.length} partials, ${keepPct.value}%)...`); } // Keyboard shortcuts @@ -422,6 +538,8 @@ viewer.showSynthFFT = !viewer.showSynthFFT; viewer.renderSpectrum(); } + } else if (e.code === 'Escape') { + if (viewer) viewer.selectPartial(-1); } }); diff --git a/tools/mq_editor/viewer.js b/tools/mq_editor/viewer.js index 7f6e862..c158536 100644 --- a/tools/mq_editor/viewer.js +++ b/tools/mq_editor/viewer.js @@ -48,6 +48,12 @@ class SpectrogramViewer { this.showSynthFFT = false; // Toggle: false=original, true=synth this.synthStftCache = null; + // Selection and editing + this.selectedPartial = -1; + this.dragState = null; // {pointIndex: 0-3} + this.onPartialSelect = null; // callback(index) + this.onRender = null; // callback() called after each render (for synced panels) + // Setup event handlers this.setupMouseHandlers(); @@ -153,6 +159,47 @@ class SpectrogramViewer { return this.stftCache.getMagnitudeDB(time, freq); } + selectPartial(index) { + this.selectedPartial = index; + this.render(); + if (this.onPartialSelect) this.onPartialSelect(index); + } + + // Hit-test bezier curves: returns index of nearest partial within threshold + hitTestPartial(x, y) { + const THRESH = 10; + let bestIdx = -1, bestDist = THRESH; + for (let p = 0; p < this.partials.length; ++p) { + const curve = this.partials[p].freqCurve; + if (!curve) continue; + for (let i = 0; i <= 50; ++i) { + const t = curve.t0 + (curve.t3 - curve.t0) * i / 50; + if (t < this.t_view_min || t > this.t_view_max) continue; + const f = evalBezier(curve, t); + if (f < this.freqStart || f > this.freqEnd) continue; + const px = this.timeToX(t), py = this.freqToY(f); + const dist = Math.hypot(px - x, py - y); + if (dist < bestDist) { bestDist = dist; bestIdx = p; } + } + } + return bestIdx; + } + + // Hit-test control points of a specific partial's freqCurve + hitTestControlPoint(x, y, partial) { + const curve = partial.freqCurve; + if (!curve) return -1; + const THRESH = 8; + for (let i = 0; i < 4; ++i) { + const t = curve['t' + i], v = curve['v' + i]; + if (t < this.t_view_min || t > this.t_view_max) continue; + if (v < this.freqStart || v > this.freqEnd) continue; + const px = this.timeToX(t), py = this.freqToY(v); + if (Math.hypot(px - x, py - y) <= THRESH) return i; + } + return -1; + } + // --- Render --- render() { @@ -162,6 +209,100 @@ class SpectrogramViewer { this.drawAxes(); this.drawPlayhead(); this.renderSpectrum(); + if (this.onRender) this.onRender(); + } + + renderPartials() { + for (let p = 0; p < this.partials.length; ++p) { + if (p === this.selectedPartial) continue; // draw selected last (on top) + this._renderPartial(p, this.partials[p], false); + } + if (this.selectedPartial >= 0 && this.selectedPartial < this.partials.length) { + this._renderPartial(this.selectedPartial, this.partials[this.selectedPartial], true); + } + this.ctx.globalAlpha = 1.0; + this.ctx.shadowBlur = 0; + } + + _renderPartial(p, partial, isSelected) { + const {ctx} = this; + const color = this.partialColor(p); + let alpha = isSelected ? 1.0 : (p < this.keepCount ? 1.0 : 0.5); + if (partial.muted && !isSelected) alpha = 0.15; + ctx.globalAlpha = alpha; + + // Raw trajectory + ctx.strokeStyle = color + '44'; + ctx.lineWidth = 1; + ctx.beginPath(); + let started = false; + for (let i = 0; i < partial.times.length; ++i) { + const t = partial.times[i]; + const f = partial.freqs[i]; + if (t < this.t_view_min || t > this.t_view_max) continue; + if (f < this.freqStart || f > this.freqEnd) continue; + const x = this.timeToX(t); + const y = this.freqToY(f); + if (!started) { ctx.moveTo(x, y); started = true; } else ctx.lineTo(x, y); + } + if (started) ctx.stroke(); + + // Bezier curve + if (partial.freqCurve) { + const curve = partial.freqCurve; + if (isSelected) { ctx.shadowColor = color; ctx.shadowBlur = 8; } + ctx.strokeStyle = color; + ctx.lineWidth = isSelected ? 3 : 2; + ctx.beginPath(); + started = false; + for (let i = 0; i <= 50; ++i) { + const t = curve.t0 + (curve.t3 - curve.t0) * i / 50; + const freq = evalBezier(curve, t); + if (t < this.t_view_min || t > this.t_view_max) continue; + if (freq < this.freqStart || freq > this.freqEnd) continue; + const x = this.timeToX(t); + const y = this.freqToY(freq); + if (!started) { ctx.moveTo(x, y); started = true; } else ctx.lineTo(x, y); + } + if (started) ctx.stroke(); + if (isSelected) ctx.shadowBlur = 0; + + ctx.fillStyle = color; + const cpR = isSelected ? 6 : 4; + this.drawControlPoint(curve.t0, curve.v0, cpR); + this.drawControlPoint(curve.t1, curve.v1, cpR); + this.drawControlPoint(curve.t2, curve.v2, cpR); + this.drawControlPoint(curve.t3, curve.v3, cpR); + } + } + + renderPeaks() { + const {ctx, frames} = this; + if (!frames || frames.length === 0) return; + + ctx.fillStyle = '#fff'; + for (const frame of frames) { + const t = frame.time; + if (t < this.t_view_min || t > this.t_view_max) continue; + const x = this.timeToX(t); + for (const peak of frame.peaks) { + if (peak.freq < this.freqStart || peak.freq > this.freqEnd) continue; + ctx.fillRect(x - 1, this.freqToY(peak.freq) - 1, 3, 3); + } + } + } + + drawControlPoint(t, v, radius = 4) { + if (t < this.t_view_min || t > this.t_view_max) return; + if (v < this.freqStart || v > this.freqEnd) return; + const x = this.timeToX(t); + const y = this.freqToY(v); + this.ctx.beginPath(); + this.ctx.arc(x, y, radius, 0, 2 * Math.PI); + this.ctx.fill(); + this.ctx.strokeStyle = '#fff'; + this.ctx.lineWidth = 1; + this.ctx.stroke(); } drawMouseCursor(x) { @@ -243,118 +384,6 @@ class SpectrogramViewer { } } - renderPartials() { - const {ctx, partials} = this; - - for (let p = 0; p < partials.length; ++p) { - const partial = partials[p]; - const color = this.partialColor(p); - ctx.globalAlpha = p < this.keepCount ? 1.0 : 0.5; - - // Raw trajectory - ctx.strokeStyle = color + '44'; - ctx.lineWidth = 1; - ctx.beginPath(); - let started = false; - for (let i = 0; i < partial.times.length; ++i) { - const t = partial.times[i]; - const f = partial.freqs[i]; - if (t < this.t_view_min || t > this.t_view_max) continue; - if (f < this.freqStart || f > this.freqEnd) continue; - const x = this.timeToX(t); - const y = this.freqToY(f); - if (!started) { ctx.moveTo(x, y); started = true; } else ctx.lineTo(x, y); - } - if (started) ctx.stroke(); - - // Bezier curve - if (partial.freqCurve) { - ctx.strokeStyle = color; - ctx.lineWidth = 2; - ctx.beginPath(); - const curve = partial.freqCurve; - started = false; - for (let i = 0; i <= 50; ++i) { - const t = curve.t0 + (curve.t3 - curve.t0) * i / 50; - const freq = evalBezier(curve, t); - if (t < this.t_view_min || t > this.t_view_max) continue; - if (freq < this.freqStart || freq > this.freqEnd) continue; - const x = this.timeToX(t); - const y = this.freqToY(freq); - if (!started) { ctx.moveTo(x, y); started = true; } else ctx.lineTo(x, y); - } - if (started) ctx.stroke(); - - ctx.fillStyle = color; - this.drawControlPoint(curve.t0, curve.v0); - this.drawControlPoint(curve.t1, curve.v1); - this.drawControlPoint(curve.t2, curve.v2); - this.drawControlPoint(curve.t3, curve.v3); - } - } - - ctx.globalAlpha = 1.0; - } - - renderPeaks() { - const {ctx, frames} = this; - if (!frames || frames.length === 0) return; - - ctx.fillStyle = '#fff'; - for (const frame of frames) { - const t = frame.time; - if (t < this.t_view_min || t > this.t_view_max) continue; - const x = this.timeToX(t); - for (const peak of frame.peaks) { - if (peak.freq < this.freqStart || peak.freq > this.freqEnd) continue; - ctx.fillRect(x - 1, this.freqToY(peak.freq) - 1, 3, 3); - } - } - } - - drawControlPoint(t, v) { - if (t < this.t_view_min || t > this.t_view_max) return; - if (v < this.freqStart || v > this.freqEnd) return; - const x = this.timeToX(t); - const y = this.freqToY(v); - this.ctx.beginPath(); - this.ctx.arc(x, y, 4, 0, 2 * Math.PI); - this.ctx.fill(); - this.ctx.strokeStyle = '#fff'; - this.ctx.lineWidth = 1; - this.ctx.stroke(); - } - - drawAxes() { - const {ctx, canvas} = this; - const width = canvas.width; - const height = canvas.height; - - ctx.strokeStyle = '#666'; - ctx.fillStyle = '#aaa'; - ctx.font = '11px monospace'; - ctx.lineWidth = 1; - - // Time axis - const timeDuration = this.t_view_max - this.t_view_min; - const timeStep = this.getAxisStep(timeDuration); - let t = Math.ceil(this.t_view_min / timeStep) * timeStep; - while (t <= this.t_view_max) { - const x = this.timeToX(t); - ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, height); ctx.stroke(); - ctx.fillText(t.toFixed(2) + 's', x + 2, height - 4); - t += timeStep; - } - - // Frequency axis (log-spaced ticks) - for (const f of [20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 16000]) { - if (f < this.freqStart || f > this.freqEnd) continue; - const y = this.freqToY(f); - ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(width, y); ctx.stroke(); - ctx.fillText((f >= 1000 ? (f/1000).toFixed(0) + 'k' : f.toFixed(0)) + 'Hz', 2, y - 2); - } - } - renderSpectrum() { if (!this.spectrumCtx || !this.stftCache) return; @@ -464,11 +493,44 @@ class SpectrogramViewer { setupMouseHandlers() { const {canvas, tooltip} = this; + canvas.addEventListener('mousedown', (e) => { + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + // Check control point drag on selected partial + if (this.selectedPartial >= 0 && this.selectedPartial < this.partials.length) { + const ptIdx = this.hitTestControlPoint(x, y, this.partials[this.selectedPartial]); + if (ptIdx >= 0) { + this.dragState = { pointIndex: ptIdx }; + canvas.style.cursor = 'grabbing'; + e.preventDefault(); + return; + } + } + + // Otherwise: select partial by click + const idx = this.hitTestPartial(x, y); + this.selectPartial(idx); + }); + canvas.addEventListener('mousemove', (e) => { const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; + 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 partial = this.partials[this.selectedPartial]; + const i = this.dragState.pointIndex; + partial.freqCurve['t' + i] = t; + partial.freqCurve['v' + i] = v; + this.render(); + e.preventDefault(); + return; + } + this.mouseX = x; this.drawMouseCursor(x); @@ -481,6 +543,14 @@ class SpectrogramViewer { this.renderSpectrum(); } + // Cursor hint for control points + if (this.selectedPartial >= 0 && this.selectedPartial < this.partials.length) { + const ptIdx = this.hitTestControlPoint(x, y, this.partials[this.selectedPartial]); + canvas.style.cursor = ptIdx >= 0 ? 'grab' : 'crosshair'; + } else { + canvas.style.cursor = 'crosshair'; + } + tooltip.style.left = (e.clientX + 10) + 'px'; tooltip.style.top = (e.clientY + 10) + 'px'; tooltip.style.display = 'block'; @@ -493,6 +563,14 @@ class SpectrogramViewer { tooltip.style.display = 'none'; }); + canvas.addEventListener('mouseup', () => { + if (this.dragState) { + this.dragState = null; + canvas.style.cursor = 'crosshair'; + if (this.onPartialSelect) this.onPartialSelect(this.selectedPartial); + } + }); + canvas.addEventListener('wheel', (e) => { e.preventDefault(); const delta = e.deltaY !== 0 ? e.deltaY : e.deltaX; @@ -529,6 +607,36 @@ class SpectrogramViewer { for (const step of steps) { if (step >= targetStep) return step; } return steps[steps.length - 1]; } + + drawAxes() { + const {ctx, canvas} = this; + const width = canvas.width; + const height = canvas.height; + + ctx.strokeStyle = '#666'; + ctx.fillStyle = '#aaa'; + ctx.font = '11px monospace'; + ctx.lineWidth = 1; + + // Time axis + const timeDuration = this.t_view_max - this.t_view_min; + const timeStep = this.getAxisStep(timeDuration); + let t = Math.ceil(this.t_view_min / timeStep) * timeStep; + while (t <= this.t_view_max) { + const x = this.timeToX(t); + ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, height); ctx.stroke(); + ctx.fillText(t.toFixed(2) + 's', x + 2, height - 4); + t += timeStep; + } + + // Frequency axis (log-spaced ticks) + for (const f of [20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 16000]) { + if (f < this.freqStart || f > this.freqEnd) continue; + const y = this.freqToY(f); + ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(width, y); ctx.stroke(); + ctx.fillText((f >= 1000 ? (f/1000).toFixed(0) + 'k' : f.toFixed(0)) + 'Hz', 2, y - 2); + } + } } // Bezier evaluation (shared utility) |
