import type { AudioProcessor, ProcessorCategory } from './AudioProcessor'; export class SpectralShift implements AudioProcessor { getName(): string { return 'Spectral Shift'; } getDescription(): string { return 'Shifts all frequencies by a fixed Hz amount creating inharmonic, metallic timbres'; } getCategory(): ProcessorCategory { return 'Spectral'; } process( leftChannel: Float32Array, rightChannel: Float32Array ): [Float32Array, Float32Array] { const length = leftChannel.length; const sampleRate = 44100; // Random parameters for frequency shift const shiftAmount = (Math.random() - 0.5) * 6000; // -3000 to +3000 Hz const feedbackAmount = Math.random() * 0.3; // 0 to 0.3 for subtle feedback const dryWet = 0.5 + Math.random() * 0.5; // 0.5 to 1.0 (always some effect) // FFT parameters const fftSize = 2048; const hopSize = Math.floor(fftSize * 0.25); // 75% overlap const outputLeft = this.processChannel( leftChannel, fftSize, hopSize, shiftAmount, feedbackAmount, dryWet, sampleRate ); const outputRight = this.processChannel( rightChannel, fftSize, hopSize, shiftAmount * (0.9 + Math.random() * 0.2), // Slight stereo variation feedbackAmount, dryWet, sampleRate ); // 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, shiftHz: number, feedbackAmount: number, dryWet: number, sampleRate: number ): Float32Array { const length = input.length; const output = new Float32Array(length + fftSize); const window = this.createHannWindow(fftSize); const feedback = new Float32Array(fftSize); // Calculate COLA normalization factor for 75% overlap with Hann window const colaNorm = this.calculateCOLANorm(window, hopSize); // Convert Hz shift to bin shift const binShift = Math.round((shiftHz * fftSize) / sampleRate); 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++) { const drySignal = input[inputPos + i]; const fbSignal = feedback[i] * feedbackAmount; frame[i] = (drySignal + fbSignal) * window[i]; } // FFT const spectrum = this.fft(frame); // Apply frequency shift const shiftedSpectrum = this.applyFrequencyShift(spectrum, binShift); // IFFT const processedFrame = this.ifft(shiftedSpectrum); // Store for feedback for (let i = 0; i < fftSize; i++) { feedback[i] = processedFrame[i].real * 0.5; } // Overlap-add with proper gain compensation and dry/wet mix for (let i = 0; i < fftSize; i++) { const wet = processedFrame[i].real * colaNorm; const dry = input[inputPos + i]; output[outputPos + i] += dry * (1 - dryWet) + wet * dryWet; } inputPos += hopSize; outputPos += hopSize; } // Process remaining samples 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 shiftedSpectrum = this.applyFrequencyShift(spectrum, binShift); const processedFrame = this.ifft(shiftedSpectrum); for (let i = 0; i < fftSize && outputPos + i < output.length; i++) { const wet = processedFrame[i].real * colaNorm; const dry = i < remainingSamples ? input[inputPos + i] : 0; output[outputPos + i] += dry * (1 - dryWet) + wet * dryWet; } } return output; } private applyFrequencyShift(spectrum: Complex[], binShift: number): Complex[] { const result: Complex[] = new Array(spectrum.length); const halfSize = Math.floor(spectrum.length / 2); // Initialize with zeros for (let i = 0; i < spectrum.length; i++) { result[i] = { real: 0, imag: 0 }; } // Shift positive frequencies for (let i = 0; i < halfSize; i++) { const newBin = i + binShift; if (newBin >= 0 && newBin < halfSize) { // Simple shift within bounds result[newBin] = { real: spectrum[i].real, imag: spectrum[i].imag }; } else if (newBin < 0) { // Frequency shifted below 0 Hz - mirror to positive with phase inversion const mirrorBin = Math.abs(newBin); if (mirrorBin < halfSize) { // Add to existing content (creates interesting beating) result[mirrorBin] = { real: result[mirrorBin].real + spectrum[i].real, imag: result[mirrorBin].imag - spectrum[i].imag // Phase inversion }; } } // Frequencies shifted above Nyquist are discarded (natural lowpass) } // Apply spectral smoothing to reduce artifacts const smoothed = this.smoothSpectrum(result, halfSize); // Reconstruct negative frequencies for Hermitian symmetry for (let i = 1; i < halfSize; i++) { const negIdx = spectrum.length - i; smoothed[negIdx] = { real: smoothed[i].real, imag: -smoothed[i].imag }; } // DC and Nyquist should be real smoothed[0].imag = 0; if (halfSize * 2 === spectrum.length) { smoothed[halfSize].imag = 0; } return smoothed; } private smoothSpectrum(spectrum: Complex[], halfSize: number): Complex[] { const result: Complex[] = new Array(spectrum.length); // Copy spectrum for (let i = 0; i < spectrum.length; i++) { result[i] = { real: spectrum[i].real, imag: spectrum[i].imag }; } // Apply 3-point smoothing to reduce artifacts for (let i = 1; i < halfSize - 1; i++) { result[i] = { real: (spectrum[i - 1].real * 0.25 + spectrum[i].real * 0.5 + spectrum[i + 1].real * 0.25), imag: (spectrum[i - 1].imag * 0.25 + spectrum[i].imag * 0.5 + spectrum[i + 1].imag * 0.25) }; } return result; } 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 and conjugate 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 (same as forward FFT) 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 }; } } } // Conjugate and scale const scale = 1 / n; for (let i = 0; i < n; i++) { output[i].real *= scale; output[i].imag *= -scale; } 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; }