summaryrefslogtreecommitdiff
path: root/tools/mq_editor/app.js
diff options
context:
space:
mode:
Diffstat (limited to 'tools/mq_editor/app.js')
-rw-r--r--tools/mq_editor/app.js232
1 files changed, 88 insertions, 144 deletions
diff --git a/tools/mq_editor/app.js b/tools/mq_editor/app.js
index 59849da..1c6d548 100644
--- a/tools/mq_editor/app.js
+++ b/tools/mq_editor/app.js
@@ -4,19 +4,36 @@
function k1ToHz(k, sr) {
if (k >= 1.0) return sr / 2;
const cosW = (2 - 2*k - k*k) / (2*(1 - k));
- return Math.acos(Math.max(-1, Math.min(1, cosW))) * sr / (2 * Math.PI);
+ return Math.acos(clamp(cosW, -1, 1)) * sr / (2 * Math.PI);
}
// HP: y[n] = k*(y[n-1]+x[n]-x[n-1]) => -3dB from peak at cos(w) = 2k/(1+k²)
function k2ToHz(k, sr) {
if (k >= 1.0) return 0;
const cosW = 2*k / (1 + k*k);
- return Math.acos(Math.max(-1, Math.min(1, cosW))) * sr / (2 * Math.PI);
+ return Math.acos(clamp(cosW, -1, 1)) * sr / (2 * Math.PI);
}
function fmtHz(f) {
return f >= 1000 ? (f/1000).toFixed(1) + 'k' : Math.round(f) + 'Hz';
}
function getSR() { return (typeof audioBuffer !== 'undefined' && audioBuffer) ? audioBuffer.sampleRate : 44100; }
+// Params dropdown toggle
+(function() {
+ const btn = document.getElementById('paramsBtn');
+ const panel = document.getElementById('paramsPanel');
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const open = panel.classList.toggle('open');
+ btn.classList.toggle('params-open', open);
+ });
+ document.addEventListener('click', (e) => {
+ if (!panel.contains(e.target) && e.target !== btn) {
+ panel.classList.remove('open');
+ btn.classList.remove('params-open');
+ }
+ });
+})();
+
// LP/HP slider live display
document.getElementById('lpK1').addEventListener('input', function() {
const k = parseFloat(this.value);
@@ -32,13 +49,18 @@ document.getElementById('hpK2').addEventListener('input', function() {
// Show/hide global resonator params when forceResonator toggled
document.getElementById('forceResonator').addEventListener('change', function() {
document.getElementById('globalResParams').style.display = this.checked ? '' : 'none';
+ if (viewer) viewer.render();
});
document.getElementById('globalR').addEventListener('input', function() {
document.getElementById('globalRVal').textContent = parseFloat(this.value).toFixed(4);
+ if (viewer) viewer.render();
});
document.getElementById('globalGain').addEventListener('input', function() {
document.getElementById('globalGainVal').textContent = parseFloat(this.value).toFixed(2);
+ if (viewer) viewer.render();
});
+document.getElementById('forceRGain').addEventListener('change', () => { if (viewer) viewer.render(); });
+
let audioBuffer = null;
let viewer = null;
let audioContext = null;
@@ -70,14 +92,18 @@ function pushUndo() {
_updateUndoRedoBtns();
}
-function _applySnapshot(snap) {
- extractedPartials = snap;
- editor.setPartials(snap);
+function refreshPartialsView(selectIdx = -1) {
+ editor.setPartials(extractedPartials);
if (viewer) {
- viewer.setPartials(snap);
- viewer.setKeepCount(snap.length > 0 ? getKeepCount() : 0);
- viewer.selectPartial(-1);
+ viewer.setPartials(extractedPartials);
+ viewer.setKeepCount(extractedPartials && extractedPartials.length > 0 ? getKeepCount() : 0);
+ viewer.selectPartial(selectIdx);
}
+}
+
+function _applySnapshot(snap) {
+ extractedPartials = snap;
+ refreshPartialsView();
_updateUndoRedoBtns();
}
@@ -168,12 +194,14 @@ function loadAudioBuffer(buffer, label) {
});
}
+ if (viewer) viewer.destroy();
viewer = new SpectrogramViewer(canvas, audioBuffer, stftCache);
viewer.setFrames(peakFrames);
document.getElementById('exploreBtn').disabled = false;
document.getElementById('contourBtn').disabled = false;
editor.setViewer(viewer);
- viewer.onPartialSelect = (i) => editor.onPartialSelect(i);
+ viewer.onGetSynthOpts = () => getSynthParams().opts;
+ viewer.onPartialSelect = (i) => editor.onPartialSelect(i);
viewer.onRender = () => editor.onRender();
viewer.onBeforeChange = pushUndo;
viewer.onExploreMove = (time, freq) => {
@@ -197,16 +225,13 @@ function loadAudioBuffer(buffer, label) {
viewer.onExploreCommit = (partial) => {
if (!extractedPartials) extractedPartials = [];
pushUndo();
- const {spread_above, spread_below} = autodetectSpread(partial, stftCache, fftSize, audioBuffer.sampleRate);
- partial.replicas = { ...partial.replicas, spread_above, spread_below };
+ const {spread} = autodetectSpread(partial, stftCache, fftSize, audioBuffer.sampleRate);
+ if (!partial.harmonics) partial.harmonics = { decay: 0.0, freq_mult: 2.0, jitter: 0.05, spread: 0.02 };
+ partial.harmonics.spread = spread;
extractedPartials.unshift(partial);
- editor.setPartials(extractedPartials);
- viewer.setPartials(extractedPartials);
- viewer.setKeepCount(getKeepCount());
- viewer.selectPartial(0);
+ refreshPartialsView(0);
setStatus(`${exploreMode}: added partial (${extractedPartials.length} total)`, 'info');
};
- if (label.startsWith('Test WAV')) validateTestWAVPeaks(stftCache);
}, 10);
}
@@ -231,25 +256,6 @@ wavFile.addEventListener('change', async (e) => {
}
});
-// 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);
@@ -295,12 +301,10 @@ function runExtraction() {
});
extractedPartials = result.partials;
- editor.setPartials(result.partials);
+ autoSpreadAll();
viewer.setFrames(result.frames);
setStatus(`Extracted ${result.partials.length} partials`, 'info');
- viewer.setPartials(result.partials);
- viewer.setKeepCount(getKeepCount());
- viewer.selectPartial(-1);
+ refreshPartialsView();
} catch (err) {
setStatus('Extraction error: ' + err.message, 'error');
@@ -334,27 +338,17 @@ function createNewPartial() {
v0: 440, v1: 440, v2: 440, v3: 440,
a0: 1.0, a1: 1.0, a2: 1.0, a3: 1.0,
},
- replicas: { decay_alpha: 0.1, jitter: 0.05, spread_above: 0.02, spread_below: 0.02 },
+ harmonics: { decay: 0.0, freq_mult: 2.0, jitter: 0.05, spread: 0.02 },
};
extractedPartials.unshift(newPartial);
- editor.setPartials(extractedPartials);
- if (viewer) {
- viewer.setPartials(extractedPartials);
- viewer.setKeepCount(getKeepCount());
- viewer.selectPartial(0);
- }
+ refreshPartialsView(0);
}
function clearAllPartials() {
if (!extractedPartials || extractedPartials.length === 0) return;
pushUndo();
extractedPartials = [];
- editor.setPartials([]);
- if (viewer) {
- viewer.setPartials([]);
- viewer.setKeepCount(0);
- viewer.selectPartial(-1);
- }
+ refreshPartialsView();
}
document.getElementById('newPartialBtn').addEventListener('click', createNewPartial);
@@ -364,22 +358,23 @@ document.getElementById('contourBtn').addEventListener('click', () => setExplore
document.getElementById('undoBtn').addEventListener('click', undo);
document.getElementById('redoBtn').addEventListener('click', redo);
-autoSpreadAllBtn.addEventListener('click', () => {
+function autoSpreadAll() {
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 };
+ const defaults = { decay: 0.0, freq_mult: 2.0, jitter: 0.05, spread: 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;
+ const {spread} = autodetectSpread(p, stftCache, fs, sr);
+ if (!p.harmonics) p.harmonics = { ...defaults };
+ p.harmonics.spread = spread;
}
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');
-});
+}
+
+autoSpreadAllBtn.addEventListener('click', autoSpreadAll);
threshold.addEventListener('change', () => {
if (stftCache) runExtraction();
@@ -428,12 +423,14 @@ function stopAudio() {
setStatus('Stopped', 'info');
}
-// Play audio
-playBtn.addEventListener('click', () => {
+function playOriginal() {
if (!audioBuffer || !audioContext) return;
stopAudio();
playAudioBuffer(audioBuffer, 'Playing...');
-});
+}
+
+// Play audio
+playBtn.addEventListener('click', playOriginal);
// Stop audio
stopBtn.addEventListener('click', () => {
@@ -464,6 +461,25 @@ function getSynthParams() {
};
}
+// Synthesize partials and return an AudioBuffer, trimmed to [t_start-margin, t_end+margin]
+function getAudioBuffer(partials, margin = 0) {
+ const sr = audioBuffer.sampleRate;
+ const {integratePhase, opts} = getSynthParams();
+ const pcm = synthesizeMQ(partials, sr, audioBuffer.duration, integratePhase, opts);
+ let startSample = 0, endSample = pcm.length;
+ if (margin >= 0 && partials.length > 0) {
+ const times = partials.flatMap(p => p.times);
+ const tStart = Math.min(...times);
+ const tEnd = Math.max(...times);
+ startSample = Math.max(0, Math.floor((tStart - margin) * sr));
+ endSample = Math.min(pcm.length, Math.ceil((tEnd + margin) * sr));
+ }
+ const trimmed = pcm.subarray(startSample, endSample);
+ const buf = audioContext.createBuffer(1, trimmed.length, sr);
+ buf.getChannelData(0).set(trimmed);
+ return buf;
+}
+
// Play synthesized audio
function playSynthesized() {
if (!extractedPartials || extractedPartials.length === 0) {
@@ -480,16 +496,14 @@ function playSynthesized() {
const partialsToUse = extractedPartials.slice(0, keepCount).filter(p => !p.muted);
setStatus(`Synthesizing ${partialsToUse.length}/${extractedPartials.length} partials (${keepPct.value}%)...`, 'info');
- const {integratePhase, opts} = getSynthParams();
- const pcm = synthesizeMQ(partialsToUse, audioBuffer.sampleRate, audioBuffer.duration,
- integratePhase, opts);
-
if (viewer) {
+ const {integratePhase, opts} = getSynthParams();
+ const pcm = synthesizeMQ(partialsToUse, audioBuffer.sampleRate, audioBuffer.duration,
+ integratePhase, opts);
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);
+ const synthBuffer = getAudioBuffer(partialsToUse);
playAudioBuffer(synthBuffer, `Playing synthesized (${partialsToUse.length}/${extractedPartials.length} partials, ${keepPct.value}%)...`);
}
@@ -505,12 +519,10 @@ document.addEventListener('keydown', (e) => {
}
if (e.code === 'Digit1') {
e.preventDefault();
- playSynthesized();
+ if (!playBtn.disabled) playOriginal();
} else if (e.code === 'Digit2') {
e.preventDefault();
- if (!playBtn.disabled) {
- playBtn.click();
- }
+ playSynthesized();
} else if (e.code === 'Digit3') {
e.preventDefault();
const sel = viewer ? viewer.selectedPartial : -1;
@@ -518,12 +530,7 @@ document.addEventListener('keydown', (e) => {
const partial = extractedPartials[sel];
if (!partial) return;
stopAudio();
- const {integratePhase, opts} = getSynthParams();
- const pcm = synthesizeMQ([partial], audioBuffer.sampleRate, audioBuffer.duration,
- integratePhase, opts);
- const buf = audioContext.createBuffer(1, pcm.length, audioBuffer.sampleRate);
- buf.getChannelData(0).set(pcm);
- playAudioBuffer(buf, `Playing partial #${sel}...`);
+ playAudioBuffer(getAudioBuffer([partial], 0.05), `Playing partial #${sel}...`);
} else if (e.code === 'KeyP') {
e.preventDefault();
if (viewer) viewer.togglePeaks();
@@ -542,6 +549,9 @@ document.addEventListener('keydown', (e) => {
} else if (e.code === 'KeyC' && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
if (!document.getElementById('contourBtn').disabled) setExploreMode(exploreMode === 'contour' ? false : 'contour');
+ } else if (e.code === 'Delete' || e.code === 'Backspace') {
+ e.preventDefault();
+ document.getElementById('deletePartialBtn').click();
} else if (e.code === 'Escape') {
if (exploreMode) { setExploreMode(false); return; }
if (viewer) viewer.selectPartial(-1);
@@ -557,69 +567,3 @@ document.querySelectorAll('.tab-btn').forEach(btn => {
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();
-}