summaryrefslogtreecommitdiff
path: root/tools/spectral_editor/curve.js
diff options
context:
space:
mode:
Diffstat (limited to 'tools/spectral_editor/curve.js')
-rw-r--r--tools/spectral_editor/curve.js282
1 files changed, 282 insertions, 0 deletions
diff --git a/tools/spectral_editor/curve.js b/tools/spectral_editor/curve.js
new file mode 100644
index 0000000..d21cc73
--- /dev/null
+++ b/tools/spectral_editor/curve.js
@@ -0,0 +1,282 @@
+// Curve.js - Object-oriented curve management with spectrogram caching
+// This eliminates redundant drawCurveToSpectrogram() calls by caching spectrograms
+
+class Curve {
+ constructor(id, dctSize, numFrames) {
+ // Identity
+ this.id = id;
+
+ // Curve data
+ this.controlPoints = []; // [{frame, freqHz, amplitude}]
+ this.profile = {
+ type: 'gaussian',
+ sigma: 30.0,
+ param2: 0.0 // For future profile types
+ };
+
+ // Visual properties
+ this.color = '#0e639c'; // Default blue
+ this.volume = 1.0;
+
+ // Spectrogram dimensions
+ this.dctSize = dctSize;
+ this.numFrames = numFrames;
+
+ // Cache management
+ this.dirty = true;
+ this.cachedSpectrogram = null;
+ }
+
+ // ============================================================================
+ // Cache Management
+ // ============================================================================
+
+ markDirty() {
+ this.dirty = true;
+ }
+
+ getSpectrogram() {
+ if (!this.dirty && this.cachedSpectrogram) {
+ return this.cachedSpectrogram;
+ }
+
+ // Recompute spectrogram
+ this.cachedSpectrogram = this.computeSpectrogram();
+ this.dirty = false;
+ return this.cachedSpectrogram;
+ }
+
+ // Force recalculation (useful when dimensions change)
+ invalidateCache() {
+ this.dirty = true;
+ this.cachedSpectrogram = null;
+ }
+
+ // Update dimensions (called when reference audio changes)
+ setDimensions(dctSize, numFrames) {
+ if (this.dctSize !== dctSize || this.numFrames !== numFrames) {
+ this.dctSize = dctSize;
+ this.numFrames = numFrames;
+ this.invalidateCache();
+ }
+ }
+
+ // ============================================================================
+ // Control Point Management
+ // ============================================================================
+
+ addControlPoint(point) {
+ this.controlPoints.push({ ...point });
+ this.markDirty();
+ }
+
+ updateControlPoint(idx, point) {
+ if (idx >= 0 && idx < this.controlPoints.length) {
+ this.controlPoints[idx] = { ...point };
+ this.markDirty();
+ }
+ }
+
+ deleteControlPoint(idx) {
+ if (idx >= 0 && idx < this.controlPoints.length) {
+ this.controlPoints.splice(idx, 1);
+ this.markDirty();
+ }
+ }
+
+ setControlPoints(points) {
+ this.controlPoints = points.map(p => ({ ...p }));
+ this.markDirty();
+ }
+
+ // ============================================================================
+ // Profile Management
+ // ============================================================================
+
+ setProfile(profileType, sigma, param2 = 0.0) {
+ const changed = this.profile.type !== profileType ||
+ this.profile.sigma !== sigma ||
+ this.profile.param2 !== param2;
+
+ if (changed) {
+ this.profile.type = profileType;
+ this.profile.sigma = sigma;
+ this.profile.param2 = param2;
+ this.markDirty();
+ }
+ }
+
+ setProfileType(profileType) {
+ if (this.profile.type !== profileType) {
+ this.profile.type = profileType;
+ this.markDirty();
+ }
+ }
+
+ setProfileSigma(sigma) {
+ if (this.profile.sigma !== sigma) {
+ this.profile.sigma = sigma;
+ this.markDirty();
+ }
+ }
+
+ // ============================================================================
+ // Visual Properties
+ // ============================================================================
+
+ setColor(color) {
+ this.color = color;
+ // Note: Color changes don't affect spectrogram, only visual rendering
+ // So we don't mark dirty
+ }
+
+ setVolume(volume) {
+ if (this.volume !== volume) {
+ this.volume = volume;
+ this.markDirty();
+ }
+ }
+
+ // ============================================================================
+ // Spectrogram Generation (Core Algorithm)
+ // ============================================================================
+
+ computeSpectrogram() {
+ const spectrogram = new Float32Array(this.dctSize * this.numFrames);
+
+ if (this.controlPoints.length === 0) {
+ return spectrogram;
+ }
+
+ // Find the frame range covered by control points
+ const frames = this.controlPoints.map(p => p.frame);
+ const minFrame = Math.max(0, Math.min(...frames));
+ const maxFrame = Math.min(this.numFrames - 1, Math.max(...frames));
+
+ // Constants (same as original drawCurveToSpectrogram)
+ const AMPLITUDE_SCALE = 50.0;
+ const SAMPLE_RATE = 32000;
+
+ // Apply curve volume
+ const curveVolume = this.volume;
+
+ // Only iterate over the range where control points exist
+ for (let frame = minFrame; frame <= maxFrame; frame++) {
+ // Evaluate Bezier curve at this frame
+ const freqHz = this.evaluateBezierLinear('freqHz', frame);
+ const amplitude = this.evaluateBezierLinear('amplitude', frame);
+
+ // Convert freq to bin
+ const freqBin0 = (freqHz / (SAMPLE_RATE / 2)) * this.dctSize;
+
+ // Apply vertical profile
+ for (let bin = 0; bin < this.dctSize; bin++) {
+ const dist = Math.abs(bin - freqBin0);
+ const profileValue = this.evaluateProfile(dist);
+
+ const value = amplitude * profileValue * AMPLITUDE_SCALE * curveVolume;
+ const idx = frame * this.dctSize + bin;
+ spectrogram[idx] += value;
+ }
+ }
+
+ return spectrogram;
+ }
+
+ // ============================================================================
+ // Bezier Evaluation (Linear Interpolation)
+ // ============================================================================
+
+ evaluateBezierLinear(property, targetFrame) {
+ const points = this.controlPoints;
+
+ if (points.length === 0) return 0;
+ if (points.length === 1) return points[0][property];
+
+ // Sort points by frame (ascending)
+ const sorted = [...points].sort((a, b) => a.frame - b.frame);
+
+ // Find the two control points that bracket targetFrame
+ let p0 = sorted[0];
+ let p1 = sorted[sorted.length - 1];
+
+ for (let i = 0; i < sorted.length - 1; i++) {
+ if (targetFrame >= sorted[i].frame && targetFrame <= sorted[i + 1].frame) {
+ p0 = sorted[i];
+ p1 = sorted[i + 1];
+ break;
+ }
+ }
+
+ // Clamp to endpoints if outside range
+ if (targetFrame < sorted[0].frame) return sorted[0][property];
+ if (targetFrame > sorted[sorted.length - 1].frame) return sorted[sorted.length - 1][property];
+
+ // Linear interpolation
+ const t = (targetFrame - p0.frame) / (p1.frame - p0.frame);
+ return p0[property] * (1 - t) + p1[property] * t;
+ }
+
+ // ============================================================================
+ // Profile Evaluation
+ // ============================================================================
+
+ evaluateProfile(dist) {
+ switch (this.profile.type) {
+ case 'gaussian':
+ return this.evaluateGaussian(dist);
+ case 'decaying_sinusoid':
+ return this.evaluateDecayingSinusoid(dist);
+ case 'noise':
+ return this.evaluateNoise(dist);
+ default:
+ return this.evaluateGaussian(dist);
+ }
+ }
+
+ evaluateGaussian(dist) {
+ const sigma = this.profile.sigma;
+ const exponent = -(dist * dist) / (2 * sigma * sigma);
+ return Math.exp(exponent);
+ }
+
+ evaluateDecayingSinusoid(dist) {
+ const sigma = this.profile.sigma;
+ const freq = 0.5; // Fixed frequency for now
+ const envelope = Math.exp(-(dist * dist) / (2 * sigma * sigma));
+ const wave = Math.cos(2 * Math.PI * freq * dist);
+ return envelope * wave * 0.5 + envelope * 0.5; // Bias to positive
+ }
+
+ evaluateNoise(dist) {
+ const sigma = this.profile.sigma;
+ const envelope = Math.exp(-(dist * dist) / (2 * sigma * sigma));
+ // Simple pseudo-random based on distance
+ const noise = Math.sin(dist * 12.9898 + dist * 78.233) * 0.5 + 0.5;
+ return envelope * noise;
+ }
+
+ // ============================================================================
+ // Serialization (for save/load)
+ // ============================================================================
+
+ toJSON() {
+ return {
+ id: this.id,
+ controlPoints: this.controlPoints,
+ profile: this.profile,
+ color: this.color,
+ volume: this.volume
+ };
+ }
+
+ static fromJSON(json, dctSize, numFrames) {
+ const curve = new Curve(json.id, dctSize, numFrames);
+ curve.controlPoints = json.controlPoints || [];
+ curve.profile = json.profile || { type: 'gaussian', sigma: 30.0, param2: 0.0 };
+ curve.color = json.color || '#0e639c';
+ curve.volume = json.volume || 1.0;
+ curve.markDirty(); // Force recomputation on load
+ return curve;
+ }
+}