summaryrefslogtreecommitdiff
path: root/tools
diff options
context:
space:
mode:
Diffstat (limited to 'tools')
-rw-r--r--tools/mq_editor/editor.js324
-rw-r--r--tools/mq_editor/index.html150
-rw-r--r--tools/mq_editor/viewer.js332
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)