This commit is contained in:
2025-09-30 21:19:43 +02:00
parent d867f12fcd
commit 21c983b41e
7 changed files with 339 additions and 31 deletions

View File

@ -1,5 +1,5 @@
{
"name": "bytesample",
"name": "bruitiste",
"private": true,
"version": "0.0.0",
"type": "module",

View File

@ -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) => {

View File

@ -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: '%'
}
]
}

View File

@ -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

View File

@ -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<string, number>): 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()
}
}
}

View File

@ -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<string, number>): 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()
}
}
}

View File

@ -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)