Files
rsgp/src/lib/audio/processors/SpectralShift.ts

431 lines
12 KiB
TypeScript

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;
}