diff --git a/src/domain/audio/effects/ReverbEffect.ts b/src/domain/audio/effects/ReverbEffect.ts index b34a2afc..b1f29b74 100644 --- a/src/domain/audio/effects/ReverbEffect.ts +++ b/src/domain/audio/effects/ReverbEffect.ts @@ -18,42 +18,33 @@ export class ReverbEffect implements Effect { 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 earlyReflectionsNode: GainNode + private earlyReflectionDelays: DelayNode[] = [] + private earlyReflectionGains: GainNode[] = [] + private earlyReflectionFilters: BiquadFilterNode[] = [] - 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 lowBandSplitter: BiquadFilterNode + private midBandLowPass: BiquadFilterNode + private midBandHighPass: BiquadFilterNode + private highBandSplitter: BiquadFilterNode - 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 lowBandProcessor: BandProcessor + private midBandProcessor: BandProcessor + private highBandProcessor: BandProcessor - private leftOut: GainNode - private rightOut: GainNode - private merger: ChannelMergerNode + private lowEnvFollower: DynamicsCompressorNode + private midEnvFollower: DynamicsCompressorNode + private highEnvFollower: DynamicsCompressorNode + + private lowToHighModGain: GainNode + private highToLowModGain: GainNode + private midToGlobalModGain: GainNode + + private bandMixer: GainNode constructor(audioContext: AudioContext) { this.audioContext = audioContext const sr = audioContext.sampleRate - const scale = sr / 29761 this.inputNode = audioContext.createGain() this.outputNode = audioContext.createGain() @@ -73,69 +64,59 @@ 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 + this.earlyReflectionsNode = audioContext.createGain() + this.buildEarlyReflections(sr) - 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.lowBandSplitter = audioContext.createBiquadFilter() + this.lowBandSplitter.type = 'lowpass' + this.lowBandSplitter.frequency.value = 250 + this.lowBandSplitter.Q.value = 0.707 - 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.midBandHighPass = audioContext.createBiquadFilter() + this.midBandHighPass.type = 'highpass' + this.midBandHighPass.frequency.value = 250 + this.midBandHighPass.Q.value = 0.707 - 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.midBandLowPass = audioContext.createBiquadFilter() + this.midBandLowPass.type = 'lowpass' + this.midBandLowPass.frequency.value = 2500 + this.midBandLowPass.Q.value = 0.707 - 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.highBandSplitter = audioContext.createBiquadFilter() + this.highBandSplitter.type = 'highpass' + this.highBandSplitter.frequency.value = 2500 + this.highBandSplitter.Q.value = 0.707 - 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.lowBandProcessor = new BandProcessor(audioContext, 'low', sr) + this.midBandProcessor = new BandProcessor(audioContext, 'mid', sr) + this.highBandProcessor = new BandProcessor(audioContext, 'high', sr) - 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.lowEnvFollower = audioContext.createDynamicsCompressor() + this.lowEnvFollower.threshold.value = -50 + this.lowEnvFollower.knee.value = 40 + this.lowEnvFollower.ratio.value = 12 + this.lowEnvFollower.attack.value = 0.003 + this.lowEnvFollower.release.value = 0.25 - this.leftOut = audioContext.createGain() - this.rightOut = audioContext.createGain() - this.merger = audioContext.createChannelMerger(2) + this.midEnvFollower = audioContext.createDynamicsCompressor() + this.midEnvFollower.threshold.value = -50 + this.midEnvFollower.knee.value = 40 + this.midEnvFollower.ratio.value = 12 + this.midEnvFollower.attack.value = 0.003 + this.midEnvFollower.release.value = 0.25 + + this.highEnvFollower = audioContext.createDynamicsCompressor() + this.highEnvFollower.threshold.value = -50 + this.highEnvFollower.knee.value = 40 + this.highEnvFollower.ratio.value = 12 + this.highEnvFollower.attack.value = 0.001 + this.highEnvFollower.release.value = 0.1 + + this.lowToHighModGain = audioContext.createGain() + this.highToLowModGain = audioContext.createGain() + this.midToGlobalModGain = audioContext.createGain() + + this.bandMixer = audioContext.createGain() this.buildGraph() this.updateDecayAndDamping() @@ -147,80 +128,82 @@ export class ReverbEffect implements Effect { this.pannerNode.connect(this.outputNode) } - private buildGraph(): void { - const input = this.inputNode - const wet = this.wetNode + private buildEarlyReflections(sr: number): void { + const primes = [17, 29, 41, 59, 71, 97, 113, 127] + const scale = sr / 48000 - input.connect(this.preDelay) + for (let i = 0; i < primes.length; i++) { + const delay = this.audioContext.createDelay(0.2) + delay.delayTime.value = (primes[i] * scale) / 1000 - 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 gain = this.audioContext.createGain() + gain.gain.value = 0.7 * Math.pow(0.85, i) - const splitter = this.audioContext.createGain() - id4Out.connect(splitter) + const filter = this.audioContext.createBiquadFilter() + filter.type = i % 2 === 0 ? 'lowpass' : 'highshelf' + filter.frequency.value = 3000 + i * 500 + filter.gain.value = -2 * i - 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) + this.earlyReflectionDelays.push(delay) + this.earlyReflectionGains.push(gain) + this.earlyReflectionFilters.push(filter) + } } - private createAllPass(input: AudioNode, delay: DelayNode, gain: GainNode): AudioNode { - const output = this.audioContext.createGain() - const feedbackGain = this.audioContext.createGain() - feedbackGain.gain.value = -1 + private buildGraph(): void { + this.inputNode.connect(this.earlyReflectionsNode) - input.connect(delay) - input.connect(feedbackGain) - feedbackGain.connect(output) + for (let i = 0; i < this.earlyReflectionDelays.length; i++) { + this.earlyReflectionsNode.connect(this.earlyReflectionDelays[i]) + this.earlyReflectionDelays[i].connect(this.earlyReflectionFilters[i]) + this.earlyReflectionFilters[i].connect(this.earlyReflectionGains[i]) + this.earlyReflectionGains[i].connect(this.wetNode) + } - delay.connect(gain) - gain.connect(output) - gain.connect(input) + this.earlyReflectionsNode.connect(this.lowBandSplitter) + this.earlyReflectionsNode.connect(this.midBandHighPass) + this.earlyReflectionsNode.connect(this.highBandSplitter) - return output + this.midBandHighPass.connect(this.midBandLowPass) + + this.lowBandSplitter.connect(this.lowBandProcessor.getInputNode()) + this.midBandLowPass.connect(this.midBandProcessor.getInputNode()) + this.highBandSplitter.connect(this.highBandProcessor.getInputNode()) + + this.lowBandProcessor.getOutputNode().connect(this.lowEnvFollower) + this.midBandProcessor.getOutputNode().connect(this.midEnvFollower) + this.highBandProcessor.getOutputNode().connect(this.highEnvFollower) + + this.lowEnvFollower.connect(this.lowToHighModGain) + this.highEnvFollower.connect(this.highToLowModGain) + this.midEnvFollower.connect(this.midToGlobalModGain) + + this.lowToHighModGain.connect(this.highBandProcessor.getModulationTarget()) + this.highToLowModGain.connect(this.lowBandProcessor.getModulationTarget()) + + this.lowBandProcessor.getOutputNode().connect(this.bandMixer) + this.midBandProcessor.getOutputNode().connect(this.bandMixer) + this.highBandProcessor.getOutputNode().connect(this.bandMixer) + + this.bandMixer.connect(this.wetNode) } private updateDecayAndDamping(): void { const decay = this.currentDecay const damping = this.currentDamping - this.leftGain.gain.setTargetAtTime(decay, this.audioContext.currentTime, 0.01) - this.rightGain.gain.setTargetAtTime(decay, this.audioContext.currentTime, 0.01) + this.lowBandProcessor.setDecay(decay * 1.2) + this.midBandProcessor.setDecay(decay) + this.highBandProcessor.setDecay(decay * 0.6) - 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) + this.lowBandProcessor.setDamping(damping * 0.5) + this.midBandProcessor.setDamping(damping) + this.highBandProcessor.setDamping(damping * 1.5) + + const modAmount = 0.3 + this.lowToHighModGain.gain.value = modAmount + this.highToLowModGain.gain.value = modAmount * 0.7 + this.midToGlobalModGain.gain.value = modAmount * 0.5 } getInputNode(): AudioNode { @@ -299,23 +282,270 @@ export class ReverbEffect implements Effect { 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() + this.earlyReflectionsNode.disconnect() + + this.earlyReflectionDelays.forEach(d => d.disconnect()) + this.earlyReflectionGains.forEach(g => g.disconnect()) + this.earlyReflectionFilters.forEach(f => f.disconnect()) + + this.lowBandSplitter.disconnect() + this.midBandHighPass.disconnect() + this.midBandLowPass.disconnect() + this.highBandSplitter.disconnect() + + this.lowBandProcessor.dispose() + this.midBandProcessor.dispose() + this.highBandProcessor.dispose() + + this.lowEnvFollower.disconnect() + this.midEnvFollower.disconnect() + this.highEnvFollower.disconnect() + + this.lowToHighModGain.disconnect() + this.highToLowModGain.disconnect() + this.midToGlobalModGain.disconnect() + + this.bandMixer.disconnect() + } +} + +class BandProcessor { + private audioContext: AudioContext + private bandType: 'low' | 'mid' | 'high' + + private inputNode: GainNode + private outputNode: GainNode + private modulationTarget: GainNode + + private delay1: DelayNode + private delay2: DelayNode + private allpass1: DelayNode + private allpass2: DelayNode + + private ap1Gain: GainNode + private ap2Gain: GainNode + + private filter1: BiquadFilterNode + private filter2: BiquadFilterNode + private filter3: BiquadFilterNode + + private feedbackGain: GainNode + private saturation: WaveShaperNode + + private feedbackMixer: GainNode + + constructor(audioContext: AudioContext, bandType: 'low' | 'mid' | 'high', sr: number) { + this.audioContext = audioContext + this.bandType = bandType + + this.inputNode = audioContext.createGain() + this.outputNode = audioContext.createGain() + this.modulationTarget = audioContext.createGain() + this.modulationTarget.gain.value = 0 + + const scale = sr / 48000 + const delayTimes = this.getDelayTimes(bandType, scale, sr) + + this.delay1 = audioContext.createDelay(1.0) + this.delay2 = audioContext.createDelay(1.0) + this.delay1.delayTime.value = delayTimes.d1 + this.delay2.delayTime.value = delayTimes.d2 + + this.allpass1 = audioContext.createDelay(0.1) + this.allpass2 = audioContext.createDelay(0.1) + this.allpass1.delayTime.value = delayTimes.ap1 + this.allpass2.delayTime.value = delayTimes.ap2 + + this.ap1Gain = audioContext.createGain() + this.ap2Gain = audioContext.createGain() + this.ap1Gain.gain.value = 0.7 + this.ap2Gain.gain.value = 0.7 + + this.filter1 = audioContext.createBiquadFilter() + this.filter2 = audioContext.createBiquadFilter() + this.filter3 = audioContext.createBiquadFilter() + this.setupFilters(bandType) + + this.feedbackGain = audioContext.createGain() + this.feedbackGain.gain.value = 0.5 + + this.saturation = audioContext.createWaveShaper() + this.saturation.curve = this.createSaturationCurve(bandType) + this.saturation.oversample = '2x' + + this.feedbackMixer = audioContext.createGain() + + this.buildGraph() + } + + private getDelayTimes(bandType: string, scale: number, sr: number) { + const times: Record = { + low: { + d1: (1201 * scale) / sr, + d2: (6171 * scale) / sr, + ap1: (2333 * scale) / sr, + ap2: (4513 * scale) / sr, + }, + mid: { + d1: (907 * scale) / sr, + d2: (4217 * scale) / sr, + ap1: (1801 * scale) / sr, + ap2: (3119 * scale) / sr, + }, + high: { + d1: (503 * scale) / sr, + d2: (2153 * scale) / sr, + ap1: (907 * scale) / sr, + ap2: (1453 * scale) / sr, + }, + } + return times[bandType] + } + + private setupFilters(bandType: string): void { + if (bandType === 'low') { + this.filter1.type = 'lowpass' + this.filter1.frequency.value = 1200 + this.filter1.Q.value = 0.707 + + this.filter2.type = 'lowshelf' + this.filter2.frequency.value = 200 + this.filter2.gain.value = 2 + + this.filter3.type = 'peaking' + this.filter3.frequency.value = 600 + this.filter3.Q.value = 1.0 + this.filter3.gain.value = -3 + } else if (bandType === 'mid') { + this.filter1.type = 'lowpass' + this.filter1.frequency.value = 5000 + this.filter1.Q.value = 0.707 + + this.filter2.type = 'peaking' + this.filter2.frequency.value = 1200 + this.filter2.Q.value = 1.5 + this.filter2.gain.value = -2 + + this.filter3.type = 'highshelf' + this.filter3.frequency.value = 3000 + this.filter3.gain.value = -4 + } else { + this.filter1.type = 'lowpass' + this.filter1.frequency.value = 12000 + this.filter1.Q.value = 0.5 + + this.filter2.type = 'lowpass' + this.filter2.frequency.value = 8000 + this.filter2.Q.value = 0.707 + + this.filter3.type = 'highshelf' + this.filter3.frequency.value = 5000 + this.filter3.gain.value = -6 + } + } + + private createSaturationCurve(bandType: string): Float32Array { + const samples = 4096 + const curve = new Float32Array(samples) + const amount = bandType === 'low' ? 0.8 : bandType === 'mid' ? 0.5 : 0.3 + + for (let i = 0; i < samples; i++) { + const x = (i * 2) / samples - 1 + curve[i] = Math.tanh(x * (1 + amount)) / (1 + amount * 0.5) + } + + return curve + } + + private buildGraph(): void { + this.inputNode.connect(this.delay1) + this.delay1.connect(this.filter1) + this.filter1.connect(this.filter2) + this.filter2.connect(this.filter3) + this.filter3.connect(this.delay2) + + const ap1Out = this.createAllPass(this.delay2, this.allpass1, this.ap1Gain) + const ap2Out = this.createAllPass(ap1Out, this.allpass2, this.ap2Gain) + + ap2Out.connect(this.feedbackGain) + this.feedbackGain.connect(this.saturation) + this.saturation.connect(this.feedbackMixer) + + this.modulationTarget.connect(this.feedbackMixer) + + this.feedbackMixer.connect(this.inputNode) + + ap2Out.connect(this.outputNode) + } + + private createAllPass(input: AudioNode, delay: DelayNode, gain: GainNode): AudioNode { + const output = this.audioContext.createGain() + const feedbackGain = this.audioContext.createGain() + feedbackGain.gain.value = -1 + + input.connect(delay) + input.connect(feedbackGain) + feedbackGain.connect(output) + + delay.connect(gain) + gain.connect(output) + gain.connect(input) + + return output + } + + getInputNode(): AudioNode { + return this.inputNode + } + + getOutputNode(): AudioNode { + return this.outputNode + } + + getModulationTarget(): AudioNode { + return this.modulationTarget + } + + setDecay(decay: number): void { + this.feedbackGain.gain.setTargetAtTime( + Math.min(0.95, decay), + this.audioContext.currentTime, + 0.01 + ) + } + + setDamping(damping: number): void { + let cutoff: number + if (this.bandType === 'low') { + cutoff = 500 + damping * 1500 + } else if (this.bandType === 'mid') { + cutoff = 2000 + damping * 6000 + } else { + cutoff = 4000 + damping * 10000 + } + + this.filter1.frequency.setTargetAtTime( + cutoff, + this.audioContext.currentTime, + 0.01 + ) + } + + dispose(): void { + this.inputNode.disconnect() + this.outputNode.disconnect() + this.modulationTarget.disconnect() + this.delay1.disconnect() + this.delay2.disconnect() + this.allpass1.disconnect() + this.allpass2.disconnect() + this.ap1Gain.disconnect() + this.ap2Gain.disconnect() + this.filter1.disconnect() + this.filter2.disconnect() + this.filter3.disconnect() + this.feedbackGain.disconnect() + this.saturation.disconnect() + this.feedbackMixer.disconnect() } }