diff options
Diffstat (limited to 'tools/spectral_editor/curve.js')
| -rw-r--r-- | tools/spectral_editor/curve.js | 282 |
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; + } +} |
