summaryrefslogtreecommitdiff
path: root/tools/mq_editor/viewer.js
diff options
context:
space:
mode:
authorskal <pascal.massimino@gmail.com>2026-02-17 20:54:15 +0100
committerskal <pascal.massimino@gmail.com>2026-02-17 20:54:15 +0100
commitcfcd238044c7ce06dfdf1f9e08c3842bfa07979b (patch)
tree12cd6da868c43af9c054bc44705326c1dae5f6de /tools/mq_editor/viewer.js
parentd151eb48b2c55d16a1d9caa6a7affb3e0793c3e7 (diff)
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 <noreply@anthropic.com>
Diffstat (limited to 'tools/mq_editor/viewer.js')
-rw-r--r--tools/mq_editor/viewer.js113
1 files changed, 68 insertions, 45 deletions
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