summaryrefslogtreecommitdiff
path: root/tools/mq_editor
diff options
context:
space:
mode:
authorskal <pascal.massimino@gmail.com>2026-02-17 16:12:21 +0100
committerskal <pascal.massimino@gmail.com>2026-02-17 16:12:21 +0100
commit03579c4a33ab3955ff9924a6dcd882fe91dd9aaa (patch)
treebe458d2ac4bc0d7160be8a18526b4e9157af33a5 /tools/mq_editor
parente3f0b002c0998c8553e782273b254869107ffc0f (diff)
feat(mq_editor): Phase 1 - MQ extraction and visualization (SPECTRAL_BRUSH_2)
Implement McAulay-Quatieri sinusoidal analysis tool for audio compression. New files: - doc/SPECTRAL_BRUSH_2.md: Complete design doc (MQ algorithm, data format, synthesis, roadmap) - tools/mq_editor/index.html: Web UI (file loader, params, canvas) - tools/mq_editor/fft.js: Radix-2 Cooley-Tukey FFT (from spectral_editor) - tools/mq_editor/mq_extract.js: MQ algorithm (peak detection, tracking, bezier fitting) - tools/mq_editor/viewer.js: Visualization (spectrogram, partials, zoom, axes) - tools/mq_editor/README.md: Usage and implementation status Features: - Load WAV → extract sinusoidal partials → fit cubic bezier curves - Time-frequency spectrogram with hot colormap (0-16 kHz) - Horizontal zoom (mousewheel) around mouse position - Axis ticks with labels (time: seconds, freq: Hz/kHz) - Mouse tooltip showing time/frequency coordinates - Real-time adjustable MQ parameters (FFT size, hop, threshold) Algorithm: - STFT with Hann windows (2048 FFT, 512 hop) - Peak detection with parabolic interpolation - Birth/death/continuation tracking (50 Hz tolerance) - Cubic bezier fitting (4 control points per trajectory) Next: Phase 2 (JS synthesizer for audio preview) handoff(Claude): MQ editor Phase 1 complete. Ready for synthesis implementation.
Diffstat (limited to 'tools/mq_editor')
-rw-r--r--tools/mq_editor/README.md55
-rw-r--r--tools/mq_editor/fft.js103
-rw-r--r--tools/mq_editor/index.html175
-rw-r--r--tools/mq_editor/mq_extract.js216
-rw-r--r--tools/mq_editor/viewer.js376
5 files changed, 925 insertions, 0 deletions
diff --git a/tools/mq_editor/README.md b/tools/mq_editor/README.md
new file mode 100644
index 0000000..89449c3
--- /dev/null
+++ b/tools/mq_editor/README.md
@@ -0,0 +1,55 @@
+# MQ Spectral Editor
+
+McAulay-Quatieri sinusoidal analysis and synthesis tool.
+
+## Usage
+
+```bash
+open tools/mq_editor/index.html
+```
+
+1. Load WAV file
+2. Adjust MQ parameters (FFT size, hop, threshold)
+3. Click "Extract Partials"
+4. View extracted sinusoidal trajectories with bezier curve fits
+
+## Features (Phase 1)
+
+- **MQ Extraction:** FFT → peak detection → trajectory tracking → bezier fitting
+- **Visualization:** Time-frequency plot with raw trajectories and bezier overlays
+- **Real-time:** Adjustable extraction parameters
+
+## Parameters
+
+- **FFT Size:** 1024-4096 (default 2048)
+- **Hop Size:** 64-2048 samples (default 512, 75% overlap)
+- **Threshold:** -80 to -20 dB (default -60 dB)
+
+## Architecture
+
+- `index.html` - UI and integration
+- `fft.js` - Fast Fourier Transform (Cooley-Tukey radix-2)
+- `mq_extract.js` - MQ algorithm (peak detection, tracking, bezier fitting)
+- `viewer.js` - Visualization (spectrogram, partials, zoom, mouse interaction)
+
+## Implementation Status
+
+- [x] Phase 1: MQ extraction + visualization
+ - [x] Spectrogram rendering with hot colormap
+ - [x] Horizontal zoom (mousewheel)
+ - [x] Axis ticks and labels
+ - [x] Mouse tooltip (time/frequency)
+- [ ] Phase 2: JS synthesizer (preview playback)
+- [ ] Phase 3: Editing UI (drag control points, replicas)
+- [ ] Phase 4: Export (.txt + C++ code generation)
+
+## Algorithm
+
+1. **STFT:** Overlapping Hann windows, radix-2 Cooley-Tukey FFT (from spectral_editor/dct.js)
+2. **Peak Detection:** Local maxima above threshold, parabolic interpolation
+3. **Tracking:** Birth/death/continuation (50 Hz tolerance, 2-frame persistence)
+4. **Bezier Fitting:** Cubic curves (4 control points), fixed endpoints
+
+## See Also
+
+- Design doc: `doc/SPECTRAL_BRUSH_2.md`
diff --git a/tools/mq_editor/fft.js b/tools/mq_editor/fft.js
new file mode 100644
index 0000000..8610222
--- /dev/null
+++ b/tools/mq_editor/fft.js
@@ -0,0 +1,103 @@
+// Fast Fourier Transform (adapted from spectral_editor/dct.js)
+// Radix-2 Cooley-Tukey algorithm
+
+// Bit-reversal permutation (in-place)
+function bitReversePermute(real, imag, N) {
+ let temp_bits = N;
+ let num_bits = 0;
+ while (temp_bits > 1) {
+ temp_bits >>= 1;
+ num_bits++;
+ }
+
+ for (let i = 0; i < N; ++i) {
+ let j = 0;
+ let temp = i;
+ for (let b = 0; b < num_bits; ++b) {
+ j = (j << 1) | (temp & 1);
+ temp >>= 1;
+ }
+
+ if (j > i) {
+ const tmp_real = real[i];
+ const tmp_imag = imag[i];
+ real[i] = real[j];
+ imag[i] = imag[j];
+ real[j] = tmp_real;
+ imag[j] = tmp_imag;
+ }
+ }
+}
+
+// In-place radix-2 FFT
+// direction: +1 for forward, -1 for inverse
+function fftRadix2(real, imag, N, direction) {
+ const PI = Math.PI;
+
+ for (let stage_size = 2; stage_size <= N; stage_size *= 2) {
+ const half_stage = stage_size / 2;
+ const angle = direction * 2.0 * PI / stage_size;
+
+ let wr = 1.0;
+ let wi = 0.0;
+ const wr_delta = Math.cos(angle);
+ const wi_delta = Math.sin(angle);
+
+ for (let k = 0; k < half_stage; ++k) {
+ for (let group_start = k; group_start < N; group_start += stage_size) {
+ const i = group_start;
+ const j = group_start + half_stage;
+
+ const temp_real = real[j] * wr - imag[j] * wi;
+ const temp_imag = real[j] * wi + imag[j] * wr;
+
+ real[j] = real[i] - temp_real;
+ imag[j] = imag[i] - temp_imag;
+ real[i] = real[i] + temp_real;
+ imag[i] = imag[i] + temp_imag;
+ }
+
+ const wr_old = wr;
+ wr = wr_old * wr_delta - wi * wi_delta;
+ wi = wr_old * wi_delta + wi * wr_delta;
+ }
+ }
+}
+
+// Forward FFT: Time domain → Frequency domain
+function fftForward(real, imag, N) {
+ bitReversePermute(real, imag, N);
+ fftRadix2(real, imag, N, +1);
+}
+
+// Real FFT wrapper for MQ extraction
+// Input: Float32Array (time-domain signal)
+// Output: Float32Array (interleaved [re0, im0, re1, im1, ...])
+function realFFT(signal) {
+ const N = signal.length;
+
+ // Must be power of 2
+ if ((N & (N - 1)) !== 0) {
+ throw new Error('FFT size must be power of 2');
+ }
+
+ const real = new Float32Array(N);
+ const imag = new Float32Array(N);
+
+ // Copy input to real part
+ for (let i = 0; i < N; ++i) {
+ real[i] = signal[i];
+ }
+
+ // Compute FFT
+ fftForward(real, imag, N);
+
+ // Interleave output
+ const spectrum = new Float32Array(N * 2);
+ for (let i = 0; i < N; ++i) {
+ spectrum[i * 2] = real[i];
+ spectrum[i * 2 + 1] = imag[i];
+ }
+
+ return spectrum;
+}
diff --git a/tools/mq_editor/index.html b/tools/mq_editor/index.html
new file mode 100644
index 0000000..d44f19b
--- /dev/null
+++ b/tools/mq_editor/index.html
@@ -0,0 +1,175 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>MQ Spectral Editor</title>
+ <style>
+ body {
+ font-family: monospace;
+ margin: 20px;
+ background: #1a1a1a;
+ color: #ddd;
+ }
+ .toolbar {
+ margin-bottom: 10px;
+ padding: 10px;
+ background: #2a2a2a;
+ border-radius: 4px;
+ }
+ button {
+ background: #3a3a3a;
+ color: #ddd;
+ border: 1px solid #555;
+ padding: 8px 16px;
+ margin-right: 8px;
+ cursor: pointer;
+ border-radius: 4px;
+ }
+ button:hover { background: #4a4a4a; }
+ button:disabled { opacity: 0.5; cursor: not-allowed; }
+ input[type="file"] { margin-right: 16px; }
+ .params {
+ display: inline-block;
+ margin-left: 20px;
+ }
+ label {
+ margin-right: 8px;
+ }
+ input[type="number"], select {
+ width: 80px;
+ background: #3a3a3a;
+ color: #ddd;
+ border: 1px solid #555;
+ padding: 4px;
+ border-radius: 3px;
+ }
+ #canvas {
+ border: 1px solid #555;
+ background: #000;
+ cursor: crosshair;
+ display: block;
+ margin-top: 10px;
+ }
+ #status {
+ margin-top: 10px;
+ padding: 8px;
+ background: #2a2a2a;
+ border-radius: 4px;
+ min-height: 20px;
+ }
+ .info {
+ color: #4af;
+ }
+ .warn {
+ color: #fa4;
+ }
+ .error {
+ color: #f44;
+ }
+ </style>
+</head>
+<body>
+ <h2>MQ Spectral Editor</h2>
+
+ <div class="toolbar">
+ <input type="file" id="wavFile" accept=".wav">
+ <button id="extractBtn" disabled>Extract Partials</button>
+
+ <div class="params">
+ <label>FFT Size:</label>
+ <select id="fftSize">
+ <option value="1024">1024</option>
+ <option value="2048" selected>2048</option>
+ <option value="4096">4096</option>
+ </select>
+
+ <label>Hop:</label>
+ <input type="number" id="hopSize" value="512" min="64" max="2048" step="64">
+
+ <label>Threshold (dB):</label>
+ <input type="number" id="threshold" value="-60" min="-80" max="-20" step="5">
+ </div>
+ </div>
+
+ <canvas id="canvas" width="1400" height="600"></canvas>
+
+ <div id="tooltip" style="position: fixed; display: none; background: #2a2a2a; padding: 4px 8px; border: 1px solid #555; border-radius: 3px; pointer-events: none; font-size: 11px; z-index: 1000;"></div>
+
+ <div id="status">Load a WAV file to begin...</div>
+
+ <script src="fft.js"></script>
+ <script src="mq_extract.js"></script>
+ <script src="viewer.js"></script>
+ <script>
+ let audioBuffer = null;
+ let viewer = null;
+
+ const wavFile = document.getElementById('wavFile');
+ const extractBtn = document.getElementById('extractBtn');
+ const canvas = document.getElementById('canvas');
+ const status = document.getElementById('status');
+
+ const fftSize = document.getElementById('fftSize');
+ const hopSize = document.getElementById('hopSize');
+ const threshold = document.getElementById('threshold');
+
+ // Load WAV file
+ wavFile.addEventListener('change', async (e) => {
+ const file = e.target.files[0];
+ if (!file) return;
+
+ setStatus('Loading WAV...', 'info');
+ try {
+ const arrayBuffer = await file.arrayBuffer();
+ const audioContext = new AudioContext();
+ audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
+
+ extractBtn.disabled = false;
+ setStatus(`Loaded: ${audioBuffer.duration.toFixed(2)}s, ${audioBuffer.sampleRate}Hz, ${audioBuffer.numberOfChannels}ch`, 'info');
+
+ // Create viewer
+ viewer = new SpectrogramViewer(canvas, audioBuffer);
+ } catch (err) {
+ setStatus('Error loading WAV: ' + err.message, 'error');
+ console.error(err);
+ }
+ });
+
+ // Extract partials
+ extractBtn.addEventListener('click', () => {
+ if (!audioBuffer) return;
+
+ setStatus('Extracting partials...', 'info');
+ extractBtn.disabled = true;
+
+ setTimeout(() => {
+ try {
+ const params = {
+ fftSize: parseInt(fftSize.value),
+ hopSize: parseInt(hopSize.value),
+ threshold: parseFloat(threshold.value),
+ sampleRate: audioBuffer.sampleRate
+ };
+
+ const partials = extractPartials(audioBuffer, params);
+
+ setStatus(`Extracted ${partials.length} partials`, 'info');
+
+ // Update viewer
+ viewer.setPartials(partials);
+
+ } catch (err) {
+ setStatus('Extraction error: ' + err.message, 'error');
+ console.error(err);
+ }
+ extractBtn.disabled = false;
+ }, 50);
+ });
+
+ function setStatus(msg, type = '') {
+ status.innerHTML = msg;
+ status.className = type;
+ }
+ </script>
+</body>
+</html>
diff --git a/tools/mq_editor/mq_extract.js b/tools/mq_editor/mq_extract.js
new file mode 100644
index 0000000..62b275c
--- /dev/null
+++ b/tools/mq_editor/mq_extract.js
@@ -0,0 +1,216 @@
+// MQ Extraction Algorithm
+// McAulay-Quatieri sinusoidal analysis
+
+// Extract partials from audio buffer
+function extractPartials(audioBuffer, params) {
+ const {fftSize, hopSize, 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
+ const frames = [];
+ 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});
+ }
+
+ // Track trajectories
+ const partials = trackPartials(frames, sampleRate);
+
+ // Fit bezier curves
+ for (const partial of partials) {
+ partial.freqCurve = fitBezier(partial.times, partial.freqs);
+ 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;
+}
+
+// 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
+ 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));
+ }
+
+ // Find local maxima above threshold
+ const peaks = [];
+ for (let i = 2; i < mag.length - 2; ++i) {
+ if (mag[i] > thresholdDB &&
+ mag[i] > mag[i-1] && mag[i] > mag[i-2] &&
+ mag[i] > mag[i+1] && mag[i] > mag[i+2]) {
+
+ // Parabolic interpolation for sub-bin accuracy
+ const alpha = mag[i-1];
+ const beta = mag[i];
+ const gamma = mag[i+1];
+ const p = 0.5 * (alpha - gamma) / (alpha - 2*beta + gamma);
+
+ const binFreq = (i + p) * sampleRate / fftSize;
+ const ampDB = beta - 0.25 * (alpha - gamma) * p;
+ const ampLin = Math.pow(10, ampDB / 20);
+
+ peaks.push({freq: binFreq, amp: ampLin});
+ }
+ }
+
+ return peaks;
+}
+
+// Track partials across frames (birth/death/continuation)
+function trackPartials(frames, sampleRate) {
+ const partials = [];
+ const activePartials = [];
+ const trackingThreshold = 50; // Hz
+
+ for (const frame of frames) {
+ const matched = new Set();
+
+ // Match peaks to existing partials
+ for (const partial of activePartials) {
+ const lastFreq = partial.freqs[partial.freqs.length - 1];
+
+ let bestPeak = null;
+ let bestDist = Infinity;
+
+ for (let i = 0; i < frame.peaks.length; ++i) {
+ if (matched.has(i)) continue;
+
+ const peak = frame.peaks[i];
+ const dist = Math.abs(peak.freq - lastFreq);
+
+ if (dist < trackingThreshold && dist < bestDist) {
+ bestPeak = peak;
+ bestDist = dist;
+ partial.matchIdx = i;
+ }
+ }
+
+ if (bestPeak) {
+ // Continuation
+ partial.times.push(frame.time);
+ partial.freqs.push(bestPeak.freq);
+ partial.amps.push(bestPeak.amp);
+ partial.age = 0;
+ matched.add(partial.matchIdx);
+ } else {
+ // No match
+ partial.age++;
+ }
+ }
+
+ // Birth new partials from unmatched peaks
+ for (let i = 0; i < frame.peaks.length; ++i) {
+ if (matched.has(i)) continue;
+
+ const peak = frame.peaks[i];
+ activePartials.push({
+ times: [frame.time],
+ freqs: [peak.freq],
+ amps: [peak.amp],
+ age: 0,
+ matchIdx: -1
+ });
+ }
+
+ // Death old partials
+ for (let i = activePartials.length - 1; i >= 0; --i) {
+ if (activePartials[i].age > 2) {
+ // Move to finished if long enough
+ if (activePartials[i].times.length >= 4) {
+ partials.push(activePartials[i]);
+ }
+ activePartials.splice(i, 1);
+ }
+ }
+ }
+
+ // Finish remaining active partials
+ for (const partial of activePartials) {
+ if (partial.times.length >= 4) {
+ partials.push(partial);
+ }
+ }
+
+ return partials;
+}
+
+// Fit cubic bezier curve to trajectory
+function fitBezier(times, values) {
+ if (times.length < 4) {
+ // Not enough points, just use linear segments
+ return {
+ t0: times[0], v0: values[0],
+ t1: times[0], v1: values[0],
+ t2: times[times.length-1], v2: values[values.length-1],
+ t3: times[times.length-1], v3: values[values.length-1]
+ };
+ }
+
+ // Fix endpoints
+ const t0 = times[0];
+ const t3 = times[times.length - 1];
+ const v0 = values[0];
+ const v3 = values[values.length - 1];
+
+ // Solve for interior control points via least squares
+ // Simplification: place at 1/3 and 2/3 positions
+ const t1 = t0 + (t3 - t0) / 3;
+ const t2 = t0 + 2 * (t3 - t0) / 3;
+
+ // Find v1, v2 by evaluating at nearest data points
+ let v1 = v0, v2 = v3;
+ let minDist1 = Infinity, minDist2 = Infinity;
+
+ for (let i = 0; i < times.length; ++i) {
+ const dist1 = Math.abs(times[i] - t1);
+ const dist2 = Math.abs(times[i] - t2);
+
+ if (dist1 < minDist1) {
+ minDist1 = dist1;
+ v1 = values[i];
+ }
+ if (dist2 < minDist2) {
+ minDist2 = dist2;
+ v2 = values[i];
+ }
+ }
+
+ return {t0, v0, t1, v1, t2, v2, t3, v3};
+}
diff --git a/tools/mq_editor/viewer.js b/tools/mq_editor/viewer.js
new file mode 100644
index 0000000..1b2f5bf
--- /dev/null
+++ b/tools/mq_editor/viewer.js
@@ -0,0 +1,376 @@
+// Spectrogram Viewer
+// Handles all visualization: spectrogram, partials, zoom, mouse interaction
+
+class SpectrogramViewer {
+ constructor(canvas, audioBuffer) {
+ this.canvas = canvas;
+ this.ctx = canvas.getContext('2d');
+ this.audioBuffer = audioBuffer;
+ this.partials = [];
+
+ // View state (time only, frequency fixed)
+ this.timeStart = 0;
+ this.timeEnd = audioBuffer.duration;
+ this.freqStart = 0;
+ this.freqEnd = 16000; // Fixed
+
+ // Tooltip
+ this.tooltip = document.getElementById('tooltip');
+
+ // Setup event handlers
+ this.setupMouseHandlers();
+
+ // Initial render
+ this.render();
+ }
+
+ setPartials(partials) {
+ this.partials = partials;
+ this.render();
+ }
+
+ reset() {
+ this.timeStart = 0;
+ this.timeEnd = this.audioBuffer.duration;
+ this.render();
+ }
+
+ render() {
+ this.renderSpectrogram();
+ this.renderPartials();
+ this.drawAxes();
+ }
+
+ // Render spectrogram background
+ renderSpectrogram() {
+ const {canvas, ctx, audioBuffer} = this;
+ const width = canvas.width;
+ const height = canvas.height;
+
+ ctx.fillStyle = '#000';
+ ctx.fillRect(0, 0, width, height);
+
+ const signal = getMono(audioBuffer);
+ const fftSize = 2048;
+ const hopSize = 512;
+ const sampleRate = audioBuffer.sampleRate;
+
+ const numFrames = Math.floor((signal.length - fftSize) / hopSize);
+
+ // Compute one FFT per ~4 pixels for wider bars
+ const pixelsPerFrame = 4;
+ const numDisplayFrames = Math.floor(width / pixelsPerFrame);
+
+ // Map view bounds to frame indices
+ const startFrameIdx = Math.floor(this.timeStart * sampleRate / hopSize);
+ const endFrameIdx = Math.floor(this.timeEnd * sampleRate / hopSize);
+ const visibleFrames = endFrameIdx - startFrameIdx;
+ const frameStep = Math.max(1, Math.floor(visibleFrames / numDisplayFrames));
+
+ for (let displayIdx = 0; displayIdx < numDisplayFrames; ++displayIdx) {
+ const frameIdx = startFrameIdx + displayIdx * frameStep;
+ if (frameIdx >= numFrames) break;
+
+ const offset = frameIdx * hopSize;
+ if (offset + fftSize > signal.length) break;
+
+ 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);
+
+ // Draw as vertical bar
+ const xStart = displayIdx * pixelsPerFrame;
+ const xEnd = Math.min(xStart + pixelsPerFrame, width);
+
+ // Draw frequency bins
+ const numBins = fftSize / 2;
+ for (let bin = 0; bin < numBins; ++bin) {
+ const freq = bin * sampleRate / fftSize;
+ if (freq < this.freqStart || freq > this.freqEnd) continue;
+
+ 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));
+
+ const normalized = (magDB + 80) / 60;
+ const intensity = Math.max(0, Math.min(1, normalized));
+
+ const freqNorm = (freq - this.freqStart) / (this.freqEnd - this.freqStart);
+ const y = Math.floor(height - freqNorm * height);
+ if (y < 0 || y >= height) continue;
+
+ const color = this.getSpectrogramColor(intensity);
+ ctx.fillStyle = `rgb(${color.r},${color.g},${color.b})`;
+ ctx.fillRect(xStart, y, xEnd - xStart, 1);
+ }
+ }
+ }
+
+ // Render extracted partials
+ renderPartials() {
+ const {ctx, canvas, partials} = this;
+ const width = canvas.width;
+ const height = canvas.height;
+
+ const colors = [
+ '#f44', '#4f4', '#44f', '#ff4', '#f4f', '#4ff',
+ '#fa4', '#4fa', '#a4f', '#af4', '#f4a', '#4af'
+ ];
+
+ const timeDuration = this.timeEnd - this.timeStart;
+ const freqRange = this.freqEnd - this.freqStart;
+
+ for (let p = 0; p < partials.length; ++p) {
+ const partial = partials[p];
+ const color = colors[p % colors.length];
+
+ // Draw raw trajectory
+ ctx.strokeStyle = color + '44';
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+
+ let started = false;
+ for (let i = 0; i < partial.times.length; ++i) {
+ const t = partial.times[i];
+ const f = partial.freqs[i];
+
+ if (t < this.timeStart || t > this.timeEnd) continue;
+ if (f < this.freqStart || f > this.freqEnd) continue;
+
+ const x = (t - this.timeStart) / timeDuration * width;
+ const y = height - (f - this.freqStart) / freqRange * height;
+
+ if (!started) {
+ ctx.moveTo(x, y);
+ started = true;
+ } else {
+ ctx.lineTo(x, y);
+ }
+ }
+
+ if (started) ctx.stroke();
+
+ // Draw bezier curve
+ if (partial.freqCurve) {
+ ctx.strokeStyle = color;
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+
+ const curve = partial.freqCurve;
+ const numSteps = 50;
+
+ started = false;
+ for (let i = 0; i <= numSteps; ++i) {
+ const t = curve.t0 + (curve.t3 - curve.t0) * i / numSteps;
+ const freq = evalBezier(curve, t);
+
+ if (t < this.timeStart || t > this.timeEnd) continue;
+ if (freq < this.freqStart || freq > this.freqEnd) continue;
+
+ const x = (t - this.timeStart) / timeDuration * width;
+ const y = height - (freq - this.freqStart) / freqRange * height;
+
+ if (!started) {
+ ctx.moveTo(x, y);
+ started = true;
+ } else {
+ ctx.lineTo(x, y);
+ }
+ }
+
+ if (started) ctx.stroke();
+
+ // Draw control points
+ ctx.fillStyle = color;
+ this.drawControlPoint(curve.t0, curve.v0);
+ this.drawControlPoint(curve.t1, curve.v1);
+ this.drawControlPoint(curve.t2, curve.v2);
+ this.drawControlPoint(curve.t3, curve.v3);
+ }
+ }
+ }
+
+ // Draw control point
+ drawControlPoint(t, v) {
+ if (t < this.timeStart || t > this.timeEnd) return;
+ if (v < this.freqStart || v > this.freqEnd) return;
+
+ const timeDuration = this.timeEnd - this.timeStart;
+ const freqRange = this.freqEnd - this.freqStart;
+
+ const x = (t - this.timeStart) / timeDuration * this.canvas.width;
+ const y = this.canvas.height - (v - this.freqStart) / freqRange * this.canvas.height;
+
+ this.ctx.beginPath();
+ this.ctx.arc(x, y, 4, 0, 2 * Math.PI);
+ this.ctx.fill();
+
+ this.ctx.strokeStyle = '#fff';
+ this.ctx.lineWidth = 1;
+ this.ctx.stroke();
+ }
+
+ // Draw axes with ticks and labels
+ drawAxes() {
+ const {ctx, canvas} = this;
+ const width = canvas.width;
+ const height = canvas.height;
+
+ ctx.strokeStyle = '#666';
+ ctx.fillStyle = '#aaa';
+ ctx.font = '11px monospace';
+ ctx.lineWidth = 1;
+
+ const timeDuration = this.timeEnd - this.timeStart;
+ const freqRange = this.freqEnd - this.freqStart;
+
+ // Time axis
+ const timeStep = this.getAxisStep(timeDuration);
+ let t = Math.ceil(this.timeStart / timeStep) * timeStep;
+ while (t <= this.timeEnd) {
+ const x = (t - this.timeStart) / timeDuration * width;
+
+ ctx.beginPath();
+ ctx.moveTo(x, 0);
+ ctx.lineTo(x, height);
+ ctx.stroke();
+
+ ctx.fillText(t.toFixed(2) + 's', x + 2, height - 4);
+ t += timeStep;
+ }
+
+ // Frequency axis
+ const freqStep = this.getAxisStep(freqRange);
+ let f = Math.ceil(this.freqStart / freqStep) * freqStep;
+ while (f <= this.freqEnd) {
+ const y = height - (f - this.freqStart) / freqRange * height;
+
+ ctx.beginPath();
+ ctx.moveTo(0, y);
+ ctx.lineTo(width, y);
+ ctx.stroke();
+
+ const label = f >= 1000 ? (f/1000).toFixed(1) + 'k' : f.toFixed(0);
+ ctx.fillText(label + 'Hz', 2, y - 2);
+ f += freqStep;
+ }
+ }
+
+ // Setup mouse event handlers
+ setupMouseHandlers() {
+ const {canvas, tooltip} = this;
+
+ // Mouse move (tooltip)
+ canvas.addEventListener('mousemove', (e) => {
+ const rect = canvas.getBoundingClientRect();
+ const x = e.clientX - rect.left;
+ const y = e.clientY - rect.top;
+
+ const time = this.canvasToTime(x);
+ const freq = this.canvasToFreq(y);
+
+ tooltip.style.left = (e.clientX + 10) + 'px';
+ tooltip.style.top = (e.clientY + 10) + 'px';
+ tooltip.style.display = 'block';
+ tooltip.textContent = `${time.toFixed(3)}s, ${freq.toFixed(1)}Hz`;
+ });
+
+ canvas.addEventListener('mouseleave', () => {
+ tooltip.style.display = 'none';
+ });
+
+ // Mouse wheel (horizontal zoom only)
+ canvas.addEventListener('wheel', (e) => {
+ e.preventDefault();
+
+ const rect = canvas.getBoundingClientRect();
+ const x = e.clientX - rect.left;
+
+ // Get mouse position in time space
+ const mouseTime = this.canvasToTime(x);
+
+ // Zoom factor
+ const zoomFactor = e.deltaY > 0 ? 1.2 : 0.8;
+
+ // Zoom time around mouse position
+ const timeDuration = this.timeEnd - this.timeStart;
+ const newTimeDuration = timeDuration * zoomFactor;
+ const timeRatio = (mouseTime - this.timeStart) / timeDuration;
+
+ this.timeStart = mouseTime - newTimeDuration * timeRatio;
+ this.timeEnd = mouseTime + newTimeDuration * (1 - timeRatio);
+
+ // Clamp time bounds
+ if (this.timeStart < 0) {
+ this.timeEnd -= this.timeStart;
+ this.timeStart = 0;
+ }
+ if (this.timeEnd > this.audioBuffer.duration) {
+ this.timeStart -= (this.timeEnd - this.audioBuffer.duration);
+ this.timeEnd = this.audioBuffer.duration;
+ }
+
+ // Re-render
+ this.render();
+ });
+ }
+
+ // Coordinate conversion
+ canvasToTime(x) {
+ return this.timeStart + (x / this.canvas.width) * (this.timeEnd - this.timeStart);
+ }
+
+ canvasToFreq(y) {
+ return this.freqEnd - (y / this.canvas.height) * (this.freqEnd - this.freqStart);
+ }
+
+ // Utilities
+ getAxisStep(range) {
+ const steps = [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000];
+ const targetSteps = 8;
+ const targetStep = range / targetSteps;
+
+ for (const step of steps) {
+ if (step >= targetStep) return step;
+ }
+
+ return steps[steps.length - 1];
+ }
+
+ getSpectrogramColor(intensity) {
+ if (intensity < 0.25) {
+ const t = intensity / 0.25;
+ return {r: 0, g: 0, b: Math.floor(t * 128)};
+ } else if (intensity < 0.5) {
+ const t = (intensity - 0.25) / 0.25;
+ return {r: 0, g: Math.floor(t * 128), b: 128};
+ } else if (intensity < 0.75) {
+ const t = (intensity - 0.5) / 0.25;
+ return {r: Math.floor(t * 255), g: 128 + Math.floor(t * 127), b: 128 - Math.floor(t * 128)};
+ } else {
+ const t = (intensity - 0.75) / 0.25;
+ return {r: 255, g: 255 - Math.floor(t * 128), b: 0};
+ }
+ }
+}
+
+// Bezier evaluation (shared utility)
+function evalBezier(curve, t) {
+ let u = (t - curve.t0) / (curve.t3 - curve.t0);
+ u = Math.max(0, Math.min(1, u));
+
+ const u1 = 1 - u;
+ return u1*u1*u1 * curve.v0 +
+ 3*u1*u1*u * curve.v1 +
+ 3*u1*u*u * curve.v2 +
+ u*u*u * curve.v3;
+}