Monorepo for Aesthetic.Computer
aesthetic.computer
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;