diff options
| author | skal <pascal.massimino@gmail.com> | 2026-02-18 16:01:13 +0100 |
|---|---|---|
| committer | skal <pascal.massimino@gmail.com> | 2026-02-18 16:01:13 +0100 |
| commit | 00ce97d64b8bf7e1dcbdb5151bdf2033132ffbc3 (patch) | |
| tree | 0a5d4a7860ee12a4ba75bea9fa0eb0687e2a91b9 | |
| parent | 7a054e8ee8566eea9d06ff1ff9c1ce48c39fe659 (diff) | |
refactor(mq_editor): consolidate duplicates, extract utils.js and app.js
- utils.js (new): evalBezier (robust), getCanvasCoords, buildBandPoints
- app.js (new): extract ~450-line inline script from index.html
- editor.js: generalize _makeJogSlider(inp, options) with onUpdate cb,
eliminate 50-line inline resonator jog duplication, use getCanvasCoords
- mq_extract.js: extract findBestPeak(), replace two identical loop bodies
- viewer.js: remove duplicate evalBezier, use getCanvasCoords/buildBandPoints
- mq_synth.js: remove duplicate evalBezier
- index.html: inline script removed, load order: utils→fft→extract→synth→viewer→editor→app
handoff(Claude): mq_editor refactor complete — no logic changes, browser-ready.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| -rw-r--r-- | doc/COMPLETED.md | 9 | ||||
| -rw-r--r-- | tools/mq_editor/app.js | 447 | ||||
| -rw-r--r-- | tools/mq_editor/editor.js | 85 | ||||
| -rw-r--r-- | tools/mq_editor/index.html | 449 | ||||
| -rw-r--r-- | tools/mq_editor/mq_extract.js | 41 | ||||
| -rw-r--r-- | tools/mq_editor/mq_synth.js | 13 | ||||
| -rw-r--r-- | tools/mq_editor/utils.js | 36 | ||||
| -rw-r--r-- | tools/mq_editor/viewer.js | 37 |
8 files changed, 547 insertions, 570 deletions
diff --git a/doc/COMPLETED.md b/doc/COMPLETED.md index 3e02c40..1fb51f5 100644 --- a/doc/COMPLETED.md +++ b/doc/COMPLETED.md @@ -557,6 +557,15 @@ Use `read @doc/archive/FILENAME.md` to access archived documents. - **Task #39: Visual Debugging System**: Implemented a comprehensive set of wireframe primitives (Sphere, Cone, Cross, Line, Trajectory) in `VisualDebug`. Updated `test_3d_render` to demonstrate usage. - **Task #68: Mesh Wireframe Rendering**: Added `add_mesh_wireframe` to `VisualDebug` to visualize triangle edges for mesh objects. Integrated into `Renderer3D` debug path and `test_mesh` tool. +#### mq_editor Refactoring (February 18, 2026) +- **`utils.js`** (new): consolidated `evalBezier` (robust dt≤0 guard), `getCanvasCoords`, `buildBandPoints` — loaded first. +- **`app.js`** (new): extracted ~450-line inline `<script>` from `index.html`. +- **`editor.js`**: generalized `_makeJogSlider(inp, options)` with `onUpdate` callback; eliminated 50-line resonator jog duplication; uses `getCanvasCoords`. +- **`mq_extract.js`**: extracted `findBestPeak`, replacing two identical loop bodies. +- **`viewer.js`**: removed duplicate `evalBezier`; uses `getCanvasCoords` and `buildBandPoints`. +- **`mq_synth.js`**: removed duplicate `evalBezier`. +- **`index.html`**: inline script removed; load order: `utils.js` → fft → mq_extract → mq_synth → viewer → editor → `app.js`. + #### CNN v2 Training Pipeline Improvements (February 14, 2026) 🎯 - **Critical Training Fixes**: Resolved checkpoint saving and argument handling bugs in CNN v2 training pipeline. **Bug 1 (Missing Checkpoints)**: Training completed successfully but no checkpoint saved when `epochs < checkpoint_every` interval. Solution: Always save final checkpoint after training completes, regardless of interval settings. **Bug 2 (Stale Checkpoints)**: Old checkpoint files from previous runs with different parameters weren't overwritten due to `if not exists` check. Solution: Remove existence check, always overwrite final checkpoint. **Bug 3 (Ignored num_layers)**: When providing comma-separated kernel sizes (e.g., `--kernel-sizes 3,1,3`), the `--num-layers` parameter was used only for validation but not derived from list length. Solution: Derive `num_layers` from kernel_sizes list length when multiple values provided. **Bug 4 (Argument Passing)**: Shell script passed unquoted variables to Python, potentially causing parsing issues with special characters. Solution: Quote all shell variables when passing to Python scripts. diff --git a/tools/mq_editor/app.js b/tools/mq_editor/app.js new file mode 100644 index 0000000..059d700 --- /dev/null +++ b/tools/mq_editor/app.js @@ -0,0 +1,447 @@ +// 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; + +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); +}; + +// 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'); + viewer = new SpectrogramViewer(canvas, audioBuffer, stftCache); + editor.setViewer(viewer); + viewer.onPartialSelect = (i) => editor.onPartialSelect(i); + viewer.onRender = () => editor.onRender(); + if (label.startsWith('Test WAV')) validateTestWAVPeaks(stftCache); + }, 10); +} + +// 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; + }, 50); +} + +extractBtn.addEventListener('click', () => { + if (!audioBuffer) return; + runExtraction(); +}); + +autoSpreadAllBtn.addEventListener('click', () => { + if (!extractedPartials || !stftCache) return; + const fs = stftCache.fftSize; + const sr = audioBuffer.sampleRate; + const defaults = { decay_alpha: 0.1, jitter: 0.05, spread_above: 0.02, spread_below: 0.02 }; + for (const p of extractedPartials) { + const {spread_above, spread_below} = autodetectSpread(p, stftCache, fs, sr); + if (!p.replicas) p.replicas = { ...defaults }; + p.replicas.spread_above = spread_above; + p.replicas.spread_below = spread_below; + } + if (viewer) viewer.render(); + const sel = viewer ? viewer.selectedPartial : -1; + if (sel >= 0) editor.onPartialSelect(sel); + setStatus(`Auto-spread applied to ${extractedPartials.length} partials`, 'info'); +}); + +threshold.addEventListener('change', () => { + if (stftCache) runExtraction(); +}); + +freqWeightCb.addEventListener('change', () => { + if (stftCache) runExtraction(); +}); + +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; +} + +// 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 = document.getElementById('integratePhase').checked; + const disableJitter = document.getElementById('disableJitter').checked; + const disableSpread = document.getElementById('disableSpread').checked; + const forceResonator = document.getElementById('forceResonator').checked; + const lpK1Raw = parseFloat(document.getElementById('lpK1').value); + const hpK2Raw = parseFloat(document.getElementById('hpK2').value); + const k1 = lpK1Raw < 1.0 ? lpK1Raw : null; + const k2 = hpK2Raw < 1.0 ? hpK2Raw : null; + const forceRGain = forceResonator && document.getElementById('forceRGain').checked; + const globalR = parseFloat(document.getElementById('globalR').value); + const globalGain = parseFloat(document.getElementById('globalGain').value); + const pcm = synthesizeMQ(partialsToUse, audioBuffer.sampleRate, audioBuffer.duration, + integratePhase, {disableJitter, disableSpread, forceResonator, + forceRGain, globalR, globalGain, k1, k2}); + + 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.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(); + } + } else if (e.code === 'KeyE') { + e.preventDefault(); + if (!extractBtn.disabled) extractBtn.click(); + } else if (e.code === 'Escape') { + if (viewer) viewer.selectPartial(-1); + } +}); + +// Curve tab switching +document.querySelectorAll('.tab-btn').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + document.querySelectorAll('.tab-pane').forEach(p => p.style.display = 'none'); + document.getElementById('tab' + btn.dataset.tab).style.display = ''; + }); +}); + +// --- Test WAV peak validation --- +function validateTestWAVPeaks(cache) { + const SR = cache.sampleRate; + 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 index 97d8a7a..301dcc5 100644 --- a/tools/mq_editor/editor.js +++ b/tools/mq_editor/editor.js @@ -181,7 +181,15 @@ class PartialEditor { }); sinInputs[p.key] = inp; - const jog = this._makeJogSlider(inp, partial, index, p, repDefaults); + 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); @@ -245,45 +253,15 @@ class PartialEditor { if (this.viewer) this.viewer.render(); }); - // Inline jog slider for resonator params - const step = parseFloat(p.step); - const sensitivity = step * 5; - const jog = document.createElement('div'); - jog.className = 'jog-slider'; - const thumb = document.createElement('div'); - thumb.className = 'jog-thumb'; - jog.appendChild(thumb); - let dragging = false, startX = 0, startVal = 0; - const onMove = (ev) => { - if (!dragging) return; - const dx = ev.clientX - startX; - const half = jog.offsetWidth / 2; - const clamped = Math.max(-half, Math.min(half, dx)); - thumb.style.transition = 'none'; - thumb.style.left = `calc(50% - 3px + ${clamped}px)`; - const newVal = Math.max(parseFloat(inp.min) || 0, - Math.min(parseFloat(inp.max) || 1e9, startVal + dx * sensitivity)); - inp.value = newVal.toFixed(4); - if (!this.partials || !this.partials[index]) return; - if (!this.partials[index].resonator) this.partials[index].resonator = { ...resDefaults }; - this.partials[index].resonator[p.key] = newVal; - if (this.viewer) this.viewer.render(); - }; - const onUp = () => { - if (!dragging) return; - dragging = false; - thumb.style.transition = ''; - thumb.style.left = 'calc(50% - 3px)'; - document.removeEventListener('mousemove', onMove); - document.removeEventListener('mouseup', onUp); - }; - jog.addEventListener('mousedown', (ev) => { - dragging = true; - startX = ev.clientX; - startVal = parseFloat(inp.value) || 0; - document.addEventListener('mousemove', onMove); - document.addEventListener('mouseup', onUp); - ev.preventDefault(); + const 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'); @@ -322,14 +300,20 @@ class PartialEditor { }); } - _makeJogSlider(inp, partial, index, p, defaults) { + _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); - const sensitivity = parseFloat(p.step) * 5; let startX = 0, startVal = 0, dragging = false; const onMove = (e) => { @@ -339,12 +323,9 @@ class PartialEditor { const clamped = Math.max(-half, Math.min(half, dx)); thumb.style.transition = 'none'; thumb.style.left = `calc(50% - 3px + ${clamped}px)`; - const newVal = Math.max(0, startVal + dx * sensitivity); - inp.value = newVal.toFixed(3); - if (!this.partials || !this.partials[index]) return; - if (!this.partials[index].replicas) this.partials[index].replicas = { ...defaults }; - this.partials[index].replicas[p.key] = newVal; - if (this.viewer) this.viewer.render(); + const newVal = Math.max(min, Math.min(max, startVal + dx * sensitivity)); + inp.value = newVal.toFixed(decimals); + onUpdate(newVal); }; const onUp = () => { @@ -359,7 +340,7 @@ class PartialEditor { slider.addEventListener('mousedown', (e) => { dragging = true; startX = e.clientX; - startVal = Math.max(0, parseFloat(inp.value) || 0); + startVal = Math.max(min, parseFloat(inp.value) || 0); document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); e.preventDefault(); @@ -504,8 +485,7 @@ class PartialEditor { if (this._selectedIndex < 0 || !this.partials) return; const partial = this.partials[this._selectedIndex]; if (!partial || !partial.ampCurve) return; - const rect = canvas.getBoundingClientRect(); - const x = e.clientX - rect.left, y = e.clientY - rect.top; + const {x, y} = getCanvasCoords(e, canvas); const curve = partial.ampCurve; for (let i = 0; i < 4; ++i) { if (Math.hypot(this._tToX(curve['t' + i]) - x, this._ampToY(curve['v' + i]) - y) <= 8) { @@ -518,8 +498,7 @@ class PartialEditor { }); canvas.addEventListener('mousemove', (e) => { - const rect = canvas.getBoundingClientRect(); - const x = e.clientX - rect.left, y = e.clientY - rect.top; + const {x, y} = getCanvasCoords(e, canvas); if (this._dragPointIndex >= 0) { const curve = this.partials[this._selectedIndex].ampCurve; diff --git a/tools/mq_editor/index.html b/tools/mq_editor/index.html index 5f6af24..4737d67 100644 --- a/tools/mq_editor/index.html +++ b/tools/mq_editor/index.html @@ -424,457 +424,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 src="editor.js"></script> - <script> - // LP: y[n] = k*x[n] + (1-k)*y[n-1] => -3dB at cos(w) = (2-2k-k²)/(2(1-k)) - function k1ToHz(k, sr) { - if (k >= 1.0) return sr / 2; - const cosW = (2 - 2*k - k*k) / (2*(1 - k)); - return Math.acos(Math.max(-1, Math.min(1, cosW))) * sr / (2 * Math.PI); - } - // HP: y[n] = k*(y[n-1]+x[n]-x[n-1]) => -3dB from peak at cos(w) = 2k/(1+k²) - function k2ToHz(k, sr) { - if (k >= 1.0) return 0; - const cosW = 2*k / (1 + k*k); - return Math.acos(Math.max(-1, Math.min(1, cosW))) * sr / (2 * Math.PI); - } - function fmtHz(f) { - return f >= 1000 ? (f/1000).toFixed(1) + 'k' : Math.round(f) + 'Hz'; - } - function getSR() { return (typeof audioBuffer !== 'undefined' && audioBuffer) ? audioBuffer.sampleRate : 44100; } - - // LP/HP slider live display - document.getElementById('lpK1').addEventListener('input', function() { - const k = parseFloat(this.value); - const f = k1ToHz(k, getSR()); - document.getElementById('lpK1Val').textContent = k >= 1.0 ? 'bypass' : fmtHz(f); - }); - document.getElementById('hpK2').addEventListener('input', function() { - const k = parseFloat(this.value); - const f = k2ToHz(k, getSR()); - document.getElementById('hpK2Val').textContent = k >= 1.0 ? 'bypass' : fmtHz(f); - }); - - // Show/hide global resonator params when forceResonator toggled - document.getElementById('forceResonator').addEventListener('change', function() { - document.getElementById('globalResParams').style.display = this.checked ? '' : 'none'; - }); - document.getElementById('globalR').addEventListener('input', function() { - document.getElementById('globalRVal').textContent = parseFloat(this.value).toFixed(4); - }); - document.getElementById('globalGain').addEventListener('input', function() { - document.getElementById('globalGainVal').textContent = parseFloat(this.value).toFixed(2); - }); - let audioBuffer = null; - let viewer = null; - let audioContext = null; - 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 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); - }; - - // 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'); - viewer = new SpectrogramViewer(canvas, audioBuffer, stftCache); - editor.setViewer(viewer); - viewer.onPartialSelect = (i) => editor.onPartialSelect(i); - viewer.onRender = () => editor.onRender(); - if (label.startsWith('Test WAV')) validateTestWAVPeaks(stftCache); - }, 10); - } - - // 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; - }, 50); - } - - extractBtn.addEventListener('click', () => { - if (!audioBuffer) return; - runExtraction(); - }); - - autoSpreadAllBtn.addEventListener('click', () => { - if (!extractedPartials || !stftCache) return; - const fs = stftCache.fftSize; - const sr = audioBuffer.sampleRate; - const defaults = { decay_alpha: 0.1, jitter: 0.05, spread_above: 0.02, spread_below: 0.02 }; - for (const p of extractedPartials) { - const {spread_above, spread_below} = autodetectSpread(p, stftCache, fs, sr); - if (!p.replicas) p.replicas = { ...defaults }; - p.replicas.spread_above = spread_above; - p.replicas.spread_below = spread_below; - } - if (viewer) viewer.render(); - const sel = viewer ? viewer.selectedPartial : -1; - if (sel >= 0) editor.onPartialSelect(sel); - setStatus(`Auto-spread applied to ${extractedPartials.length} partials`, 'info'); - }); - - threshold.addEventListener('change', () => { - if (stftCache) runExtraction(); - }); - - freqWeightCb.addEventListener('change', () => { - if (stftCache) runExtraction(); - }); - - 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; - } - - // 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 = document.getElementById('integratePhase').checked; - const disableJitter = document.getElementById('disableJitter').checked; - const disableSpread = document.getElementById('disableSpread').checked; - const forceResonator = document.getElementById('forceResonator').checked; - const lpK1Raw = parseFloat(document.getElementById('lpK1').value); - const hpK2Raw = parseFloat(document.getElementById('hpK2').value); - const k1 = lpK1Raw < 1.0 ? lpK1Raw : null; - const k2 = hpK2Raw < 1.0 ? hpK2Raw : null; - const forceRGain = forceResonator && document.getElementById('forceRGain').checked; - const globalR = parseFloat(document.getElementById('globalR').value); - const globalGain = parseFloat(document.getElementById('globalGain').value); - const pcm = synthesizeMQ(partialsToUse, audioBuffer.sampleRate, audioBuffer.duration, - integratePhase, {disableJitter, disableSpread, forceResonator, - forceRGain, globalR, globalGain, k1, k2}); - - 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.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(); - } - } else if (e.code === 'KeyE') { - e.preventDefault(); - if (!extractBtn.disabled) extractBtn.click(); - } else if (e.code === 'Escape') { - if (viewer) viewer.selectPartial(-1); - } - }); - - // Curve tab switching - document.querySelectorAll('.tab-btn').forEach(btn => { - btn.addEventListener('click', () => { - document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); - btn.classList.add('active'); - document.querySelectorAll('.tab-pane').forEach(p => p.style.display = 'none'); - document.getElementById('tab' + btn.dataset.tab).style.display = ''; - }); - }); - - // --- Test WAV peak validation --- - function validateTestWAVPeaks(cache) { - const SR = cache.sampleRate; - 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="app.js"></script> </body> </html> diff --git a/tools/mq_editor/mq_extract.js b/tools/mq_editor/mq_extract.js index 3f7490d..107b2ac 100644 --- a/tools/mq_editor/mq_extract.js +++ b/tools/mq_editor/mq_extract.js @@ -102,6 +102,21 @@ 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 { @@ -134,19 +149,8 @@ function trackPartials(frames, params) { const predictedPhase = lastPhase + phaseAdvance; const tol = Math.max(predictedFreq * trackingRatio, minTrackingHz); - let bestIdx = -1, bestCost = Infinity; - // Find the peak in the new frame with the lowest cost (freq + phase error). - for (let i = 0; i < frame.peaks.length; ++i) { - if (matched.has(i)) continue; - const pk = frame.peaks[i]; - const freqError = Math.abs(pk.freq - predictedFreq); - if (freqError > tol) continue; - - const phaseError = Math.abs(normalizeAngle(pk.phase - predictedPhase)); - const cost = freqError + phaseErrorWeight * phaseError * predictedFreq; - if (cost < bestCost) { bestCost = cost; bestIdx = i; } - } + const { bestIdx } = findBestPeak(frame.peaks, matched, predictedFreq, predictedPhase, tol, phaseErrorWeight); if (bestIdx >= 0) { const pk = frame.peaks[bestIdx]; @@ -175,18 +179,7 @@ function trackPartials(frames, params) { const predictedPhase = lastPhase + phaseAdvance; const tol = Math.max(predictedFreq * trackingRatio, minTrackingHz); - let bestIdx = -1, bestCost = Infinity; - - for (let j = 0; j < frame.peaks.length; ++j) { - if (matched.has(j)) continue; - const pk = frame.peaks[j]; - const freqError = Math.abs(pk.freq - predictedFreq); - if (freqError > tol) continue; - - const phaseError = Math.abs(normalizeAngle(pk.phase - predictedPhase)); - const cost = freqError + phaseErrorWeight * phaseError * predictedFreq; - if (cost < bestCost) { bestCost = cost; bestIdx = j; } - } + const { bestIdx } = findBestPeak(frame.peaks, matched, predictedFreq, predictedPhase, tol, phaseErrorWeight); if (bestIdx >= 0) { const pk = frame.peaks[bestIdx]; diff --git a/tools/mq_editor/mq_synth.js b/tools/mq_editor/mq_synth.js index 2d4cf1b..4c68056 100644 --- a/tools/mq_editor/mq_synth.js +++ b/tools/mq_editor/mq_synth.js @@ -1,19 +1,6 @@ // MQ Synthesizer // Replica oscillator bank for sinusoidal synthesis, plus two-pole resonator mode -// 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; -} - // Deterministic LCG PRNG function randFloat(seed, min, max) { seed = (1664525 * seed + 1013904223) % 0x100000000; diff --git a/tools/mq_editor/utils.js b/tools/mq_editor/utils.js new file mode 100644 index 0000000..c38b1f5 --- /dev/null +++ b/tools/mq_editor/utils.js @@ -0,0 +1,36 @@ +// Shared utilities for mq_editor + +// Evaluate cubic bezier curve at time t (robust: handles dt<=0) +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; +} + +// 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 76c57e2..2575cac 100644 --- a/tools/mq_editor/viewer.js +++ b/tools/mq_editor/viewer.js @@ -288,15 +288,7 @@ class SpectrogramViewer { const sa = rep.spread_above != null ? rep.spread_above : 0.02; const sb = rep.spread_below != null ? rep.spread_below : 0.02; - const STEPS = 60; - const upper = [], lower = []; - for (let i = 0; i <= STEPS; ++i) { - const t = curve.t0 + (curve.t3 - curve.t0) * i / STEPS; - if (t < this.t_view_min - 0.01 || t > this.t_view_max + 0.01) continue; - const f = evalBezier(curve, t); - upper.push([this.timeToX(t), this.freqToY(f * (1 + sa))]); - lower.push([this.timeToX(t), this.freqToY(f * (1 - sb))]); - } + const {upper, lower} = buildBandPoints(this, curve, sa, sb); if (upper.length < 2) return; const savedAlpha = ctx.globalAlpha; @@ -327,14 +319,7 @@ class SpectrogramViewer { ctx.setLineDash([]); // 50% drop-off reference lines (dotted, dimmer) - const p5upper = [], p5lower = []; - for (let i = 0; i <= STEPS; ++i) { - const t = curve.t0 + (curve.t3 - curve.t0) * i / STEPS; - if (t < this.t_view_min - 0.01 || t > this.t_view_max + 0.01) continue; - const f = evalBezier(curve, t); - p5upper.push([this.timeToX(t), this.freqToY(f * 1.50)]); - p5lower.push([this.timeToX(t), this.freqToY(f * 0.50)]); - } + const {upper: p5upper, lower: p5lower} = buildBandPoints(this, curve, 0.50, 0.50); if (p5upper.length >= 2) { ctx.globalAlpha = 0.55; ctx.strokeStyle = color; @@ -572,9 +557,7 @@ class SpectrogramViewer { const {canvas, tooltip} = this; canvas.addEventListener('mousedown', (e) => { - const rect = canvas.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; + const {x, y} = getCanvasCoords(e, canvas); // Check control point drag on selected partial if (this.selectedPartial >= 0 && this.selectedPartial < this.partials.length) { @@ -593,9 +576,7 @@ class SpectrogramViewer { }); 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))); @@ -717,13 +698,3 @@ class SpectrogramViewer { } } -// 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; -} |
