Monorepo for Aesthetic.Computer aesthetic.computer
at main 343 lines 12 kB view raw
1// 🎵 Audio Analyzer - Shared module for consistent audio analysis 2// This module provides the exact same algorithms used in speaker.mjs (AudioWorklet) 3// so that both the AC runtime and kidlisp.com editor produce identical values. 4// 5// Usage in main thread (kidlisp.com editor): 6// const analyzer = new AudioAnalyzer(audioContext.sampleRate); 7// const audioData = analyzer.analyzeAudioData(float32Array); 8// // audioData = { amp, leftAmp, rightAmp, beat, kick, frequencies } 9// 10// The speaker.mjs AudioWorklet uses these same algorithms internally. 11 12export const AUDIO_ANALYZER_VERSION = "1.0.0"; 13 14// Configuration constants (same as speaker.mjs) 15export const FFT_SIZE = 512; 16export const ENERGY_HISTORY_SIZE = 20; 17export const BEAT_SENSITIVITY = 1.15; 18export const BEAT_COOLDOWN = 0.08; // seconds 19 20// Frequency band definitions (same as speaker.mjs) 21export const FREQUENCY_BANDS = [ 22 { name: 'subBass', min: 20, max: 100 }, 23 { name: 'lowMid', min: 100, max: 400 }, 24 { name: 'mid', min: 400, max: 1000 }, 25 { name: 'highMid', min: 1000, max: 2500 }, 26 { name: 'presence', min: 2500, max: 5000 }, 27 { name: 'treble', min: 5000, max: 10000 }, 28 { name: 'air', min: 10000, max: 16000 }, 29 { name: 'ultra', min: 16000, max: 20000 } 30]; 31 32/** 33 * Iterative FFT implementation - same as speaker.mjs 34 * @param {Float32Array|number[]} buffer - Audio samples 35 * @returns {Array<{real: number, imag: number}>} Complex FFT result 36 */ 37export function fft(buffer) { 38 const N = buffer.length; 39 if (N <= 1) return Array.from(buffer).map(x => ({ real: x, imag: 0 })); 40 41 // Ensure power of 2 and use smaller size for consistency 42 const powerOf2 = Math.min(FFT_SIZE, Math.pow(2, Math.floor(Math.log2(N)))); 43 const input = Array.from(buffer).slice(0, powerOf2); 44 45 // Use iterative FFT instead of recursive for better performance 46 const result = input.map(x => ({ real: x, imag: 0 })); 47 48 // Bit-reverse permutation 49 for (let i = 0; i < powerOf2; i++) { 50 let j = 0; 51 for (let k = 0; k < Math.log2(powerOf2); k++) { 52 j = (j << 1) | ((i >> k) & 1); 53 } 54 if (j > i) { 55 [result[i], result[j]] = [result[j], result[i]]; 56 } 57 } 58 59 // Iterative FFT 60 for (let len = 2; len <= powerOf2; len *= 2) { 61 const w = { real: Math.cos(-2 * Math.PI / len), imag: Math.sin(-2 * Math.PI / len) }; 62 for (let i = 0; i < powerOf2; i += len) { 63 let wn = { real: 1, imag: 0 }; 64 for (let j = 0; j < len / 2; j++) { 65 const u = result[i + j]; 66 const v = { 67 real: result[i + j + len / 2].real * wn.real - result[i + j + len / 2].imag * wn.imag, 68 imag: result[i + j + len / 2].real * wn.imag + result[i + j + len / 2].imag * wn.real 69 }; 70 result[i + j] = { real: u.real + v.real, imag: u.imag + v.imag }; 71 result[i + j + len / 2] = { real: u.real - v.real, imag: u.imag - v.imag }; 72 const temp = { real: wn.real * w.real - wn.imag * w.imag, imag: wn.real * w.imag + wn.imag * w.real }; 73 wn = temp; 74 } 75 } 76 } 77 return result; 78} 79 80/** 81 * Analyze frequencies and return structured frequency bands - same as speaker.mjs 82 * @param {Float32Array|number[]} buffer - Audio samples (should be FFT_SIZE length) 83 * @param {number} sampleRate - Audio sample rate 84 * @returns {Array<{name: string, frequency: {min: number, max: number}, amplitude: number}>} 85 */ 86export function analyzeFrequencies(buffer, sampleRate) { 87 if (buffer.length < FFT_SIZE) return []; 88 89 // Simplified windowing - use rectangular window for consistency 90 const windowedBuffer = Array.from(buffer).slice(0, FFT_SIZE); 91 92 // Perform FFT 93 const fftResult = fft(windowedBuffer); 94 95 // Calculate magnitude spectrum 96 const magnitudes = fftResult.map(complex => 97 Math.sqrt(complex.real * complex.real + complex.imag * complex.imag) 98 ); 99 100 // Calculate bin frequency resolution 101 const binFreq = sampleRate / FFT_SIZE; 102 103 // Analyze each frequency band 104 return FREQUENCY_BANDS.map(band => { 105 const startBin = Math.floor(band.min / binFreq); 106 const endBin = Math.min(Math.floor(band.max / binFreq), magnitudes.length / 2); 107 108 let sum = 0; 109 let count = 0; 110 for (let i = startBin; i < endBin; i++) { 111 sum += magnitudes[i]; 112 count++; 113 } 114 115 const amplitude = count > 0 ? sum / count : 0; 116 117 // Apply power scaling for better dynamic range (same as speaker.mjs) 118 let scaledAmplitude = amplitude; 119 if (scaledAmplitude > 0) { 120 scaledAmplitude = Math.pow(scaledAmplitude, 0.7); 121 } 122 123 return { 124 name: band.name, 125 frequency: { min: band.min, max: band.max }, 126 amplitude: Math.min(0.9, scaledAmplitude), // 90% clamp 127 binRange: { start: startBin, end: endBin } 128 }; 129 }); 130} 131 132/** 133 * Calculate peak amplitude from audio samples 134 * @param {Float32Array|number[]} samples - Audio samples 135 * @returns {number} Peak amplitude (0-1) 136 */ 137export function calculateAmplitude(samples) { 138 let peak = 0; 139 for (let i = 0; i < samples.length; i++) { 140 const abs = Math.abs(samples[i]); 141 if (abs > peak) peak = abs; 142 } 143 return peak; 144} 145 146/** 147 * Audio Analyzer class - maintains state for beat detection 148 * Provides the same analysis as speaker.mjs AudioWorkletProcessor 149 */ 150export class AudioAnalyzer { 151 #sampleRate; 152 #fftBufferLeft = []; 153 #fftBufferRight = []; 154 #energyHistory = []; 155 #energyHistorySize = ENERGY_HISTORY_SIZE; 156 #beatSensitivity = BEAT_SENSITIVITY; 157 #beatCooldown = BEAT_COOLDOWN; 158 #lastBeatTime = 0; 159 #currentBeat = false; 160 #beatStrength = 0; 161 #adaptiveThreshold = BEAT_SENSITIVITY; 162 #energyVariance = 0; 163 #recentEnergyPeaks = []; 164 #lastAnalysisTime = 0; 165 166 constructor(sampleRate = 44100) { 167 this.#sampleRate = sampleRate; 168 } 169 170 /** 171 * Get the current time in seconds (for beat detection timing) 172 * Override this if you need custom timing 173 */ 174 getCurrentTime() { 175 return performance.now() / 1000; 176 } 177 178 /** 179 * Analyze stereo audio data and return all audio parameters 180 * @param {Float32Array|number[]} leftChannel - Left channel samples 181 * @param {Float32Array|number[]|null} rightChannel - Right channel samples (optional, defaults to left) 182 * @returns {{ 183 * amp: number, 184 * leftAmp: number, 185 * rightAmp: number, 186 * beat: number, 187 * kick: number, 188 * frequencies: {left: Array, right: Array}, 189 * beatStrength: number 190 * }} 191 */ 192 analyze(leftChannel, rightChannel = null) { 193 const currentTime = this.getCurrentTime(); 194 rightChannel = rightChannel || leftChannel; 195 196 // Calculate amplitudes (peak detection, same as speaker.mjs) 197 const leftAmp = calculateAmplitude(leftChannel); 198 const rightAmp = calculateAmplitude(rightChannel); 199 const amp = (leftAmp + rightAmp) / 2; 200 201 // Add samples to FFT buffers 202 this.#fftBufferLeft.push(...leftChannel); 203 this.#fftBufferRight.push(...rightChannel); 204 205 // Keep buffer size manageable 206 if (this.#fftBufferLeft.length > FFT_SIZE) { 207 this.#fftBufferLeft = this.#fftBufferLeft.slice(-FFT_SIZE); 208 this.#fftBufferRight = this.#fftBufferRight.slice(-FFT_SIZE); 209 } 210 211 // Analyze frequencies 212 let frequencyBandsLeft = []; 213 let frequencyBandsRight = []; 214 if (this.#fftBufferLeft.length >= FFT_SIZE) { 215 frequencyBandsLeft = analyzeFrequencies(this.#fftBufferLeft, this.#sampleRate); 216 frequencyBandsRight = analyzeFrequencies(this.#fftBufferRight, this.#sampleRate); 217 } 218 219 // Beat detection (same algorithm as speaker.mjs) 220 this.#detectBeats(this.#fftBufferLeft, currentTime); 221 222 // Scale amplitudes to 0-10 range for KidLisp (same as AC runtime) 223 const scaledAmp = amp * 10; 224 const scaledLeftAmp = leftAmp * 10; 225 const scaledRightAmp = rightAmp * 10; 226 227 return { 228 amp: scaledAmp, 229 leftAmp: scaledLeftAmp, 230 rightAmp: scaledRightAmp, 231 beat: this.#currentBeat ? 1 : 0, 232 kick: this.#currentBeat ? 1 : 0, // alias for beat 233 frequencies: { 234 left: frequencyBandsLeft, 235 right: frequencyBandsRight 236 }, 237 beatStrength: this.#beatStrength 238 }; 239 } 240 241 /** 242 * Beat detection using energy-based onset detection - same as speaker.mjs 243 * @private 244 */ 245 #detectBeats(buffer, currentTime) { 246 if (buffer.length < FFT_SIZE) return; 247 248 // Calculate current energy (sum of squares in frequency domain) 249 const fftData = fft(buffer); 250 let currentEnergy = 0; 251 252 // Focus on lower frequencies for beat detection (bass/kick drums) 253 const bassEndBin = Math.floor(250 * FFT_SIZE / this.#sampleRate); 254 for (let i = 1; i < Math.min(bassEndBin, fftData.length / 2); i++) { 255 const complex = fftData[i] || { real: 0, imag: 0 }; 256 currentEnergy += complex.real * complex.real + complex.imag * complex.imag; 257 } 258 259 // Normalize energy 260 currentEnergy = Math.sqrt(currentEnergy / bassEndBin); 261 262 // Add to energy history 263 this.#energyHistory.push(currentEnergy); 264 if (this.#energyHistory.length > this.#energyHistorySize) { 265 this.#energyHistory.shift(); 266 } 267 268 // Clear expired beat flag (beat lasts 50ms) 269 if (this.#currentBeat && currentTime - this.#lastBeatTime > 0.05) { 270 this.#currentBeat = false; 271 this.#beatStrength = 0; 272 } 273 274 // Need enough history for comparison 275 if (this.#energyHistory.length < this.#energyHistorySize) return; 276 277 // Calculate average energy over recent history 278 const avgEnergy = this.#energyHistory.reduce((sum, e) => sum + e, 0) / this.#energyHistory.length; 279 280 // Calculate energy variance for adaptive sensitivity 281 const variance = this.#energyHistory.reduce((sum, e) => sum + Math.pow(e - avgEnergy, 2), 0) / this.#energyHistory.length; 282 this.#energyVariance = Math.sqrt(variance); 283 284 // Track recent energy peaks for adaptive threshold 285 if (currentEnergy > avgEnergy) { 286 this.#recentEnergyPeaks.push(currentEnergy); 287 if (this.#recentEnergyPeaks.length > 20) { 288 this.#recentEnergyPeaks.shift(); 289 } 290 } 291 292 // Adaptive threshold based on recent activity and variance 293 let adaptiveMultiplier = 1.0; 294 if (this.#energyVariance > 0 && avgEnergy > 0) { 295 const normalizedVariance = Math.min(this.#energyVariance / 50, 1.0); 296 297 if (avgEnergy > 20) { 298 // Loud music: be much more sensitive 299 adaptiveMultiplier = Math.max(0.4, 0.8 - normalizedVariance * 0.3); 300 } else if (normalizedVariance > 0.3) { 301 // Dynamic music: moderately more sensitive 302 adaptiveMultiplier = Math.max(0.7, 1.1 - normalizedVariance * 0.4); 303 } else { 304 // Quiet/steady music: standard sensitivity 305 adaptiveMultiplier = 1.0 + normalizedVariance * 0.2; 306 } 307 } 308 309 this.#adaptiveThreshold = this.#beatSensitivity * adaptiveMultiplier; 310 311 // Time-based sensitivity boost 312 const timeSinceLastBeat = currentTime - this.#lastBeatTime; 313 let timeBasedSensitivity = 1.0; 314 if (timeSinceLastBeat > 0.3) { 315 timeBasedSensitivity = 1.0 + Math.min(0.4, (timeSinceLastBeat - 0.3) * 0.8); 316 } 317 318 const finalThreshold = this.#adaptiveThreshold / timeBasedSensitivity; 319 const energyRatio = avgEnergy > 0 ? currentEnergy / avgEnergy : 0; 320 321 // Detect beat 322 if (energyRatio > finalThreshold && timeSinceLastBeat > this.#beatCooldown) { 323 this.#currentBeat = true; 324 this.#beatStrength = Math.min(1.0, (energyRatio - finalThreshold) / 2.0); 325 this.#lastBeatTime = currentTime; 326 } 327 } 328 329 /** 330 * Reset the analyzer state 331 */ 332 reset() { 333 this.#fftBufferLeft = []; 334 this.#fftBufferRight = []; 335 this.#energyHistory = []; 336 this.#lastBeatTime = 0; 337 this.#currentBeat = false; 338 this.#beatStrength = 0; 339 this.#recentEnergyPeaks = []; 340 } 341} 342 343export default AudioAnalyzer;