import type { AudioProcessor, ProcessorCategory } from './AudioProcessor'; export class SpectralBlur implements AudioProcessor { getName(): string { return 'Spectral Blur'; } getDescription(): string { return 'Smears frequency content across neighboring bins for dreamy, diffused textures'; } getCategory(): ProcessorCategory { return 'Spectral'; } process( leftChannel: Float32Array, rightChannel: Float32Array ): [Float32Array, Float32Array] { const length = leftChannel.length; // Random parameters const blurAmount = 0.3 + Math.random() * 0.6; // 0.3 to 0.9 const frequencyRangeLow = Math.random() * 0.2; // 0 to 0.2 (low freq cutoff) const frequencyRangeHigh = 0.5 + Math.random() * 0.5; // 0.5 to 1.0 (high freq cutoff) // FFT parameters for quality spectral processing const fftSize = 4096; const hopSize = Math.floor(fftSize * 0.25); // 75% overlap const overlap = fftSize - hopSize; const outputLeft = this.processChannel( leftChannel, fftSize, hopSize, blurAmount, frequencyRangeLow, frequencyRangeHigh ); const outputRight = this.processChannel( rightChannel, fftSize, hopSize, blurAmount, frequencyRangeLow, frequencyRangeHigh ); // Ensure output matches input length const finalLeft = new Float32Array(length); const finalRight = new Float32Array(length); const copyLength = Math.min(length, outputLeft.length); for (let i = 0; i < copyLength; i++) { finalLeft[i] = outputLeft[i]; finalRight[i] = outputRight[i]; } // Match output RMS to input RMS to maintain perceived loudness const inputRMS = this.calculateRMS(leftChannel, rightChannel); const outputRMS = this.calculateRMS(finalLeft, finalRight); if (outputRMS > 0.0001) { const rmsScale = inputRMS / outputRMS; for (let i = 0; i < length; i++) { finalLeft[i] *= rmsScale; finalRight[i] *= rmsScale; } } // Safety limiter to prevent clipping const maxAmp = this.findMaxAmplitude(finalLeft, finalRight); if (maxAmp > 0.99) { const scale = 0.99 / maxAmp; for (let i = 0; i < length; i++) { finalLeft[i] *= scale; finalRight[i] *= scale; } } return [finalLeft, finalRight]; } private processChannel( input: Float32Array, fftSize: number, hopSize: number, blurAmount: number, freqRangeLow: number, freqRangeHigh: number ): Float32Array { const length = input.length; const output = new Float32Array(length + fftSize); const window = this.createHannWindow(fftSize); // Calculate COLA normalization factor for 75% overlap with Hann window // With hopSize = fftSize * 0.25, we have 4x overlap const overlap = fftSize / hopSize; const colaNorm = this.calculateCOLANorm(window, hopSize); let inputPos = 0; let outputPos = 0; while (inputPos + fftSize <= length) { // Extract windowed frame const frame = new Float32Array(fftSize); for (let i = 0; i < fftSize; i++) { frame[i] = input[inputPos + i] * window[i]; } // FFT const spectrum = this.fft(frame); // Apply spectral blur const blurredSpectrum = this.applySpectralBlur( spectrum, blurAmount, freqRangeLow, freqRangeHigh ); // IFFT const processedFrame = this.ifft(blurredSpectrum); // Overlap-add with proper gain compensation for (let i = 0; i < fftSize; i++) { output[outputPos + i] += processedFrame[i].real * colaNorm; } inputPos += hopSize; outputPos += hopSize; } // Process remaining samples if any if (inputPos < length) { const remainingSamples = length - inputPos; const frame = new Float32Array(fftSize); for (let i = 0; i < remainingSamples; i++) { frame[i] = input[inputPos + i] * window[i]; } const spectrum = this.fft(frame); const blurredSpectrum = this.applySpectralBlur( spectrum, blurAmount, freqRangeLow, freqRangeHigh ); const processedFrame = this.ifft(blurredSpectrum); for (let i = 0; i < fftSize; i++) { if (outputPos + i < output.length) { output[outputPos + i] += processedFrame[i].real * colaNorm; } } } return output; } private applySpectralBlur( spectrum: Complex[], blurAmount: number, freqRangeLow: number, freqRangeHigh: number ): Complex[] { const result = new Array(spectrum.length); const halfSize = Math.floor(spectrum.length / 2); // Create gaussian blur kernel const kernelSize = Math.floor(blurAmount * 20) | 1; // Ensure odd const kernel = this.createGaussianKernel(kernelSize, blurAmount * 5); const kernelCenter = Math.floor(kernelSize / 2); // Calculate frequency bin range to process const binLow = Math.floor(freqRangeLow * halfSize); const binHigh = Math.floor(freqRangeHigh * halfSize); // Process positive frequencies for (let i = 0; i < halfSize; i++) { if (i >= binLow && i <= binHigh) { // Apply blur in the specified frequency range let realSum = 0; let imagSum = 0; let weightSum = 0; for (let k = 0; k < kernelSize; k++) { const binIdx = i + k - kernelCenter; if (binIdx >= 0 && binIdx < halfSize) { const weight = kernel[k]; realSum += spectrum[binIdx].real * weight; imagSum += spectrum[binIdx].imag * weight; weightSum += weight; } } if (weightSum > 0) { result[i] = { real: realSum / weightSum, imag: imagSum / weightSum }; } else { result[i] = spectrum[i]; } } else { // Keep original spectrum outside the blur range result[i] = spectrum[i]; } } // Mirror for negative frequencies (maintain Hermitian symmetry) for (let i = halfSize; i < spectrum.length; i++) { const mirrorIdx = spectrum.length - i; if (mirrorIdx > 0 && mirrorIdx < halfSize) { result[i] = { real: result[mirrorIdx].real, imag: -result[mirrorIdx].imag }; } else { result[i] = spectrum[i]; } } return result; } private createGaussianKernel(size: number, sigma: number): Float32Array { const kernel = new Float32Array(size); const center = Math.floor(size / 2); let sum = 0; for (let i = 0; i < size; i++) { const x = i - center; kernel[i] = Math.exp(-(x * x) / (2 * sigma * sigma)); sum += kernel[i]; } // Normalize for (let i = 0; i < size; i++) { kernel[i] /= sum; } return kernel; } private createHannWindow(size: number): Float32Array { const window = new Float32Array(size); for (let i = 0; i < size; i++) { window[i] = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / (size - 1)); } return window; } private calculateCOLANorm(window: Float32Array, hopSize: number): number { // Calculate the sum of overlapping windows at any point const fftSize = window.length; let windowSum = 0; // Sum all overlapping windows at the center point for (let offset = 0; offset < fftSize; offset += hopSize) { const idx = Math.floor(fftSize / 2) - offset; if (idx >= 0 && idx < fftSize) { windowSum += window[idx] * window[idx]; } } // Also check overlap from the other direction for (let offset = hopSize; offset < fftSize; offset += hopSize) { const idx = Math.floor(fftSize / 2) + offset; if (idx >= 0 && idx < fftSize) { windowSum += window[idx] * window[idx]; } } return windowSum > 0 ? 1.0 / windowSum : 1.0; } // Cooley-Tukey FFT implementation private fft(input: Float32Array): Complex[] { const n = input.length; const output: Complex[] = new Array(n); // Initialize with input as real values for (let i = 0; i < n; i++) { output[i] = { real: input[i], imag: 0 }; } // Bit reversal const bits = Math.log2(n); for (let i = 0; i < n; i++) { const j = this.bitReverse(i, bits); if (j > i) { const temp = output[i]; output[i] = output[j]; output[j] = temp; } } // Cooley-Tukey butterfly operations for (let size = 2; size <= n; size *= 2) { const halfSize = size / 2; const angleStep = -2 * Math.PI / size; for (let start = 0; start < n; start += size) { for (let i = 0; i < halfSize; i++) { const angle = angleStep * i; const twiddle = { real: Math.cos(angle), imag: Math.sin(angle) }; const evenIdx = start + i; const oddIdx = start + i + halfSize; const even = output[evenIdx]; const odd = output[oddIdx]; const twiddledOdd = { real: odd.real * twiddle.real - odd.imag * twiddle.imag, imag: odd.real * twiddle.imag + odd.imag * twiddle.real }; output[evenIdx] = { real: even.real + twiddledOdd.real, imag: even.imag + twiddledOdd.imag }; output[oddIdx] = { real: even.real - twiddledOdd.real, imag: even.imag - twiddledOdd.imag }; } } } return output; } // Inverse FFT private ifft(input: Complex[]): Complex[] { const n = input.length; const output: Complex[] = new Array(n); // Copy input for (let i = 0; i < n; i++) { output[i] = { real: input[i].real, imag: input[i].imag }; } // Conjugate for (let i = 0; i < n; i++) { output[i].imag = -output[i].imag; } // Forward FFT const transformed = this.fftComplex(output); // Conjugate and scale const scale = 1 / n; for (let i = 0; i < n; i++) { transformed[i].real *= scale; transformed[i].imag *= -scale; } return transformed; } // FFT for complex input (used by IFFT) private fftComplex(input: Complex[]): Complex[] { const n = input.length; const output: Complex[] = new Array(n); // Copy input for (let i = 0; i < n; i++) { output[i] = { real: input[i].real, imag: input[i].imag }; } // Bit reversal const bits = Math.log2(n); for (let i = 0; i < n; i++) { const j = this.bitReverse(i, bits); if (j > i) { const temp = output[i]; output[i] = output[j]; output[j] = temp; } } // Cooley-Tukey butterfly operations for (let size = 2; size <= n; size *= 2) { const halfSize = size / 2; const angleStep = -2 * Math.PI / size; for (let start = 0; start < n; start += size) { for (let i = 0; i < halfSize; i++) { const angle = angleStep * i; const twiddle = { real: Math.cos(angle), imag: Math.sin(angle) }; const evenIdx = start + i; const oddIdx = start + i + halfSize; const even = output[evenIdx]; const odd = output[oddIdx]; const twiddledOdd = { real: odd.real * twiddle.real - odd.imag * twiddle.imag, imag: odd.real * twiddle.imag + odd.imag * twiddle.real }; output[evenIdx] = { real: even.real + twiddledOdd.real, imag: even.imag + twiddledOdd.imag }; output[oddIdx] = { real: even.real - twiddledOdd.real, imag: even.imag - twiddledOdd.imag }; } } } return output; } private bitReverse(n: number, bits: number): number { let reversed = 0; for (let i = 0; i < bits; i++) { reversed = (reversed << 1) | ((n >> i) & 1); } return reversed; } private findMaxAmplitude(left: Float32Array, right: Float32Array): number { let max = 0; for (let i = 0; i < left.length; i++) { max = Math.max(max, Math.abs(left[i]), Math.abs(right[i])); } return max; } private calculateRMS(left: Float32Array, right: Float32Array): number { let sumSquares = 0; const length = left.length; for (let i = 0; i < length; i++) { sumSquares += left[i] * left[i] + right[i] * right[i]; } return Math.sqrt(sumSquares / (2 * length)); } } interface Complex { real: number; imag: number; }