From cfcd238044c7ce06dfdf1f9e08c3842bfa07979b Mon Sep 17 00:00:00 2001 From: skal Date: Tue, 17 Feb 2026 20:54:15 +0100 Subject: feat(mq_editor): Complete Phase 2 - JS synthesizer with STFT cache Phase 2 - JS Synthesizer: - Created mq_synth.js with replica oscillator bank - Bezier curve evaluation (cubic De Casteljau algorithm) - Replica synthesis: frequency spread, amplitude decay, phase jitter - PCM buffer generation from extracted MQ partials - Normalization to prevent clipping - Key '1' plays synthesized audio, key '2' plays original - Playback comparison with animated playhead STFT Cache Optimization: - Created STFTCache class in fft.js for pre-computed windowed FFT frames - Clean interface: getFFT(t), getMagnitudeDB(t, freq), setHopSize() - Pre-computes all frames on WAV load (eliminates redundant FFT calls) - Dynamic cache update when hop size changes - Shared across spectrogram, tooltip, and mini-spectrum viewer - Significant performance improvement Mini-Spectrum Viewer: - Bottom-right overlay (200x100) matching spectral_editor style - Real-time FFT display at playhead or mouse position - 100-bar visualization with cyan-to-yellow gradient - Updates during playback or mouse hover Files: - tools/mq_editor/mq_synth.js (new) - tools/mq_editor/fft.js (STFTCache class added) - tools/mq_editor/index.html (synthesis playback, cache integration) - tools/mq_editor/viewer.js (cache-based rendering, spectrum viewer) Co-Authored-By: Claude Sonnet 4.5 --- tools/mq_editor/viewer.js | 113 ++++++++++++++++++++++++++++------------------ 1 file changed, 68 insertions(+), 45 deletions(-) (limited to 'tools/mq_editor/viewer.js') diff --git a/tools/mq_editor/viewer.js b/tools/mq_editor/viewer.js index 459cd9e..451421d 100644 --- a/tools/mq_editor/viewer.js +++ b/tools/mq_editor/viewer.js @@ -2,10 +2,11 @@ // Handles all visualization: spectrogram, partials, zoom, mouse interaction class SpectrogramViewer { - constructor(canvas, audioBuffer) { + constructor(canvas, audioBuffer, stftCache) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.audioBuffer = audioBuffer; + this.stftCache = stftCache; this.partials = []; // Fixed time bounds @@ -30,6 +31,11 @@ class SpectrogramViewer { // Playhead this.playheadTime = -1; // -1 = not playing + // Spectrum viewer + this.spectrumCanvas = document.getElementById('spectrumCanvas'); + this.spectrumCtx = this.spectrumCanvas ? this.spectrumCanvas.getContext('2d') : null; + this.spectrumTime = 0; // Time to display spectrum for + // Setup event handlers this.setupMouseHandlers(); @@ -40,6 +46,9 @@ class SpectrogramViewer { setPlayheadTime(time) { this.playheadTime = time; + if (time >= 0) { + this.spectrumTime = time; + } this.render(); } @@ -88,6 +97,7 @@ class SpectrogramViewer { this.renderPartials(); this.drawAxes(); this.drawPlayhead(); + this.renderSpectrum(); } drawPlayhead() { @@ -108,46 +118,36 @@ class SpectrogramViewer { // Render spectrogram background renderSpectrogram() { - const {canvas, ctx, audioBuffer} = this; + const {canvas, ctx, stftCache} = this; const width = canvas.width; const height = canvas.height; ctx.fillStyle = '#000'; ctx.fillRect(0, 0, width, height); - const signal = getMono(audioBuffer); - const fftSize = 1024; - const hopSize = 256; - const sampleRate = audioBuffer.sampleRate; + if (!stftCache) return; - const numFrames = Math.floor((signal.length - fftSize) / hopSize); + const sampleRate = this.audioBuffer.sampleRate; + const hopSize = stftCache.hopSize; + const fftSize = stftCache.fftSize; const frameDuration = hopSize / sampleRate; const viewDuration = this.t_view_max - this.t_view_min; // Map view bounds to frame indices const startFrameIdx = Math.floor(this.t_view_min * sampleRate / hopSize); const endFrameIdx = Math.ceil(this.t_view_max * sampleRate / hopSize); + const numFrames = stftCache.getNumFrames(); for (let frameIdx = startFrameIdx; frameIdx < endFrameIdx; ++frameIdx) { if (frameIdx < 0 || frameIdx >= numFrames) continue; - const offset = frameIdx * hopSize; - if (offset + fftSize > signal.length) break; + const frame = stftCache.getFrameAtIndex(frameIdx); + if (!frame) continue; - const frame = signal.slice(offset, offset + fftSize); - - // Windowing - 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 - const spectrum = realFFT(windowed); + const spectrum = frame.spectrum; // Compute frame time range - const frameTime = frameIdx * hopSize / sampleRate; + const frameTime = frame.time; const frameTimeEnd = frameTime + frameDuration; const xStart = Math.floor((frameTime - this.t_view_min) / viewDuration * width); @@ -350,6 +350,12 @@ class SpectrogramViewer { const freq = this.canvasToFreq(y); const intensity = this.getIntensityAt(time, freq); + // Update spectrum time when not playing + if (this.playheadTime < 0) { + this.spectrumTime = time; + this.renderSpectrum(); + } + tooltip.style.left = (e.clientX + 10) + 'px'; tooltip.style.top = (e.clientY + 10) + 'px'; tooltip.style.display = 'block'; @@ -408,38 +414,55 @@ class SpectrogramViewer { } getIntensityAt(time, freq) { - const signal = getMono(this.audioBuffer); - const fftSize = 1024; - const hopSize = 256; - const sampleRate = this.audioBuffer.sampleRate; + if (!this.stftCache) return -80; + return this.stftCache.getMagnitudeDB(time, freq); + } - const frameIdx = Math.floor(time * sampleRate / hopSize); - const offset = frameIdx * hopSize; + renderSpectrum() { + if (!this.spectrumCtx || !this.stftCache) return; - if (offset < 0 || offset + fftSize > signal.length) return -80; + const canvas = this.spectrumCanvas; + const ctx = this.spectrumCtx; + const width = canvas.width; + const height = canvas.height; - const frame = signal.slice(offset, offset + fftSize); + // Clear + ctx.fillStyle = '#1e1e1e'; + ctx.fillRect(0, 0, width, height); - // 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; - } + const spectrum = this.stftCache.getFFT(this.spectrumTime); + if (!spectrum) return; - // FFT - const spectrum = realFFT(windowed); + const fftSize = this.stftCache.fftSize; - // Find closest bin - const bin = Math.round(freq * fftSize / sampleRate); - if (bin < 0 || bin >= fftSize / 2) return -80; + // Draw bars + const numBars = 100; + const barWidth = width / numBars; + const numBins = fftSize / 2; - const re = spectrum[bin * 2]; - const im = spectrum[bin * 2 + 1]; - const mag = Math.sqrt(re * re + im * im); - const magDB = 20 * Math.log10(Math.max(mag, 1e-10)); + for (let i = 0; i < numBars; ++i) { + const binIdx = Math.floor(i * numBins / numBars); + const re = spectrum[binIdx * 2]; + const im = spectrum[binIdx * 2 + 1]; + const mag = Math.sqrt(re * re + im * im); + const magDB = 20 * Math.log10(Math.max(mag, 1e-10)); - return magDB; + // Normalize to [0, 1] + const normalized = (magDB + 80) / 80; + const clamped = Math.max(0, Math.min(1, normalized)); + const intensity = Math.pow(clamped, 0.3); + + const barHeight = intensity * height; + const x = i * barWidth; + + // Gradient from bottom (cyan) to top (yellow) + const gradient = ctx.createLinearGradient(0, height - barHeight, 0, height); + gradient.addColorStop(0, '#4af'); + gradient.addColorStop(1, '#fa4'); + + ctx.fillStyle = gradient; + ctx.fillRect(x, height - barHeight, barWidth - 1, barHeight); + } } // Utilities -- cgit v1.2.3