460 lines
12 KiB
TypeScript
460 lines
12 KiB
TypeScript
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<Complex>(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;
|
|
} |