diff options
Diffstat (limited to 'tools')
| -rw-r--r-- | tools/mq_editor/PHASE_TRACKING_PLAN.md | 73 | ||||
| -rw-r--r-- | tools/mq_editor/README.md | 146 | ||||
| -rw-r--r-- | tools/mq_editor/app.js | 625 | ||||
| -rw-r--r-- | tools/mq_editor/editor.js | 562 | ||||
| -rw-r--r-- | tools/mq_editor/fft.js | 6 | ||||
| -rw-r--r-- | tools/mq_editor/index.html | 657 | ||||
| -rw-r--r-- | tools/mq_editor/mq_extract.js | 461 | ||||
| -rw-r--r-- | tools/mq_editor/mq_synth.js | 154 | ||||
| -rw-r--r-- | tools/mq_editor/utils.js | 68 | ||||
| -rw-r--r-- | tools/mq_editor/viewer.js | 498 |
10 files changed, 2656 insertions, 594 deletions
diff --git a/tools/mq_editor/PHASE_TRACKING_PLAN.md b/tools/mq_editor/PHASE_TRACKING_PLAN.md new file mode 100644 index 0000000..5111692 --- /dev/null +++ b/tools/mq_editor/PHASE_TRACKING_PLAN.md @@ -0,0 +1,73 @@ +# Implementation Plan: Phase-Coherent Partial Tracking + +This document outlines the plan to integrate phase prediction into the existing MQ tracking algorithm. The core idea is to use phase coherence as a primary factor for linking peaks across frames, making the tracking more robust, especially for crossing or closely-spaced partials. + +--- + +### **Stage 1: Cache Per-Bin Phase in `fft.js`** + +**Objective:** Augment the `STFTCache` to compute and store the phase angle for every frequency bin in every frame, making it available for the tracking algorithm. + +1. **Locate the FFT Processing Loop:** + * In `tools/mq_editor/fft.js`, within the `STFTCache` class (likely in the constructor or an initialization method), find the loop that iterates through each frame to compute the FFT. + * This is where `squaredAmplitude` is currently being calculated from the `real` and `imag` components. + +2. **Compute and Store Phase:** + * In the same loop, immediately after calculating the squared amplitude, calculate the phase for each bin. + * Create a new `Float32Array` for phase, let's call it `ph`. + * Inside the bin loop, compute: `ph[k] = Math.atan2(imag[k], real[k]);` + * Store this new phase array in the frame object, parallel to the existing `squaredAmplitude` array. + + **Resulting Change in `fft.js`:** + + ```javascript + // Inside the STFTCache frame processing loop... + + // Existing code: + const sq = new Float32Array(this.fftSize / 2); + for (let k = 0; k < this.fftSize / 2; k++) { + sq[k] = real[k] * real[k] + imag[k] * imag[k]; + } + + // New code to add: + const ph = new Float32Array(this.fftSize / 2); + for (let k = 0; k < this.fftSize / 2; k++) { + ph[k] = Math.atan2(imag[k], real[k]); + } + + // Update the stored frame object + this.frames.push({ + time: t, + squaredAmplitude: sq, + phase: ph // The newly cached phase data + }); + ``` + +--- + +### **Stage 2: Utilize Phase for Tracking in `mq_extract.js`** + +**Objective:** Modify the main forward/backward tracking algorithm to use phase coherence for identifying and linking peaks. + +1. **Extract Interpolated Peak Phase:** + * In `tools/mq_editor/mq_extract.js`, find the function responsible for peak detection within a single frame (e.g., `findPeaks`). + * This function currently takes a `squaredAmplitude` array. It must now also access the corresponding `phase` array from the cached frame data. + * When a peak is found at bin `k`, use the same parabolic interpolation logic that calculates the true frequency and amplitude to also calculate the **true phase**. This involves interpolating the phase values from bins `k-1`, `k`, and `k+1`. + * **Crucially, this interpolation must handle phase wrapping.** A helper function will be needed to correctly find the shortest angular distance between phase values. + +2. **Update Tracking Data Structures:** + * The data structures holding candidate and live partials must be updated to store the phase of each point in the trajectory, not just frequency and amplitude. + +3. **Implement Phase Prediction Logic:** + * In the main tracking loop that steps from frame `n` to `n+1`: + * For each active partial, calculate its `predictedPhase` for frame `n+1`. + * `phase_delta = (2 * Math.PI * last_freq * params.hopSize) / params.sampleRate;` + * `predictedPhase = last_phase + phase_delta;` + +4. **Refine the Candidate Matching Score:** + * Modify the logic that links a partial to peaks in the next frame. + * Instead of matching based on frequency proximity alone, calculate a `cost` based on both frequency and phase deviation: + * `freqError = Math.abs(peak.freq - partial.last_freq);` + * `phaseError = Math.abs(normalize_angle(peak.phase - predictedPhase));` // Difference on a circle + * `cost = (freq_weight * freqError) + (phase_weight * phaseError);` + * The peak with the lowest `cost` below a certain threshold is the correct continuation. The `phase_weight` should be high, as a low phase error is a strong indicator of a correct match. diff --git a/tools/mq_editor/README.md b/tools/mq_editor/README.md index 4250d33..d79a2f8 100644 --- a/tools/mq_editor/README.md +++ b/tools/mq_editor/README.md @@ -9,7 +9,7 @@ open tools/mq_editor/index.html ``` 1. Click **Open WAV** (or **⚗ Test WAV** for a built-in 440+660 Hz test signal) -2. Click **Extract Partials** +2. Click **Extract Partials** (optional — Explore/Contour modes work immediately after load) 3. Press **1** to play synthesized, **2** to play original ## UI @@ -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 @@ -25,9 +25,62 @@ open tools/mq_editor/index.html ## Parameters -- **Hop Size:** 64–1024 samples (default 256) -- **Threshold:** dB floor for peak detection (default −60 dB) -- **Keep %:** slider to limit how many partials are shown/synthesized +Parameters are grouped into four sections in the toolbar. + +### STFT +| Param | Default | Description | +|-------|---------|-------------| +| **Hop** | 256 | STFT hop size in samples. Smaller = finer time resolution, more frames, slower. | + +### Peak Detection +| Param | Default | Description | +|-------|---------|-------------| +| **Threshold (dB)** | −20 | Minimum spectral peak amplitude. Peaks below this are ignored. | +| **Prominence (dB)** | 1.0 | How much a peak must rise above its surrounding valley. Suppresses weak shoulders. | +| **f·Power** | off | Weight spectrum by `f × Power(f)` before detection. Boosts high-frequency peaks relative to low-frequency ones. | + +### Tracking +| Param | Default | Description | +|-------|---------|-------------| +| **Birth** | 3 | Frames a candidate must persist before becoming a partial. Higher = fewer spurious bursts. | +| **Death** | 5 | Frames a partial can go unmatched before termination. Higher = bridges short gaps. | +| **Phase Wt** | 2.0 | Weight of phase prediction error in the peak-matching cost function. Higher = stricter phase coherence. | +| **Min Len** | 10 | Minimum frame count for a partial to survive after tracking. Discards very short partials. | + +### Filter +| Param | Default | Description | +|-------|---------|-------------| +| **Keep %** | 100% | Retain only the strongest N% of extracted partials by peak amplitude. | + +## Interactive Partial Creation + +Three ways to add partials beyond automatic extraction: + +### ⊕ Explore — Peak Tracking (key: `X`) + +Hover the mouse over any spectral peak in the spectrogram. The tracker +snaps to the nearest detected peak and runs MQ tracking forward and +backward from the cursor position, showing an **orange dashed preview**. +Click to commit the partial. + +- Works immediately after WAV load (no extraction required) +- Uses **Birth / Death / Phase Wt** params from the toolbar +- Good for: strong peaks missed by global extraction, fine-grained control + +### ≋ Contour — Iso-Energy Tracking (key: `C`) + +Hover over any region of the spectrogram. The tracker follows the +iso-energy contour at the energy level under the cursor (`E₀ = mag(t, f)`), +frame by frame, without requiring a spectral peak. Preview shown in **cyan**. +Click to commit. + +- Designed for **broad, diffuse bass regions** where no peaks are detected +- Search window: ±15% in frequency; declares a miss if nearest bin deviates >15 dB +- Spread is auto-detected on commit (naturally comes out large for broad regions) +- Good for: bass smear, noisy resonances, sub-threshold energy + +Both modes are mutually exclusive. `Escape` exits either mode. +Committed partials are prepended to the partial list with full undo support. ## Keyboard Shortcuts @@ -35,17 +88,26 @@ open tools/mq_editor/index.html |-----|--------| | `1` | Play synthesized audio | | `2` | Play original audio | +| `3` | Play selected partial alone | +| `E` | Extract Partials | +| `N` | New partial (flat 440 Hz, full duration) | +| `X` | Toggle ⊕ Explore mode (peak tracking) | +| `C` | Toggle ≋ Contour mode (iso-energy tracking) | | `P` | Toggle raw peak overlay | | `A` | Toggle mini-spectrum: original ↔ synthesized | +| `Esc` | Exit explore/contour mode · deselect partial | +| `Ctrl+Z` | Undo | +| `Ctrl+Y` / `Ctrl+Shift+Z` | Redo | | 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 +123,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:** Phase-coherent birth/death/continuation. Configurable `Birth`, `Death`, `Phase Wt`, and `Min Len`. Uses velocity prediction and phase advance to resolve ambiguous peak matches. 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 +198,16 @@ 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 + - [x] Explore mode: interactive peak tracking from mouse position (`X`) + - [x] Contour mode: iso-energy tracking for bass/diffuse regions (`C`) - [ ] Phase 4: Export (.spec + C++ code generation) ## See Also diff --git a/tools/mq_editor/app.js b/tools/mq_editor/app.js new file mode 100644 index 0000000..59849da --- /dev/null +++ b/tools/mq_editor/app.js @@ -0,0 +1,625 @@ +// MQ Editor — application glue (extracted from index.html) + +// 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; +let currentSource = null; +let extractedPartials = null; +let stftCache = null; +let exploreMode = false; // false | 'peak' | 'contour' + +function setExploreMode(mode) { // false | 'peak' | 'contour' + exploreMode = mode; + document.getElementById('exploreBtn').classList.toggle('explore-active', mode === 'peak'); + document.getElementById('contourBtn').classList.toggle('contour-active', mode === 'contour'); + if (viewer) viewer.setExploreMode(mode); +} + +// Undo/redo +const undoStack = []; +const redoStack = []; + +function _updateUndoRedoBtns() { + document.getElementById('undoBtn').disabled = undoStack.length === 0; + document.getElementById('redoBtn').disabled = redoStack.length === 0; +} + +function pushUndo() { + undoStack.push(JSON.parse(JSON.stringify(extractedPartials || []))); + redoStack.length = 0; + if (undoStack.length > 50) undoStack.shift(); + _updateUndoRedoBtns(); +} + +function _applySnapshot(snap) { + extractedPartials = snap; + editor.setPartials(snap); + if (viewer) { + viewer.setPartials(snap); + viewer.setKeepCount(snap.length > 0 ? getKeepCount() : 0); + viewer.selectPartial(-1); + } + _updateUndoRedoBtns(); +} + +function undo() { + if (!undoStack.length) return; + redoStack.push(JSON.parse(JSON.stringify(extractedPartials || []))); + _applySnapshot(undoStack.pop()); +} + +function redo() { + if (!redoStack.length) return; + undoStack.push(JSON.parse(JSON.stringify(extractedPartials || []))); + _applySnapshot(redoStack.pop()); +} + +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'); +const status = document.getElementById('status'); +const fileLabel = document.getElementById('fileLabel'); + +const hopSize = document.getElementById('hopSize'); +const threshold = document.getElementById('threshold'); +const prominence = document.getElementById('prominence'); +const freqWeightCb = document.getElementById('freqWeight'); +const birthPersistenceEl = document.getElementById('birthPersistence'); +const deathAgeEl = document.getElementById('deathAge'); +const phaseErrorWeightEl = document.getElementById('phaseErrorWeight'); +const minLengthEl = document.getElementById('minLength'); +const keepPct = document.getElementById('keepPct'); +const keepPctLabel = document.getElementById('keepPctLabel'); +const fftSize = 1024; // Fixed + +function getKeepCount() { + return Math.max(1, Math.ceil(extractedPartials.length * parseInt(keepPct.value) / 100)); +} + +keepPct.addEventListener('input', () => { + keepPctLabel.textContent = keepPct.value + '%'; + if (viewer && extractedPartials) viewer.setKeepCount(getKeepCount()); +}); + +// --- Editor --- +const editor = new PartialEditor(); +editor.onPartialDeleted = () => { + if (viewer && extractedPartials) + viewer.setKeepCount(extractedPartials.length > 0 ? getKeepCount() : 0); +}; +editor.onBeforeChange = pushUndo; + +// Initialize audio context +function initAudioContext() { + if (!audioContext) { + audioContext = new (window.AudioContext || window.webkitAudioContext)(); + } +} + +// Shared: initialize editor from an AudioBuffer +function loadAudioBuffer(buffer, label) { + audioBuffer = buffer; + initAudioContext(); + extractBtn.disabled = false; + 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'); + + // Pre-compute peak frames so explore mode works immediately (before Extract) + const peakFrames = []; + for (let i = 0; i < stftCache.getNumFrames(); ++i) { + const f = stftCache.getFrameAtIndex(i); + peakFrames.push({ + time: f.time, + peaks: detectPeaks(f.squaredAmplitude, f.phase, fftSize, audioBuffer.sampleRate, + parseFloat(threshold.value), freqWeightCb.checked, + parseFloat(prominence.value)), + }); + } + + viewer = new SpectrogramViewer(canvas, audioBuffer, stftCache); + viewer.setFrames(peakFrames); + document.getElementById('exploreBtn').disabled = false; + document.getElementById('contourBtn').disabled = false; + editor.setViewer(viewer); + viewer.onPartialSelect = (i) => editor.onPartialSelect(i); + viewer.onRender = () => editor.onRender(); + viewer.onBeforeChange = pushUndo; + viewer.onExploreMove = (time, freq) => { + let partial = null; + if (exploreMode === 'peak') { + if (!viewer.frames || viewer.frames.length === 0) return; + partial = trackFromSeed(viewer.frames, time, freq, { + hopSize: Math.max(64, parseInt(hopSize.value) || 64), + sampleRate: audioBuffer.sampleRate, + deathAge: parseInt(deathAgeEl.value), + phaseErrorWeight: parseFloat(phaseErrorWeightEl.value), + }); + } else if (exploreMode === 'contour') { + partial = trackIsoContour(stftCache, time, freq, { + sampleRate: audioBuffer.sampleRate, + deathAge: parseInt(deathAgeEl.value), + }); + } + viewer.setPreviewPartial(partial); + }; + viewer.onExploreCommit = (partial) => { + if (!extractedPartials) extractedPartials = []; + pushUndo(); + const {spread_above, spread_below} = autodetectSpread(partial, stftCache, fftSize, audioBuffer.sampleRate); + partial.replicas = { ...partial.replicas, spread_above, spread_below }; + extractedPartials.unshift(partial); + editor.setPartials(extractedPartials); + viewer.setPartials(extractedPartials); + viewer.setKeepCount(getKeepCount()); + viewer.selectPartial(0); + setStatus(`${exploreMode}: added partial (${extractedPartials.length} total)`, 'info'); + }; + if (label.startsWith('Test WAV')) validateTestWAVPeaks(stftCache); + }, 10); +} + +// File chooser button +chooseFileBtn.addEventListener('click', () => wavFile.click()); + +// Load WAV file +wavFile.addEventListener('change', async (e) => { + const file = e.target.files[0]; + if (!file) return; + + fileLabel.textContent = file.name; + setStatus('Loading WAV...', 'info'); + try { + const arrayBuffer = await file.arrayBuffer(); + const ctx = new AudioContext(); + const buf = await ctx.decodeAudioData(arrayBuffer); + loadAudioBuffer(buf, `Loaded: ${file.name}`); + } catch (err) { + setStatus('Error loading WAV: ' + err.message, 'error'); + console.error(err); + } +}); + +// Test WAV: generate synthetic signal (two sine waves) in-memory +document.getElementById('testWavBtn').addEventListener('click', () => { + initAudioContext(); + const SR = 32000; + const duration = 2.0; + const numSamples = SR * duration; + + // Two sine waves: 440 Hz (A4) + 660 Hz (E5, perfect fifth), equal amplitude + const buf = audioContext.createBuffer(1, numSamples, SR); + const data = buf.getChannelData(0); + for (let i = 0; i < numSamples; ++i) { + data[i] = 0.5 * Math.sin(2 * Math.PI * 440 * i / SR) + + 0.5 * Math.sin(2 * Math.PI * 660 * i / SR); + } + + fileLabel.textContent = 'test-440+660hz.wav'; + loadAudioBuffer(buf, 'Test WAV: 440Hz + 660Hz (2s, 32kHz)'); +}); + +// Update cache when hop size changes +hopSize.addEventListener('change', () => { + const val = Math.max(64, parseInt(hopSize.value) || 64); + hopSize.value = val; + if (stftCache) { + setStatus('Updating STFT cache...', 'info'); + setTimeout(() => { + stftCache.setHopSize(val); + setStatus(`Cache updated (${stftCache.getNumFrames()} frames)`, 'info'); + if (viewer) viewer.render(); + }, 10); + } +}); + +function runExtraction() { + if (!stftCache) return; + + setStatus('Extracting partials...', 'info'); + extractBtn.disabled = true; + + setTimeout(() => { + try { + const params = { + fftSize: fftSize, + hopSize: parseInt(hopSize.value), + threshold: parseFloat(threshold.value), + prominence: parseFloat(prominence.value), + freqWeight: freqWeightCb.checked, + birthPersistence: parseInt(birthPersistenceEl.value), + deathAge: parseInt(deathAgeEl.value), + phaseErrorWeight: parseFloat(phaseErrorWeightEl.value), + minLength: parseInt(minLengthEl.value), + sampleRate: audioBuffer.sampleRate + }; + + const result = extractPartials(params, stftCache); + + // Sort by decreasing peak amplitude + result.partials.sort((a, b) => { + const peakA = a.amps.reduce((m, v) => Math.max(m, v), 0); + const peakB = b.amps.reduce((m, v) => Math.max(m, v), 0); + return peakB - peakA; + }); + + 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; + document.getElementById('newPartialBtn').disabled = false; + document.getElementById('clearAllBtn').disabled = false; + undoStack.length = 0; redoStack.length = 0; _updateUndoRedoBtns(); + }, 50); +} + +extractBtn.addEventListener('click', () => { + if (!audioBuffer) return; + runExtraction(); +}); + +function createNewPartial() { + if (!audioBuffer || !extractedPartials) return; + pushUndo(); + const dur = audioBuffer.duration; + const newPartial = { + times: [0, dur], + freqs: [440, 440], + amps: [1.0, 1.0], + phases: [0, 0], + muted: false, + freqCurve: { + t0: 0, t1: dur / 3, t2: dur * 2 / 3, t3: dur, + v0: 440, v1: 440, v2: 440, v3: 440, + a0: 1.0, a1: 1.0, a2: 1.0, a3: 1.0, + }, + replicas: { decay_alpha: 0.1, jitter: 0.05, spread_above: 0.02, spread_below: 0.02 }, + }; + extractedPartials.unshift(newPartial); + editor.setPartials(extractedPartials); + if (viewer) { + viewer.setPartials(extractedPartials); + viewer.setKeepCount(getKeepCount()); + viewer.selectPartial(0); + } +} + +function clearAllPartials() { + if (!extractedPartials || extractedPartials.length === 0) return; + pushUndo(); + extractedPartials = []; + editor.setPartials([]); + if (viewer) { + viewer.setPartials([]); + viewer.setKeepCount(0); + viewer.selectPartial(-1); + } +} + +document.getElementById('newPartialBtn').addEventListener('click', createNewPartial); +document.getElementById('clearAllBtn').addEventListener('click', clearAllPartials); +document.getElementById('exploreBtn').addEventListener('click', () => setExploreMode(exploreMode === 'peak' ? false : 'peak')); +document.getElementById('contourBtn').addEventListener('click', () => setExploreMode(exploreMode === 'contour' ? false : 'contour')); +document.getElementById('undoBtn').addEventListener('click', undo); +document.getElementById('redoBtn').addEventListener('click', redo); + +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(); +}); + +for (const el of [birthPersistenceEl, deathAgeEl, phaseErrorWeightEl, minLengthEl]) { + el.addEventListener('change', () => { if (stftCache) runExtraction(); }); +} + +function playAudioBuffer(buffer, statusMsg) { + const startTime = audioContext.currentTime; + currentSource = audioContext.createBufferSource(); + currentSource.buffer = buffer; + currentSource.connect(audioContext.destination); + currentSource.start(); + currentSource.onended = () => { + currentSource = null; + playBtn.disabled = false; + stopBtn.disabled = true; + viewer.setPlayheadTime(-1); + setStatus('Stopped', 'info'); + }; + playBtn.disabled = true; + stopBtn.disabled = false; + setStatus(statusMsg, 'info'); + function tick() { + if (!currentSource) return; + viewer.setPlayheadTime(audioContext.currentTime - startTime); + requestAnimationFrame(tick); + } + tick(); +} + +function stopAudio() { + if (currentSource) { + try { currentSource.stop(); } catch (e) {} + currentSource = null; + } + if (viewer) viewer.setPlayheadTime(-1); + playBtn.disabled = false; + stopBtn.disabled = true; + setStatus('Stopped', 'info'); +} + +// Play audio +playBtn.addEventListener('click', () => { + if (!audioBuffer || !audioContext) return; + stopAudio(); + playAudioBuffer(audioBuffer, 'Playing...'); +}); + +// Stop audio +stopBtn.addEventListener('click', () => { + stopAudio(); +}); + +function setStatus(msg, type = '') { + status.innerHTML = msg; + status.className = type; +} + +function getSynthParams() { + const forceResonator = document.getElementById('forceResonator').checked; + const lpK1Raw = parseFloat(document.getElementById('lpK1').value); + const hpK2Raw = parseFloat(document.getElementById('hpK2').value); + return { + integratePhase: document.getElementById('integratePhase').checked, + opts: { + disableJitter: document.getElementById('disableJitter').checked, + disableSpread: document.getElementById('disableSpread').checked, + forceResonator, + forceRGain: forceResonator && document.getElementById('forceRGain').checked, + globalR: parseFloat(document.getElementById('globalR').value), + globalGain: parseFloat(document.getElementById('globalGain').value), + k1: lpK1Raw < 1.0 ? lpK1Raw : null, + k2: hpK2Raw < 1.0 ? hpK2Raw : null, + }, + }; +} + +// Play synthesized audio +function playSynthesized() { + if (!extractedPartials || extractedPartials.length === 0) { + setStatus('No partials extracted yet', 'warn'); + return; + } + if (!audioBuffer || !audioContext) return; + + stopAudio(); + + setStatus('Synthesizing...', 'info'); + + const keepCount = getKeepCount(); + const partialsToUse = extractedPartials.slice(0, keepCount).filter(p => !p.muted); + setStatus(`Synthesizing ${partialsToUse.length}/${extractedPartials.length} partials (${keepPct.value}%)...`, 'info'); + + const {integratePhase, opts} = getSynthParams(); + const pcm = synthesizeMQ(partialsToUse, audioBuffer.sampleRate, audioBuffer.duration, + integratePhase, opts); + + if (viewer) { + viewer.setSynthStftCache(new STFTCache(pcm, audioBuffer.sampleRate, fftSize, parseInt(hopSize.value))); + } + + const synthBuffer = audioContext.createBuffer(1, pcm.length, audioBuffer.sampleRate); + synthBuffer.getChannelData(0).set(pcm); + playAudioBuffer(synthBuffer, `Playing synthesized (${partialsToUse.length}/${extractedPartials.length} partials, ${keepPct.value}%)...`); +} + +// Keyboard shortcuts +document.addEventListener('keydown', (e) => { + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; + if (e.ctrlKey && e.code === 'KeyZ' && !e.shiftKey) { + e.preventDefault(); undo(); return; + } else if (e.ctrlKey && (e.code === 'KeyY' || (e.code === 'KeyZ' && e.shiftKey))) { + e.preventDefault(); redo(); return; + } else if (e.code === 'KeyN' && !e.ctrlKey && !e.metaKey) { + e.preventDefault(); createNewPartial(); return; + } + if (e.code === 'Digit1') { + e.preventDefault(); + playSynthesized(); + } else if (e.code === 'Digit2') { + e.preventDefault(); + if (!playBtn.disabled) { + playBtn.click(); + } + } else if (e.code === 'Digit3') { + e.preventDefault(); + const sel = viewer ? viewer.selectedPartial : -1; + if (sel < 0 || !extractedPartials || !audioBuffer || !audioContext) return; + const partial = extractedPartials[sel]; + if (!partial) return; + stopAudio(); + const {integratePhase, opts} = getSynthParams(); + const pcm = synthesizeMQ([partial], audioBuffer.sampleRate, audioBuffer.duration, + integratePhase, opts); + const buf = audioContext.createBuffer(1, pcm.length, audioBuffer.sampleRate); + buf.getChannelData(0).set(pcm); + playAudioBuffer(buf, `Playing partial #${sel}...`); + } else if (e.code === 'KeyP') { + e.preventDefault(); + if (viewer) viewer.togglePeaks(); + } else if (e.code === 'KeyA') { + e.preventDefault(); + if (viewer) { + viewer.showSynthFFT = !viewer.showSynthFFT; + viewer.renderSpectrum(); + } + } else if (e.code === 'KeyE') { + e.preventDefault(); + if (!extractBtn.disabled) extractBtn.click(); + } else if (e.code === 'KeyX' && !e.ctrlKey && !e.metaKey) { + e.preventDefault(); + if (!document.getElementById('exploreBtn').disabled) setExploreMode(exploreMode === 'peak' ? false : 'peak'); + } else if (e.code === 'KeyC' && !e.ctrlKey && !e.metaKey) { + e.preventDefault(); + if (!document.getElementById('contourBtn').disabled) setExploreMode(exploreMode === 'contour' ? false : 'contour'); + } else if (e.code === 'Escape') { + if (exploreMode) { setExploreMode(false); return; } + 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; + const N = cache.fftSize; + const binWidth = SR / N; // Hz per bin + const numBins = N / 2; + const numBars = 100; // mini-spectrum bar count + + // Use a mid-signal frame (avoid edge effects) + const midFrame = cache.frames[Math.floor(cache.frames.length / 2)]; + if (!midFrame) { console.error('[TestWAV] No frames computed'); return; } + const sq = midFrame.squaredAmplitude; + const t = midFrame.time; + + console.group('[TestWAV] Peak validation @ t=' + t.toFixed(3) + 's'); + + // Top 5 bins by magnitude + const ranked = Array.from(sq) + .map((v, i) => ({ bin: i, freq: i * binWidth, db: 10 * Math.log10(Math.max(v, 1e-20)) })) + .sort((a, b) => b.db - a.db); + console.log('Top 5 FFT bins:'); + ranked.slice(0, 5).forEach(x => + console.log(` bin ${x.bin.toString().padStart(3)}: ${x.freq.toFixed(1).padStart(7)}Hz ${x.db.toFixed(1)}dB`)); + + // Expected bins for 440/660 Hz + const bin440 = Math.round(440 / binWidth); + const bin660 = Math.round(660 / binWidth); + const db440 = 10 * Math.log10(Math.max(sq[bin440], 1e-20)); + const db660 = 10 * Math.log10(Math.max(sq[bin660], 1e-20)); + console.log(`440Hz → bin ${bin440} (${(bin440 * binWidth).toFixed(1)}Hz): ${db440.toFixed(1)}dB`); + console.log(`660Hz → bin ${bin660} (${(bin660 * binWidth).toFixed(1)}Hz): ${db660.toFixed(1)}dB`); + + // Validate: 440/660 Hz must be in top-10 + const top10Freqs = ranked.slice(0, 10).map(x => x.freq); + const pass440 = top10Freqs.some(f => Math.abs(f - 440) < binWidth * 2); + const pass660 = top10Freqs.some(f => Math.abs(f - 660) < binWidth * 2); + console.log('Peak check: 440Hz ' + (pass440 ? 'PASS ✓' : 'FAIL ✗') + + ', 660Hz ' + (pass660 ? 'PASS ✓' : 'FAIL ✗')); + + // Mini-spectrum: which bar do these peaks land in? + const bar440 = Math.floor(bin440 * numBars / numBins); + const bar660 = Math.floor(bin660 * numBars / numBins); + const sampledBin440 = Math.floor(bar440 * numBins / numBars); + const sampledBin660 = Math.floor(bar660 * numBars / numBars); + console.log('Mini-spectrum (linear scale, 100 bars):'); + console.log(` 440Hz (bin ${bin440}) → bar ${bar440}/100 [bar samples bin ${sampledBin440} = ${(sampledBin440 * binWidth).toFixed(1)}Hz]`); + console.log(` 660Hz (bin ${bin660}) → bar ${bar660}/100 [bar samples bin ${Math.floor(bar660 * numBins / numBars)} = ${(Math.floor(bar660 * numBins / numBars) * binWidth).toFixed(1)}Hz]`); + if (bar440 < 5 || bar660 < 5) { + console.warn(' ⚠ BUG: peaks fall in bars ' + bar440 + ' and ' + bar660 + + ' (leftmost ~' + Math.max(bar440, bar660) * 2 + 'px of 200px canvas)' + + ' — linear scale hides low-frequency peaks. Need log-scale bar mapping.'); + } + + // Main spectrogram: confirm bins are in draw range + const mainFreqStart = 20, mainFreqEnd = 16000; + const inRange440 = 440 >= mainFreqStart && 440 <= mainFreqEnd; + const inRange660 = 660 >= mainFreqStart && 660 <= mainFreqEnd; + const norm440 = (Math.log2(440) - Math.log2(mainFreqStart)) / (Math.log2(mainFreqEnd) - Math.log2(mainFreqStart)); + const norm660 = (Math.log2(660) - Math.log2(mainFreqStart)) / (Math.log2(mainFreqEnd) - Math.log2(mainFreqStart)); + console.log('Main spectrogram (log Y-axis, 600px):'); + console.log(` 440Hz: in range=${inRange440}, y=${Math.round(600 * (1 - norm440))}px, db=${db440.toFixed(1)}dB → intensity=${Math.min(1, Math.pow(Math.max(0, (db440 + 80) / 80), 2)).toFixed(2)}`); + console.log(` 660Hz: in range=${inRange660}, y=${Math.round(600 * (1 - norm660))}px, db=${db660.toFixed(1)}dB → intensity=${Math.min(1, Math.pow(Math.max(0, (db660 + 80) / 80), 2)).toFixed(2)}`); + + console.groupEnd(); +} diff --git a/tools/mq_editor/editor.js b/tools/mq_editor/editor.js new file mode 100644 index 0000000..a7d0879 --- /dev/null +++ b/tools/mq_editor/editor.js @@ -0,0 +1,562 @@ +// Partial Editor +// Property panel (right) and amplitude bezier editor (bottom) for selected partials + +class PartialEditor { + constructor() { + // DOM refs + this._propPanel = document.getElementById('partialProps'); + this._noSelMsg = document.getElementById('noSelMsg'); + this._ampPanel = document.getElementById('ampEditPanel'); + this._ampCanvas = document.getElementById('ampEditCanvas'); + this._ampTitle = document.getElementById('ampEditTitle'); + this._freqGrid = document.getElementById('freqCurveGrid'); + this._ampGrid = document.getElementById('ampCurveGrid'); + this._synthGrid = document.getElementById('synthGrid'); + this._ampCtx = this._ampCanvas ? this._ampCanvas.getContext('2d') : null; + + // References set by host + this.viewer = null; + this.partials = null; + + // Callback: called after a partial is deleted so the host can update keepCount + this.onPartialDeleted = null; + // Callback: called before any mutation (for undo/redo) + this.onBeforeChange = null; + + // Private state + this._selectedIndex = -1; + this._dragPointIndex = -1; + this._amp = { tMin: 0, tMax: 1, ampTop: 1 }; + + this._setupButtons(); + this._setupAmpDrag(); + } + + // --- Public API --- + + setViewer(v) { this.viewer = v; } + setPartials(p) { this.partials = p; } + + // Wire to viewer.onPartialSelect + onPartialSelect(index) { + this._selectedIndex = index; + this._updatePropPanel(index); + this._showAmpEditor(index); + } + + // Wire to viewer.onRender — keeps amp editor in sync with zoom/scroll + onRender() { + this._renderAmpEditor(); + } + + // --- Property panel --- + + _updatePropPanel(index) { + if (index < 0 || !this.partials || index >= this.partials.length) { + this._propPanel.style.display = 'none'; + this._noSelMsg.style.display = 'block'; + return; + } + + this._propPanel.style.display = 'block'; + this._noSelMsg.style.display = 'none'; + + const partial = this.partials[index]; + const color = this.viewer ? this.viewer.partialColor(index) : '#888'; + + const titleEl = document.getElementById('propTitle'); + const badge = partial.resonator && partial.resonator.enabled + ? ' <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, 'freqCurve', 'a', index, 'a'); + this._buildSynthGrid(partial, index); + } + + _buildCurveGrid(grid, partial, curveKey, valueLabel, partialIndex, valueKey = 'v') { + grid.innerHTML = ''; + const curve = partial[curveKey]; + if (!curve) { grid.style.color = '#444'; grid.textContent = 'none'; return; } + + for (let i = 0; i < 4; ++i) { + const lbl = document.createElement('span'); + lbl.textContent = 'P' + i; + + const tInput = document.createElement('input'); + tInput.type = 'number'; + tInput.value = curve['t' + i].toFixed(4); + tInput.step = '0.001'; + tInput.title = 't' + i; + tInput.addEventListener('change', this._makeCurveUpdater(partialIndex, curveKey, 't', i)); + + const vInput = document.createElement('input'); + vInput.type = 'number'; + vInput.value = valueKey === 'v' ? curve['v' + i].toFixed(2) : curve[valueKey + i].toFixed(4); + vInput.step = valueKey === 'v' ? '1' : '0.0001'; + vInput.title = valueLabel + i; + vInput.addEventListener('change', this._makeCurveUpdater(partialIndex, curveKey, valueKey, i)); + + grid.appendChild(lbl); + grid.appendChild(tInput); + grid.appendChild(vInput); + } + } + + _buildSynthGrid(partial, index) { + const grid = this._synthGrid; + grid.innerHTML = ''; + + const 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, { + step: parseFloat(p.step), + onUpdate: (newVal) => { + if (!this.partials || !this.partials[index]) return; + if (!this.partials[index].replicas) this.partials[index].replicas = { ...repDefaults }; + this.partials[index].replicas[p.key] = newVal; + if (this.viewer) this.viewer.render(); + } + }); + const wrap = document.createElement('div'); + wrap.className = 'synth-field-wrap'; + wrap.appendChild(inp); + wrap.appendChild(jog); + sinSection.appendChild(lbl); + sinSection.appendChild(wrap); + } + + // Auto-detect spread button + const autoLbl = document.createElement('span'); + autoLbl.textContent = '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(); + }); + + const jog = this._makeJogSlider(inp, { + step: parseFloat(p.step), + decimals: 4, + onUpdate: (newVal) => { + if (!this.partials || !this.partials[index]) return; + if (!this.partials[index].resonator) this.partials[index].resonator = { ...resDefaults }; + this.partials[index].resonator[p.key] = newVal; + if (this.viewer) this.viewer.render(); + } + }); + + const wrap = document.createElement('div'); + wrap.className = 'synth-field-wrap'; + wrap.appendChild(inp); + wrap.appendChild(jog); + resSection.appendChild(lbl); + resSection.appendChild(wrap); + } + + // Show/hide helper + const applyMode = (resonator) => { + for (const el of sinSection.children) el.style.display = resonator ? 'none' : ''; + for (const el of resSection.children) el.style.display = resonator ? '' : 'none'; + btnSin.classList.toggle('active', !resonator); + btnRes.classList.toggle('active', resonator); + }; + + // Initial state + grid.appendChild(sinSection); + grid.appendChild(resSection); + applyMode(isResonator); + + // Toggle handlers + btnSin.addEventListener('click', () => { + if (!this.partials) return; + if (!this.partials[index].resonator) this.partials[index].resonator = { ...resDefaults }; + this.partials[index].resonator.enabled = false; + applyMode(false); + }); + 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, options) { + const {step, onUpdate, decimals = 3} = options; + const min = options.min != null ? options.min : + (inp.min !== '' && !isNaN(parseFloat(inp.min)) ? parseFloat(inp.min) : 0); + const max = options.max != null ? options.max : + (inp.max !== '' && !isNaN(parseFloat(inp.max)) ? parseFloat(inp.max) : Infinity); + const sensitivity = step * 5; + + const slider = document.createElement('div'); + slider.className = 'jog-slider'; + const thumb = document.createElement('div'); + thumb.className = 'jog-thumb'; + slider.appendChild(thumb); + + let startX = 0, startVal = 0, dragging = false; + + const onMove = (e) => { + if (!dragging) return; + const dx = e.clientX - startX; + const half = slider.offsetWidth / 2; + const clamped = Math.max(-half, Math.min(half, dx)); + thumb.style.transition = 'none'; + thumb.style.left = `calc(50% - 3px + ${clamped}px)`; + const newVal = Math.max(min, Math.min(max, startVal + dx * sensitivity)); + inp.value = newVal.toFixed(decimals); + onUpdate(newVal); + }; + + const onUp = () => { + if (!dragging) return; + dragging = false; + thumb.style.transition = ''; + thumb.style.left = 'calc(50% - 3px)'; + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + }; + + slider.addEventListener('mousedown', (e) => { + dragging = true; + startX = e.clientX; + startVal = Math.max(min, parseFloat(inp.value) || 0); + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + e.preventDefault(); + }); + + return slider; + } + + _makeCurveUpdater(partialIndex, curveKey, field, pointIndex) { + return (e) => { + if (!this.partials) return; + const val = parseFloat(e.target.value); + if (isNaN(val)) return; + if (this.onBeforeChange) this.onBeforeChange(); + this.partials[partialIndex][curveKey][field + pointIndex] = val; + if (this.viewer) this.viewer.render(); + }; + } + + _setupButtons() { + document.getElementById('mutePartialBtn').addEventListener('click', () => { + if (this._selectedIndex < 0 || !this.partials) return; + if (this.onBeforeChange) this.onBeforeChange(); + const p = this.partials[this._selectedIndex]; + p.muted = !p.muted; + if (this.viewer) this.viewer.render(); + this._updatePropPanel(this._selectedIndex); + }); + + document.getElementById('deletePartialBtn').addEventListener('click', () => { + if (this._selectedIndex < 0 || !this.partials || !this.viewer) return; + if (this.onBeforeChange) this.onBeforeChange(); + this.partials.splice(this._selectedIndex, 1); + this.viewer.selectPartial(-1); + if (this.onPartialDeleted) this.onPartialDeleted(); + }); + } + + // --- Amplitude bezier editor --- + + _showAmpEditor(index) { + if (index < 0 || !this.partials || index >= this.partials.length) { + this._ampPanel.style.display = 'none'; + return; + } + this._ampPanel.style.display = 'block'; + const color = this.viewer ? this.viewer.partialColor(index) : '#888'; + this._ampTitle.textContent = 'Partial #' + index; + this._ampTitle.style.color = color; + this._renderAmpEditor(); + } + + _renderAmpEditor() { + if (this._selectedIndex < 0 || !this.partials || !this._ampCtx) return; + const partial = this.partials[this._selectedIndex]; + if (!partial) return; + + const canvas = this._ampCanvas; + const ctx = this._ampCtx; + const W = canvas.width, H = canvas.height; + + // Sync time range with viewer + const amp = this._amp; + amp.tMin = this.viewer ? this.viewer.t_view_min : 0; + amp.tMax = this.viewer ? this.viewer.t_view_max : 1; + amp.ampTop = Math.max(partial.amps.reduce((m, v) => Math.max(m, v), 0), 0.001) * 1.3; + + ctx.fillStyle = '#0e0e0e'; + ctx.fillRect(0, 0, W, H); + + // Horizontal grid (0, 25, 50, 75, 100% of ampTop) + ctx.lineWidth = 1; + ctx.font = '9px monospace'; + for (let k = 0; k <= 4; ++k) { + const a = amp.ampTop * k / 4; + const y = this._ampToY(a); + ctx.strokeStyle = k === 0 ? '#2a2a2a' : '#1a1a1a'; + ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); + ctx.fillStyle = '#383838'; + ctx.fillText(a.toFixed(3), W - 40, y - 2); + } + + // Vertical time grid (matching main view step) + if (this.viewer) { + const step = this.viewer.getAxisStep(amp.tMax - amp.tMin); + let t = Math.ceil(amp.tMin / step) * step; + ctx.strokeStyle = '#1a1a1a'; + ctx.fillStyle = '#383838'; + while (t <= amp.tMax) { + const x = this._tToX(t); + ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke(); + ctx.fillText(t.toFixed(2) + 's', x + 2, H - 2); + t += step; + } + } + + // Raw amp data (faint dots) + ctx.fillStyle = '#2e2e2e'; + for (let i = 0; i < partial.times.length; ++i) { + const x = this._tToX(partial.times[i]); + if (x < -2 || x > W + 2) continue; + ctx.fillRect(x - 1, this._ampToY(partial.amps[i]) - 1, 2, 2); + } + + // Bezier curve + const curve = partial.freqCurve; + if (!curve) return; + + const color = this.viewer ? this.viewer.partialColor(this._selectedIndex) : '#f44'; + ctx.strokeStyle = color; + ctx.lineWidth = 2; + ctx.beginPath(); + let started = false; + for (let i = 0; i <= 120; ++i) { + const t = curve.t0 + (curve.t3 - curve.t0) * i / 120; + const x = this._tToX(t); + if (x < -1 || x > W + 1) { started = false; continue; } + const y = this._ampToY(evalBezierAmp(curve, t)); + if (!started) { ctx.moveTo(x, y); started = true; } else ctx.lineTo(x, y); + } + ctx.stroke(); + + // Control points + for (let i = 0; i < 4; ++i) { + const x = this._tToX(curve['t' + i]); + const y = this._ampToY(curve['a' + i]); + ctx.fillStyle = color; + ctx.beginPath(); + ctx.arc(x, y, 6, 0, 2 * Math.PI); + ctx.fill(); + ctx.strokeStyle = '#fff'; + ctx.lineWidth = 1.5; + ctx.stroke(); + ctx.fillStyle = '#888'; + ctx.font = '9px monospace'; + ctx.fillText('P' + i, x + 8, y - 4); + } + } + + _setupAmpDrag() { + const canvas = this._ampCanvas; + if (!canvas) return; + + canvas.addEventListener('mousedown', (e) => { + if (this._selectedIndex < 0 || !this.partials) return; + const partial = this.partials[this._selectedIndex]; + if (!partial || !partial.freqCurve) return; + const {x, y} = getCanvasCoords(e, canvas); + const curve = partial.freqCurve; + for (let i = 0; i < 4; ++i) { + if (Math.hypot(this._tToX(curve['t' + i]) - x, this._ampToY(curve['a' + i]) - y) <= 8) { + if (this.onBeforeChange) this.onBeforeChange(); + this._dragPointIndex = i; + canvas.style.cursor = 'grabbing'; + e.preventDefault(); + return; + } + } + }); + + canvas.addEventListener('mousemove', (e) => { + const {x, y} = getCanvasCoords(e, canvas); + + if (this._dragPointIndex >= 0) { + const curve = this.partials[this._selectedIndex].freqCurve; + const i = this._dragPointIndex; + curve['a' + i] = Math.max(0, this._yToAmp(y)); + this._renderAmpEditor(); + if (this.viewer) this.viewer.render(); + e.preventDefault(); + return; + } + + // Cursor hint + if (this._selectedIndex >= 0 && this.partials) { + const curve = this.partials[this._selectedIndex]?.freqCurve; + if (curve) { + let near = false; + for (let i = 0; i < 4; ++i) { + if (Math.hypot(this._tToX(curve['t' + i]) - x, this._ampToY(curve['a' + 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..dea6e50 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,13 +40,36 @@ } button:hover { background: #4a4a4a; } button:disabled { opacity: 0.5; cursor: not-allowed; } + #extractBtn { background: #666; color: #fff; font-weight: bold; border-color: #888; } + button.explore-active { background: #554; border-color: #aa8; color: #ffd; } + button.contour-active { background: #145; border-color: #0cc; color: #aff; } input[type="file"] { display: none; } .params { - display: inline-block; - margin-left: 20px; + display: flex; + margin-top: 8px; + background: #222; + border: 1px solid #444; + border-radius: 3px; + } + .param-group { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 12px; + border-right: 1px solid #444; + flex-wrap: wrap; + } + .param-group:last-child { border-right: none; } + .group-label { + font-size: 9px; + color: #666; + text-transform: uppercase; + letter-spacing: 1px; + white-space: nowrap; + margin-right: 2px; } label { - margin-right: 8px; + margin-right: 4px; } input[type="number"], select { width: 80px; @@ -73,19 +97,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 +124,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,38 +283,149 @@ <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> + <button id="newPartialBtn" disabled>+ Partial</button> + <button id="clearAllBtn" disabled>✕ Clear All</button> + <button id="exploreBtn" disabled>⊕ Explore</button> + <button id="contourBtn" disabled>≋ Contour</button> + <button id="undoBtn" disabled>↩ Undo</button> + <button id="redoBtn" disabled>↪ Redo</button> <div class="params"> - <label>Hop:</label> - <input type="number" id="hopSize" value="256" min="64" max="1024" step="64"> + <div class="param-group"> + <span class="group-label">STFT</span> + <label title="STFT hop size in samples. Smaller = finer time resolution, more frames, slower.">Hop</label> + <input type="number" id="hopSize" value="256" min="64" max="1024" step="64" style="width:60px;"> + </div> - <label>Threshold (dB):</label> - <input type="number" id="threshold" value="-60" step="any"> + <div class="param-group"> + <span class="group-label">Peak Detection</span> + <label title="Minimum spectral peak amplitude in dB. Peaks below this are ignored.">Threshold (dB)</label> + <input type="number" id="threshold" value="-20" step="any"> + <label title="Minimum prominence in dB: how much a peak must stand above its surrounding valley. Suppresses weak shoulders.">Prominence (dB)</label> + <input type="number" id="prominence" value="1.0" step="0.1" min="0"> + <label title="Weight spectrum by frequency before peak detection: f × Power(f). Boosts high-frequency peaks relative to low-frequency ones."> + <input type="checkbox" id="freqWeight"> f·Power + </label> + </div> - <label style="margin-left:16px;">Keep:</label> - <input type="range" id="keepPct" min="1" max="100" value="100" style="width:100px; vertical-align:middle;"> - <span id="keepPctLabel" style="margin-left:4px;">100%</span> + <div class="param-group"> + <span class="group-label">Tracking</span> + <label title="Frames a candidate must persist consecutively before being promoted to a tracked partial. Higher = fewer spurious short bursts.">Birth</label> + <input type="number" id="birthPersistence" value="3" min="1" max="10" step="1" style="width:50px;"> + <label title="Frames a partial can go unmatched before it is terminated. Higher = bridges short gaps; lower = cuts off quickly.">Death</label> + <input type="number" id="deathAge" value="5" min="1" max="20" step="1" style="width:50px;"> + <label title="Weight of phase prediction error in the peak-matching cost function. Higher = stricter phase coherence required to continue a partial.">Phase Wt</label> + <input type="number" id="phaseErrorWeight" value="2.0" min="0" max="10" step="0.5" style="width:55px;"> + <label title="Minimum number of frames a tracked partial must span. Shorter partials are discarded after tracking.">Min Len</label> + <input type="number" id="minLength" value="10" min="1" max="50" step="1" style="width:50px;"> + </div> + + <div class="param-group"> + <span class="group-label">Filter</span> + <label title="Keep only the strongest N% of extracted partials, ranked by peak amplitude.">Keep</label> + <input type="range" id="keepPct" min="1" max="100" value="100" style="width:100px; vertical-align:middle;"> + <span id="keepPctLabel">100%</span> + </div> </div> </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> + <canvas id="playheadCanvas" 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> + </div> + </div> - <!-- 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> + <!-- 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> @@ -157,339 +433,12 @@ <div id="status">Load a WAV file to begin...</div> + <script src="utils.js"></script> <script src="fft.js"></script> <script src="mq_extract.js"></script> <script src="mq_synth.js"></script> <script src="viewer.js"></script> - <script> - let audioBuffer = null; - let viewer = null; - let audioContext = null; - let currentSource = null; - let extractedPartials = null; - let stftCache = null; - - const wavFile = document.getElementById('wavFile'); - const chooseFileBtn = document.getElementById('chooseFileBtn'); - const extractBtn = document.getElementById('extractBtn'); - const playBtn = document.getElementById('playBtn'); - const stopBtn = document.getElementById('stopBtn'); - const canvas = document.getElementById('canvas'); - const status = document.getElementById('status'); - const fileLabel = document.getElementById('fileLabel'); - - const hopSize = document.getElementById('hopSize'); - const threshold = document.getElementById('threshold'); - const keepPct = document.getElementById('keepPct'); - const keepPctLabel = document.getElementById('keepPctLabel'); - const fftSize = 1024; // Fixed - - function getKeepCount() { - return Math.max(1, Math.ceil(extractedPartials.length * parseInt(keepPct.value) / 100)); - } - - keepPct.addEventListener('input', () => { - keepPctLabel.textContent = keepPct.value + '%'; - if (viewer && extractedPartials) viewer.setKeepCount(getKeepCount()); - }); - - // Initialize audio context - function initAudioContext() { - if (!audioContext) { - audioContext = new (window.AudioContext || window.webkitAudioContext)(); - } - } - - // Shared: initialize editor from an AudioBuffer - function loadAudioBuffer(buffer, label) { - audioBuffer = buffer; - initAudioContext(); - extractBtn.disabled = false; - playBtn.disabled = false; - setStatus('Computing STFT cache...', 'info'); - - 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); - if (label.startsWith('Test WAV')) validateTestWAVPeaks(stftCache); - }, 10); - } - - // File chooser button - chooseFileBtn.addEventListener('click', () => wavFile.click()); - - // Load WAV file - wavFile.addEventListener('change', async (e) => { - const file = e.target.files[0]; - if (!file) return; - - fileLabel.textContent = file.name; - setStatus('Loading WAV...', 'info'); - try { - const arrayBuffer = await file.arrayBuffer(); - const ctx = new AudioContext(); - const buf = await ctx.decodeAudioData(arrayBuffer); - loadAudioBuffer(buf, `Loaded: ${file.name}`); - } catch (err) { - setStatus('Error loading WAV: ' + err.message, 'error'); - console.error(err); - } - }); - - // Test WAV: generate synthetic signal (two sine waves) in-memory - document.getElementById('testWavBtn').addEventListener('click', () => { - initAudioContext(); - const SR = 32000; - const duration = 2.0; - const numSamples = SR * duration; - - // Two sine waves: 440 Hz (A4) + 660 Hz (E5, perfect fifth), equal amplitude - const buf = audioContext.createBuffer(1, numSamples, SR); - const data = buf.getChannelData(0); - for (let i = 0; i < numSamples; ++i) { - data[i] = 0.5 * Math.sin(2 * Math.PI * 440 * i / SR) - + 0.5 * Math.sin(2 * Math.PI * 660 * i / SR); - } - - fileLabel.textContent = 'test-440+660hz.wav'; - loadAudioBuffer(buf, 'Test WAV: 440Hz + 660Hz (2s, 32kHz)'); - }); - - // Update cache when hop size changes - hopSize.addEventListener('change', () => { - const val = Math.max(64, parseInt(hopSize.value) || 64); - hopSize.value = val; - if (stftCache) { - setStatus('Updating STFT cache...', 'info'); - setTimeout(() => { - stftCache.setHopSize(val); - setStatus(`Cache updated (${stftCache.getNumFrames()} frames)`, 'info'); - if (viewer) viewer.render(); - }, 10); - } - }); - - function runExtraction() { - if (!stftCache) return; - - setStatus('Extracting partials...', 'info'); - extractBtn.disabled = true; - - setTimeout(() => { - try { - const params = { - fftSize: fftSize, - hopSize: parseInt(hopSize.value), - threshold: parseFloat(threshold.value), - sampleRate: audioBuffer.sampleRate - }; - - const result = extractPartials(params, stftCache); - - // Sort by decreasing peak amplitude - result.partials.sort((a, b) => { - const peakA = a.amps.reduce((m, v) => Math.max(m, v), 0); - const peakB = b.amps.reduce((m, v) => Math.max(m, v), 0); - return peakB - peakA; - }); - - extractedPartials = result.partials; - viewer.setFrames(result.frames); - setStatus(`Extracted ${result.partials.length} partials`, 'info'); - viewer.setPartials(result.partials); - viewer.setKeepCount(getKeepCount()); - - } catch (err) { - setStatus('Extraction error: ' + err.message, 'error'); - console.error(err); - } - extractBtn.disabled = false; - }, 50); - } - - // Extract partials - extractBtn.addEventListener('click', () => { - if (!audioBuffer) return; - runExtraction(); - }); - - threshold.addEventListener('change', () => { - if (stftCache) runExtraction(); - }); - - function playAudioBuffer(buffer, statusMsg) { - const startTime = audioContext.currentTime; - currentSource = audioContext.createBufferSource(); - currentSource.buffer = buffer; - currentSource.connect(audioContext.destination); - currentSource.start(); - currentSource.onended = () => { - currentSource = null; - playBtn.disabled = false; - stopBtn.disabled = true; - viewer.setPlayheadTime(-1); - setStatus('Stopped', 'info'); - }; - playBtn.disabled = true; - stopBtn.disabled = false; - setStatus(statusMsg, 'info'); - function tick() { - if (!currentSource) return; - viewer.setPlayheadTime(audioContext.currentTime - startTime); - requestAnimationFrame(tick); - } - tick(); - } - - function stopAudio() { - if (currentSource) { - try { currentSource.stop(); } catch (e) {} - currentSource = null; - } - if (viewer) viewer.setPlayheadTime(-1); - playBtn.disabled = false; - stopBtn.disabled = true; - setStatus('Stopped', 'info'); - } - - // Play audio - playBtn.addEventListener('click', () => { - if (!audioBuffer || !audioContext) return; - stopAudio(); - playAudioBuffer(audioBuffer, 'Playing...'); - }); - - // Stop audio - stopBtn.addEventListener('click', () => { - stopAudio(); - }); - - function setStatus(msg, type = '') { - status.innerHTML = msg; - status.className = type; - } - - // Play synthesized audio - function playSynthesized() { - if (!extractedPartials || extractedPartials.length === 0) { - setStatus('No partials extracted yet', 'warn'); - return; - } - if (!audioBuffer || !audioContext) return; - - stopAudio(); - - setStatus('Synthesizing...', 'info'); - - const keepCount = getKeepCount(); - const partialsToUse = extractedPartials.slice(0, keepCount); - setStatus(`Synthesizing ${keepCount}/${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 pcm = synthesizeMQ(partialsToUse, audioBuffer.sampleRate, audioBuffer.duration, - integratePhase, {disableJitter, disableSpread}); - - if (viewer) { - viewer.setSynthStftCache(new STFTCache(pcm, audioBuffer.sampleRate, fftSize, parseInt(hopSize.value))); - } - - const synthBuffer = audioContext.createBuffer(1, pcm.length, audioBuffer.sampleRate); - synthBuffer.getChannelData(0).set(pcm); - playAudioBuffer(synthBuffer, `Playing synthesized (${keepCount}/${extractedPartials.length} partials, ${keepPct.value}%)...`); - } - - // Keyboard shortcuts - document.addEventListener('keydown', (e) => { - if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; - if (e.code === 'Digit1') { - e.preventDefault(); - playSynthesized(); - } else if (e.code === 'Digit2') { - e.preventDefault(); - if (!playBtn.disabled) { - playBtn.click(); - } - } else if (e.code === 'KeyP') { - e.preventDefault(); - if (viewer) viewer.togglePeaks(); - } else if (e.code === 'KeyA') { - e.preventDefault(); - if (viewer) { - viewer.showSynthFFT = !viewer.showSynthFFT; - viewer.renderSpectrum(); - } - } - }); - - // --- Test WAV peak validation --- - function validateTestWAVPeaks(cache) { - const SR = cache.sampleRate; - const N = cache.fftSize; - const binWidth = SR / N; // Hz per bin - const numBins = N / 2; - const numBars = 100; // mini-spectrum bar count - - // Use a mid-signal frame (avoid edge effects) - const midFrame = cache.frames[Math.floor(cache.frames.length / 2)]; - if (!midFrame) { console.error('[TestWAV] No frames computed'); return; } - const sq = midFrame.squaredAmplitude; - const t = midFrame.time; - - console.group('[TestWAV] Peak validation @ t=' + t.toFixed(3) + 's'); - - // Top 5 bins by magnitude - const ranked = Array.from(sq) - .map((v, i) => ({ bin: i, freq: i * binWidth, db: 10 * Math.log10(Math.max(v, 1e-20)) })) - .sort((a, b) => b.db - a.db); - console.log('Top 5 FFT bins:'); - ranked.slice(0, 5).forEach(x => - console.log(` bin ${x.bin.toString().padStart(3)}: ${x.freq.toFixed(1).padStart(7)}Hz ${x.db.toFixed(1)}dB`)); - - // Expected bins for 440/660 Hz - const bin440 = Math.round(440 / binWidth); - const bin660 = Math.round(660 / binWidth); - const db440 = 10 * Math.log10(Math.max(sq[bin440], 1e-20)); - const db660 = 10 * Math.log10(Math.max(sq[bin660], 1e-20)); - console.log(`440Hz → bin ${bin440} (${(bin440 * binWidth).toFixed(1)}Hz): ${db440.toFixed(1)}dB`); - console.log(`660Hz → bin ${bin660} (${(bin660 * binWidth).toFixed(1)}Hz): ${db660.toFixed(1)}dB`); - - // Validate: 440/660 Hz must be in top-10 - const top10Freqs = ranked.slice(0, 10).map(x => x.freq); - const pass440 = top10Freqs.some(f => Math.abs(f - 440) < binWidth * 2); - const pass660 = top10Freqs.some(f => Math.abs(f - 660) < binWidth * 2); - console.log('Peak check: 440Hz ' + (pass440 ? 'PASS ✓' : 'FAIL ✗') + - ', 660Hz ' + (pass660 ? 'PASS ✓' : 'FAIL ✗')); - - // Mini-spectrum: which bar do these peaks land in? - const bar440 = Math.floor(bin440 * numBars / numBins); - const bar660 = Math.floor(bin660 * numBars / numBins); - const sampledBin440 = Math.floor(bar440 * numBins / numBars); - const sampledBin660 = Math.floor(bar660 * numBars / numBars); - console.log('Mini-spectrum (linear scale, 100 bars):'); - console.log(` 440Hz (bin ${bin440}) → bar ${bar440}/100 [bar samples bin ${sampledBin440} = ${(sampledBin440 * binWidth).toFixed(1)}Hz]`); - console.log(` 660Hz (bin ${bin660}) → bar ${bar660}/100 [bar samples bin ${Math.floor(bar660 * numBins / numBars)} = ${(Math.floor(bar660 * numBins / numBars) * binWidth).toFixed(1)}Hz]`); - if (bar440 < 5 || bar660 < 5) { - console.warn(' ⚠ BUG: peaks fall in bars ' + bar440 + ' and ' + bar660 + - ' (leftmost ~' + Math.max(bar440, bar660) * 2 + 'px of 200px canvas)' + - ' — linear scale hides low-frequency peaks. Need log-scale bar mapping.'); - } - - // Main spectrogram: confirm bins are in draw range - const mainFreqStart = 20, mainFreqEnd = 16000; - const inRange440 = 440 >= mainFreqStart && 440 <= mainFreqEnd; - const inRange660 = 660 >= mainFreqStart && 660 <= mainFreqEnd; - const norm440 = (Math.log2(440) - Math.log2(mainFreqStart)) / (Math.log2(mainFreqEnd) - Math.log2(mainFreqStart)); - const norm660 = (Math.log2(660) - Math.log2(mainFreqStart)) / (Math.log2(mainFreqEnd) - Math.log2(mainFreqStart)); - console.log('Main spectrogram (log Y-axis, 600px):'); - console.log(` 440Hz: in range=${inRange440}, y=${Math.round(600 * (1 - norm440))}px, db=${db440.toFixed(1)}dB → intensity=${Math.min(1, Math.pow(Math.max(0, (db440 + 80) / 80), 2)).toFixed(2)}`); - console.log(` 660Hz: in range=${inRange660}, y=${Math.round(600 * (1 - norm660))}px, db=${db660.toFixed(1)}dB → intensity=${Math.min(1, Math.pow(Math.max(0, (db660 + 80) / 80), 2)).toFixed(2)}`); - - console.groupEnd(); - } - </script> + <script src="editor.js"></script> + <script src="app.js"></script> </body> </html> diff --git a/tools/mq_editor/mq_extract.js b/tools/mq_editor/mq_extract.js index 8a0ea0e..18897fb 100644 --- a/tools/mq_editor/mq_extract.js +++ b/tools/mq_editor/mq_extract.js @@ -3,36 +3,59 @@ // 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); for (const partial of partials) { partial.freqCurve = fitBezier(partial.times, partial.freqs); - partial.ampCurve = fitBezier(partial.times, partial.amps); + const ac = fitBezier(partial.times, partial.amps); + partial.freqCurve.a0 = ac.v0; partial.freqCurve.a1 = ac.v1; + partial.freqCurve.a2 = ac.v2; partial.freqCurve.a3 = ac.v3; } 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) * 0.5 * 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,96 +64,158 @@ 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)); +} + +// Find best matching peak for a predicted freq/phase. Returns {bestIdx, bestCost}. +function findBestPeak(peaks, matched, predictedFreq, predictedPhase, tol, phaseErrorWeight) { + let bestIdx = -1, bestCost = Infinity; + for (let i = 0; i < peaks.length; ++i) { + if (matched.has(i)) continue; + const pk = 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; } + } + return { bestIdx, bestCost }; +} + +// Track partials across frames using phase coherence for robust matching. +function trackPartials(frames, params) { + const { + sampleRate, hopSize, + birthPersistence = 3, + deathAge = 5, + minLength = 10, + phaseErrorWeight = 2.0 + } = params; const partials = []; const activePartials = []; const candidates = []; // pre-birth const trackingRatio = 0.05; // 5% frequency tolerance const minTrackingHz = 20; - const birthPersistence = 3; // frames before partial is born - const deathAge = 5; // frames without match before death - const minLength = 10; // frames required to keep partial 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; - - 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 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. + // Multiply by (age+1) to account for frames missed during a gap. + const phaseAdvance = 2 * Math.PI * lastFreq * (partial.age + 1) * hopSize / sampleRate; + const predictedPhase = lastPhase + phaseAdvance; + + const tol = Math.max(predictedFreq * trackingRatio, minTrackingHz); + // Find the peak in the new frame with the lowest cost (freq + phase error). + const { bestIdx } = findBestPeak(frame.peaks, matched, predictedFreq, predictedPhase, tol, phaseErrorWeight); if (bestIdx >= 0) { const pk = frame.peaks[bestIdx]; 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; - 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; } - } + // Candidates die on first miss so age is always 0 here, but kept consistent. + const phaseAdvance = 2 * Math.PI * lastFreq * hopSize / sampleRate; + const predictedPhase = lastPhase + phaseAdvance; + + const tol = Math.max(predictedFreq * trackingRatio, minTrackingHz); + const { bestIdx } = findBestPeak(frame.peaks, matched, predictedFreq, predictedPhase, tol, phaseErrorWeight); if (bestIdx >= 0) { const pk = frame.peaks[bestIdx]; 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 +224,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 +243,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 +265,315 @@ 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}; +} + +// Track a single partial starting from a (time, freq) seed position. +// Snaps to nearest spectral peak, then tracks forward and backward. +// Returns a partial object (with freqCurve), or null if no peak found near seed. +function trackFromSeed(frames, seedTime, seedFreq, params) { + if (!frames || frames.length === 0) return null; + + // Find nearest frame to seedTime + let seedFrameIdx = 0; + let bestDt = Infinity; + for (let i = 0; i < frames.length; ++i) { + const dt = Math.abs(frames[i].time - seedTime); + if (dt < bestDt) { bestDt = dt; seedFrameIdx = i; } + } + + // Snap to nearest spectral peak within 10% freq tolerance + const seedFrame = frames[seedFrameIdx]; + const snapTol = Math.max(seedFreq * 0.10, 50); + let seedPeak = null, bestDist = snapTol; + for (const pk of seedFrame.peaks) { + const d = Math.abs(pk.freq - seedFreq); + if (d < bestDist) { bestDist = d; seedPeak = pk; } + } + if (!seedPeak) return null; + + const { hopSize, sampleRate, deathAge = 5, phaseErrorWeight = 2.0 } = params; + const trackingRatio = 0.05; + const minTrackingHz = 20; + + // Forward pass from seed frame + const times = [seedFrame.time]; + const freqs = [seedPeak.freq]; + const amps = [seedPeak.amp]; + const phases = [seedPeak.phase]; + + let fwdFreq = seedPeak.freq, fwdPhase = seedPeak.phase, fwdVel = 0, fwdAge = 0; + for (let i = seedFrameIdx + 1; i < frames.length; ++i) { + const predicted = fwdFreq + fwdVel; + const predPhase = fwdPhase + 2 * Math.PI * fwdFreq * (fwdAge + 1) * hopSize / sampleRate; + const tol = Math.max(predicted * trackingRatio, minTrackingHz); + const { bestIdx } = findBestPeak(frames[i].peaks, new Set(), predicted, predPhase, tol, phaseErrorWeight); + if (bestIdx >= 0) { + const pk = frames[i].peaks[bestIdx]; + times.push(frames[i].time); + freqs.push(pk.freq); + amps.push(pk.amp); + phases.push(pk.phase); + fwdVel = pk.freq - fwdFreq; + fwdFreq = pk.freq; fwdPhase = pk.phase; fwdAge = 0; + } else { + fwdAge++; + if (fwdAge > deathAge) break; + } + } + + // Backward pass from seed frame + const bwdTimes = [], bwdFreqs = [], bwdAmps = [], bwdPhases = []; + let bwdFreq = seedPeak.freq, bwdAge = 0; + for (let i = seedFrameIdx - 1; i >= 0; --i) { + const tol = Math.max(bwdFreq * trackingRatio, minTrackingHz); + let bestIdx = -1, bDist = tol; + for (let j = 0; j < frames[i].peaks.length; ++j) { + const d = Math.abs(frames[i].peaks[j].freq - bwdFreq); + if (d < bDist) { bDist = d; bestIdx = j; } + } + if (bestIdx >= 0) { + const pk = frames[i].peaks[bestIdx]; + bwdTimes.unshift(frames[i].time); + bwdFreqs.unshift(pk.freq); + bwdAmps.unshift(pk.amp); + bwdPhases.unshift(pk.phase); + bwdFreq = pk.freq; bwdAge = 0; + } else { + bwdAge++; + if (bwdAge > deathAge) break; } } + + const allTimes = [...bwdTimes, ...times]; + const allFreqs = [...bwdFreqs, ...freqs]; + const allAmps = [...bwdAmps, ...amps]; + const allPhases = [...bwdPhases, ...phases]; + + if (allTimes.length < 2) return null; + + const freqCurve = fitBezier(allTimes, allFreqs); + const ac = fitBezier(allTimes, allAmps); + freqCurve.a0 = ac.v0; freqCurve.a1 = ac.v1; + freqCurve.a2 = ac.v2; freqCurve.a3 = ac.v3; + + return { + times: allTimes, freqs: allFreqs, amps: allAmps, phases: allPhases, + muted: false, freqCurve, + replicas: { decay_alpha: 0.1, jitter: 0.05, spread_above: 0.02, spread_below: 0.02 }, + }; } -// Fit cubic bezier to trajectory using samples at ~1/3 and ~2/3 as control points +// Track an iso-energy contour starting from (seedTime, seedFreq). +// Instead of following spectral peaks, follows where energy ≈ seedEnergy. +// Useful for broad/diffuse bass regions with no detectable peaks. +// Returns a partial with large default spread, or null if seed energy is zero. +function trackIsoContour(stftCache, seedTime, seedFreq, params) { + const { sampleRate, deathAge = 8 } = params; + const numFrames = stftCache.getNumFrames(); + const fftSize = stftCache.fftSize; + const binHz = sampleRate / fftSize; + const halfBins = fftSize / 2; + + // Find seed frame + let seedFrameIdx = 0, bestDt = Infinity; + for (let i = 0; i < numFrames; ++i) { + const dt = Math.abs(stftCache.getFrameAtIndex(i).time - seedTime); + if (dt < bestDt) { bestDt = dt; seedFrameIdx = i; } + } + + const seedFrame = stftCache.getFrameAtIndex(seedFrameIdx); + const seedBin = Math.max(1, Math.min(halfBins - 2, Math.round(seedFreq / binHz))); + const targetSq = seedFrame.squaredAmplitude[seedBin]; + if (targetSq <= 0) return null; + const targetDB = 10 * Math.log10(targetSq); + + const trackingRatio = 0.15; // larger search window than peak tracker + const minTrackHz = 30; + const maxDbDev = 15; // dB: declare miss if nothing within this range + + // Find bin minimizing |dB(b) - targetDB| near refBin, with mild position bias. + function findContourBin(sq, refBin) { + const tol = Math.max(refBin * binHz * trackingRatio, minTrackHz); + const tolBins = Math.ceil(tol / binHz); + const lo = Math.max(1, refBin - tolBins); + const hi = Math.min(halfBins - 2, refBin + tolBins); + let bestBin = -1, bestCost = Infinity; + for (let b = lo; b <= hi; ++b) { + const dE = Math.abs(10 * Math.log10(Math.max(sq[b], 1e-20)) - targetDB); + if (dE > maxDbDev) continue; + const dPos = Math.abs(b - refBin) / Math.max(1, tolBins); + const cost = dE + 3 * dPos; // energy match dominates, position breaks ties + if (cost < bestCost) { bestCost = cost; bestBin = b; } + } + return bestBin; + } + + const times = [seedFrame.time]; + const freqs = [seedBin * binHz]; + const amps = [Math.sqrt(Math.max(0, targetSq))]; + + // Forward pass + let fwdBin = seedBin, fwdAge = 0; + for (let i = seedFrameIdx + 1; i < numFrames; ++i) { + const frame = stftCache.getFrameAtIndex(i); + const b = findContourBin(frame.squaredAmplitude, fwdBin); + if (b >= 0) { + times.push(frame.time); + freqs.push(b * binHz); + amps.push(Math.sqrt(Math.max(0, frame.squaredAmplitude[b]))); + fwdBin = b; fwdAge = 0; + } else { if (++fwdAge > deathAge) break; } + } + + // Backward pass + const bwdTimes = [], bwdFreqs = [], bwdAmps = []; + let bwdBin = seedBin, bwdAge = 0; + for (let i = seedFrameIdx - 1; i >= 0; --i) { + const frame = stftCache.getFrameAtIndex(i); + const b = findContourBin(frame.squaredAmplitude, bwdBin); + if (b >= 0) { + bwdTimes.unshift(frame.time); + bwdFreqs.unshift(b * binHz); + bwdAmps.unshift(Math.sqrt(Math.max(0, frame.squaredAmplitude[b]))); + bwdBin = b; bwdAge = 0; + } else { if (++bwdAge > deathAge) break; } + } + + const allTimes = [...bwdTimes, ...times]; + const allFreqs = [...bwdFreqs, ...freqs]; + const allAmps = [...bwdAmps, ...amps]; + if (allTimes.length < 2) return null; + + const freqCurve = fitBezier(allTimes, allFreqs); + const ac = fitBezier(allTimes, allAmps); + freqCurve.a0 = ac.v0; freqCurve.a1 = ac.v1; + freqCurve.a2 = ac.v2; freqCurve.a3 = ac.v3; + + return { + times: allTimes, freqs: allFreqs, amps: allAmps, + phases: new Array(allTimes.length).fill(0), + muted: false, freqCurve, + replicas: { decay_alpha: 0.1, jitter: 0.05, spread_above: 0.15, spread_below: 0.15 }, + }; +} + +// Fit interpolating curve to trajectory via least-squares for inner control point values. +// Inner knots fixed at u=1/3 and u=2/3 (t = t0+dt/3, t0+2*dt/3). +// The curve passes through all 4 control points (Lagrange interpolation). +// TODO: support arbitrary number of 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) { + 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}; + } + + // Lagrange basis with inner knots at u1=1/3, u2=2/3 + // l1(u) = u*(u-2/3)*(u-1) / ((1/3)*(1/3-2/3)*(1/3-1)) = 13.5*u*(u-2/3)*(u-1) + // l2(u) = u*(u-1/3)*(u-1) / ((2/3)*(2/3-1/3)*(2/3-1)) = -13.5*u*(u-1/3)*(u-1) + // l0(u) = (u-1/3)*(u-2/3)*(u-1) / ((-1/3)*(-2/3)*(-1)) = -4.5*(u-1/3)*(u-2/3)*(u-1) + // l3(u) = u*(u-1/3)*(u-2/3) / ((2/3)*(1/3)) = 4.5*u*(u-1/3)*(u-2/3) + // Least-squares: minimize Σ(l1*v1 + l2*v2 - target_i)^2 + // target_i = values[i] - l0*v0 - l3*v3 + + 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 l0 = -4.5 * (u - 1/3) * (u - 2/3) * (u - 1); + const l1 = 13.5 * u * (u - 2/3) * (u - 1); + const l2 = -13.5 * u * (u - 1/3) * (u - 1); + const l3 = 4.5 * u * (u - 1/3) * (u - 2/3); + const A = l1, B = l2; + const target = values[i] - l0 * v0 - l3 * v3; + sA2 += A * A; sB2 += B * B; sAB += A * B; + sAT += A * target; sBT += B * target; } - const v1 = values[Math.round(n / 3)]; - const v2 = values[Math.round(2 * n / 3)]; + const det = sA2 * sB2 - sAB * sAB; + let v1, v2; + + if (Math.abs(det) < 1e-9) { + 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..00867a9 100644 --- a/tools/mq_editor/mq_synth.js +++ b/tools/mq_editor/mq_synth.js @@ -1,18 +1,5 @@ // MQ Synthesizer -// Replica oscillator bank for sinusoidal synthesis - -// Evaluate cubic bezier curve at time t -function evalBezier(curve, t) { - const dt = curve.t3 - curve.t0; - if (dt <= 0) return curve.v0; - let u = (t - curve.t0) / dt; - u = Math.max(0, Math.min(1, u)); - const u1 = 1.0 - u; - return u1*u1*u1 * curve.v0 + - 3*u1*u1*u * curve.v1 + - 3*u1*u*u * curve.v2 + - u*u*u * curve.v3; -} +// Replica oscillator bank for sinusoidal synthesis, plus two-pole resonator mode // Deterministic LCG PRNG function randFloat(seed, min, max) { @@ -21,10 +8,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 (with a0-a3 for amp), 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 +33,44 @@ 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 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, + 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, decay_alpha, replicaData }); + } } for (let i = 0; i < numSamples; ++i) { @@ -71,32 +78,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} = 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 = evalBezierAmp(fc, 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 = evalBezierAmp(fc, t); + const {decay_alpha, replicaData} = cfg; - sample += A * Math.sin(phase); + 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)); + + 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/utils.js b/tools/mq_editor/utils.js new file mode 100644 index 0000000..2c6b2f5 --- /dev/null +++ b/tools/mq_editor/utils.js @@ -0,0 +1,68 @@ +// Shared utilities for mq_editor + +// Evaluate interpolating curve at time t via Lagrange interpolation. +// The curve passes through all 4 control points at their stored time positions. +// Knot positions: u_k = (t_k - t0) / (t3 - t0), k=0..3. +// TODO: support arbitrary number of inner control points +function evalBezier(curve, t) { + const dt = curve.t3 - curve.t0; + if (dt <= 0) return curve.v0; + let u = (t - curve.t0) / dt; + u = Math.max(0, Math.min(1, u)); + const u1 = (curve.t1 - curve.t0) / dt; + const u2 = (curve.t2 - curve.t0) / dt; + const d0 = (-u1) * (-u2) * (-1); + const d1 = u1 * (u1 - u2) * (u1 - 1); + const d2 = u2 * (u2 - u1) * (u2 - 1); + const d3 = (1 - u1) * (1 - u2); + if (Math.abs(d0) < 1e-9 || Math.abs(d1) < 1e-9 || Math.abs(d2) < 1e-9 || Math.abs(d3) < 1e-9) + return curve.v0 + (curve.v3 - curve.v0) * u; + const l0 = (u - u1) * (u - u2) * (u - 1) / d0; + const l1 = u * (u - u2) * (u - 1) / d1; + const l2 = u * (u - u1) * (u - 1) / d2; + const l3 = u * (u - u1) * (u - u2) / d3; + return l0 * curve.v0 + l1 * curve.v1 + l2 * curve.v2 + l3 * curve.v3; +} + +// Evaluate amplitude component of interpolating curve at time t +function evalBezierAmp(curve, t) { + const dt = curve.t3 - curve.t0; + if (dt <= 0) return curve.a0; + let u = (t - curve.t0) / dt; + u = Math.max(0, Math.min(1, u)); + const u1 = (curve.t1 - curve.t0) / dt; + const u2 = (curve.t2 - curve.t0) / dt; + const d0 = (-u1) * (-u2) * (-1); + const d1 = u1 * (u1 - u2) * (u1 - 1); + const d2 = u2 * (u2 - u1) * (u2 - 1); + const d3 = (1 - u1) * (1 - u2); + if (Math.abs(d0) < 1e-9 || Math.abs(d1) < 1e-9 || Math.abs(d2) < 1e-9 || Math.abs(d3) < 1e-9) + return curve.a0 + (curve.a3 - curve.a0) * u; + const l0 = (u - u1) * (u - u2) * (u - 1) / d0; + const l1 = u * (u - u2) * (u - 1) / d1; + const l2 = u * (u - u1) * (u - 1) / d2; + const l3 = u * (u - u1) * (u - u2) / d3; + return l0 * curve.a0 + l1 * curve.a1 + l2 * curve.a2 + l3 * curve.a3; +} + +// Get canvas-relative {x, y} from a mouse event +function getCanvasCoords(e, canvas) { + const rect = canvas.getBoundingClientRect(); + return { x: e.clientX - rect.left, y: e.clientY - rect.top }; +} + +// Build upper/lower band point arrays for a frequency curve. +// factorAbove/factorBelow are fractional offsets (e.g. 0.02 = ±2%). +// Returns { upper: [[x,y],...], lower: [[x,y],...] } +function buildBandPoints(viewer, curve, factorAbove, factorBelow) { + 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 < viewer.t_view_min - 0.01 || t > viewer.t_view_max + 0.01) continue; + const f = evalBezier(curve, t); + upper.push([viewer.timeToX(t), viewer.freqToY(f * (1 + factorAbove))]); + lower.push([viewer.timeToX(t), viewer.freqToY(f * (1 - factorBelow))]); + } + return { upper, lower }; +} diff --git a/tools/mq_editor/viewer.js b/tools/mq_editor/viewer.js index 7f6e862..d7b5ac1 100644 --- a/tools/mq_editor/viewer.js +++ b/tools/mq_editor/viewer.js @@ -38,7 +38,9 @@ class SpectrogramViewer { this.cursorCtx = this.cursorCanvas ? this.cursorCanvas.getContext('2d') : null; this.mouseX = -1; - // Playhead + // Playhead overlay + this.playheadCanvas = document.getElementById('playheadCanvas'); + this.playheadCtx = this.playheadCanvas ? this.playheadCanvas.getContext('2d') : null; this.playheadTime = -1; // -1 = not playing // Spectrum viewer @@ -48,6 +50,19 @@ 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) + this.onBeforeChange = null; // callback() called before any mutation (for undo/redo) + + // Explore mode + this.exploreMode = false; + this.previewPartial = null; + this.onExploreMove = null; // callback(time, freq) + this.onExploreCommit = null; // callback(partial) + // Setup event handlers this.setupMouseHandlers(); @@ -112,10 +127,11 @@ class SpectrogramViewer { this.playheadTime = time; if (time >= 0) { this.spectrumTime = time; + this.renderSpectrum(); } else if (this.mouseX >= 0) { this.spectrumTime = this.canvasToTime(this.mouseX); } - this.render(); + this.drawPlayhead(); } setPartials(partials) { @@ -153,6 +169,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 < this.keepCount; ++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 +219,163 @@ 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 {upper, lower} = buildBandPoints(this, curve, sa, 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 {upper: p5upper, lower: p5lower} = buildBandPoints(this, curve, 0.50, 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) { @@ -170,24 +384,69 @@ class SpectrogramViewer { const h = this.cursorCanvas.height; ctx.clearRect(0, 0, this.cursorCanvas.width, h); if (x < 0) return; - ctx.strokeStyle = 'rgba(255, 60, 60, 0.7)'; + ctx.strokeStyle = this.exploreMode === 'contour' ? 'rgba(0,220,220,0.8)' + : this.exploreMode ? 'rgba(255,160,0,0.8)' + : 'rgba(255,60,60,0.7)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); + if (this.exploreMode && this.previewPartial) { + this._drawPreviewPartial(ctx, this.previewPartial); + } + } + + setExploreMode(enabled) { + this.exploreMode = enabled; + if (!enabled) this.previewPartial = null; + this.drawMouseCursor(this.mouseX); + this.canvas.style.cursor = enabled ? 'cell' : 'crosshair'; + } + + setPreviewPartial(partial) { + this.previewPartial = partial; + this.drawMouseCursor(this.mouseX); + } + + _drawPreviewPartial(ctx, partial) { + const curve = partial.freqCurve; + if (!curve) return; + const col = this.exploreMode === 'contour' ? '0,220,220' : '255,160,0'; + ctx.save(); + ctx.strokeStyle = `rgba(${col},0.9)`; + ctx.lineWidth = 2; + ctx.setLineDash([6, 3]); + ctx.shadowColor = `rgba(${col},0.5)`; + ctx.shadowBlur = 6; + ctx.beginPath(); + let started = false; + for (let i = 0; i <= 80; ++i) { + const t = curve.t0 + (curve.t3 - curve.t0) * i / 80; + 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 px = this.timeToX(t); + const py = this.freqToY(freq); + if (!started) { ctx.moveTo(px, py); started = true; } else ctx.lineTo(px, py); + } + if (started) ctx.stroke(); + ctx.restore(); } drawPlayhead() { + if (!this.playheadCtx) return; + const ctx = this.playheadCtx; + const h = this.playheadCanvas.height; + ctx.clearRect(0, 0, this.playheadCanvas.width, h); if (this.playheadTime < 0) return; if (this.playheadTime < this.t_view_min || this.playheadTime > this.t_view_max) return; - const {ctx, canvas} = this; const x = this.timeToX(this.playheadTime); - ctx.strokeStyle = '#f00'; + ctx.strokeStyle = 'rgba(255, 80, 80, 0.9)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(x, 0); - ctx.lineTo(x, canvas.height); + ctx.lineTo(x, h); ctx.stroke(); } @@ -243,118 +502,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 +533,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,16 +611,59 @@ class SpectrogramViewer { setupMouseHandlers() { const {canvas, tooltip} = this; + canvas.addEventListener('mousedown', (e) => { + const {x, y} = getCanvasCoords(e, canvas); + + // Explore mode: commit preview on click + if (this.exploreMode) { + if (this.previewPartial && this.onExploreCommit) { + this.onExploreCommit(this.previewPartial); + } + return; + } + + // 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) { + if (this.onBeforeChange) this.onBeforeChange(); + 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; + const {x, y} = getCanvasCoords(e, canvas); + + 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); const time = this.canvasToTime(x); const freq = this.canvasToFreq(y); + + if (this.exploreMode && this.onExploreMove) { + this.onExploreMove(time, freq); // may call setPreviewPartial → redraws cursor canvas + } + const intensity = this.getIntensityAt(time, freq); if (this.playheadTime < 0) { @@ -481,6 +671,16 @@ class SpectrogramViewer { this.renderSpectrum(); } + // Cursor hint for control points (skip in explore mode) + if (!this.exploreMode) { + 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 +693,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,15 +737,35 @@ class SpectrogramViewer { for (const step of steps) { if (step >= targetStep) return step; } return steps[steps.length - 1]; } -} -// Bezier evaluation (shared utility) -function evalBezier(curve, t) { - let u = (t - curve.t0) / (curve.t3 - curve.t0); - u = Math.max(0, Math.min(1, u)); - const u1 = 1 - u; - return u1*u1*u1 * curve.v0 + - 3*u1*u1*u * curve.v1 + - 3*u1*u*u * curve.v2 + - u*u*u * curve.v3; + 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); + } + } } + |
