// 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; } }