diff options
| -rw-r--r-- | tools/mq_editor/README.md | 18 | ||||
| -rw-r--r-- | tools/mq_editor/app.js | 4 | ||||
| -rw-r--r-- | tools/mq_editor/editor.js | 19 | ||||
| -rw-r--r-- | tools/mq_editor/mq_extract.js | 2 | ||||
| -rw-r--r-- | tools/mq_editor/mq_synth.js | 8 | ||||
| -rw-r--r-- | tools/mq_editor/test_fft.html | 229 | ||||
| -rw-r--r-- | tools/mq_editor/utils.js | 6 | ||||
| -rw-r--r-- | tools/mq_editor/viewer.js | 6 |
8 files changed, 44 insertions, 248 deletions
diff --git a/tools/mq_editor/README.md b/tools/mq_editor/README.md index 9435883..6f75a52 100644 --- a/tools/mq_editor/README.md +++ b/tools/mq_editor/README.md @@ -95,6 +95,7 @@ Committed partials are prepended to the partial list with full undo support. | `C` | Toggle ≋ Contour mode (iso-energy tracking) | | `P` | Toggle raw peak overlay | | `A` | Toggle mini-spectrum: original ↔ synthesized | +| `Delete` | Delete selected partial | | `Esc` | Exit explore/contour mode · deselect partial | | `Ctrl+Z` | Undo | | `Ctrl+Y` / `Ctrl+Shift+Z` | Redo | @@ -104,10 +105,12 @@ Committed partials are prepended to the partial list with full undo support. ## Architecture - `index.html` — UI, playback, extraction orchestration, keyboard shortcuts +- `style.css` — All UI styles (extracted from inline styles) - `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 + two-pole resonator synthesis from extracted partials +- `utils.js` — Shared helpers: `evalBezier`, `evalBezierAmp`, `clamp`, canvas coordinate utilities - `viewer.js` — Visualization: coordinate API, spectrogram, partials, mini-spectrum, mouse/zoom ### viewer.js coordinate API @@ -123,6 +126,19 @@ Committed partials are prepended to the partial list with full undo support. | `normalizeDB(db, maxDB)` | dB → intensity [0..1] over 80 dB range | | `partialColor(p)` | partial index → display color | +## Amplitude Bezier Editor + +The bottom panel shows the amplitude envelope for the selected partial as a **Lagrange interpolating curve** through four control points P0–P3. + +- **P0, P3** (endpoints): drag vertically to adjust amplitude at the start/end of the partial. Time positions are fixed to `t_start`/`t_end`. +- **P1, P2** (inner points): drag in **both axes** — vertically for amplitude, horizontally for time position. The ordering constraint `t0 < t1 < t2 < t3` is enforced automatically. + +Cursor hints: `ns-resize` for P0/P3, `move` for P1/P2. + +All four time and amplitude values are also editable as numbers in the property panel above. + +--- + ## Post-Synthesis Filters Global LP/HP filters applied after the oscillator bank, before normalization. @@ -200,7 +216,7 @@ For a partial at 440 Hz with `spread = 0.02`: `BW ≈ 8.8 Hz`, `r ≈ exp(−π - [x] Synthesized STFT cache for FFT comparison - [x] Phase 3: Editing UI - [x] Partial selection with property panel (freq/amp/synth tabs) - - [x] Amplitude bezier drag editor + - [x] Amplitude bezier drag editor (P1/P2 horizontally movable, ordering constrained) - [x] Synth params with jog sliders (decay, jitter, spread) - [x] Auto-spread detection per partial and global - [x] Mute / delete partials diff --git a/tools/mq_editor/app.js b/tools/mq_editor/app.js index 9b05789..dd0a24f 100644 --- a/tools/mq_editor/app.js +++ b/tools/mq_editor/app.js @@ -4,13 +4,13 @@ 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); + return Math.acos(clamp(cosW, -1, 1)) * 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); + return Math.acos(clamp(cosW, -1, 1)) * sr / (2 * Math.PI); } function fmtHz(f) { return f >= 1000 ? (f/1000).toFixed(1) + 'k' : Math.round(f) + 'Hz'; diff --git a/tools/mq_editor/editor.js b/tools/mq_editor/editor.js index a7d0879..6ea6d73 100644 --- a/tools/mq_editor/editor.js +++ b/tools/mq_editor/editor.js @@ -322,10 +322,10 @@ class PartialEditor { if (!dragging) return; const dx = e.clientX - startX; const half = slider.offsetWidth / 2; - const clamped = Math.max(-half, Math.min(half, dx)); + const clamped = clamp(dx, -half, half); thumb.style.transition = 'none'; thumb.style.left = `calc(50% - 3px + ${clamped}px)`; - const newVal = Math.max(min, Math.min(max, startVal + dx * sensitivity)); + const newVal = clamp(startVal + dx * sensitivity, min, max); inp.value = newVal.toFixed(decimals); onUpdate(newVal); }; @@ -496,7 +496,7 @@ class PartialEditor { 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'; + canvas.style.cursor = (i === 1 || i === 2) ? 'move' : 'ns-resize'; e.preventDefault(); return; } @@ -510,6 +510,12 @@ class PartialEditor { const curve = this.partials[this._selectedIndex].freqCurve; const i = this._dragPointIndex; curve['a' + i] = Math.max(0, this._yToAmp(y)); + // Inner control points are also horizontally draggable + if (i === 1) { + curve.t1 = clamp(this._xToT(x), curve.t0 + 1e-4, curve.t2 - 1e-4); + } else if (i === 2) { + curve.t2 = clamp(this._xToT(x), curve.t1 + 1e-4, curve.t3 - 1e-4); + } this._renderAmpEditor(); if (this.viewer) this.viewer.render(); e.preventDefault(); @@ -520,13 +526,14 @@ class PartialEditor { if (this._selectedIndex >= 0 && this.partials) { const curve = this.partials[this._selectedIndex]?.freqCurve; if (curve) { - let near = false; + let cursor = 'crosshair'; 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; + cursor = (i === 1 || i === 2) ? 'move' : 'ns-resize'; + break; } } - canvas.style.cursor = near ? 'grab' : 'crosshair'; + canvas.style.cursor = cursor; } } }); diff --git a/tools/mq_editor/mq_extract.js b/tools/mq_editor/mq_extract.js index 18897fb..42215d3 100644 --- a/tools/mq_editor/mq_extract.js +++ b/tools/mq_editor/mq_extract.js @@ -451,7 +451,7 @@ function trackIsoContour(stftCache, seedTime, seedFreq, params) { } const seedFrame = stftCache.getFrameAtIndex(seedFrameIdx); - const seedBin = Math.max(1, Math.min(halfBins - 2, Math.round(seedFreq / binHz))); + const seedBin = clamp(Math.round(seedFreq / binHz), 1, halfBins - 2); const targetSq = seedFrame.squaredAmplitude[seedBin]; if (targetSq <= 0) return null; const targetDB = 10 * Math.log10(targetSq); diff --git a/tools/mq_editor/mq_synth.js b/tools/mq_editor/mq_synth.js index 00867a9..a9f387c 100644 --- a/tools/mq_editor/mq_synth.js +++ b/tools/mq_editor/mq_synth.js @@ -42,8 +42,8 @@ function synthesizeMQ(partials, sampleRate, duration, integratePhase = true, opt // 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 r = options.forceRGain ? clamp(options.globalR, 0, 0.9999) + : (res.r != null ? clamp(res.r, 0, 0.9999) : 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)); @@ -131,7 +131,7 @@ function synthesizeMQ(partials, sampleRate, duration, integratePhase = true, opt // 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)); + const k1 = clamp(options.k1, 0, 1); let y = 0.0; for (let i = 0; i < numSamples; ++i) { y = k1 * pcm[i] + (1.0 - k1) * y; @@ -139,7 +139,7 @@ function synthesizeMQ(partials, sampleRate, duration, integratePhase = true, opt } } if (options.k2 != null) { - const k2 = Math.max(0, Math.min(1, options.k2)); + const k2 = clamp(options.k2, 0, 1); let y = 0.0, xp = 0.0; for (let i = 0; i < numSamples; ++i) { const x = pcm[i]; diff --git a/tools/mq_editor/test_fft.html b/tools/mq_editor/test_fft.html deleted file mode 100644 index b4e7f48..0000000 --- a/tools/mq_editor/test_fft.html +++ /dev/null @@ -1,229 +0,0 @@ -<!DOCTYPE html> -<!-- - test_fft.html — Isolated FFT correctness tests for fft.js - Open directly in a browser (no server needed). - - Tests fftForward(), realFFT(), and STFTCache from fft.js. - - Test summary: - 1 DC impulse — all-ones input → bin 0 must equal N - 2 Single tone — 440 Hz pure sine → peak bin matches expected frequency - 3 STFT magnitude — STFTCache.getMagnitudeDB() returns loud value at 440 Hz - 4 Pair equal — 220 + 880 Hz, both peaks found - 5 Pair equal — 440 + 1320 Hz, both peaks found - 6 Pair wide — 300 + 3000 Hz (decade separation), both peaks found - 7 Pair extreme — 100 + 8000 Hz (80× ratio), both peaks found - 8 Pair unequal — 440 Hz + 2000 Hz at 10:1 amplitude, weak peak still found - 9 Triplet chord — C4 (261.63) + E4 (329.63) + G4 (392) major chord - 10 Triplet octaves — 110 + 220 + 440 Hz octave stack - 11 Triplet harmonic — 500 + 1500 + 4500 Hz (1:3:9 ratio) - 12 Triplet unequal — 440 + 880 + 1760 Hz at 1.0 / 0.5 / 0.25 (decaying harmonics) - - Pass criteria: - - Each expected frequency bin is found within ±guard bins (guard ≈ 2 × bin_width). - - Bin width = SR / N = 32000 / 4096 ≈ 7.8 Hz. - - All spectra are drawn as linear-magnitude plots (0..Nyquist on x-axis). - Colored vertical markers show expected frequency positions. ---> -<html> -<head> -<meta charset="utf-8"> -<title>FFT Test</title> -<style> - body { font-family: monospace; background: #111; color: #ccc; padding: 20px; } - h2 { color: #fff; } - canvas { border: 1px solid #444; display: block; margin: 10px 0; } - .pass { color: #4f4; } - .fail { color: #f44; } - pre { background: #222; padding: 10px; } -</style> -</head> -<body> -<h2>FFT Isolation Test</h2> -<pre id="log"></pre> -<canvas id="spectrum" width="800" height="200"></canvas> -<script src="fft.js"></script> -<script> -const log = document.getElementById('log'); -function print(msg, cls) { - const span = document.createElement('span'); - if (cls) span.className = cls; - span.textContent = msg + '\n'; - log.appendChild(span); -} - -// --- Test 1: Single bin impulse (DC) --- -{ - const N = 8; - const real = new Float32Array([1,1,1,1,1,1,1,1]); - const imag = new Float32Array(N); - fftForward(real, imag, N); - const ok = Math.abs(real[0] - 8) < 1e-4 && Math.abs(imag[0]) < 1e-4; - print(`Test 1 DC impulse: real[0]=${real[0].toFixed(4)} ${ok?'PASS':'FAIL'}`, ok?'pass':'fail'); -} - -// --- Test 2: 440 Hz peak detection --- -{ - const SR = 32000; - const N = 2048; - const signal = new Float32Array(N); - for (let i = 0; i < N; ++i) - signal[i] = Math.sin(2 * Math.PI * 440 * i / SR); - - const spectrum = realFFT(signal); - - let peakBin = 0, peakVal = 0; - for (let i = 0; i < N / 2; ++i) { - const re = spectrum[i * 2], im = spectrum[i * 2 + 1]; - const mag = re * re + im * im; - if (mag > peakVal) { peakVal = mag; peakBin = i; } - } - const peakFreq = peakBin * SR / N; - const ok = Math.abs(peakFreq - 440) < SR / N; - print(`Test 2 440Hz peak: bin=${peakBin} freq=${peakFreq.toFixed(1)}Hz ${ok?'PASS':'FAIL'}`, ok?'pass':'fail'); - - // Draw spectrum - const canvas = document.getElementById('spectrum'); - const ctx = canvas.getContext('2d'); - ctx.fillStyle = '#111'; - ctx.fillRect(0, 0, canvas.width, canvas.height); - ctx.strokeStyle = '#0af'; - ctx.beginPath(); - const halfN = N / 2; - for (let i = 0; i < halfN; ++i) { - const re = spectrum[i * 2], im = spectrum[i * 2 + 1]; - const mag = Math.sqrt(re * re + im * im) / (N / 2); - const x = i / halfN * canvas.width; - const y = canvas.height - mag * canvas.height; - i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); - } - ctx.stroke(); - - // Mark 440Hz - ctx.strokeStyle = '#f80'; - ctx.beginPath(); - const x440 = peakBin / halfN * canvas.width; - ctx.moveTo(x440, 0); ctx.lineTo(x440, canvas.height); - ctx.stroke(); - ctx.fillStyle = '#f80'; - ctx.fillText(`440Hz (bin ${peakBin})`, x440 + 4, 16); -} - -// --- Test 3: STFT getMagnitudeDB at t=0 --- -{ - const SR = 32000; - const N = 44032; // ~1.375s - const signal = new Float32Array(N); - for (let i = 0; i < N; ++i) - signal[i] = Math.sin(2 * Math.PI * 440 * i / SR); - - const stft = new STFTCache(signal, SR, 2048, 512); - const db = stft.getMagnitudeDB(0.0, 440); - const ok = db > -10; - print(`Test 3 STFT getMagnitudeDB(0, 440Hz): ${db.toFixed(1)} dB ${ok?'PASS':'FAIL'}`, ok?'pass':'fail'); -} - -// Helper: find top-K peaks in spectrum (bin indices), ignoring neighbors within 'guard' bins -function findPeaks(spectrum, halfN, k, guard) { - const mags = new Float32Array(halfN); - for (let i = 0; i < halfN; ++i) { - const re = spectrum[i * 2], im = spectrum[i * 2 + 1]; - mags[i] = re * re + im * im; - } - const peaks = []; - const used = new Uint8Array(halfN); - for (let p = 0; p < k; ++p) { - let best = -1, bestVal = 0; - for (let i = 1; i < halfN; ++i) { - if (!used[i] && mags[i] > bestVal) { bestVal = mags[i]; best = i; } - } - if (best < 0) break; - peaks.push(best); - for (let g = Math.max(0, best - guard); g <= Math.min(halfN - 1, best + guard); ++g) - used[g] = 1; - } - return peaks; -} - -// Helper: draw labeled spectrum on a new canvas -function drawSpectrum(label, spectrum, N, SR, markerFreqs) { - const halfN = N / 2; - const colors = ['#f80', '#0f8', '#f0f', '#08f']; - const canvas = document.createElement('canvas'); - canvas.width = 800; canvas.height = 160; - const div = document.createElement('div'); - div.style.cssText = 'color:#888;font-family:monospace;font-size:12px;margin-top:8px'; - div.textContent = label; - document.body.appendChild(div); - document.body.appendChild(canvas); - - const ctx = canvas.getContext('2d'); - ctx.fillStyle = '#111'; - ctx.fillRect(0, 0, canvas.width, canvas.height); - ctx.strokeStyle = '#0af'; - ctx.beginPath(); - for (let i = 0; i < halfN; ++i) { - const re = spectrum[i * 2], im = spectrum[i * 2 + 1]; - const mag = Math.sqrt(re * re + im * im) / (N / 2); - const x = i / halfN * canvas.width; - const y = canvas.height - mag * canvas.height; - i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); - } - ctx.stroke(); - - markerFreqs.forEach((f, idx) => { - const bin = Math.round(f * N / SR); - const x = bin / halfN * canvas.width; - ctx.strokeStyle = colors[idx % colors.length]; - ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, canvas.height); ctx.stroke(); - ctx.fillStyle = colors[idx % colors.length]; - ctx.fillText(`${f}Hz`, x + 3, 14 + idx * 14); - }); -} - -// Helper: test multi-frequency signal, return pass/fail -function testMultiFreq(label, freqs, amplitudes, SR, N, testNum) { - const signal = new Float32Array(N); - for (let i = 0; i < N; ++i) - for (let f = 0; f < freqs.length; ++f) - signal[i] += (amplitudes ? amplitudes[f] : 1.0) * Math.sin(2 * Math.PI * freqs[f] * i / SR); - - const spectrum = realFFT(signal); - const halfN = N / 2; - const guard = Math.ceil(SR / N * 2); // ~2 bins tolerance - const peaks = findPeaks(spectrum, halfN, freqs.length, guard); - - const detectedFreqs = peaks.map(b => (b * SR / N).toFixed(1)); - const allFound = freqs.every(f => { - const expectedBin = Math.round(f * N / SR); - return peaks.some(b => Math.abs(b - expectedBin) <= guard); - }); - - const freqStr = freqs.map((f, i) => amplitudes ? `${f}Hz@${amplitudes[i].toFixed(1)}` : `${f}Hz`).join(' + '); - print(`Test ${testNum} ${label} [${freqStr}]: peaks=[${detectedFreqs.join(', ')}] ${allFound?'PASS':'FAIL'}`, - allFound ? 'pass' : 'fail'); - - drawSpectrum(`Test ${testNum}: ${label} — ${freqStr}`, spectrum, N, SR, freqs); - return allFound; -} - -const SR = 32000, N = 4096; - -// --- Pairs --- -print('\n-- Pairs --'); -testMultiFreq('pair', [220, 880], null, SR, N, 4); -testMultiFreq('pair', [440, 1320], null, SR, N, 5); -testMultiFreq('pair', [300, 3000], null, SR, N, 6); -testMultiFreq('pair', [100, 8000], null, SR, N, 7); -testMultiFreq('pair unequal amp', [440, 2000], [1.0, 0.1], SR, N, 8); - -// --- Triplets --- -print('\n-- Triplets --'); -testMultiFreq('triplet', [261.63, 329.63, 392.00], null, SR, N, 9); // C E G chord -testMultiFreq('triplet', [110, 220, 440], null, SR, N, 10); // octave stack -testMultiFreq('triplet', [500, 1500, 4500], null, SR, N, 11); // harmonic series -testMultiFreq('triplet unequal', [440, 880, 1760], [1.0, 0.5, 0.25],SR, N, 12); // decaying harmonics -</script> -</body> -</html> diff --git a/tools/mq_editor/utils.js b/tools/mq_editor/utils.js index 2c6b2f5..ed34b8e 100644 --- a/tools/mq_editor/utils.js +++ b/tools/mq_editor/utils.js @@ -8,7 +8,7 @@ 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)); + u = clamp(u, 0, 1); const u1 = (curve.t1 - curve.t0) / dt; const u2 = (curve.t2 - curve.t0) / dt; const d0 = (-u1) * (-u2) * (-1); @@ -29,7 +29,7 @@ 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)); + u = clamp(u, 0, 1); const u1 = (curve.t1 - curve.t0) / dt; const u2 = (curve.t2 - curve.t0) / dt; const d0 = (-u1) * (-u2) * (-1); @@ -45,6 +45,8 @@ function evalBezierAmp(curve, t) { return l0 * curve.a0 + l1 * curve.a1 + l2 * curve.a2 + l3 * curve.a3; } +function clamp(v, lo, hi) { return v < lo ? lo : v > hi ? hi : v; } + // Get canvas-relative {x, y} from a mouse event function getCanvasCoords(e, canvas) { const rect = canvas.getBoundingClientRect(); diff --git a/tools/mq_editor/viewer.js b/tools/mq_editor/viewer.js index d7b5ac1..1bc5fc9 100644 --- a/tools/mq_editor/viewer.js +++ b/tools/mq_editor/viewer.js @@ -109,7 +109,7 @@ class SpectrogramViewer { // DB value -> normalized intensity [0..1], relative to cache maxDB over 80dB range normalizeDB(magDB, maxDB) { - return Math.max(0, Math.min(1, (magDB - (maxDB - 80)) / 80)); + return clamp((magDB - (maxDB - 80)) / 80, 0, 1); } // Partial index -> display color @@ -643,8 +643,8 @@ class SpectrogramViewer { 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 t = clamp(this.canvasToTime(x), 0, this.t_max); + const v = clamp(this.canvasToFreq(y), this.freqStart, this.freqEnd); const partial = this.partials[this.selectedPartial]; const i = this.dragState.pointIndex; partial.freqCurve['t' + i] = t; |
