From 03579c4a33ab3955ff9924a6dcd882fe91dd9aaa Mon Sep 17 00:00:00 2001 From: skal Date: Tue, 17 Feb 2026 16:12:21 +0100 Subject: feat(mq_editor): Phase 1 - MQ extraction and visualization (SPECTRAL_BRUSH_2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- tools/mq_editor/README.md | 55 ++++++ tools/mq_editor/fft.js | 103 ++++++++++++ tools/mq_editor/index.html | 175 ++++++++++++++++++++ tools/mq_editor/mq_extract.js | 216 ++++++++++++++++++++++++ tools/mq_editor/viewer.js | 376 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 925 insertions(+) create mode 100644 tools/mq_editor/README.md create mode 100644 tools/mq_editor/fft.js create mode 100644 tools/mq_editor/index.html create mode 100644 tools/mq_editor/mq_extract.js create mode 100644 tools/mq_editor/viewer.js (limited to 'tools/mq_editor') 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 @@ + + + + + MQ Spectral Editor + + + +

MQ Spectral Editor

+ +
+ + + +
+ + + + + + + + +
+
+ + + + + +
Load a WAV file to begin...
+ + + + + + + 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; +} -- cgit v1.2.3