diff options
| author | skal <pascal.massimino@gmail.com> | 2026-02-17 21:28:58 +0100 |
|---|---|---|
| committer | skal <pascal.massimino@gmail.com> | 2026-02-17 21:28:58 +0100 |
| commit | 99e94d18ce54d1ad73c7c0349119d4cd8fb4f965 (patch) | |
| tree | e39536f70a9ff62cc42315ecb1fb5397ccf762b8 | |
| parent | fbdfa2a7b54894a565fd57d5e27f19d752fe39fa (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.js | 24 | ||||
| -rw-r--r-- | tools/mq_editor/index.html | 31 | ||||
| -rw-r--r-- | tools/mq_editor/mq_extract.js | 60 | ||||
| -rw-r--r-- | tools/mq_editor/viewer.js | 37 |
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; |
