summaryrefslogtreecommitdiff
path: root/tools/mq_editor
diff options
context:
space:
mode:
Diffstat (limited to 'tools/mq_editor')
-rw-r--r--tools/mq_editor/PHASE_TRACKING_PLAN.md73
-rw-r--r--tools/mq_editor/README.md79
-rw-r--r--tools/mq_editor/editor.js578
-rw-r--r--tools/mq_editor/fft.js6
-rw-r--r--tools/mq_editor/index.html380
-rw-r--r--tools/mq_editor/mq_extract.js258
-rw-r--r--tools/mq_editor/mq_synth.js142
-rw-r--r--tools/mq_editor/viewer.js418
8 files changed, 1718 insertions, 216 deletions
diff --git a/tools/mq_editor/PHASE_TRACKING_PLAN.md b/tools/mq_editor/PHASE_TRACKING_PLAN.md
new file mode 100644
index 0000000..5111692
--- /dev/null
+++ b/tools/mq_editor/PHASE_TRACKING_PLAN.md
@@ -0,0 +1,73 @@
+# Implementation Plan: Phase-Coherent Partial Tracking
+
+This document outlines the plan to integrate phase prediction into the existing MQ tracking algorithm. The core idea is to use phase coherence as a primary factor for linking peaks across frames, making the tracking more robust, especially for crossing or closely-spaced partials.
+
+---
+
+### **Stage 1: Cache Per-Bin Phase in `fft.js`**
+
+**Objective:** Augment the `STFTCache` to compute and store the phase angle for every frequency bin in every frame, making it available for the tracking algorithm.
+
+1. **Locate the FFT Processing Loop:**
+ * In `tools/mq_editor/fft.js`, within the `STFTCache` class (likely in the constructor or an initialization method), find the loop that iterates through each frame to compute the FFT.
+ * This is where `squaredAmplitude` is currently being calculated from the `real` and `imag` components.
+
+2. **Compute and Store Phase:**
+ * In the same loop, immediately after calculating the squared amplitude, calculate the phase for each bin.
+ * Create a new `Float32Array` for phase, let's call it `ph`.
+ * Inside the bin loop, compute: `ph[k] = Math.atan2(imag[k], real[k]);`
+ * Store this new phase array in the frame object, parallel to the existing `squaredAmplitude` array.
+
+ **Resulting Change in `fft.js`:**
+
+ ```javascript
+ // Inside the STFTCache frame processing loop...
+
+ // Existing code:
+ const sq = new Float32Array(this.fftSize / 2);
+ for (let k = 0; k < this.fftSize / 2; k++) {
+ sq[k] = real[k] * real[k] + imag[k] * imag[k];
+ }
+
+ // New code to add:
+ const ph = new Float32Array(this.fftSize / 2);
+ for (let k = 0; k < this.fftSize / 2; k++) {
+ ph[k] = Math.atan2(imag[k], real[k]);
+ }
+
+ // Update the stored frame object
+ this.frames.push({
+ time: t,
+ squaredAmplitude: sq,
+ phase: ph // The newly cached phase data
+ });
+ ```
+
+---
+
+### **Stage 2: Utilize Phase for Tracking in `mq_extract.js`**
+
+**Objective:** Modify the main forward/backward tracking algorithm to use phase coherence for identifying and linking peaks.
+
+1. **Extract Interpolated Peak Phase:**
+ * In `tools/mq_editor/mq_extract.js`, find the function responsible for peak detection within a single frame (e.g., `findPeaks`).
+ * This function currently takes a `squaredAmplitude` array. It must now also access the corresponding `phase` array from the cached frame data.
+ * When a peak is found at bin `k`, use the same parabolic interpolation logic that calculates the true frequency and amplitude to also calculate the **true phase**. This involves interpolating the phase values from bins `k-1`, `k`, and `k+1`.
+ * **Crucially, this interpolation must handle phase wrapping.** A helper function will be needed to correctly find the shortest angular distance between phase values.
+
+2. **Update Tracking Data Structures:**
+ * The data structures holding candidate and live partials must be updated to store the phase of each point in the trajectory, not just frequency and amplitude.
+
+3. **Implement Phase Prediction Logic:**
+ * In the main tracking loop that steps from frame `n` to `n+1`:
+ * For each active partial, calculate its `predictedPhase` for frame `n+1`.
+ * `phase_delta = (2 * Math.PI * last_freq * params.hopSize) / params.sampleRate;`
+ * `predictedPhase = last_phase + phase_delta;`
+
+4. **Refine the Candidate Matching Score:**
+ * Modify the logic that links a partial to peaks in the next frame.
+ * Instead of matching based on frequency proximity alone, calculate a `cost` based on both frequency and phase deviation:
+ * `freqError = Math.abs(peak.freq - partial.last_freq);`
+ * `phaseError = Math.abs(normalize_angle(peak.phase - predictedPhase));` // Difference on a circle
+ * `cost = (freq_weight * freqError) + (phase_weight * phaseError);`
+ * The peak with the lowest `cost` below a certain threshold is the correct continuation. The `phase_weight` should be high, as a low phase error is a strong indicator of a correct match.
diff --git a/tools/mq_editor/README.md b/tools/mq_editor/README.md
index 4250d33..c1f2732 100644
--- a/tools/mq_editor/README.md
+++ b/tools/mq_editor/README.md
@@ -17,7 +17,7 @@ open tools/mq_editor/index.html
- **Top bar:** title + loaded filename
- **Toolbar:** file, extraction, playback controls and parameters
- **Main view:** log-scale time-frequency spectrogram with partial trajectories
-- **Right panel:** synthesis checkboxes (integrate phase, disable jitter, disable spread)
+- **Right panel:** per-partial mode toggle (Sinusoid / Resonator), synth params, global checkboxes
- **Mini-spectrum** (bottom-right overlay): FFT slice at mouse/playhead time
- Blue/orange gradient: original spectrum; green/yellow: synthesized
- Green bars: detected spectral peaks
@@ -27,6 +27,8 @@ open tools/mq_editor/index.html
- **Hop Size:** 64–1024 samples (default 256)
- **Threshold:** dB floor for peak detection (default −60 dB)
+- **Prominence:** Min dB height of a peak above its surrounding "valley floor" (default 1.0 dB). Filters out insignificant local maxima.
+- **f·Power:** checkbox — weight spectrum by frequency (`f·FFT_Power(f)`) before peak detection, accentuating high-frequency peaks
- **Keep %:** slider to limit how many partials are shown/synthesized
## Keyboard Shortcuts
@@ -35,17 +37,20 @@ open tools/mq_editor/index.html
|-----|--------|
| `1` | Play synthesized audio |
| `2` | Play original audio |
+| `E` | Extract Partials |
| `P` | Toggle raw peak overlay |
| `A` | Toggle mini-spectrum: original ↔ synthesized |
+| `Esc` | Deselect partial |
| Shift+scroll | Zoom time axis |
| Scroll | Pan time axis |
## Architecture
-- `index.html` — UI, playback, extraction orchestration
+- `index.html` — UI, playback, extraction orchestration, keyboard shortcuts
+- `editor.js` — Property panel and amplitude bezier editor for selected partials
- `fft.js` — Cooley-Tukey radix-2 FFT + STFT cache
- `mq_extract.js` — MQ algorithm: peak detection, forward tracking, backward expansion, bezier fitting
-- `mq_synth.js` — Oscillator bank synthesis from extracted partials
+- `mq_synth.js` — Oscillator bank + two-pole resonator synthesis from extracted partials
- `viewer.js` — Visualization: coordinate API, spectrogram, partials, mini-spectrum, mouse/zoom
### viewer.js coordinate API
@@ -61,13 +66,66 @@ open tools/mq_editor/index.html
| `normalizeDB(db, maxDB)` | dB → intensity [0..1] over 80 dB range |
| `partialColor(p)` | partial index → display color |
+## Post-Synthesis Filters
+
+Global LP/HP filters applied after the oscillator bank, before normalization.
+Sliders in the **Synthesis** panel; display shows -3 dB cutoff frequency.
+
+| Control | Filter | Formula | Bypass |
+|---------|--------|---------|--------|
+| **LP k1** | `y[n] = k1·x[n] + (1−k1)·y[n−1]` | `-3dB: cos(ω) = (2−2k−k²)/(2(1−k))` | k1 = 1.0 |
+| **HP k2** | `y[n] = k2·(y[n−1] + x[n] − x[n−1])` | `-3dB from peak: cos(ω) = 2k/(1+k²)` | k2 = 1.0 |
+
+Both default to 1.0 (bypass). Frequency display uses `audioBuffer.sampleRate` when loaded, falls back to 44100 Hz.
+
+---
+
+## Resonator Synthesis Mode
+
+Each partial has a **per-partial synthesis mode** selectable in the **Synth** tab:
+
+### Sinusoid (default)
+Replica oscillator bank — direct additive sinusoids with optional spread/jitter/decay shaping.
+
+### Resonator
+Two-pole IIR bandpass resonator driven by band-limited noise:
+
+```
+y[n] = 2r·cos(ω₀)·y[n-1] − r²·y[n-2] + A(t)·√(1−r²)·noise[n]
+```
+
+- **ω₀** = `2π·f₀(t)/SR` — recomputed each sample from the freq Bezier curve (handles glides/vibrato)
+- **A(t)** — amp Bezier curve scales excitation continuously
+- **√(1−r²)** — power normalization, keeps output level ≈ sinusoidal mode at `gainComp = 1`
+- **noise[n]** — deterministic per-partial LCG (reproducible renders)
+
+**Parameters:**
+
+| Param | Default | Range | Meaning |
+|-------|---------|-------|---------|
+| `r (pole)` | 0.995 | [0, 0.9999] | Pole radius. r→1 = narrow BW / long ring. r→0 = wide / fast decay. |
+| `gain` | 1.0 | [0, ∞) | Output multiplier on top of power normalization. |
+
+**Coefficient translation from spread:**
+`r = exp(−π · BW / SR)` where `BW = f₀ · (spread_above + spread_below) / 2`.
+For a partial at 440 Hz with `spread = 0.02`: `BW ≈ 8.8 Hz`, `r ≈ exp(−π·8.8/32000) ≈ 0.9991`.
+
+**When to use:**
+- Metallic / percussive partials with natural exponential decay
+- Wide spectral peaks (large spread) where a bandpass filter is more physically accurate
+- Comparing resonator vs. sinusoidal timbre on the same partial
+
+**Note:** `RES` badge appears in the panel header when a partial is in resonator mode.
+
+---
+
## Algorithm
1. **STFT:** Overlapping Hann windows, radix-2 FFT
-2. **Peak Detection:** Local maxima above threshold + parabolic interpolation
-3. **Forward Tracking:** Birth/death/continuation with frequency-dependent tolerance, candidate persistence
+2. **Peak Detection:** Local maxima above threshold + parabolic interpolation. Includes **Prominence Filtering** (rejects peaks not significantly higher than surroundings). Optional `f·Power(f)` weighting.
+3. **Forward Tracking:** Birth/death/continuation with frequency-dependent tolerance. Includes **Predictive Kinematic Tracking** (uses velocity to track rapidly moving partials).
4. **Backward Expansion:** Second pass extends each partial leftward to recover onset frames
-5. **Bezier Fitting:** Cubic curves with control points at t/3 and 2t/3
+5. **Bezier Fitting:** Cubic curves optimized via **Least-Squares** (minimizes error across all points).
## Implementation Status
@@ -83,7 +141,14 @@ open tools/mq_editor/index.html
- [x] Replica oscillator bank (spread, jitter, phase integration)
- [x] Synthesis debug checkboxes (disable jitter/spread)
- [x] Synthesized STFT cache for FFT comparison
-- [ ] Phase 3: Editing UI (drag control points, replicas)
+- [x] Phase 3: Editing UI
+ - [x] Partial selection with property panel (freq/amp/synth tabs)
+ - [x] Amplitude bezier drag editor
+ - [x] Synth params with jog sliders (decay, jitter, spread)
+ - [x] Auto-spread detection per partial and global
+ - [x] Mute / delete partials
+ - [x] Per-partial resonator synthesis mode (Synth tab toggle)
+ - [x] Global LP/HP post-synthesis filter sliders with Hz display
- [ ] Phase 4: Export (.spec + C++ code generation)
## See Also
diff --git a/tools/mq_editor/editor.js b/tools/mq_editor/editor.js
new file mode 100644
index 0000000..97d8a7a
--- /dev/null
+++ b/tools/mq_editor/editor.js
@@ -0,0 +1,578 @@
+// Partial Editor
+// Property panel (right) and amplitude bezier editor (bottom) for selected partials
+
+class PartialEditor {
+ constructor() {
+ // DOM refs
+ this._propPanel = document.getElementById('partialProps');
+ this._noSelMsg = document.getElementById('noSelMsg');
+ this._ampPanel = document.getElementById('ampEditPanel');
+ this._ampCanvas = document.getElementById('ampEditCanvas');
+ this._ampTitle = document.getElementById('ampEditTitle');
+ this._freqGrid = document.getElementById('freqCurveGrid');
+ this._ampGrid = document.getElementById('ampCurveGrid');
+ this._synthGrid = document.getElementById('synthGrid');
+ this._ampCtx = this._ampCanvas ? this._ampCanvas.getContext('2d') : null;
+
+ // References set by host
+ this.viewer = null;
+ this.partials = null;
+
+ // Callback: called after a partial is deleted so the host can update keepCount
+ this.onPartialDeleted = null;
+
+ // Private state
+ this._selectedIndex = -1;
+ this._dragPointIndex = -1;
+ this._amp = { tMin: 0, tMax: 1, ampTop: 1 };
+
+ this._setupButtons();
+ this._setupAmpDrag();
+ }
+
+ // --- Public API ---
+
+ setViewer(v) { this.viewer = v; }
+ setPartials(p) { this.partials = p; }
+
+ // Wire to viewer.onPartialSelect
+ onPartialSelect(index) {
+ this._selectedIndex = index;
+ this._updatePropPanel(index);
+ this._showAmpEditor(index);
+ }
+
+ // Wire to viewer.onRender — keeps amp editor in sync with zoom/scroll
+ onRender() {
+ this._renderAmpEditor();
+ }
+
+ // --- Property panel ---
+
+ _updatePropPanel(index) {
+ if (index < 0 || !this.partials || index >= this.partials.length) {
+ this._propPanel.style.display = 'none';
+ this._noSelMsg.style.display = 'block';
+ return;
+ }
+
+ this._propPanel.style.display = 'block';
+ this._noSelMsg.style.display = 'none';
+
+ const partial = this.partials[index];
+ const color = this.viewer ? this.viewer.partialColor(index) : '#888';
+
+ const titleEl = document.getElementById('propTitle');
+ const badge = partial.resonator && partial.resonator.enabled
+ ? ' <span class="res-badge">RES</span>' : '';
+ titleEl.innerHTML = 'Partial #' + index + badge;
+ document.getElementById('propSwatch').style.background = color;
+
+ let peakAmp = 0, peakIdx = 0;
+ for (let i = 0; i < partial.amps.length; ++i) {
+ if (partial.amps[i] > peakAmp) { peakAmp = partial.amps[i]; peakIdx = i; }
+ }
+ document.getElementById('propPeak').textContent =
+ partial.freqs[peakIdx].toFixed(1) + ' Hz ' + peakAmp.toFixed(3);
+
+ const t0 = partial.freqCurve ? partial.freqCurve.t0 : partial.times[0];
+ const t3 = partial.freqCurve ? partial.freqCurve.t3 : partial.times[partial.times.length - 1];
+ document.getElementById('propTime').textContent =
+ t0.toFixed(3) + 's\u2013' + t3.toFixed(3) + 's';
+
+ const muteBtn = document.getElementById('mutePartialBtn');
+ muteBtn.textContent = partial.muted ? 'Unmute' : 'Mute';
+ muteBtn.style.color = partial.muted ? '#fa4' : '';
+
+ this._buildCurveGrid(this._freqGrid, partial, 'freqCurve', 'f', index);
+ this._buildCurveGrid(this._ampGrid, partial, 'ampCurve', 'a', index);
+ this._buildSynthGrid(partial, index);
+ }
+
+ _buildCurveGrid(grid, partial, curveKey, valueLabel, partialIndex) {
+ grid.innerHTML = '';
+ const curve = partial[curveKey];
+ if (!curve) { grid.style.color = '#444'; grid.textContent = 'none'; return; }
+
+ for (let i = 0; i < 4; ++i) {
+ const lbl = document.createElement('span');
+ lbl.textContent = 'P' + i;
+
+ const tInput = document.createElement('input');
+ tInput.type = 'number';
+ tInput.value = curve['t' + i].toFixed(4);
+ tInput.step = '0.001';
+ tInput.title = 't' + i;
+ tInput.addEventListener('change', this._makeCurveUpdater(partialIndex, curveKey, 't', i));
+
+ const vInput = document.createElement('input');
+ vInput.type = 'number';
+ vInput.value = curveKey === 'freqCurve' ? curve['v' + i].toFixed(2) : curve['v' + i].toFixed(4);
+ vInput.step = curveKey === 'freqCurve' ? '1' : '0.0001';
+ vInput.title = valueLabel + i;
+ vInput.addEventListener('change', this._makeCurveUpdater(partialIndex, curveKey, 'v', i));
+
+ grid.appendChild(lbl);
+ grid.appendChild(tInput);
+ grid.appendChild(vInput);
+ }
+ }
+
+ _buildSynthGrid(partial, index) {
+ const grid = this._synthGrid;
+ grid.innerHTML = '';
+
+ const repDefaults = { decay_alpha: 0.1, jitter: 0.05, spread_above: 0.02, spread_below: 0.02 };
+ const resDefaults = { r: 0.995, gainComp: 1.0 };
+
+ const isResonator = !!(partial.resonator && partial.resonator.enabled);
+
+ // --- Mode toggle ---
+ const modeLbl = document.createElement('span');
+ modeLbl.textContent = 'mode';
+
+ const modeWrap = document.createElement('div');
+ modeWrap.style.cssText = 'display:flex;gap:3px;';
+
+ const btnSin = document.createElement('button');
+ btnSin.textContent = 'Sinusoid';
+ btnSin.className = 'tab-btn' + (isResonator ? '' : ' active');
+ btnSin.style.cssText = 'flex:1;padding:2px 4px;font-size:11px;margin:0;';
+
+ const btnRes = document.createElement('button');
+ btnRes.textContent = 'Resonator';
+ btnRes.className = 'tab-btn' + (isResonator ? ' active' : '');
+ btnRes.style.cssText = 'flex:1;padding:2px 4px;font-size:11px;margin:0;';
+
+ modeWrap.appendChild(btnSin);
+ modeWrap.appendChild(btnRes);
+ grid.appendChild(modeLbl);
+ grid.appendChild(modeWrap);
+
+ // --- Sinusoid section ---
+ const sinSection = document.createElement('div');
+ sinSection.style.cssText = 'display:contents;';
+ sinSection.dataset.section = 'sinusoid';
+
+ const rep = partial.replicas || {};
+ const sinParams = [
+ { key: 'decay_alpha', label: 'decay', step: '0.001' },
+ { key: 'jitter', label: 'jitter', step: '0.001' },
+ { key: 'spread_above', label: 'spread ↑', step: '0.001' },
+ { key: 'spread_below', label: 'spread ↓', step: '0.001' },
+ ];
+ const sinInputs = {};
+ for (const p of sinParams) {
+ const val = rep[p.key] != null ? rep[p.key] : repDefaults[p.key];
+ const lbl = document.createElement('span');
+ lbl.textContent = p.label;
+ const inp = document.createElement('input');
+ inp.type = 'number';
+ inp.value = val.toFixed(3);
+ inp.step = p.step;
+ inp.min = '0';
+ inp.addEventListener('change', (e) => {
+ if (!this.partials) return;
+ const v = parseFloat(e.target.value);
+ if (isNaN(v)) return;
+ if (!this.partials[index].replicas) this.partials[index].replicas = { ...repDefaults };
+ this.partials[index].replicas[p.key] = v;
+ if (this.viewer) this.viewer.render();
+ });
+ sinInputs[p.key] = inp;
+
+ const jog = this._makeJogSlider(inp, partial, index, p, repDefaults);
+ const wrap = document.createElement('div');
+ wrap.className = 'synth-field-wrap';
+ wrap.appendChild(inp);
+ wrap.appendChild(jog);
+ sinSection.appendChild(lbl);
+ sinSection.appendChild(wrap);
+ }
+
+ // Auto-detect spread button
+ const autoLbl = document.createElement('span');
+ autoLbl.textContent = 'spread';
+ const autoBtn = document.createElement('button');
+ autoBtn.textContent = 'Auto';
+ autoBtn.title = 'Infer spread_above/below from frequency variance around the bezier curve';
+ autoBtn.addEventListener('click', () => {
+ if (!this.partials) return;
+ const p = this.partials[index];
+ const sc = this.viewer ? this.viewer.stftCache : null;
+ const sr = this.viewer ? this.viewer.audioBuffer.sampleRate : 44100;
+ const fs = sc ? sc.fftSize : 2048;
+ const {spread_above, spread_below} = autodetectSpread(p, sc, fs, sr);
+ if (!p.replicas) p.replicas = { ...repDefaults };
+ p.replicas.spread_above = spread_above;
+ p.replicas.spread_below = spread_below;
+ sinInputs['spread_above'].value = spread_above.toFixed(4);
+ sinInputs['spread_below'].value = spread_below.toFixed(4);
+ });
+ sinSection.appendChild(autoLbl);
+ sinSection.appendChild(autoBtn);
+
+ // --- Resonator section ---
+ const resSection = document.createElement('div');
+ resSection.style.cssText = 'display:contents;';
+ resSection.dataset.section = 'resonator';
+
+ const resObj = partial.resonator || {};
+ const resParams = [
+ { key: 'r', label: 'r (pole)', step: '0.001', min: '0.75', max: '0.9999',
+ title: 'Pole radius. r→1 = narrow bandwidth / long ring. r→0 = wide / fast decay.' },
+ { key: 'gainComp', label: 'gain', step: '0.01', min: '0', max: '100',
+ title: 'Output gain multiplier (gainNorm=√(1-r²) normalises power; use this to trim level).' },
+ ];
+ for (const p of resParams) {
+ const val = resObj[p.key] != null ? resObj[p.key] : resDefaults[p.key];
+ const lbl = document.createElement('span');
+ lbl.textContent = p.label;
+ lbl.title = p.title || '';
+ const inp = document.createElement('input');
+ inp.type = 'number';
+ inp.value = val.toFixed(4);
+ inp.step = p.step;
+ inp.min = p.min || '0';
+ inp.max = p.max || '';
+ inp.title = p.title || '';
+ inp.addEventListener('change', (e) => {
+ if (!this.partials) return;
+ const v = parseFloat(e.target.value);
+ if (isNaN(v)) return;
+ if (!this.partials[index].resonator) this.partials[index].resonator = { ...resDefaults };
+ this.partials[index].resonator[p.key] = v;
+ if (this.viewer) this.viewer.render();
+ });
+
+ // Inline jog slider for resonator params
+ const step = parseFloat(p.step);
+ const sensitivity = step * 5;
+ const jog = document.createElement('div');
+ jog.className = 'jog-slider';
+ const thumb = document.createElement('div');
+ thumb.className = 'jog-thumb';
+ jog.appendChild(thumb);
+ let dragging = false, startX = 0, startVal = 0;
+ const onMove = (ev) => {
+ if (!dragging) return;
+ const dx = ev.clientX - startX;
+ const half = jog.offsetWidth / 2;
+ const clamped = Math.max(-half, Math.min(half, dx));
+ thumb.style.transition = 'none';
+ thumb.style.left = `calc(50% - 3px + ${clamped}px)`;
+ const newVal = Math.max(parseFloat(inp.min) || 0,
+ Math.min(parseFloat(inp.max) || 1e9, startVal + dx * sensitivity));
+ inp.value = newVal.toFixed(4);
+ if (!this.partials || !this.partials[index]) return;
+ if (!this.partials[index].resonator) this.partials[index].resonator = { ...resDefaults };
+ this.partials[index].resonator[p.key] = newVal;
+ if (this.viewer) this.viewer.render();
+ };
+ const onUp = () => {
+ if (!dragging) return;
+ dragging = false;
+ thumb.style.transition = '';
+ thumb.style.left = 'calc(50% - 3px)';
+ document.removeEventListener('mousemove', onMove);
+ document.removeEventListener('mouseup', onUp);
+ };
+ jog.addEventListener('mousedown', (ev) => {
+ dragging = true;
+ startX = ev.clientX;
+ startVal = parseFloat(inp.value) || 0;
+ document.addEventListener('mousemove', onMove);
+ document.addEventListener('mouseup', onUp);
+ ev.preventDefault();
+ });
+
+ const wrap = document.createElement('div');
+ wrap.className = 'synth-field-wrap';
+ wrap.appendChild(inp);
+ wrap.appendChild(jog);
+ resSection.appendChild(lbl);
+ resSection.appendChild(wrap);
+ }
+
+ // Show/hide helper
+ const applyMode = (resonator) => {
+ for (const el of sinSection.children) el.style.display = resonator ? 'none' : '';
+ for (const el of resSection.children) el.style.display = resonator ? '' : 'none';
+ btnSin.classList.toggle('active', !resonator);
+ btnRes.classList.toggle('active', resonator);
+ };
+
+ // Initial state
+ grid.appendChild(sinSection);
+ grid.appendChild(resSection);
+ applyMode(isResonator);
+
+ // Toggle handlers
+ btnSin.addEventListener('click', () => {
+ if (!this.partials) return;
+ if (!this.partials[index].resonator) this.partials[index].resonator = { ...resDefaults };
+ this.partials[index].resonator.enabled = false;
+ applyMode(false);
+ });
+ btnRes.addEventListener('click', () => {
+ if (!this.partials) return;
+ if (!this.partials[index].resonator) this.partials[index].resonator = { ...resDefaults };
+ this.partials[index].resonator.enabled = true;
+ applyMode(true);
+ });
+ }
+
+ _makeJogSlider(inp, partial, index, p, defaults) {
+ const slider = document.createElement('div');
+ slider.className = 'jog-slider';
+ const thumb = document.createElement('div');
+ thumb.className = 'jog-thumb';
+ slider.appendChild(thumb);
+
+ const sensitivity = parseFloat(p.step) * 5;
+ let startX = 0, startVal = 0, dragging = false;
+
+ const onMove = (e) => {
+ if (!dragging) return;
+ const dx = e.clientX - startX;
+ const half = slider.offsetWidth / 2;
+ const clamped = Math.max(-half, Math.min(half, dx));
+ thumb.style.transition = 'none';
+ thumb.style.left = `calc(50% - 3px + ${clamped}px)`;
+ const newVal = Math.max(0, startVal + dx * sensitivity);
+ inp.value = newVal.toFixed(3);
+ if (!this.partials || !this.partials[index]) return;
+ if (!this.partials[index].replicas) this.partials[index].replicas = { ...defaults };
+ this.partials[index].replicas[p.key] = newVal;
+ if (this.viewer) this.viewer.render();
+ };
+
+ const onUp = () => {
+ if (!dragging) return;
+ dragging = false;
+ thumb.style.transition = '';
+ thumb.style.left = 'calc(50% - 3px)';
+ document.removeEventListener('mousemove', onMove);
+ document.removeEventListener('mouseup', onUp);
+ };
+
+ slider.addEventListener('mousedown', (e) => {
+ dragging = true;
+ startX = e.clientX;
+ startVal = Math.max(0, parseFloat(inp.value) || 0);
+ document.addEventListener('mousemove', onMove);
+ document.addEventListener('mouseup', onUp);
+ e.preventDefault();
+ });
+
+ return slider;
+ }
+
+ _makeCurveUpdater(partialIndex, curveKey, field, pointIndex) {
+ return (e) => {
+ if (!this.partials) return;
+ const val = parseFloat(e.target.value);
+ if (isNaN(val)) return;
+ this.partials[partialIndex][curveKey][field + pointIndex] = val;
+ if (this.viewer) this.viewer.render();
+ };
+ }
+
+ _setupButtons() {
+ document.getElementById('mutePartialBtn').addEventListener('click', () => {
+ if (this._selectedIndex < 0 || !this.partials) return;
+ const p = this.partials[this._selectedIndex];
+ p.muted = !p.muted;
+ if (this.viewer) this.viewer.render();
+ this._updatePropPanel(this._selectedIndex);
+ });
+
+ document.getElementById('deletePartialBtn').addEventListener('click', () => {
+ if (this._selectedIndex < 0 || !this.partials || !this.viewer) return;
+ this.partials.splice(this._selectedIndex, 1);
+ this.viewer.selectPartial(-1);
+ if (this.onPartialDeleted) this.onPartialDeleted();
+ });
+ }
+
+ // --- Amplitude bezier editor ---
+
+ _showAmpEditor(index) {
+ if (index < 0 || !this.partials || index >= this.partials.length) {
+ this._ampPanel.style.display = 'none';
+ return;
+ }
+ this._ampPanel.style.display = 'block';
+ const color = this.viewer ? this.viewer.partialColor(index) : '#888';
+ this._ampTitle.textContent = 'Partial #' + index;
+ this._ampTitle.style.color = color;
+ this._renderAmpEditor();
+ }
+
+ _renderAmpEditor() {
+ if (this._selectedIndex < 0 || !this.partials || !this._ampCtx) return;
+ const partial = this.partials[this._selectedIndex];
+ if (!partial) return;
+
+ const canvas = this._ampCanvas;
+ const ctx = this._ampCtx;
+ const W = canvas.width, H = canvas.height;
+
+ // Sync time range with viewer
+ const amp = this._amp;
+ amp.tMin = this.viewer ? this.viewer.t_view_min : 0;
+ amp.tMax = this.viewer ? this.viewer.t_view_max : 1;
+ amp.ampTop = Math.max(partial.amps.reduce((m, v) => Math.max(m, v), 0), 0.001) * 1.3;
+
+ ctx.fillStyle = '#0e0e0e';
+ ctx.fillRect(0, 0, W, H);
+
+ // Horizontal grid (0, 25, 50, 75, 100% of ampTop)
+ ctx.lineWidth = 1;
+ ctx.font = '9px monospace';
+ for (let k = 0; k <= 4; ++k) {
+ const a = amp.ampTop * k / 4;
+ const y = this._ampToY(a);
+ ctx.strokeStyle = k === 0 ? '#2a2a2a' : '#1a1a1a';
+ ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
+ ctx.fillStyle = '#383838';
+ ctx.fillText(a.toFixed(3), W - 40, y - 2);
+ }
+
+ // Vertical time grid (matching main view step)
+ if (this.viewer) {
+ const step = this.viewer.getAxisStep(amp.tMax - amp.tMin);
+ let t = Math.ceil(amp.tMin / step) * step;
+ ctx.strokeStyle = '#1a1a1a';
+ ctx.fillStyle = '#383838';
+ while (t <= amp.tMax) {
+ const x = this._tToX(t);
+ ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
+ ctx.fillText(t.toFixed(2) + 's', x + 2, H - 2);
+ t += step;
+ }
+ }
+
+ // Raw amp data (faint dots)
+ ctx.fillStyle = '#2e2e2e';
+ for (let i = 0; i < partial.times.length; ++i) {
+ const x = this._tToX(partial.times[i]);
+ if (x < -2 || x > W + 2) continue;
+ ctx.fillRect(x - 1, this._ampToY(partial.amps[i]) - 1, 2, 2);
+ }
+
+ // Bezier curve
+ const curve = partial.ampCurve;
+ if (!curve) return;
+
+ const color = this.viewer ? this.viewer.partialColor(this._selectedIndex) : '#f44';
+ ctx.strokeStyle = color;
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ let started = false;
+ for (let i = 0; i <= 120; ++i) {
+ const t = curve.t0 + (curve.t3 - curve.t0) * i / 120;
+ const x = this._tToX(t);
+ if (x < -1 || x > W + 1) { started = false; continue; }
+ const y = this._ampToY(evalBezier(curve, t));
+ if (!started) { ctx.moveTo(x, y); started = true; } else ctx.lineTo(x, y);
+ }
+ ctx.stroke();
+
+ // Control points
+ for (let i = 0; i < 4; ++i) {
+ const x = this._tToX(curve['t' + i]);
+ const y = this._ampToY(curve['v' + i]);
+ ctx.fillStyle = color;
+ ctx.beginPath();
+ ctx.arc(x, y, 6, 0, 2 * Math.PI);
+ ctx.fill();
+ ctx.strokeStyle = '#fff';
+ ctx.lineWidth = 1.5;
+ ctx.stroke();
+ ctx.fillStyle = '#888';
+ ctx.font = '9px monospace';
+ ctx.fillText('P' + i, x + 8, y - 4);
+ }
+ }
+
+ _setupAmpDrag() {
+ const canvas = this._ampCanvas;
+ if (!canvas) return;
+
+ canvas.addEventListener('mousedown', (e) => {
+ if (this._selectedIndex < 0 || !this.partials) return;
+ const partial = this.partials[this._selectedIndex];
+ if (!partial || !partial.ampCurve) return;
+ const rect = canvas.getBoundingClientRect();
+ const x = e.clientX - rect.left, y = e.clientY - rect.top;
+ const curve = partial.ampCurve;
+ for (let i = 0; i < 4; ++i) {
+ if (Math.hypot(this._tToX(curve['t' + i]) - x, this._ampToY(curve['v' + i]) - y) <= 8) {
+ this._dragPointIndex = i;
+ canvas.style.cursor = 'grabbing';
+ e.preventDefault();
+ return;
+ }
+ }
+ });
+
+ canvas.addEventListener('mousemove', (e) => {
+ const rect = canvas.getBoundingClientRect();
+ const x = e.clientX - rect.left, y = e.clientY - rect.top;
+
+ if (this._dragPointIndex >= 0) {
+ const curve = this.partials[this._selectedIndex].ampCurve;
+ const i = this._dragPointIndex;
+ curve['t' + i] = Math.max(0, Math.min(this.viewer ? this.viewer.t_max : 1e6, this._xToT(x)));
+ curve['v' + i] = Math.max(0, this._yToAmp(y));
+ this._renderAmpEditor();
+ if (this.viewer) this.viewer.render();
+ e.preventDefault();
+ return;
+ }
+
+ // Cursor hint
+ if (this._selectedIndex >= 0 && this.partials) {
+ const curve = this.partials[this._selectedIndex]?.ampCurve;
+ if (curve) {
+ let near = false;
+ for (let i = 0; i < 4; ++i) {
+ if (Math.hypot(this._tToX(curve['t' + i]) - x, this._ampToY(curve['v' + i]) - y) <= 8) {
+ near = true; break;
+ }
+ }
+ canvas.style.cursor = near ? 'grab' : 'crosshair';
+ }
+ }
+ });
+
+ canvas.addEventListener('mouseup', () => {
+ if (this._dragPointIndex >= 0) {
+ this._dragPointIndex = -1;
+ canvas.style.cursor = 'crosshair';
+ this._updatePropPanel(this._selectedIndex); // sync text inputs
+ }
+ });
+ }
+
+ // --- Coordinate helpers (amp canvas) ---
+
+ _tToX(t) {
+ return (t - this._amp.tMin) / (this._amp.tMax - this._amp.tMin) * this._ampCanvas.width;
+ }
+
+ _xToT(x) {
+ return this._amp.tMin + (x / this._ampCanvas.width) * (this._amp.tMax - this._amp.tMin);
+ }
+
+ _ampToY(a) {
+ const PADY = 10, H = this._ampCanvas.height;
+ return PADY + (1 - a / this._amp.ampTop) * (H - 2 * PADY);
+ }
+
+ _yToAmp(y) {
+ const PADY = 10, H = this._ampCanvas.height;
+ return (1 - (y - PADY) / (H - 2 * PADY)) * this._amp.ampTop;
+ }
+}
diff --git a/tools/mq_editor/fft.js b/tools/mq_editor/fft.js
index 10a5b45..0d54eae 100644
--- a/tools/mq_editor/fft.js
+++ b/tools/mq_editor/fft.js
@@ -132,18 +132,20 @@ class STFTCache {
windowed[i] = frame[i] * w;
}
- // Compute FFT, store only squared amplitudes (re*re + im*im, no sqrt)
+ // Compute FFT, store squared amplitudes and phase
const fftOut = realFFT(windowed);
const squaredAmplitude = new Float32Array(this.fftSize / 2);
+ const phase = new Float32Array(this.fftSize / 2);
for (let i = 0; i < this.fftSize / 2; ++i) {
const re = fftOut[i * 2];
const im = fftOut[i * 2 + 1];
squaredAmplitude[i] = re * re + im * im;
+ phase[i] = Math.atan2(im, re); // Cache phase for tracking
const db = 10 * Math.log10(Math.max(squaredAmplitude[i], 1e-20));
if (db > this.maxDB) this.maxDB = db;
}
- this.frames.push({time, offset, squaredAmplitude});
+ this.frames.push({time, offset, squaredAmplitude, phase});
}
if (!isFinite(this.maxDB)) this.maxDB = 0;
diff --git a/tools/mq_editor/index.html b/tools/mq_editor/index.html
index 60076b3..a2daff5 100644
--- a/tools/mq_editor/index.html
+++ b/tools/mq_editor/index.html
@@ -6,6 +6,7 @@
<style>
body {
font-family: monospace;
+ font-size: 14px;
margin: 20px;
background: #1a1a1a;
color: #ddd;
@@ -39,6 +40,7 @@
}
button:hover { background: #4a4a4a; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
+ #extractBtn { background: #666; color: #fff; font-weight: bold; border-color: #888; }
input[type="file"] { display: none; }
.params {
display: inline-block;
@@ -73,19 +75,26 @@
border: 1px solid #555;
border-radius: 4px;
padding: 12px;
- min-width: 160px;
+ min-width: 260px;
+ max-width: 260px;
+ max-height: 700px;
+ overflow-y: auto;
display: flex;
flex-direction: column;
- gap: 10px;
+ gap: 6px;
+ box-sizing: border-box;
}
- .right-panel .panel-title {
- font-size: 11px;
+ .panel-title {
+ font-size: 12px;
color: #888;
text-transform: uppercase;
letter-spacing: 1px;
border-bottom: 1px solid #444;
padding-bottom: 6px;
margin-bottom: 2px;
+ display: flex;
+ align-items: center;
+ gap: 6px;
}
.right-panel label {
display: flex;
@@ -93,7 +102,141 @@
gap: 6px;
margin: 0;
cursor: pointer;
+ font-size: 14px;
+ }
+ /* Partial properties */
+ .prop-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
font-size: 13px;
+ padding: 2px 0;
+ }
+ .prop-label { color: #777; font-size: 12px; }
+ .curve-tabs {
+ display: flex;
+ gap: 2px;
+ margin-top: 8px;
+ margin-bottom: 4px;
+ }
+ .tab-btn {
+ flex: 1;
+ padding: 4px 0;
+ font-size: 12px;
+ margin: 0;
+ background: #222;
+ border-color: #444;
+ color: #888;
+ }
+ .tab-btn.active {
+ background: #3a3a3a;
+ border-color: #666;
+ color: #ddd;
+ }
+ .curve-grid {
+ display: grid;
+ grid-template-columns: 18px 1fr 1fr;
+ gap: 3px 4px;
+ align-items: center;
+ }
+ .curve-grid span { color: #666; font-size: 11px; }
+ .curve-grid input[type="number"] {
+ width: 100%;
+ background: #333;
+ color: #ccc;
+ border: 1px solid #444;
+ padding: 2px 4px;
+ border-radius: 2px;
+ font-size: 11px;
+ 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: 8px;
+ }
+ .partial-actions button {
+ flex: 1;
+ padding: 4px 6px;
+ font-size: 12px;
+ margin: 0;
+ }
+ .synth-grid {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: 4px 8px;
+ align-items: center;
+ }
+ .synth-grid span { color: #888; font-size: 12px; }
+ .synth-field-wrap {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ }
+ .synth-field-wrap input[type="number"] {
+ flex: 1;
+ min-width: 0;
+ background: #333;
+ color: #ccc;
+ border: 1px solid #444;
+ padding: 2px 4px;
+ border-radius: 2px;
+ font-size: 11px;
+ font-family: monospace;
+ box-sizing: border-box;
+ }
+ .synth-field-wrap input[type="number"]:focus { border-color: #666; outline: none; }
+ .jog-slider {
+ width: 44px;
+ height: 16px;
+ background: #1e1e1e;
+ border: 1px solid #444;
+ border-radius: 3px;
+ cursor: ew-resize;
+ position: relative;
+ flex-shrink: 0;
+ user-select: none;
+ overflow: hidden;
+ }
+ .jog-slider::before {
+ content: '';
+ position: absolute;
+ left: 50%; top: 3px; bottom: 3px;
+ width: 1px;
+ background: #484848;
+ transform: translateX(-50%);
+ }
+ .jog-thumb {
+ position: absolute;
+ top: 3px; bottom: 3px;
+ width: 6px;
+ background: #888;
+ border-radius: 2px;
+ left: calc(50% - 3px);
+ transition: left 0.12s ease;
+ }
+ .jog-slider:hover .jog-thumb { background: #aaa; }
+ .synth-grid input[type="number"]:focus { border-color: #666; outline: none; }
+ .synth-section {
+ border-top: 1px solid #444;
+ padding-top: 8px;
+ margin-top: auto;
+ }
+ /* resonator mode badge shown in partial header color swatch area */
+ .res-badge {
+ font-size: 9px;
+ color: #8cf;
+ border: 1px solid #8cf;
+ border-radius: 2px;
+ padding: 0 3px;
+ vertical-align: middle;
+ margin-left: 4px;
+ opacity: 0.8;
}
#status {
margin-top: 10px;
@@ -118,6 +261,7 @@
<button id="chooseFileBtn">&#x1F4C2; Open WAV</button>
<button id="testWavBtn">⚗ Test WAV</button>
<button id="extractBtn" disabled>Extract Partials</button>
+ <button id="autoSpreadAllBtn" disabled>Auto Spread All</button>
<button id="playBtn" disabled>▶ Play</button>
<button id="stopBtn" disabled>■ Stop</button>
@@ -126,7 +270,14 @@
<input type="number" id="hopSize" value="256" min="64" max="1024" step="64">
<label>Threshold (dB):</label>
- <input type="number" id="threshold" value="-60" step="any">
+ <input type="number" id="threshold" value="-20" step="any">
+
+ <label>Prominence (dB):</label>
+ <input type="number" id="prominence" value="1.0" step="0.1" min="0">
+
+ <label style="margin-left:16px;" title="Weight spectrum by frequency before peak detection (f * FFT_Power(f)), accentuates high-frequency peaks">
+ <input type="checkbox" id="freqWeight"> f·Power
+ </label>
<label style="margin-left:16px;">Keep:</label>
<input type="range" id="keepPct" min="1" max="100" value="100" style="width:100px; vertical-align:middle;">
@@ -135,21 +286,98 @@
</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="curve-tabs">
+ <button class="tab-btn active" data-tab="Freq">Freq</button>
+ <button class="tab-btn" data-tab="Amp">Amp</button>
+ <button class="tab-btn" data-tab="Synth">Synth</button>
+ </div>
+ <div class="tab-pane" id="tabFreq">
+ <div class="curve-grid" id="freqCurveGrid"></div>
+ </div>
+ <div class="tab-pane" id="tabAmp" style="display:none;">
+ <div class="curve-grid" id="ampCurveGrid"></div>
+ </div>
+ <div class="tab-pane" id="tabSynth" style="display:none;">
+ <div class="synth-grid" id="synthGrid"></div>
+ </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:13px;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>
+ <label title="Test mode: force resonator synthesis for all partials (ignores per-partial mode setting)"><input type="checkbox" id="forceResonator"> Resonator (all)</label>
+ <div id="globalResParams" style="display:none;margin-top:4px;padding:4px 0 2px 12px;border-left:2px solid #555;">
+ <label style="display:flex;align-items:center;gap:6px;" title="Global pole radius r in (0,1). Applied to all partials in resonator mode.">
+ r (pole)
+ <input type="range" id="globalR" min="0.75" max="0.9999" step="0.0001" value="0.995" style="flex:1;min-width:0;">
+ <span id="globalRVal" style="width:44px;text-align:right;">0.9950</span>
+ </label>
+ <label style="display:flex;align-items:center;gap:6px;" title="Global gain compensation applied to all partials in resonator mode.">
+ gain
+ <input type="range" id="globalGain" min="0.0" max="4.0" step="0.01" value="1.0" style="flex:1;min-width:0;">
+ <span id="globalGainVal" style="width:44px;text-align:right;">1.00</span>
+ </label>
+ <label title="Override per-partial r/gain with global values during playback"><input type="checkbox" id="forceRGain"> force r/gain</label>
+ </div>
+ <div style="margin-top:6px;">
+ <label style="display:flex;align-items:center;gap:6px;" title="LP filter coefficient k1 in (0,1]. 1.0 = bypass.">
+ LP k1
+ <input type="range" id="lpK1" min="0.001" max="1.0" step="0.001" value="1.0" style="flex:1;min-width:0;">
+ <span id="lpK1Val" style="width:44px;text-align:right;">bypass</span>
+ </label>
+ <label style="display:flex;align-items:center;gap:6px;" title="HP filter coefficient k2 in (0,1]. 1.0 = bypass.">
+ HP k2
+ <input type="range" id="hpK2" min="0.001" max="1.0" step="0.001" value="1.0" style="flex:1;min-width:0;">
+ <span id="hpK2Val" style="width:44px;text-align:right;">bypass</span>
+ </label>
+ </div>
+ </div>
</div>
</div>
@@ -161,7 +389,47 @@
<script src="mq_extract.js"></script>
<script src="mq_synth.js"></script>
<script src="viewer.js"></script>
+ <script src="editor.js"></script>
<script>
+ // LP: y[n] = k*x[n] + (1-k)*y[n-1] => -3dB at cos(w) = (2-2k-k²)/(2(1-k))
+ function k1ToHz(k, sr) {
+ if (k >= 1.0) return sr / 2;
+ const cosW = (2 - 2*k - k*k) / (2*(1 - k));
+ return Math.acos(Math.max(-1, Math.min(1, cosW))) * sr / (2 * Math.PI);
+ }
+ // HP: y[n] = k*(y[n-1]+x[n]-x[n-1]) => -3dB from peak at cos(w) = 2k/(1+k²)
+ function k2ToHz(k, sr) {
+ if (k >= 1.0) return 0;
+ const cosW = 2*k / (1 + k*k);
+ return Math.acos(Math.max(-1, Math.min(1, cosW))) * sr / (2 * Math.PI);
+ }
+ function fmtHz(f) {
+ return f >= 1000 ? (f/1000).toFixed(1) + 'k' : Math.round(f) + 'Hz';
+ }
+ function getSR() { return (typeof audioBuffer !== 'undefined' && audioBuffer) ? audioBuffer.sampleRate : 44100; }
+
+ // LP/HP slider live display
+ document.getElementById('lpK1').addEventListener('input', function() {
+ const k = parseFloat(this.value);
+ const f = k1ToHz(k, getSR());
+ document.getElementById('lpK1Val').textContent = k >= 1.0 ? 'bypass' : fmtHz(f);
+ });
+ document.getElementById('hpK2').addEventListener('input', function() {
+ const k = parseFloat(this.value);
+ const f = k2ToHz(k, getSR());
+ document.getElementById('hpK2Val').textContent = k >= 1.0 ? 'bypass' : fmtHz(f);
+ });
+
+ // Show/hide global resonator params when forceResonator toggled
+ document.getElementById('forceResonator').addEventListener('change', function() {
+ document.getElementById('globalResParams').style.display = this.checked ? '' : 'none';
+ });
+ document.getElementById('globalR').addEventListener('input', function() {
+ document.getElementById('globalRVal').textContent = parseFloat(this.value).toFixed(4);
+ });
+ document.getElementById('globalGain').addEventListener('input', function() {
+ document.getElementById('globalGainVal').textContent = parseFloat(this.value).toFixed(2);
+ });
let audioBuffer = null;
let viewer = null;
let audioContext = null;
@@ -172,6 +440,7 @@
const wavFile = document.getElementById('wavFile');
const chooseFileBtn = document.getElementById('chooseFileBtn');
const extractBtn = document.getElementById('extractBtn');
+ const autoSpreadAllBtn = document.getElementById('autoSpreadAllBtn');
const playBtn = document.getElementById('playBtn');
const stopBtn = document.getElementById('stopBtn');
const canvas = document.getElementById('canvas');
@@ -180,6 +449,8 @@
const hopSize = document.getElementById('hopSize');
const threshold = document.getElementById('threshold');
+ const prominence = document.getElementById('prominence');
+ const freqWeightCb = document.getElementById('freqWeight');
const keepPct = document.getElementById('keepPct');
const keepPctLabel = document.getElementById('keepPctLabel');
const fftSize = 1024; // Fixed
@@ -193,6 +464,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) {
@@ -208,11 +486,18 @@
playBtn.disabled = false;
setStatus('Computing STFT cache...', 'info');
+ // Reset partials from previous file
+ extractedPartials = null;
+ editor.setPartials(null);
+
setTimeout(() => {
const signal = audioBuffer.getChannelData(0);
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);
}
@@ -283,6 +568,8 @@
fftSize: fftSize,
hopSize: parseInt(hopSize.value),
threshold: parseFloat(threshold.value),
+ prominence: parseFloat(prominence.value),
+ freqWeight: freqWeightCb.checked,
sampleRate: audioBuffer.sampleRate
};
@@ -296,29 +583,52 @@
});
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');
console.error(err);
}
extractBtn.disabled = false;
+ autoSpreadAllBtn.disabled = false;
}, 50);
}
- // Extract partials
extractBtn.addEventListener('click', () => {
if (!audioBuffer) return;
runExtraction();
});
+ autoSpreadAllBtn.addEventListener('click', () => {
+ if (!extractedPartials || !stftCache) return;
+ const fs = stftCache.fftSize;
+ const sr = audioBuffer.sampleRate;
+ const defaults = { decay_alpha: 0.1, jitter: 0.05, spread_above: 0.02, spread_below: 0.02 };
+ for (const p of extractedPartials) {
+ const {spread_above, spread_below} = autodetectSpread(p, stftCache, fs, sr);
+ if (!p.replicas) p.replicas = { ...defaults };
+ p.replicas.spread_above = spread_above;
+ p.replicas.spread_below = spread_below;
+ }
+ if (viewer) viewer.render();
+ const sel = viewer ? viewer.selectedPartial : -1;
+ if (sel >= 0) editor.onPartialSelect(sel);
+ setStatus(`Auto-spread applied to ${extractedPartials.length} partials`, 'info');
+ });
+
threshold.addEventListener('change', () => {
if (stftCache) runExtraction();
});
+ freqWeightCb.addEventListener('change', () => {
+ if (stftCache) runExtraction();
+ });
+
function playAudioBuffer(buffer, statusMsg) {
const startTime = audioContext.currentTime;
currentSource = audioContext.createBufferSource();
@@ -384,14 +694,23 @@
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;
- const disableSpread = document.getElementById('disableSpread').checked;
+ const integratePhase = document.getElementById('integratePhase').checked;
+ const disableJitter = document.getElementById('disableJitter').checked;
+ const disableSpread = document.getElementById('disableSpread').checked;
+ const forceResonator = document.getElementById('forceResonator').checked;
+ const lpK1Raw = parseFloat(document.getElementById('lpK1').value);
+ const hpK2Raw = parseFloat(document.getElementById('hpK2').value);
+ const k1 = lpK1Raw < 1.0 ? lpK1Raw : null;
+ const k2 = hpK2Raw < 1.0 ? hpK2Raw : null;
+ const forceRGain = forceResonator && document.getElementById('forceRGain').checked;
+ const globalR = parseFloat(document.getElementById('globalR').value);
+ const globalGain = parseFloat(document.getElementById('globalGain').value);
const pcm = synthesizeMQ(partialsToUse, audioBuffer.sampleRate, audioBuffer.duration,
- integratePhase, {disableJitter, disableSpread});
+ integratePhase, {disableJitter, disableSpread, forceResonator,
+ forceRGain, globalR, globalGain, k1, k2});
if (viewer) {
viewer.setSynthStftCache(new STFTCache(pcm, audioBuffer.sampleRate, fftSize, parseInt(hopSize.value)));
@@ -399,7 +718,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,9 +741,24 @@
viewer.showSynthFFT = !viewer.showSynthFFT;
viewer.renderSpectrum();
}
+ } else if (e.code === 'KeyE') {
+ e.preventDefault();
+ if (!extractBtn.disabled) extractBtn.click();
+ } else if (e.code === 'Escape') {
+ if (viewer) viewer.selectPartial(-1);
}
});
+ // Curve tab switching
+ document.querySelectorAll('.tab-btn').forEach(btn => {
+ btn.addEventListener('click', () => {
+ document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
+ btn.classList.add('active');
+ document.querySelectorAll('.tab-pane').forEach(p => p.style.display = 'none');
+ document.getElementById('tab' + btn.dataset.tab).style.display = '';
+ });
+ });
+
// --- Test WAV peak validation ---
function validateTestWAVPeaks(cache) {
const SR = cache.sampleRate;
diff --git a/tools/mq_editor/mq_extract.js b/tools/mq_editor/mq_extract.js
index 8a0ea0e..a530960 100644
--- a/tools/mq_editor/mq_extract.js
+++ b/tools/mq_editor/mq_extract.js
@@ -3,18 +3,19 @@
// Extract partials from audio buffer
function extractPartials(params, stftCache) {
- const {fftSize, threshold, sampleRate} = params;
+ const {fftSize, threshold, sampleRate, freqWeight, prominence} = params;
const numFrames = stftCache.getNumFrames();
const frames = [];
for (let i = 0; i < numFrames; ++i) {
const cachedFrame = stftCache.getFrameAtIndex(i);
- const squaredAmp = stftCache.getSquaredAmplitude(cachedFrame.time);
- const peaks = detectPeaks(squaredAmp, fftSize, sampleRate, threshold);
+ const squaredAmp = cachedFrame.squaredAmplitude;
+ const phase = cachedFrame.phase;
+ const peaks = detectPeaks(squaredAmp, phase, fftSize, sampleRate, threshold, freqWeight, prominence);
frames.push({time: cachedFrame.time, peaks});
}
- const partials = trackPartials(frames);
+ const partials = trackPartials(frames, params);
// Second pass: extend partials leftward to recover onset frames
expandPartialsLeft(partials, frames);
@@ -27,12 +28,32 @@ function extractPartials(params, stftCache) {
return {partials, frames};
}
+// Helper to interpolate phase via quadratic formula on unwrapped neighbors.
+// This provides a more accurate phase estimate at the sub-bin peak location.
+function phaseInterp(p_minus, p_center, p_plus, p_frac) {
+ // unwrap neighbors relative to center
+ let dp_minus = p_minus - p_center;
+ while (dp_minus > Math.PI) dp_minus -= 2 * Math.PI;
+ while (dp_minus < -Math.PI) dp_minus += 2 * Math.PI;
+
+ let dp_plus = p_plus - p_center;
+ while (dp_plus > Math.PI) dp_plus -= 2 * Math.PI;
+ while (dp_plus < -Math.PI) dp_plus += 2 * Math.PI;
+
+ const p_interp = p_center + (dp_plus - dp_minus) * p_frac * 0.5 + (dp_plus + dp_minus) * p_frac * p_frac;
+ return p_interp;
+}
+
// Detect spectral peaks via local maxima + parabolic interpolation
// squaredAmp: pre-computed re*re+im*im per bin
-function detectPeaks(squaredAmp, fftSize, sampleRate, thresholdDB) {
+// phase: pre-computed atan2(im,re) per bin
+// freqWeight: if true, weight by f before peak detection (f * Power(f))
+function detectPeaks(squaredAmp, phase, fftSize, sampleRate, thresholdDB, freqWeight, prominenceDB = 0) {
const mag = new Float32Array(fftSize / 2);
+ const binHz = sampleRate / fftSize;
for (let i = 0; i < fftSize / 2; ++i) {
- mag[i] = 10 * Math.log10(Math.max(squaredAmp[i], 1e-20));
+ const w = freqWeight ? (i * binHz) : 1.0;
+ mag[i] = 10 * Math.log10(Math.max(squaredAmp[i] * w, 1e-20));
}
const peaks = [];
@@ -41,23 +62,49 @@ function detectPeaks(squaredAmp, fftSize, sampleRate, thresholdDB) {
mag[i] > mag[i-1] && mag[i] > mag[i-2] &&
mag[i] > mag[i+1] && mag[i] > mag[i+2]) {
- // Parabolic interpolation for sub-bin accuracy
+ // Check prominence if requested
+ if (prominenceDB > 0) {
+ let minLeft = mag[i];
+ for (let k = i - 1; k >= 0; --k) {
+ if (mag[k] > mag[i]) break; // Found higher peak
+ if (mag[k] < minLeft) minLeft = mag[k];
+ }
+
+ let minRight = mag[i];
+ for (let k = i + 1; k < mag.length; ++k) {
+ if (mag[k] > mag[i]) break; // Found higher peak
+ if (mag[k] < minRight) minRight = mag[k];
+ }
+
+ const valley = Math.max(minLeft, minRight);
+ if (mag[i] - valley < prominenceDB) continue;
+ }
+
+ // Parabolic interpolation for sub-bin accuracy on frequency, amplitude, and phase
const alpha = mag[i-1];
const beta = mag[i];
const gamma = mag[i+1];
const p = 0.5 * (alpha - gamma) / (alpha - 2*beta + gamma);
+
+ const p_phase = phaseInterp(phase[i-1], phase[i], phase[i+1], p);
const freq = (i + p) * sampleRate / fftSize;
const ampDB = beta - 0.25 * (alpha - gamma) * p;
- peaks.push({freq, amp: Math.pow(10, ampDB / 20)});
+ peaks.push({freq, amp: Math.pow(10, ampDB / 20), phase: p_phase});
}
}
return peaks;
}
-// Track partials across frames (birth/death/continuation)
-function trackPartials(frames) {
+// Helper to compute shortest angle difference (e.g., between -pi and pi)
+function normalizeAngle(angle) {
+ return angle - 2 * Math.PI * Math.floor((angle + Math.PI) / (2 * Math.PI));
+}
+
+// Track partials across frames using phase coherence for robust matching.
+function trackPartials(frames, params) {
+ const { sampleRate, hopSize } = params;
const partials = [];
const activePartials = [];
const candidates = []; // pre-birth
@@ -68,19 +115,37 @@ function trackPartials(frames) {
const deathAge = 5; // frames without match before death
const minLength = 10; // frames required to keep partial
+ // Weight phase error heavily in cost function, scaled by frequency.
+ // This makes phase deviation more significant for high-frequency partials.
+ const phaseErrorWeight = 2.0;
+
for (const frame of frames) {
const matched = new Set();
- // Continue active partials
+ // --- Continue active partials ---
for (const partial of activePartials) {
const lastFreq = partial.freqs[partial.freqs.length - 1];
- const tol = Math.max(lastFreq * trackingRatio, minTrackingHz);
- let bestIdx = -1, bestDist = Infinity;
+ const lastPhase = partial.phases[partial.phases.length - 1];
+ const velocity = partial.velocity || 0;
+ const predictedFreq = lastFreq + velocity;
+
+ // Predict phase for the current frame based on the last frame's frequency.
+ const phaseAdvance = 2 * Math.PI * lastFreq * hopSize / sampleRate;
+ const predictedPhase = lastPhase + phaseAdvance;
+
+ const tol = Math.max(predictedFreq * trackingRatio, minTrackingHz);
+ let bestIdx = -1, bestCost = Infinity;
+ // Find the peak in the new frame with the lowest cost (freq + phase error).
for (let i = 0; i < frame.peaks.length; ++i) {
if (matched.has(i)) continue;
- const dist = Math.abs(frame.peaks[i].freq - lastFreq);
- if (dist < tol && dist < bestDist) { bestDist = dist; bestIdx = i; }
+ const pk = frame.peaks[i];
+ const freqError = Math.abs(pk.freq - predictedFreq);
+ if (freqError > tol) continue;
+
+ const phaseError = Math.abs(normalizeAngle(pk.phase - predictedPhase));
+ const cost = freqError + phaseErrorWeight * phaseError * predictedFreq;
+ if (cost < bestCost) { bestCost = cost; bestIdx = i; }
}
if (bestIdx >= 0) {
@@ -88,24 +153,38 @@ function trackPartials(frames) {
partial.times.push(frame.time);
partial.freqs.push(pk.freq);
partial.amps.push(pk.amp);
+ partial.phases.push(pk.phase);
partial.age = 0;
+ partial.velocity = pk.freq - lastFreq;
matched.add(bestIdx);
} else {
partial.age++;
}
}
- // Advance candidates
+ // --- Advance candidates ---
for (let i = candidates.length - 1; i >= 0; --i) {
const cand = candidates[i];
const lastFreq = cand.freqs[cand.freqs.length - 1];
- const tol = Math.max(lastFreq * trackingRatio, minTrackingHz);
- let bestIdx = -1, bestDist = Infinity;
+ const lastPhase = cand.phases[cand.phases.length - 1];
+ const velocity = cand.velocity || 0;
+ const predictedFreq = lastFreq + velocity;
+
+ const phaseAdvance = 2 * Math.PI * lastFreq * hopSize / sampleRate;
+ const predictedPhase = lastPhase + phaseAdvance;
+
+ const tol = Math.max(predictedFreq * trackingRatio, minTrackingHz);
+ let bestIdx = -1, bestCost = Infinity;
for (let j = 0; j < frame.peaks.length; ++j) {
if (matched.has(j)) continue;
- const dist = Math.abs(frame.peaks[j].freq - lastFreq);
- if (dist < tol && dist < bestDist) { bestDist = dist; bestIdx = j; }
+ const pk = frame.peaks[j];
+ const freqError = Math.abs(pk.freq - predictedFreq);
+ if (freqError > tol) continue;
+
+ const phaseError = Math.abs(normalizeAngle(pk.phase - predictedPhase));
+ const cost = freqError + phaseErrorWeight * phaseError * predictedFreq;
+ if (cost < bestCost) { bestCost = cost; bestIdx = j; }
}
if (bestIdx >= 0) {
@@ -113,24 +192,34 @@ function trackPartials(frames) {
cand.times.push(frame.time);
cand.freqs.push(pk.freq);
cand.amps.push(pk.amp);
+ cand.phases.push(pk.phase);
+ cand.velocity = pk.freq - lastFreq;
matched.add(bestIdx);
+ // "graduate" a candidate to a full partial
if (cand.times.length >= birthPersistence) {
activePartials.push(cand);
candidates.splice(i, 1);
}
} else {
- candidates.splice(i, 1);
+ candidates.splice(i, 1); // kill candidate
}
}
- // Spawn candidates from unmatched peaks
+ // --- Spawn new candidates from unmatched peaks ---
for (let i = 0; i < frame.peaks.length; ++i) {
if (matched.has(i)) continue;
const pk = frame.peaks[i];
- candidates.push({times: [frame.time], freqs: [pk.freq], amps: [pk.amp], age: 0});
+ candidates.push({
+ times: [frame.time],
+ freqs: [pk.freq],
+ amps: [pk.amp],
+ phases: [pk.phase],
+ age: 0,
+ velocity: 0
+ });
}
- // Kill aged-out partials
+ // --- Kill aged-out partials ---
for (let i = activePartials.length - 1; i >= 0; --i) {
if (activePartials[i].age > deathAge) {
if (activePartials[i].times.length >= minLength) partials.push(activePartials[i]);
@@ -139,7 +228,7 @@ function trackPartials(frames) {
}
}
- // Collect remaining active partials
+ // --- Collect remaining active partials ---
for (const partial of activePartials) {
if (partial.times.length >= minLength) partials.push(partial);
}
@@ -158,6 +247,8 @@ function expandPartialsLeft(partials, frames) {
for (let i = 0; i < frames.length; ++i) timeToIdx.set(frames[i].time, i);
for (const partial of partials) {
+ if (!partial.phases) partial.phases = []; // Ensure old partials have phase array
+
let startIdx = timeToIdx.get(partial.times[0]);
if (startIdx == null || startIdx === 0) continue;
@@ -178,23 +269,130 @@ function expandPartialsLeft(partials, frames) {
partial.times.unshift(frame.time);
partial.freqs.unshift(pk.freq);
partial.amps.unshift(pk.amp);
+ partial.phases.unshift(pk.phase);
+ }
+ }
+}
+
+// Autodetect spread_above / spread_below from the spectrogram.
+// For each (subsampled) STFT frame within the partial, measures the
+// half-power (-3dB) width of the spectral peak above and below the center.
+// spread = half_bandwidth / f0 (fractional).
+function autodetectSpread(partial, stftCache, fftSize, sampleRate) {
+ const curve = partial.freqCurve;
+ if (!curve || !stftCache) return {spread_above: 0.02, spread_below: 0.02};
+
+ const numFrames = stftCache.getNumFrames();
+ const binHz = sampleRate / fftSize;
+ const halfBins = fftSize / 2;
+
+ let sumAbove = 0, sumBelow = 0, count = 0;
+
+ const STEP = 4;
+ for (let fi = 0; fi < numFrames; fi += STEP) {
+ const frame = stftCache.getFrameAtIndex(fi);
+ if (!frame) continue;
+ const t = frame.time;
+ if (t < curve.t0 || t > curve.t3) continue;
+
+ const f0 = evalBezier(curve, t);
+ if (f0 <= 0) continue;
+
+ const sq = frame.squaredAmplitude;
+ if (!sq) continue;
+
+ // Find peak bin in ±10% window
+ const binCenter = f0 / binHz;
+ const searchBins = Math.max(3, Math.round(f0 * 0.10 / binHz));
+ const binLo = Math.max(1, Math.floor(binCenter - searchBins));
+ const binHi = Math.min(halfBins - 2, Math.ceil(binCenter + searchBins));
+
+ let peakBin = binLo, peakVal = sq[binLo];
+ for (let b = binLo + 1; b <= binHi; ++b) {
+ if (sq[b] > peakVal) { peakVal = sq[b]; peakBin = b; }
}
+
+ const halfPower = peakVal * 0.5; // -3dB in power
+
+ // Walk above peak until half-power, interpolate crossing
+ let aboveBin = peakBin;
+ while (aboveBin < halfBins - 1 && sq[aboveBin] > halfPower) ++aboveBin;
+ const tA = aboveBin > peakBin && sq[aboveBin - 1] !== sq[aboveBin]
+ ? (halfPower - sq[aboveBin - 1]) / (sq[aboveBin] - sq[aboveBin - 1])
+ : 0;
+ const widthAbove = (aboveBin - 1 + tA - peakBin) * binHz;
+
+ // Walk below peak until half-power, interpolate crossing
+ let belowBin = peakBin;
+ while (belowBin > 1 && sq[belowBin] > halfPower) --belowBin;
+ const tB = belowBin < peakBin && sq[belowBin + 1] !== sq[belowBin]
+ ? (halfPower - sq[belowBin + 1]) / (sq[belowBin] - sq[belowBin + 1])
+ : 0;
+ const widthBelow = (peakBin - belowBin - 1 + tB) * binHz;
+
+ sumAbove += (widthAbove / f0) * (widthAbove / f0);
+ sumBelow += (widthBelow / f0) * (widthBelow / f0);
+ ++count;
}
+
+ const spread_above = count > 0 ? Math.sqrt(sumAbove / count) : 0.01;
+ const spread_below = count > 0 ? Math.sqrt(sumBelow / count) : 0.01;
+ return {spread_above, spread_below};
}
-// Fit cubic bezier to trajectory using samples at ~1/3 and ~2/3 as control points
+// Fit cubic bezier to trajectory using least-squares for inner control points
function fitBezier(times, values) {
const n = times.length - 1;
const t0 = times[0], v0 = values[0];
const t3 = times[n], v3 = values[n];
const dt = t3 - t0;
- if (dt <= 0 || n === 0) {
- return {t0, v0, t1: t0, v1: v0, t2: t3, v2: v3, t3, v3};
+ if (dt <= 1e-9 || n < 2) {
+ // Linear fallback for too few points or zero duration
+ return {t0, v0, t1: t0 + dt / 3, v1: v0 + (v3 - v0) / 3, t2: t0 + 2 * dt / 3, v2: v0 + 2 * (v3 - v0) / 3, t3, v3};
}
- const v1 = values[Math.round(n / 3)];
- const v2 = values[Math.round(2 * n / 3)];
+ // Least squares solve for v1, v2
+ // Bezier: B(u) = (1-u)^3*v0 + 3(1-u)^2*u*v1 + 3(1-u)*u^2*v2 + u^3*v3
+ // Target_i = val_i - (1-u)^3*v0 - u^3*v3
+ // Model_i = A_i*v1 + B_i*v2
+ // A_i = 3(1-u)^2*u
+ // B_i = 3(1-u)*u^2
+
+ let sA2 = 0, sB2 = 0, sAB = 0, sAT = 0, sBT = 0;
+
+ for (let i = 0; i <= n; ++i) {
+ const u = (times[i] - t0) / dt;
+ const u2 = u * u;
+ const u3 = u2 * u;
+ const invU = 1.0 - u;
+ const invU2 = invU * invU;
+ const invU3 = invU2 * invU;
+
+ const A = 3 * invU2 * u;
+ const B = 3 * invU * u2;
+ const target = values[i] - (invU3 * v0 + u3 * v3);
+
+ sA2 += A * A;
+ sB2 += B * B;
+ sAB += A * B;
+ sAT += A * target;
+ sBT += B * target;
+ }
+
+ const det = sA2 * sB2 - sAB * sAB;
+ let v1, v2;
+
+ if (Math.abs(det) < 1e-9) {
+ // Fallback to simple 1/3, 2/3 heuristic if matrix is singular
+ const idx1 = Math.round(n / 3);
+ const idx2 = Math.round(2 * n / 3);
+ v1 = values[idx1];
+ v2 = values[idx2];
+ } else {
+ v1 = (sB2 * sAT - sAB * sBT) / det;
+ v2 = (sA2 * sBT - sAB * sAT) / det;
+ }
return {t0, v0, t1: t0 + dt / 3, v1, t2: t0 + 2 * dt / 3, v2, t3, v3};
}
diff --git a/tools/mq_editor/mq_synth.js b/tools/mq_editor/mq_synth.js
index 1eec709..2d4cf1b 100644
--- a/tools/mq_editor/mq_synth.js
+++ b/tools/mq_editor/mq_synth.js
@@ -1,5 +1,5 @@
// MQ Synthesizer
-// Replica oscillator bank for sinusoidal synthesis
+// Replica oscillator bank for sinusoidal synthesis, plus two-pole resonator mode
// Evaluate cubic bezier curve at time t
function evalBezier(curve, t) {
@@ -21,10 +21,13 @@ function randFloat(seed, min, max) {
}
// Synthesize audio from MQ partials
-// partials: array of {freqCurve, ampCurve, replicas?}
-// replicas: {offsets, decay_alpha, jitter, spread_above, spread_below}
+// partials: array of {freqCurve, ampCurve, replicas?, resonator?}
+// replicas: {offsets, decay_alpha, jitter, spread_above, spread_below}
+// resonator: {enabled, r, gainComp} — two-pole resonator mode per partial
// integratePhase: true = accumulate 2π*f/SR per sample (correct for varying freq)
// false = 2π*f*t (simpler, only correct for constant freq)
+// options.k1: LP coefficient in (0,1] — omit to bypass
+// options.k2: HP coefficient in (0,1] — omit to bypass
function synthesizeMQ(partials, sampleRate, duration, integratePhase = true, options = {}) {
const numSamples = Math.floor(sampleRate * duration);
const pcm = new Float32Array(numSamples);
@@ -43,27 +46,45 @@ function synthesizeMQ(partials, sampleRate, duration, integratePhase = true, opt
// Pre-build per-partial configs with fixed spread/jitter and phase accumulators
const configs = [];
for (let p = 0; p < partials.length; ++p) {
- const rep = partials[p].replicas != null ? partials[p].replicas : defaultReplicas;
- const offsets = rep.offsets != null ? rep.offsets : [1.0];
- const decay_alpha = rep.decay_alpha != null ? rep.decay_alpha : 0.0;
- const jitter = rep.jitter != null ? rep.jitter : 0.0;
- const spread_above = rep.spread_above != null ? rep.spread_above : 0.0;
- const spread_below = rep.spread_below != null ? rep.spread_below : 0.0;
+ const partial = partials[p];
+ const fc = partial.freqCurve;
+ const ac = partial.ampCurve;
- const replicaData = [];
- for (let r = 0; r < offsets.length; ++r) {
- // Fixed per-replica spread (frequency detuning) and initial phase (jitter)
- const spread = spreadMult * randFloat(p * 67890 + r * 999, -spread_below, spread_above);
- const initPhase = randFloat(p * 67890 + r, 0.0, 1.0) * (jitter * jitterMult) * 2.0 * Math.PI;
- replicaData.push({ratio: offsets[r], spread, phase: initPhase});
- }
+ if ((partial.resonator && partial.resonator.enabled) || options.forceResonator) {
+ // --- Two-pole resonator mode ---
+ // Driven by band-limited noise scaled by amp curve.
+ // r controls pole radius (bandwidth): r→1 = narrow, r→0 = wide.
+ // gainNorm = sqrt(1 - r²) normalises steady-state output power to ~A.
+ const res = partial.resonator || {};
+ const r = options.forceRGain ? Math.min(0.9999, Math.max(0, options.globalR))
+ : (res.r != null ? Math.min(0.9999, Math.max(0, res.r)) : 0.995);
+ const gainComp = options.forceRGain ? options.globalGain
+ : (res.gainComp != null ? res.gainComp : 1.0);
+ const gainNorm = Math.sqrt(Math.max(0, 1.0 - r * r));
+ configs.push({
+ mode: 'resonator',
+ fc, ac,
+ r, gainComp, gainNorm,
+ y1: 0.0, y2: 0.0,
+ noiseSeed: ((p * 1664525 + 1013904223) & 0xFFFFFFFF) >>> 0
+ });
+ } else {
+ // --- Sinusoidal (replica) mode ---
+ const rep = partial.replicas != null ? partial.replicas : defaultReplicas;
+ const offsets = rep.offsets != null ? rep.offsets : [1.0];
+ const decay_alpha = rep.decay_alpha != null ? rep.decay_alpha : 0.0;
+ const jitter = rep.jitter != null ? rep.jitter : 0.0;
+ const spread_above = rep.spread_above != null ? rep.spread_above : 0.0;
+ const spread_below = rep.spread_below != null ? rep.spread_below : 0.0;
- configs.push({
- fc: partials[p].freqCurve,
- ac: partials[p].ampCurve,
- decay_alpha,
- replicaData
- });
+ const replicaData = [];
+ for (let r = 0; r < offsets.length; ++r) {
+ const spread = spreadMult * randFloat(p * 67890 + r * 999, -spread_below, spread_above);
+ const initPhase = randFloat(p * 67890 + r, 0.0, 1.0) * (jitter * jitterMult) * 2.0 * Math.PI;
+ replicaData.push({ratio: offsets[r], spread, phase: initPhase});
+ }
+ configs.push({ mode: 'sinusoid', fc, ac, decay_alpha, replicaData });
+ }
}
for (let i = 0; i < numSamples; ++i) {
@@ -71,32 +92,77 @@ function synthesizeMQ(partials, sampleRate, duration, integratePhase = true, opt
let sample = 0.0;
for (let p = 0; p < configs.length; ++p) {
- const {fc, ac, decay_alpha, replicaData} = configs[p];
- if (t < fc.t0 || t > fc.t3) continue;
+ const cfg = configs[p];
+ const {fc, ac} = cfg;
- const f0 = evalBezier(fc, t);
- const A0 = evalBezier(ac, t);
+ if (cfg.mode === 'resonator') {
+ if (t < fc.t0 || t > fc.t3) { cfg.y1 = 0.0; cfg.y2 = 0.0; continue; }
- for (let r = 0; r < replicaData.length; ++r) {
- const rep = replicaData[r];
- const f = f0 * rep.ratio * (1.0 + rep.spread);
- const A = A0 * Math.exp(-decay_alpha * Math.abs(f - f0));
+ const f0 = evalBezier(fc, t);
+ const A = evalBezier(ac, t);
+ const omega = 2.0 * Math.PI * f0 / sampleRate;
+ const b1 = 2.0 * cfg.r * Math.cos(omega);
- let phase;
- if (integratePhase) {
- rep.phase += 2.0 * Math.PI * f / sampleRate;
- phase = rep.phase;
- } else {
- phase = 2.0 * Math.PI * f * t + rep.phase;
- }
+ // LCG noise excitation (deterministic per-partial)
+ cfg.noiseSeed = (Math.imul(1664525, cfg.noiseSeed) + 1013904223) >>> 0;
+ const noise = cfg.noiseSeed / 0x100000000 * 2.0 - 1.0;
+
+ const x = A * cfg.gainNorm * noise;
+ const y = b1 * cfg.y1 - cfg.r * cfg.r * cfg.y2 + x;
+ cfg.y2 = cfg.y1;
+ cfg.y1 = y;
+ sample += y * cfg.gainComp;
+
+ } else {
+ if (t < fc.t0 || t > fc.t3) continue;
+
+ const f0 = evalBezier(fc, t);
+ const A0 = evalBezier(ac, t);
+ const {decay_alpha, replicaData} = cfg;
+
+ for (let r = 0; r < replicaData.length; ++r) {
+ const rep = replicaData[r];
+ const f = f0 * rep.ratio * (1.0 + rep.spread);
+ const A = A0 * Math.exp(-decay_alpha * Math.abs(f - f0));
- sample += A * Math.sin(phase);
+ let phase;
+ if (integratePhase) {
+ rep.phase += 2.0 * Math.PI * f / sampleRate;
+ phase = rep.phase;
+ } else {
+ phase = 2.0 * Math.PI * f * t + rep.phase;
+ }
+
+ sample += A * Math.sin(phase);
+ }
}
}
pcm[i] = sample;
}
+ // Post-synthesis filters (applied before normalization)
+ // LP: y[n] = k1*x[n] + (1-k1)*y[n-1] — options.k1 in (0,1], omit to bypass
+ // HP: y[n] = k2*(y[n-1] + x[n] - x[n-1]) — options.k2 in (0,1], omit to bypass
+ if (options.k1 != null) {
+ const k1 = Math.max(0, Math.min(1, options.k1));
+ let y = 0.0;
+ for (let i = 0; i < numSamples; ++i) {
+ y = k1 * pcm[i] + (1.0 - k1) * y;
+ pcm[i] = y;
+ }
+ }
+ if (options.k2 != null) {
+ const k2 = Math.max(0, Math.min(1, options.k2));
+ let y = 0.0, xp = 0.0;
+ for (let i = 0; i < numSamples; ++i) {
+ const x = pcm[i];
+ y = k2 * (y + x - xp);
+ xp = x;
+ pcm[i] = y;
+ }
+ }
+
// Normalize
let maxAbs = 0;
for (let i = 0; i < numSamples; ++i) maxAbs = Math.max(maxAbs, Math.abs(pcm[i]));
diff --git a/tools/mq_editor/viewer.js b/tools/mq_editor/viewer.js
index 7f6e862..76c57e2 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,178 @@ 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.12);
+ 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();
+
+ // Spread band (selected only)
+ if (isSelected && partial.freqCurve) {
+ this._renderSpreadBand(partial, color);
+ }
+
+ // 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);
+ }
+ }
+
+ _renderSpreadBand(partial, color) {
+ const {ctx} = this;
+ const curve = partial.freqCurve;
+ const rep = partial.replicas || {};
+ const sa = rep.spread_above != null ? rep.spread_above : 0.02;
+ const sb = rep.spread_below != null ? rep.spread_below : 0.02;
+
+ const STEPS = 60;
+ const upper = [], lower = [];
+ for (let i = 0; i <= STEPS; ++i) {
+ const t = curve.t0 + (curve.t3 - curve.t0) * i / STEPS;
+ if (t < this.t_view_min - 0.01 || t > this.t_view_max + 0.01) continue;
+ const f = evalBezier(curve, t);
+ upper.push([this.timeToX(t), this.freqToY(f * (1 + sa))]);
+ lower.push([this.timeToX(t), this.freqToY(f * (1 - sb))]);
+ }
+ if (upper.length < 2) return;
+
+ const savedAlpha = ctx.globalAlpha;
+
+ // Outer soft fill
+ ctx.beginPath();
+ ctx.moveTo(upper[0][0], upper[0][1]);
+ for (let i = 1; i < upper.length; ++i) ctx.lineTo(upper[i][0], upper[i][1]);
+ for (let i = lower.length - 1; i >= 0; --i) ctx.lineTo(lower[i][0], lower[i][1]);
+ ctx.closePath();
+ ctx.fillStyle = color;
+ ctx.globalAlpha = 0.13;
+ ctx.fill();
+
+ // Dashed boundary lines
+ ctx.globalAlpha = 0.75;
+ ctx.strokeStyle = color;
+ ctx.lineWidth = 1.5;
+ ctx.setLineDash([4, 3]);
+ ctx.beginPath();
+ ctx.moveTo(upper[0][0], upper[0][1]);
+ for (let i = 1; i < upper.length; ++i) ctx.lineTo(upper[i][0], upper[i][1]);
+ ctx.stroke();
+ ctx.beginPath();
+ ctx.moveTo(lower[0][0], lower[0][1]);
+ for (let i = 1; i < lower.length; ++i) ctx.lineTo(lower[i][0], lower[i][1]);
+ ctx.stroke();
+ ctx.setLineDash([]);
+
+ // 50% drop-off reference lines (dotted, dimmer)
+ const p5upper = [], p5lower = [];
+ for (let i = 0; i <= STEPS; ++i) {
+ const t = curve.t0 + (curve.t3 - curve.t0) * i / STEPS;
+ if (t < this.t_view_min - 0.01 || t > this.t_view_max + 0.01) continue;
+ const f = evalBezier(curve, t);
+ p5upper.push([this.timeToX(t), this.freqToY(f * 1.50)]);
+ p5lower.push([this.timeToX(t), this.freqToY(f * 0.50)]);
+ }
+ if (p5upper.length >= 2) {
+ ctx.globalAlpha = 0.55;
+ ctx.strokeStyle = color;
+ ctx.lineWidth = 1;
+ ctx.setLineDash([1, 5]);
+ ctx.beginPath();
+ ctx.moveTo(p5upper[0][0], p5upper[0][1]);
+ for (let i = 1; i < p5upper.length; ++i) ctx.lineTo(p5upper[i][0], p5upper[i][1]);
+ ctx.stroke();
+ ctx.beginPath();
+ ctx.moveTo(p5lower[0][0], p5lower[0][1]);
+ for (let i = 1; i < p5lower.length; ++i) ctx.lineTo(p5lower[i][0], p5lower[i][1]);
+ ctx.stroke();
+ ctx.setLineDash([]);
+ }
+
+ ctx.globalAlpha = savedAlpha;
+ }
+
+ 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 +462,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;
@@ -386,11 +493,11 @@ class SpectrogramViewer {
const bStart = Math.max(0, Math.floor(fStart / binWidth));
const bEnd = Math.min(numBins - 1, Math.ceil(fEnd / binWidth));
- let sum = 0, count = 0;
- for (let b = bStart; b <= bEnd; ++b) { sum += squaredAmp[b]; ++count; }
- if (count === 0) continue;
+ let maxSq = 0;
+ for (let b = bStart; b <= bEnd; ++b) { if (squaredAmp[b] > maxSq) maxSq = squaredAmp[b]; }
+ if (bStart > bEnd) continue;
- const magDB = 10 * Math.log10(Math.max(sum / count, 1e-20));
+ const magDB = 10 * Math.log10(Math.max(maxSq, 1e-20));
const barHeight = Math.round(this.normalizeDB(magDB, cache.maxDB) * height);
if (barHeight === 0) continue;
@@ -464,11 +571,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 +621,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 +641,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 +685,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)