summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--doc/COMPLETED.md9
-rw-r--r--tools/mq_editor/app.js447
-rw-r--r--tools/mq_editor/editor.js85
-rw-r--r--tools/mq_editor/index.html449
-rw-r--r--tools/mq_editor/mq_extract.js41
-rw-r--r--tools/mq_editor/mq_synth.js13
-rw-r--r--tools/mq_editor/utils.js36
-rw-r--r--tools/mq_editor/viewer.js37
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;
-}