/** * Dynamics processing effects (Compressor, Limiter, Gate/Expander) */ import { getAudioContext } from '../context'; export interface CompressorParameters { threshold: number; // dB - level where compression starts ratio: number; // Compression ratio (e.g., 4 = 4:1) attack: number; // ms - how quickly to compress release: number; // ms - how quickly to stop compressing knee: number; // dB - width of soft knee (0 = hard knee) makeupGain: number; // dB - gain to apply after compression } export interface LimiterParameters { threshold: number; // dB - maximum level attack: number; // ms - how quickly to limit release: number; // ms - how quickly to stop limiting makeupGain: number; // dB - gain to apply after limiting } export interface GateParameters { threshold: number; // dB - level below which gate activates ratio: number; // Expansion ratio (e.g., 2 = 2:1) attack: number; // ms - how quickly to close gate release: number; // ms - how quickly to open gate knee: number; // dB - width of soft knee } /** * Apply compression to audio buffer */ export async function applyCompressor( buffer: AudioBuffer, params: CompressorParameters ): Promise { const audioContext = getAudioContext(); const channels = buffer.numberOfChannels; const length = buffer.length; const sampleRate = buffer.sampleRate; // Create output buffer const outputBuffer = audioContext.createBuffer(channels, length, sampleRate); // Convert time constants to samples const attackSamples = (params.attack / 1000) * sampleRate; const releaseSamples = (params.release / 1000) * sampleRate; // Convert dB to linear const thresholdLinear = dbToLinear(params.threshold); const makeupGainLinear = dbToLinear(params.makeupGain); const kneeLinear = dbToLinear(params.knee); // Process each channel for (let channel = 0; channel < channels; channel++) { const inputData = buffer.getChannelData(channel); const outputData = outputBuffer.getChannelData(channel); let envelope = 0; for (let i = 0; i < length; i++) { const input = inputData[i]; const inputAbs = Math.abs(input); // Envelope follower with attack/release if (inputAbs > envelope) { envelope = envelope + (inputAbs - envelope) / attackSamples; } else { envelope = envelope + (inputAbs - envelope) / releaseSamples; } // Calculate gain reduction let gain = 1.0; if (envelope > thresholdLinear) { // Soft knee calculation const overThreshold = envelope - thresholdLinear; const kneeRange = kneeLinear / 2; if (params.knee > 0 && overThreshold < kneeRange) { // In the knee region - smooth transition const kneeRatio = overThreshold / kneeRange; const compressionAmount = (1 - 1 / params.ratio) * kneeRatio; gain = 1 - compressionAmount * (overThreshold / envelope); } else { // Above knee - full compression const exceededDb = linearToDb(envelope) - params.threshold; const gainReductionDb = exceededDb * (1 - 1 / params.ratio); gain = dbToLinear(-gainReductionDb); } } // Apply gain reduction and makeup gain outputData[i] = input * gain * makeupGainLinear; } } return outputBuffer; } /** * Apply limiting to audio buffer */ export async function applyLimiter( buffer: AudioBuffer, params: LimiterParameters ): Promise { // Limiter is essentially a compressor with infinite ratio return applyCompressor(buffer, { threshold: params.threshold, ratio: 100, // Very high ratio approximates infinity:1 attack: params.attack, release: params.release, knee: 0, // Hard knee for brick-wall limiting makeupGain: params.makeupGain, }); } /** * Apply gate/expander to audio buffer */ export async function applyGate( buffer: AudioBuffer, params: GateParameters ): Promise { const audioContext = getAudioContext(); const channels = buffer.numberOfChannels; const length = buffer.length; const sampleRate = buffer.sampleRate; // Create output buffer const outputBuffer = audioContext.createBuffer(channels, length, sampleRate); // Convert time constants to samples const attackSamples = (params.attack / 1000) * sampleRate; const releaseSamples = (params.release / 1000) * sampleRate; // Convert dB to linear const thresholdLinear = dbToLinear(params.threshold); const kneeLinear = dbToLinear(params.knee); // Process each channel for (let channel = 0; channel < channels; channel++) { const inputData = buffer.getChannelData(channel); const outputData = outputBuffer.getChannelData(channel); let envelope = 0; for (let i = 0; i < length; i++) { const input = inputData[i]; const inputAbs = Math.abs(input); // Envelope follower with attack/release if (inputAbs > envelope) { envelope = envelope + (inputAbs - envelope) / attackSamples; } else { envelope = envelope + (inputAbs - envelope) / releaseSamples; } // Calculate gain reduction let gain = 1.0; if (envelope < thresholdLinear) { // Below threshold - apply expansion/gating const belowThreshold = thresholdLinear - envelope; const kneeRange = kneeLinear / 2; if (params.knee > 0 && belowThreshold < kneeRange) { // In the knee region - smooth transition const kneeRatio = belowThreshold / kneeRange; const expansionAmount = (1 - params.ratio) * kneeRatio; gain = 1 + expansionAmount * (belowThreshold / thresholdLinear); } else { // Below knee - full expansion const belowDb = params.threshold - linearToDb(envelope); const gainReductionDb = belowDb * (params.ratio - 1); gain = dbToLinear(-gainReductionDb); } // Clamp to prevent extreme amplification gain = Math.max(0, Math.min(1, gain)); } // Apply gain outputData[i] = input * gain; } } return outputBuffer; } /** * Convert decibels to linear gain */ function dbToLinear(db: number): number { return Math.pow(10, db / 20); } /** * Convert linear gain to decibels */ function linearToDb(linear: number): number { return 20 * Math.log10(Math.max(linear, 0.00001)); // Prevent log(0) }