diff options
| -rw-r--r-- | doc/COMPLETED.md | 12 | ||||
| -rw-r--r-- | doc/archive/MQ_EXTRACTION_IMPROVEMENTS.md | 42 | ||||
| -rw-r--r-- | tools/mq_editor/PHASE_TRACKING_PLAN.md | 73 | ||||
| -rw-r--r-- | tools/mq_editor/README.md | 79 | ||||
| -rw-r--r-- | tools/mq_editor/editor.js | 578 | ||||
| -rw-r--r-- | tools/mq_editor/fft.js | 6 | ||||
| -rw-r--r-- | tools/mq_editor/index.html | 380 | ||||
| -rw-r--r-- | tools/mq_editor/mq_extract.js | 258 | ||||
| -rw-r--r-- | tools/mq_editor/mq_synth.js | 142 | ||||
| -rw-r--r-- | tools/mq_editor/viewer.js | 418 |
10 files changed, 1772 insertions, 216 deletions
diff --git a/doc/COMPLETED.md b/doc/COMPLETED.md index 67724a1..3e02c40 100644 --- a/doc/COMPLETED.md +++ b/doc/COMPLETED.md @@ -29,6 +29,18 @@ Detailed historical documents have been moved to `doc/archive/` for reference: Use `read @doc/archive/FILENAME.md` to access archived documents. +## Recently Completed (February 18, 2026) + +- [x] **MQ Spectral Editor Improvements** + - **Goal**: Improve tracking accuracy and Bezier curve fitting for sinusoidal analysis. + - **Implementation**: + - **Predictive Kinematic Tracking**: Added velocity tracking to `mq_extract.js`. Partials now predict their next frequency (`freq + velocity`) during the search phase, improving tracking for fast glissandos and vibrato. + - **Peak Prominence Pruning**: Added `prominence` parameter (default 1.0 dB) to filtering. Discards peaks that don't stand out sufficiently from their surrounding "valley floor," reducing noise. + - **Least-Squares Bezier Fitting**: Replaced heuristic 1/3-2/3 control point placement with a proper least-squares solver for cubic Bezier curves. Minimizes global error across the entire partial trajectory. + - **UI Update**: Wired up the "Prominence" input in `index.html` to pass the value to the extraction engine. + - **Documentation**: Updated `tools/mq_editor/README.md` with new parameters and algorithm details. + - **Files**: `tools/mq_editor/mq_extract.js`, `tools/mq_editor/index.html`, `tools/mq_editor/README.md` + ## Recently Completed (February 17, 2026) - [x] **MQ Spectral Editor Phase 2: JS Synthesizer** diff --git a/doc/archive/MQ_EXTRACTION_IMPROVEMENTS.md b/doc/archive/MQ_EXTRACTION_IMPROVEMENTS.md new file mode 100644 index 0000000..7bc5c5d --- /dev/null +++ b/doc/archive/MQ_EXTRACTION_IMPROVEMENTS.md @@ -0,0 +1,42 @@ +# MQ Extraction Improvements + +This document outlines three enhancements to the partial extraction algorithm (`mq_extract.js`) to improve tracking accuracy and the quality of the resulting sinusoidal model. + +## 1. Proposal: Predictive Kinematic Tracking + +- **Problem**: The original tracking algorithm assumes a partial's frequency is relatively static between frames. It can fail to track partials with significant, rapid frequency changes (e.g., fast vibrato or glissando) if the change exceeds the fixed frequency tolerance (`trackingRatio`). + +- **Solution**: A simple kinematic model has been added to the tracking logic. + - For each active partial, we now estimate its frequency "velocity" (the rate of change between the last two frames). + - When searching for a matching peak in the next frame, the search is centered around a *predicted* frequency: `predicted_freq = last_freq + velocity`. + - This allows the tracker to anticipate movement and maintain lock on partials that are undergoing rapid, continuous change. + +- **Implementation**: + - The `trackPartials` function now stores a `velocity` property on active partial objects. + - This velocity is updated each time a new peak is added to the partial. + - The core matching logic now uses the predicted frequency as its reference. + +## 2. Proposal: Peak Prominence Pruning + +- **Problem**: The original peak detection algorithm identified any local maximum within a 5-bin window. This could lead to the detection of many small, noisy, or spurious peaks that are not musically significant, creating a large number of short, irrelevant partials for the tracker to process. + +- **Solution**: The `detectPeaks` function has been enhanced with a "prominence" filter. + - A peak's prominence is its height in decibels relative to the lowest "valley" between it and the next higher peak on either side. This measures how much a peak "stands out" from the surrounding spectral landscape. + - A new **Prominence (dB)** parameter is available in the UI. Only peaks that exceed this prominence threshold are passed to the tracking stage. + +- **Implementation**: + - After a local maximum is found, a new algorithm searches left and right to find the lowest point (the valley floor) before encountering a bin with a higher magnitude than the peak itself. + - The prominence is calculated as `peak_magnitude - valley_floor_magnitude`. + - If this value is below the user-defined threshold, the peak is discarded. This effectively prunes insignificant peaks, cleaning the data for the tracker. + +## 3. Proposal: Least-Squares Bezier Fitting + +- **Problem**: The original `fitBezier` function used a simple heuristic. It forced the Bezier curve to pass exactly through four points (start, end, and two internal points at 1/3 and 2/3 of the duration). For noisy or complex partials, this could result in a curve that did not accurately represent the partial's overall trajectory. + +- **Solution**: The heuristic has been replaced with a proper **least-squares fitting algorithm**. + - This method calculates the cubic Bezier curve that minimizes the overall error across *all* points in the partial's trajectory. + - The start and end points of the curve are fixed to match the partial's birth and death, but the two intermediate control points are mathematically optimized to produce the best possible fit to the data. + +- **Implementation**: + - A new `fitBezier` function implements the normal equations to solve the 2x2 linear system for the optimal `v1` and `v2` control point values. + - This results in a smoother, more representative curve that is less sensitive to individual noisy points within the partial. 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">📂 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) |
