diff --git a/package.json b/package.json index 0c5a6f44..1c642a4a 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "bytesample", + "name": "bruitiste", "private": true, "version": "0.0.0", "type": "module", diff --git a/src/App.tsx b/src/App.tsx index 4f6889a0..6143cf41 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -45,6 +45,7 @@ function App() { playbackManagerRef.current.stop() playbackManagerRef.current.setEffects(effectValues) + playbackManagerRef.current.setPitch(engineValues.pitch ?? 1) const success = playbackManagerRef.current.play(formula, sampleRate, duration) @@ -112,6 +113,10 @@ function App() { if (parameterId === 'masterVolume' && playbackManagerRef.current) { playbackManagerRef.current.setEffects(effectValues) } + + if (parameterId === 'pitch' && playbackManagerRef.current) { + playbackManagerRef.current.setPitch(value) + } } const handleEffectChange = (parameterId: string, value: number | boolean) => { diff --git a/src/config/effects.ts b/src/config/effects.ts index 1f14a522..29cbc4bc 100644 --- a/src/config/effects.ts +++ b/src/config/effects.ts @@ -49,6 +49,15 @@ export const ENGINE_CONTROLS: EffectConfig[] = [ default: 75, step: 1, unit: '%' + }, + { + id: 'pitch', + label: 'Pitch', + min: 0.1, + max: 4, + default: 1, + step: 0.01, + unit: 'x' } ] } @@ -113,8 +122,8 @@ export const EFFECTS: EffectConfig[] = [ { id: 'delayTime', label: 'Time', - min: 0, - max: 1000, + min: 10, + max: 2000, default: 250, step: 10, unit: 'ms' @@ -127,6 +136,42 @@ export const EFFECTS: EffectConfig[] = [ default: 50, step: 1, unit: '%' + }, + { + id: 'delayWetDry', + label: 'Mix', + min: 0, + max: 100, + default: 50, + step: 1, + unit: '%' + }, + { + id: 'delayTone', + label: 'Tone', + min: 0, + max: 100, + default: 70, + step: 1, + unit: '%' + }, + { + id: 'delaySaturation', + label: 'Saturation', + min: 0, + max: 100, + default: 20, + step: 1, + unit: '%' + }, + { + id: 'delayFlutter', + label: 'Flutter', + min: 0, + max: 100, + default: 15, + step: 1, + unit: '%' } ] }, @@ -143,6 +188,42 @@ export const EFFECTS: EffectConfig[] = [ default: 0, step: 1, unit: '%' + }, + { + id: 'reverbDecay', + label: 'Decay', + min: 0.1, + max: 5, + default: 2, + step: 0.1, + unit: 's' + }, + { + id: 'reverbDamping', + label: 'Damping', + min: 0, + max: 100, + default: 50, + step: 1, + unit: '%' + }, + { + id: 'reverbPanRate', + label: 'Pan Rate', + min: 0, + max: 10, + default: 0, + step: 0.1, + unit: 'Hz' + }, + { + id: 'reverbPanWidth', + label: 'Pan Width', + min: 0, + max: 100, + default: 50, + step: 1, + unit: '%' } ] } diff --git a/src/domain/audio/AudioPlayer.ts b/src/domain/audio/AudioPlayer.ts index f84364f9..0de15bad 100644 --- a/src/domain/audio/AudioPlayer.ts +++ b/src/domain/audio/AudioPlayer.ts @@ -16,6 +16,7 @@ export class AudioPlayer { private isLooping: boolean = true private sampleRate: number private duration: number + private pitch: number = 1 constructor(options: AudioPlayerOptions) { this.sampleRate = options.sampleRate @@ -38,6 +39,17 @@ export class AudioPlayer { } } + setPitch(pitch: number): void { + this.pitch = pitch + if (this.sourceNode && this.audioContext) { + this.sourceNode.playbackRate.setTargetAtTime( + pitch, + this.audioContext.currentTime, + 0.015 + ) + } + } + play(buffer: Float32Array, onEnded?: () => void): void { if (!this.audioContext) { this.audioContext = new AudioContext({ sampleRate: this.sampleRate }) @@ -58,6 +70,7 @@ export class AudioPlayer { this.sourceNode = this.audioContext.createBufferSource() this.sourceNode.buffer = audioBuffer this.sourceNode.loop = this.isLooping + this.sourceNode.playbackRate.value = this.pitch if (onEnded) { this.sourceNode.onended = onEnded diff --git a/src/domain/audio/effects/DelayEffect.ts b/src/domain/audio/effects/DelayEffect.ts index b3d146ce..368e22b5 100644 --- a/src/domain/audio/effects/DelayEffect.ts +++ b/src/domain/audio/effects/DelayEffect.ts @@ -3,37 +3,76 @@ import type { Effect } from './Effect.interface' export class DelayEffect implements Effect { readonly id = 'delay' + private audioContext: AudioContext private inputNode: GainNode private outputNode: GainNode private delayNode: DelayNode private feedbackNode: GainNode private wetNode: GainNode private dryNode: GainNode + private filterNode: BiquadFilterNode + private saturatorNode: WaveShaperNode + private lfoNode: OscillatorNode + private lfoGainNode: GainNode private bypassed: boolean = false - private currentWetValue: number = 0 - private currentDryValue: number = 1 + private currentWetValue: number = 0.5 + private currentDryValue: number = 0.5 constructor(audioContext: AudioContext) { + this.audioContext = audioContext this.inputNode = audioContext.createGain() this.outputNode = audioContext.createGain() this.delayNode = audioContext.createDelay(2.0) this.feedbackNode = audioContext.createGain() this.wetNode = audioContext.createGain() this.dryNode = audioContext.createGain() + this.filterNode = audioContext.createBiquadFilter() + this.saturatorNode = audioContext.createWaveShaper() - this.dryNode.gain.value = 1 - this.wetNode.gain.value = 0 + this.delayNode.delayTime.value = 0.25 + this.dryNode.gain.value = 0.5 + this.wetNode.gain.value = 0.5 this.feedbackNode.gain.value = 0.5 + this.filterNode.type = 'lowpass' + this.filterNode.frequency.value = 5000 + this.filterNode.Q.value = 0.7 + + this.createSaturationCurve(0.2) + + this.lfoNode = audioContext.createOscillator() + this.lfoGainNode = audioContext.createGain() + this.lfoNode.frequency.value = 2.5 + this.lfoGainNode.gain.value = 0.0015 + this.lfoNode.connect(this.lfoGainNode) + this.lfoGainNode.connect(this.delayNode.delayTime) + this.lfoNode.start() + this.inputNode.connect(this.dryNode) this.inputNode.connect(this.delayNode) - this.delayNode.connect(this.feedbackNode) + this.delayNode.connect(this.saturatorNode) + this.saturatorNode.connect(this.filterNode) + this.filterNode.connect(this.feedbackNode) this.feedbackNode.connect(this.delayNode) this.delayNode.connect(this.wetNode) this.dryNode.connect(this.outputNode) this.wetNode.connect(this.outputNode) } + private createSaturationCurve(amount: number): void { + const samples = 2048 + const curve = new Float32Array(samples) + const drive = 1 + amount * 9 + + for (let i = 0; i < samples; i++) { + const x = (i * 2) / samples - 1 + curve[i] = Math.tanh(x * drive) / Math.tanh(drive) + } + + this.saturatorNode.curve = curve + this.saturatorNode.oversample = '2x' + } + getInputNode(): AudioNode { return this.inputNode } @@ -55,28 +94,80 @@ export class DelayEffect implements Effect { updateParams(values: Record): void { if (values.delayTime !== undefined) { - this.delayNode.delayTime.value = values.delayTime / 1000 - const delayAmount = Math.min(values.delayTime / 1000, 0.5) - this.currentWetValue = delayAmount - this.currentDryValue = 1 - delayAmount - - if (!this.bypassed) { - this.wetNode.gain.value = this.currentWetValue - this.dryNode.gain.value = this.currentDryValue - } + const time = values.delayTime / 1000 + this.delayNode.delayTime.setTargetAtTime( + time, + this.audioContext.currentTime, + 0.01 + ) } if (values.delayFeedback !== undefined) { - this.feedbackNode.gain.value = values.delayFeedback / 100 + const feedback = values.delayFeedback / 100 + this.feedbackNode.gain.setTargetAtTime( + feedback * 0.95, + this.audioContext.currentTime, + 0.01 + ) + } + + if (values.delayWetDry !== undefined) { + const wet = values.delayWetDry / 100 + this.currentWetValue = wet + this.currentDryValue = 1 - wet + + if (!this.bypassed) { + this.wetNode.gain.setTargetAtTime( + this.currentWetValue, + this.audioContext.currentTime, + 0.01 + ) + this.dryNode.gain.setTargetAtTime( + this.currentDryValue, + this.audioContext.currentTime, + 0.01 + ) + } + } + + if (values.delayTone !== undefined) { + const tone = values.delayTone / 100 + const freq = 200 + tone * 7800 + this.filterNode.frequency.setTargetAtTime( + freq, + this.audioContext.currentTime, + 0.01 + ) + } + + if (values.delaySaturation !== undefined) { + const saturation = values.delaySaturation / 100 + this.createSaturationCurve(saturation) + } + + if (values.delayFlutter !== undefined) { + const flutter = values.delayFlutter / 100 + const baseDelay = this.delayNode.delayTime.value + const modDepth = baseDelay * flutter * 0.1 + this.lfoGainNode.gain.setTargetAtTime( + modDepth, + this.audioContext.currentTime, + 0.01 + ) } } dispose(): void { + this.lfoNode.stop() + this.lfoNode.disconnect() + this.lfoGainNode.disconnect() this.inputNode.disconnect() this.outputNode.disconnect() this.delayNode.disconnect() this.feedbackNode.disconnect() this.wetNode.disconnect() this.dryNode.disconnect() + this.filterNode.disconnect() + this.saturatorNode.disconnect() } -} \ No newline at end of file +} diff --git a/src/domain/audio/effects/ReverbEffect.ts b/src/domain/audio/effects/ReverbEffect.ts index 0a358138..b3537687 100644 --- a/src/domain/audio/effects/ReverbEffect.ts +++ b/src/domain/audio/effects/ReverbEffect.ts @@ -6,45 +6,120 @@ export class ReverbEffect implements Effect { private audioContext: AudioContext private inputNode: GainNode private outputNode: GainNode + private finalOutputNode: GainNode private convolverNode: ConvolverNode private wetNode: GainNode private dryNode: GainNode + private pannerNode: StereoPannerNode + private panLfoNode: OscillatorNode + private panLfoGainNode: GainNode private bypassed: boolean = false private currentWetValue: number = 0 private currentDryValue: number = 1 + private currentDecay: number = 2 + private currentDamping: number = 50 constructor(audioContext: AudioContext) { this.audioContext = audioContext this.inputNode = audioContext.createGain() this.outputNode = audioContext.createGain() + this.finalOutputNode = audioContext.createGain() this.convolverNode = audioContext.createConvolver() this.wetNode = audioContext.createGain() this.dryNode = audioContext.createGain() + this.pannerNode = audioContext.createStereoPanner() + this.panLfoNode = audioContext.createOscillator() + this.panLfoGainNode = audioContext.createGain() this.wetNode.gain.value = 0 this.dryNode.gain.value = 1 + this.panLfoNode.frequency.value = 0 + this.panLfoGainNode.gain.value = 0 + this.panLfoNode.connect(this.panLfoGainNode) + this.panLfoGainNode.connect(this.pannerNode.pan) + this.panLfoNode.start() + this.inputNode.connect(this.dryNode) this.inputNode.connect(this.convolverNode) this.convolverNode.connect(this.wetNode) this.dryNode.connect(this.outputNode) this.wetNode.connect(this.outputNode) + this.outputNode.connect(this.pannerNode) + this.pannerNode.connect(this.finalOutputNode) - this.generateImpulseResponse() + this.generateReverb(this.currentDecay, this.currentDamping) } - private generateImpulseResponse(): void { - const length = this.audioContext.sampleRate * 2 - const impulse = this.audioContext.createBuffer(2, length, this.audioContext.sampleRate) - const left = impulse.getChannelData(0) - const right = impulse.getChannelData(1) + 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) - for (let i = 0; i < length; i++) { - left[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / length, 2) - right[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / length, 2) + const reverbIR = this.audioContext.createBuffer(numChannels, numSampleFrames, sampleRate) + + 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 + } } - this.convolverNode.buffer = impulse + const lpFreqStart = 10000 + const lpFreqEnd = 200 + (damping / 100) * 7800 + + this.applyGradualLowpass(reverbIR, lpFreqStart, lpFreqEnd, decayTime, (buffer) => { + this.convolverNode.buffer = buffer + }) + } + + private applyGradualLowpass( + input: AudioBuffer, + lpFreqStart: number, + lpFreqEnd: number, + lpFreqEndAt: number, + callback: (buffer: AudioBuffer) => void + ): void { + if (lpFreqStart === 0) { + callback(input) + return + } + + const context = new OfflineAudioContext( + input.numberOfChannels, + input.length, + input.sampleRate + ) + + 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() } getInputNode(): AudioNode { @@ -52,7 +127,7 @@ export class ReverbEffect implements Effect { } getOutputNode(): AudioNode { - return this.outputNode + return this.finalOutputNode } setBypass(bypass: boolean): void { @@ -67,6 +142,18 @@ export class ReverbEffect implements Effect { } updateParams(values: Record): void { + let needsRegeneration = false + + if (values.reverbDecay !== undefined && values.reverbDecay !== this.currentDecay) { + this.currentDecay = values.reverbDecay + needsRegeneration = true + } + + if (values.reverbDamping !== undefined && values.reverbDamping !== this.currentDamping) { + this.currentDamping = values.reverbDamping + needsRegeneration = true + } + if (values.reverbWetDry !== undefined) { const wet = values.reverbWetDry / 100 this.currentWetValue = wet @@ -77,13 +164,40 @@ export class ReverbEffect implements Effect { this.dryNode.gain.value = this.currentDryValue } } + + if (values.reverbPanRate !== undefined) { + const rate = values.reverbPanRate + this.panLfoNode.frequency.setTargetAtTime( + rate, + this.audioContext.currentTime, + 0.01 + ) + } + + if (values.reverbPanWidth !== undefined) { + const width = values.reverbPanWidth / 100 + this.panLfoGainNode.gain.setTargetAtTime( + width, + this.audioContext.currentTime, + 0.01 + ) + } + + if (needsRegeneration) { + this.generateReverb(this.currentDecay, this.currentDamping) + } } dispose(): void { + this.panLfoNode.stop() + this.panLfoNode.disconnect() + this.panLfoGainNode.disconnect() this.inputNode.disconnect() this.outputNode.disconnect() + this.finalOutputNode.disconnect() this.convolverNode.disconnect() this.wetNode.disconnect() this.dryNode.disconnect() + this.pannerNode.disconnect() } -} \ No newline at end of file +} diff --git a/src/services/PlaybackManager.ts b/src/services/PlaybackManager.ts index d5681fdb..e7e0506e 100644 --- a/src/services/PlaybackManager.ts +++ b/src/services/PlaybackManager.ts @@ -27,6 +27,10 @@ export class PlaybackManager { this.player.setEffects(values) } + setPitch(pitch: number): void { + this.player.setPitch(pitch) + } + play(formula: string, sampleRate: number, duration: number): boolean { const result = compileFormula(formula)