summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorskal <pascal.massimino@gmail.com>2026-02-17 21:28:58 +0100
committerskal <pascal.massimino@gmail.com>2026-02-17 21:28:58 +0100
commit99e94d18ce54d1ad73c7c0349119d4cd8fb4f965 (patch)
treee39536f70a9ff62cc42315ecb1fb5397ccf762b8
parentfbdfa2a7b54894a565fd57d5e27f19d752fe39fa (diff)
feat(mq_editor): peaks display, STFT cache refactor, threshold reactivity
- STFTCache: cache squaredAmplitude (re²+im²) per frame, add getSquaredAmplitude(t) - getMagnitudeDB uses cached sq amp (10*log10, no sqrt) - detectPeaks uses squaredAmp from cache instead of recomputing FFT - extractPartials: use cache frames, return {partials, frames} - Press 'p' to toggle raw peak overlay in viewer - threshold input: step=any, change event triggers re-extraction - runExtraction() shared by button and threshold change handoff(Claude): mq_partial peaks/cache refactor complete
-rw-r--r--tools/mq_editor/fft.js24
-rw-r--r--tools/mq_editor/index.html31
-rw-r--r--tools/mq_editor/mq_extract.js60
-rw-r--r--tools/mq_editor/viewer.js37
4 files changed, 88 insertions, 64 deletions
diff --git a/tools/mq_editor/fft.js b/tools/mq_editor/fft.js
index 0668906..36b9936 100644
--- a/tools/mq_editor/fft.js
+++ b/tools/mq_editor/fft.js
@@ -134,7 +134,15 @@ class STFTCache {
// Compute FFT
const spectrum = realFFT(windowed);
- this.frames.push({time, offset, spectrum});
+ // Cache squared amplitudes (re*re + im*im, no sqrt)
+ const squaredAmplitude = new Float32Array(this.fftSize / 2);
+ for (let i = 0; i < this.fftSize / 2; ++i) {
+ const re = spectrum[i * 2];
+ const im = spectrum[i * 2 + 1];
+ squaredAmplitude[i] = re * re + im * im;
+ }
+
+ this.frames.push({time, offset, spectrum, squaredAmplitude});
}
}
@@ -172,18 +180,20 @@ class STFTCache {
return frame ? frame.spectrum : null;
}
+ getSquaredAmplitude(t) {
+ const frame = this.getFrameAtTime(t);
+ return frame ? frame.squaredAmplitude : null;
+ }
+
// Get magnitude in dB at specific time and frequency
getMagnitudeDB(t, freq) {
- const spectrum = this.getFFT(t);
- if (!spectrum) return -80;
+ const sq = this.getSquaredAmplitude(t);
+ if (!sq) return -80;
const bin = Math.round(freq * this.fftSize / this.sampleRate);
if (bin < 0 || bin >= this.fftSize / 2) return -80;
- const re = spectrum[bin * 2];
- const im = spectrum[bin * 2 + 1];
- const mag = Math.sqrt(re * re + im * im);
- return 20 * Math.log10(Math.max(mag, 1e-10));
+ return 10 * Math.log10(Math.max(sq[bin], 1e-20));
}
}
diff --git a/tools/mq_editor/index.html b/tools/mq_editor/index.html
index 84abd1c..27eba2a 100644
--- a/tools/mq_editor/index.html
+++ b/tools/mq_editor/index.html
@@ -82,7 +82,7 @@
<input type="number" id="hopSize" value="256" min="64" max="1024" step="64">
<label>Threshold (dB):</label>
- <input type="number" id="threshold" value="-60" min="-80" max="-20" step="5">
+ <input type="number" id="threshold" value="-60" step="any">
</div>
</div>
@@ -173,9 +173,8 @@
}
});
- // Extract partials
- extractBtn.addEventListener('click', () => {
- if (!audioBuffer) return;
+ function runExtraction() {
+ if (!stftCache) return;
setStatus('Extracting partials...', 'info');
extractBtn.disabled = true;
@@ -189,13 +188,12 @@
sampleRate: audioBuffer.sampleRate
};
- const partials = extractPartials(audioBuffer, params);
+ const result = extractPartials(params, stftCache);
- extractedPartials = partials;
- setStatus(`Extracted ${partials.length} partials`, 'info');
-
- // Update viewer
- viewer.setPartials(partials);
+ extractedPartials = result.partials;
+ viewer.setFrames(result.frames);
+ setStatus(`Extracted ${result.partials.length} partials`, 'info');
+ viewer.setPartials(result.partials);
} catch (err) {
setStatus('Extraction error: ' + err.message, 'error');
@@ -203,6 +201,16 @@
}
extractBtn.disabled = false;
}, 50);
+ }
+
+ // Extract partials
+ extractBtn.addEventListener('click', () => {
+ if (!audioBuffer) return;
+ runExtraction();
+ });
+
+ threshold.addEventListener('change', () => {
+ if (stftCache) runExtraction();
});
// Play audio
@@ -325,6 +333,9 @@
if (!playBtn.disabled) {
playBtn.click();
}
+ } else if (e.code === 'KeyP') {
+ e.preventDefault();
+ if (viewer) viewer.togglePeaks();
}
});
</script>
diff --git a/tools/mq_editor/mq_extract.js b/tools/mq_editor/mq_extract.js
index c7ead4d..237f1ab 100644
--- a/tools/mq_editor/mq_extract.js
+++ b/tools/mq_editor/mq_extract.js
@@ -2,21 +2,17 @@
// McAulay-Quatieri sinusoidal analysis
// Extract partials from audio buffer
-function extractPartials(audioBuffer, params) {
- const {fftSize, hopSize, threshold, sampleRate} = params;
+function extractPartials(params, stftCache) {
+ const {fftSize, threshold, sampleRate} = params;
- // Get mono channel (mix to mono if stereo)
- const signal = getMono(audioBuffer);
- const numFrames = Math.floor((signal.length - fftSize) / hopSize);
-
- // Analyze frames
+ // Analyze frames from cache
const frames = [];
+ const numFrames = stftCache.getNumFrames();
for (let i = 0; i < numFrames; ++i) {
- const offset = i * hopSize;
- const frame = signal.slice(offset, offset + fftSize);
- const peaks = detectPeaks(frame, fftSize, sampleRate, threshold);
- const time = offset / sampleRate;
- frames.push({time, peaks});
+ const cachedFrame = stftCache.getFrameAtIndex(i);
+ const squaredAmp = stftCache.getSquaredAmplitude(cachedFrame.time);
+ const peaks = detectPeaks(squaredAmp, fftSize, sampleRate, threshold);
+ frames.push({time: cachedFrame.time, peaks});
}
// Track trajectories
@@ -28,45 +24,15 @@ function extractPartials(audioBuffer, params) {
partial.ampCurve = fitBezier(partial.times, partial.amps);
}
- return partials;
-}
-
-// Get mono signal
-function getMono(audioBuffer) {
- const data = audioBuffer.getChannelData(0);
- if (audioBuffer.numberOfChannels === 1) {
- return data;
- }
-
- // Mix to mono
- const left = audioBuffer.getChannelData(0);
- const right = audioBuffer.getChannelData(1);
- const mono = new Float32Array(left.length);
- for (let i = 0; i < left.length; ++i) {
- mono[i] = (left[i] + right[i]) * 0.5;
- }
- return mono;
+ return {partials, frames};
}
-// Detect peaks in FFT frame
-function detectPeaks(frame, fftSize, sampleRate, thresholdDB) {
- // Apply Hann window
- const windowed = new Float32Array(fftSize);
- for (let i = 0; i < fftSize; ++i) {
- const w = 0.5 - 0.5 * Math.cos(2 * Math.PI * i / fftSize);
- windowed[i] = frame[i] * w;
- }
-
- // FFT (using built-in)
- const spectrum = realFFT(windowed);
-
- // Convert to magnitude dB
+// Detect peaks in FFT frame (squaredAmp is pre-computed cached re*re+im*im)
+function detectPeaks(squaredAmp, fftSize, sampleRate, thresholdDB) {
+ // Convert squared amplitude to dB (10*log10 == 20*log10 of magnitude)
const mag = new Float32Array(fftSize / 2);
for (let i = 0; i < fftSize / 2; ++i) {
- const re = spectrum[i * 2];
- const im = spectrum[i * 2 + 1];
- const magLin = Math.sqrt(re * re + im * im);
- mag[i] = 20 * Math.log10(Math.max(magLin, 1e-10));
+ mag[i] = 10 * Math.log10(Math.max(squaredAmp[i], 1e-20));
}
// Find local maxima above threshold
diff --git a/tools/mq_editor/viewer.js b/tools/mq_editor/viewer.js
index c57d693..5065498 100644
--- a/tools/mq_editor/viewer.js
+++ b/tools/mq_editor/viewer.js
@@ -8,6 +8,8 @@ class SpectrogramViewer {
this.audioBuffer = audioBuffer;
this.stftCache = stftCache;
this.partials = [];
+ this.frames = [];
+ this.showPeaks = false;
// Fixed time bounds
this.t_min = 0;
@@ -57,6 +59,15 @@ class SpectrogramViewer {
this.render();
}
+ setFrames(frames) {
+ this.frames = frames;
+ }
+
+ togglePeaks() {
+ this.showPeaks = !this.showPeaks;
+ this.render();
+ }
+
reset() {
this.zoom_factor = 1.0;
this.t_center = this.audioBuffer.duration / 2;
@@ -94,6 +105,7 @@ class SpectrogramViewer {
render() {
this.renderSpectrogram();
+ if (this.showPeaks) this.renderPeaks();
this.renderPartials();
this.drawAxes();
this.drawPlayhead();
@@ -271,6 +283,31 @@ class SpectrogramViewer {
}
}
+ // Render raw peaks from mq_extract (before partial tracking)
+ renderPeaks() {
+ const {ctx, canvas, frames} = this;
+ if (!frames || frames.length === 0) return;
+
+ const timeDuration = this.t_view_max - this.t_view_min;
+ const freqRange = this.freqEnd - this.freqStart;
+
+ ctx.fillStyle = '#fff';
+
+ for (const frame of frames) {
+ const t = frame.time;
+ if (t < this.t_view_min || t > this.t_view_max) continue;
+
+ const x = (t - this.t_view_min) / timeDuration * canvas.width;
+
+ for (const peak of frame.peaks) {
+ if (peak.freq < this.freqStart || peak.freq > this.freqEnd) continue;
+
+ const y = canvas.height - (peak.freq - this.freqStart) / freqRange * canvas.height;
+ ctx.fillRect(x - 1, y - 1, 3, 3);
+ }
+ }
+ }
+
// Draw control point
drawControlPoint(t, v) {
if (t < this.t_view_min || t > this.t_view_max) return;