import type { AudioProcessor, ProcessorCategory } from './AudioProcessor'; type RoomType = 'small' | 'medium' | 'large' | 'hall' | 'plate' | 'chamber'; interface ReverbParams { roomType: RoomType; decayTime: number; preDelay: number; wetMix: number; earlyReflections: number; diffusion: number; } export class ConvolutionReverb implements AudioProcessor { private readonly sampleRate = 44100; getName(): string { return 'Convolution Reverb'; } getDescription(): string { return 'Realistic room ambience using Web Audio ConvolverNode with synthetic impulse responses'; } getCategory(): ProcessorCategory { return 'Space'; } async process( leftChannel: Float32Array, rightChannel: Float32Array ): Promise<[Float32Array, Float32Array]> { const inputLength = leftChannel.length; const params = this.randomParams(); // Create offline context for processing const offlineContext = new OfflineAudioContext(2, inputLength, this.sampleRate); // Create input buffer from our channels (copy to ensure proper ArrayBuffer type) const inputBuffer = offlineContext.createBuffer(2, inputLength, this.sampleRate); inputBuffer.copyToChannel(new Float32Array(leftChannel), 0); inputBuffer.copyToChannel(new Float32Array(rightChannel), 1); // Generate impulse response with slight stereo differences const irBuffer = this.generateImpulseResponseBuffer(offlineContext, params); // Create audio graph: source -> convolver -> destination const source = offlineContext.createBufferSource(); source.buffer = inputBuffer; const convolver = offlineContext.createConvolver(); convolver.buffer = irBuffer; convolver.normalize = false; // We normalize the IR ourselves for consistent gain const dryGain = offlineContext.createGain(); const wetGain = offlineContext.createGain(); // Equal-power crossfade dryGain.gain.value = Math.sqrt(1 - params.wetMix); wetGain.gain.value = Math.sqrt(params.wetMix); // Connect dry path source.connect(dryGain); dryGain.connect(offlineContext.destination); // Connect wet path source.connect(convolver); convolver.connect(wetGain); wetGain.connect(offlineContext.destination); source.start(0); // Render offline const renderedBuffer = await offlineContext.startRendering(); // Extract channels and ensure exact length match const outLeft = new Float32Array(inputLength); const outRight = new Float32Array(inputLength); renderedBuffer.copyFromChannel(outLeft, 0); renderedBuffer.copyFromChannel(outRight, 1); // Normalize to prevent clipping while maintaining dynamics this.normalizeInPlace(outLeft, outRight); return [outLeft, outRight]; } private randomParams(): ReverbParams { const roomTypes: RoomType[] = ['small', 'medium', 'large', 'hall', 'plate', 'chamber']; const roomType = roomTypes[Math.floor(Math.random() * roomTypes.length)]; let decayTime: number; let diffusion: number; switch (roomType) { case 'small': decayTime = Math.random() * 0.3 + 0.2; diffusion = Math.random() * 0.3 + 0.5; break; case 'medium': decayTime = Math.random() * 0.5 + 0.4; diffusion = Math.random() * 0.2 + 0.6; break; case 'large': decayTime = Math.random() * 0.7 + 0.6; diffusion = Math.random() * 0.2 + 0.7; break; case 'hall': decayTime = Math.random() * 0.5 + 0.8; diffusion = Math.random() * 0.15 + 0.75; break; case 'plate': decayTime = Math.random() * 0.4 + 0.5; diffusion = Math.random() * 0.3 + 0.6; break; case 'chamber': decayTime = Math.random() * 0.6 + 0.5; diffusion = Math.random() * 0.2 + 0.65; break; } return { roomType, decayTime, preDelay: Math.random() * 0.025 + 0.005, wetMix: Math.random() * 0.4 + 0.3, earlyReflections: Math.random() * 0.3 + 0.4, diffusion }; } private generateImpulseResponseBuffer(context: OfflineAudioContext, params: ReverbParams): AudioBuffer { // IR length based on decay time, with reasonable maximum const irDuration = Math.min(params.decayTime * 4, 4.0); const irLength = Math.floor(irDuration * this.sampleRate); const irBuffer = context.createBuffer(2, irLength, this.sampleRate); const irLeft = new Float32Array(irLength); const irRight = new Float32Array(irLength); // Generate stereo IRs with slight differences for width this.generateImpulseResponse(irLeft, params, 0); this.generateImpulseResponse(irRight, params, 1); irBuffer.copyToChannel(irLeft, 0); irBuffer.copyToChannel(irRight, 1); return irBuffer; } private generateImpulseResponse(ir: Float32Array, params: ReverbParams, channel: number): void { const irLength = ir.length; const preDelaySamples = Math.floor(params.preDelay * this.sampleRate); // Room-specific parameters const roomSizes: Record = { small: 0.2, medium: 0.4, large: 0.6, hall: 0.85, plate: 0.5, chamber: 0.55 }; const roomSize = roomSizes[params.roomType]; const earlyDecayTime = roomSize * 0.05; // Generate early reflections as sparse impulses const numEarlyReflections = Math.floor(roomSize * 30) + 8; const earlyReflectionTime = earlyDecayTime * this.sampleRate; for (let i = 0; i < numEarlyReflections; i++) { const time = preDelaySamples + Math.pow(Math.random(), 1.5) * earlyReflectionTime; const index = Math.floor(time); if (index < irLength) { const distanceAttenuation = 1.0 / (1.0 + time / this.sampleRate); const amplitude = params.earlyReflections * distanceAttenuation * (0.5 + Math.random() * 0.5); // Stereo width variation const stereoOffset = (channel === 0 ? -1 : 1) * Math.random() * 0.3; ir[index] += amplitude * (1.0 + stereoOffset); } } // Generate diffuse tail using filtered noise const diffuseStart = preDelaySamples + Math.floor(earlyReflectionTime); const decaySamples = params.decayTime * this.sampleRate; // Multi-band diffusion for realistic reverb character const numBands = 4; const bandGains = new Float32Array(numBands); for (let band = 0; band < numBands; band++) { bandGains[band] = 0.7 + Math.random() * 0.3; } // Generate noise with exponential decay and frequency-dependent damping for (let i = diffuseStart; i < irLength; i++) { const t = (i - diffuseStart) / decaySamples; const envelope = Math.exp(-t * 6.0); if (envelope < 0.001) break; let sample = 0; // Multi-band noise for frequency-dependent decay for (let band = 0; band < numBands; band++) { const noise = (Math.random() * 2 - 1) * bandGains[band]; const bandEnvelope = Math.exp(-t * (3.0 + band * 2.0)); sample += noise * bandEnvelope; } sample *= envelope * params.diffusion; // Apply low-pass filtering for natural high-frequency damping if (i > diffuseStart) { const damping = params.roomType === 'plate' ? 0.4 : 0.5; sample = sample * (1 - damping) + ir[i - 1] * damping; } ir[i] += sample; } // Normalize IR to consistent level let maxAmp = 0; for (let i = 0; i < irLength; i++) { maxAmp = Math.max(maxAmp, Math.abs(ir[i])); } if (maxAmp > 0) { const normGain = 0.5 / maxAmp; for (let i = 0; i < irLength; i++) { ir[i] *= normGain; } } } private normalizeInPlace(left: Float32Array, right: Float32Array): void { let maxAmp = 0; for (let i = 0; i < left.length; i++) { maxAmp = Math.max(maxAmp, Math.abs(left[i]), Math.abs(right[i])); } if (maxAmp > 0.98) { const gain = 0.95 / maxAmp; for (let i = 0; i < left.length; i++) { left[i] *= gain; right[i] *= gain; } } } }