From c6cc1a47c08d9f1186f6ed15e540469e8c374eff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Forment?= Date: Sat, 4 Oct 2025 12:54:43 +0200 Subject: [PATCH] Better reverberation --- src/domain/audio/effects/ReverbEffect.ts | 286 ++++++++++++++++------- 1 file changed, 196 insertions(+), 90 deletions(-) diff --git a/src/domain/audio/effects/ReverbEffect.ts b/src/domain/audio/effects/ReverbEffect.ts index 075f5efb..b34a2afc 100644 --- a/src/domain/audio/effects/ReverbEffect.ts +++ b/src/domain/audio/effects/ReverbEffect.ts @@ -6,7 +6,6 @@ export class ReverbEffect implements Effect { private audioContext: AudioContext private inputNode: GainNode private outputNode: GainNode - private convolverNode: ConvolverNode private wetNode: GainNode private dryNode: GainNode private mixNode: GainNode @@ -16,15 +15,49 @@ export class ReverbEffect implements Effect { private bypassed: boolean = false private currentWetValue: number = 0 private currentDryValue: number = 1 - private currentDecay: number = 2 - private currentDamping: number = 50 + private currentDecay: number = 0.5 + private currentDamping: number = 0.5 + + private preDelay: DelayNode + private inputDiffusion1: DelayNode + private inputDiffusion2: DelayNode + private inputDiffusion3: DelayNode + private inputDiffusion4: DelayNode + private inputDiffusionGain1: GainNode + private inputDiffusionGain2: GainNode + private inputDiffusionGain3: GainNode + private inputDiffusionGain4: GainNode + + private leftDelay1: DelayNode + private leftDelay2: DelayNode + private leftFilter: BiquadFilterNode + private leftGain: GainNode + private leftAp1: DelayNode + private leftAp2: DelayNode + private leftApGain1: GainNode + private leftApGain2: GainNode + + private rightDelay1: DelayNode + private rightDelay2: DelayNode + private rightFilter: BiquadFilterNode + private rightGain: GainNode + private rightAp1: DelayNode + private rightAp2: DelayNode + private rightApGain1: GainNode + private rightApGain2: GainNode + + private leftOut: GainNode + private rightOut: GainNode + private merger: ChannelMergerNode constructor(audioContext: AudioContext) { this.audioContext = audioContext + const sr = audioContext.sampleRate + const scale = sr / 29761 + this.inputNode = audioContext.createGain() this.outputNode = audioContext.createGain() this.mixNode = audioContext.createGain() - this.convolverNode = audioContext.createConvolver() this.wetNode = audioContext.createGain() this.dryNode = audioContext.createGain() this.pannerNode = audioContext.createStereoPanner() @@ -40,98 +73,154 @@ export class ReverbEffect implements Effect { this.panLfoGainNode.connect(this.pannerNode.pan) this.panLfoNode.start() + this.preDelay = audioContext.createDelay(0.1) + this.preDelay.delayTime.value = 0.0 + + const diffusionCoef = 0.75 + this.inputDiffusion1 = audioContext.createDelay(0.1) + this.inputDiffusion2 = audioContext.createDelay(0.1) + this.inputDiffusion3 = audioContext.createDelay(0.1) + this.inputDiffusion4 = audioContext.createDelay(0.1) + this.inputDiffusion1.delayTime.value = (142 * scale) / sr + this.inputDiffusion2.delayTime.value = (107 * scale) / sr + this.inputDiffusion3.delayTime.value = (379 * scale) / sr + this.inputDiffusion4.delayTime.value = (277 * scale) / sr + + this.inputDiffusionGain1 = audioContext.createGain() + this.inputDiffusionGain2 = audioContext.createGain() + this.inputDiffusionGain3 = audioContext.createGain() + this.inputDiffusionGain4 = audioContext.createGain() + this.inputDiffusionGain1.gain.value = diffusionCoef + this.inputDiffusionGain2.gain.value = diffusionCoef + this.inputDiffusionGain3.gain.value = diffusionCoef + this.inputDiffusionGain4.gain.value = diffusionCoef + + this.leftDelay1 = audioContext.createDelay(0.5) + this.leftDelay2 = audioContext.createDelay(0.5) + this.leftDelay1.delayTime.value = (672 * scale) / sr + this.leftDelay2.delayTime.value = (4453 * scale) / sr + this.leftFilter = audioContext.createBiquadFilter() + this.leftFilter.type = 'lowpass' + this.leftFilter.frequency.value = 5000 + this.leftGain = audioContext.createGain() + this.leftGain.gain.value = 0.5 + + this.leftAp1 = audioContext.createDelay(0.1) + this.leftAp2 = audioContext.createDelay(0.2) + this.leftAp1.delayTime.value = (1800 * scale) / sr + this.leftAp2.delayTime.value = (3720 * scale) / sr + this.leftApGain1 = audioContext.createGain() + this.leftApGain2 = audioContext.createGain() + this.leftApGain1.gain.value = 0.7 + this.leftApGain2.gain.value = 0.7 + + this.rightDelay1 = audioContext.createDelay(0.5) + this.rightDelay2 = audioContext.createDelay(0.5) + this.rightDelay1.delayTime.value = (908 * scale) / sr + this.rightDelay2.delayTime.value = (4217 * scale) / sr + this.rightFilter = audioContext.createBiquadFilter() + this.rightFilter.type = 'lowpass' + this.rightFilter.frequency.value = 5000 + this.rightGain = audioContext.createGain() + this.rightGain.gain.value = 0.5 + + this.rightAp1 = audioContext.createDelay(0.1) + this.rightAp2 = audioContext.createDelay(0.2) + this.rightAp1.delayTime.value = (2656 * scale) / sr + this.rightAp2.delayTime.value = (3163 * scale) / sr + this.rightApGain1 = audioContext.createGain() + this.rightApGain2 = audioContext.createGain() + this.rightApGain1.gain.value = 0.7 + this.rightApGain2.gain.value = 0.7 + + this.leftOut = audioContext.createGain() + this.rightOut = audioContext.createGain() + this.merger = audioContext.createChannelMerger(2) + + this.buildGraph() + this.updateDecayAndDamping() + this.inputNode.connect(this.dryNode) - this.inputNode.connect(this.convolverNode) - this.convolverNode.connect(this.wetNode) this.dryNode.connect(this.mixNode) this.wetNode.connect(this.mixNode) this.mixNode.connect(this.pannerNode) this.pannerNode.connect(this.outputNode) - - this.convolverNode.buffer = this.createDummyBuffer() - this.generateReverb(this.currentDecay, this.currentDamping) } - private createDummyBuffer(): AudioBuffer { - const buffer = this.audioContext.createBuffer(2, this.audioContext.sampleRate * 0.1, this.audioContext.sampleRate) - for (let i = 0; i < 2; i++) { - const data = buffer.getChannelData(i) - for (let j = 0; j < data.length; j++) { - data[j] = (Math.random() * 2 - 1) * Math.exp(-j / (this.audioContext.sampleRate * 0.05)) - } - } - return buffer + private buildGraph(): void { + const input = this.inputNode + const wet = this.wetNode + + input.connect(this.preDelay) + + const id1In = this.createAllPass(this.preDelay, this.inputDiffusion1, this.inputDiffusionGain1) + const id2In = this.createAllPass(id1In, this.inputDiffusion2, this.inputDiffusionGain2) + const id3In = this.createAllPass(id2In, this.inputDiffusion3, this.inputDiffusionGain3) + const id4Out = this.createAllPass(id3In, this.inputDiffusion4, this.inputDiffusionGain4) + + const splitter = this.audioContext.createGain() + id4Out.connect(splitter) + + const leftIn = this.audioContext.createGain() + const rightIn = this.audioContext.createGain() + splitter.connect(leftIn) + splitter.connect(rightIn) + + leftIn.connect(this.leftDelay1) + this.leftDelay1.connect(this.leftFilter) + this.leftFilter.connect(this.leftDelay2) + + const leftAp1Out = this.createAllPass(this.leftDelay2, this.leftAp1, this.leftApGain1) + const leftAp2Out = this.createAllPass(leftAp1Out, this.leftAp2, this.leftApGain2) + + leftAp2Out.connect(this.leftGain) + this.leftGain.connect(rightIn) + + leftAp2Out.connect(this.leftOut) + this.leftOut.connect(this.merger, 0, 0) + + rightIn.connect(this.rightDelay1) + this.rightDelay1.connect(this.rightFilter) + this.rightFilter.connect(this.rightDelay2) + + const rightAp1Out = this.createAllPass(this.rightDelay2, this.rightAp1, this.rightApGain1) + const rightAp2Out = this.createAllPass(rightAp1Out, this.rightAp2, this.rightApGain2) + + rightAp2Out.connect(this.rightGain) + this.rightGain.connect(leftIn) + + rightAp2Out.connect(this.rightOut) + this.rightOut.connect(this.merger, 0, 1) + + this.merger.connect(wet) } - private generateReverb(decayTime: number, damping: number): void { - const sampleRate = this.audioContext.sampleRate - const numChannels = 2 - const totalTime = decayTime * 1.5 - const decaySampleFrames = Math.round(decayTime * sampleRate) - const numSampleFrames = Math.round(totalTime * sampleRate) - const fadeInTime = 0.05 - const fadeInSampleFrames = Math.round(fadeInTime * sampleRate) - const decayBase = Math.pow(1 / 1000, 1 / decaySampleFrames) + private createAllPass(input: AudioNode, delay: DelayNode, gain: GainNode): AudioNode { + const output = this.audioContext.createGain() + const feedbackGain = this.audioContext.createGain() + feedbackGain.gain.value = -1 - const reverbIR = this.audioContext.createBuffer(numChannels, numSampleFrames, sampleRate) + input.connect(delay) + input.connect(feedbackGain) + feedbackGain.connect(output) - for (let i = 0; i < numChannels; i++) { - const chan = reverbIR.getChannelData(i) - for (let j = 0; j < numSampleFrames; j++) { - chan[j] = (Math.random() * 2 - 1) * Math.pow(decayBase, j) - } - for (let j = 0; j < fadeInSampleFrames; j++) { - chan[j] *= j / fadeInSampleFrames - } - } + delay.connect(gain) + gain.connect(output) + gain.connect(input) - const lpFreqStart = 10000 - const lpFreqEnd = 200 + (damping / 100) * 7800 - - this.applyGradualLowpass(reverbIR, lpFreqStart, lpFreqEnd, decayTime, (buffer) => { - this.convolverNode.buffer = buffer - }) + return output } - private applyGradualLowpass( - input: AudioBuffer, - lpFreqStart: number, - lpFreqEnd: number, - lpFreqEndAt: number, - callback: (buffer: AudioBuffer) => void - ): void { - if (lpFreqStart === 0) { - callback(input) - return - } + private updateDecayAndDamping(): void { + const decay = this.currentDecay + const damping = this.currentDamping - const context = new OfflineAudioContext( - input.numberOfChannels, - input.length, - input.sampleRate - ) + this.leftGain.gain.setTargetAtTime(decay, this.audioContext.currentTime, 0.01) + this.rightGain.gain.setTargetAtTime(decay, this.audioContext.currentTime, 0.01) - const player = context.createBufferSource() - player.buffer = input - - const filter = context.createBiquadFilter() - lpFreqStart = Math.min(lpFreqStart, input.sampleRate / 2) - lpFreqEnd = Math.min(lpFreqEnd, input.sampleRate / 2) - - filter.type = 'lowpass' - filter.Q.value = 0.0001 - filter.frequency.setValueAtTime(lpFreqStart, 0) - filter.frequency.linearRampToValueAtTime(lpFreqEnd, lpFreqEndAt) - - player.connect(filter) - filter.connect(context.destination) - player.start() - - context.oncomplete = (event) => { - callback(event.renderedBuffer) - } - - context.startRendering() + const cutoff = 1000 + damping * 14000 + this.leftFilter.frequency.setTargetAtTime(cutoff, this.audioContext.currentTime, 0.01) + this.rightFilter.frequency.setTargetAtTime(cutoff, this.audioContext.currentTime, 0.01) } getInputNode(): AudioNode { @@ -154,16 +243,16 @@ export class ReverbEffect implements Effect { } updateParams(values: Record): void { - let needsRegeneration = false + let needsUpdate = false - if (values.reverbDecay !== undefined && typeof values.reverbDecay === 'number' && values.reverbDecay !== this.currentDecay) { - this.currentDecay = values.reverbDecay - needsRegeneration = true + if (values.reverbDecay !== undefined && typeof values.reverbDecay === 'number') { + this.currentDecay = values.reverbDecay / 100 + needsUpdate = true } - if (values.reverbDamping !== undefined && typeof values.reverbDamping === 'number' && values.reverbDamping !== this.currentDamping) { - this.currentDamping = values.reverbDamping - needsRegeneration = true + if (values.reverbDamping !== undefined && typeof values.reverbDamping === 'number') { + this.currentDamping = values.reverbDamping / 100 + needsUpdate = true } if (values.reverbWetDry !== undefined && typeof values.reverbWetDry === 'number') { @@ -195,8 +284,8 @@ export class ReverbEffect implements Effect { ) } - if (needsRegeneration) { - this.generateReverb(this.currentDecay, this.currentDamping) + if (needsUpdate) { + this.updateDecayAndDamping() } } @@ -207,9 +296,26 @@ export class ReverbEffect implements Effect { this.inputNode.disconnect() this.outputNode.disconnect() this.mixNode.disconnect() - this.convolverNode.disconnect() this.wetNode.disconnect() this.dryNode.disconnect() this.pannerNode.disconnect() + this.preDelay.disconnect() + this.inputDiffusion1.disconnect() + this.inputDiffusion2.disconnect() + this.inputDiffusion3.disconnect() + this.inputDiffusion4.disconnect() + this.leftDelay1.disconnect() + this.leftDelay2.disconnect() + this.leftFilter.disconnect() + this.leftGain.disconnect() + this.leftAp1.disconnect() + this.leftAp2.disconnect() + this.rightDelay1.disconnect() + this.rightDelay2.disconnect() + this.rightFilter.disconnect() + this.rightGain.disconnect() + this.rightAp1.disconnect() + this.rightAp2.disconnect() + this.merger.disconnect() } }