diff --git a/src/lib/audio/engines/DustNoise.ts b/src/lib/audio/engines/DustNoise.ts new file mode 100644 index 0000000..ed98766 --- /dev/null +++ b/src/lib/audio/engines/DustNoise.ts @@ -0,0 +1,528 @@ +import type { SynthEngine, PitchLock } from './SynthEngine'; + +interface DustNoiseParams { + // Dust density and character + dustDensity: number; + crackleAmount: number; + popDensity: number; + + // Dust particle characteristics + particleDecay: number; + particlePitchRange: number; + particleResonance: number; + + // Background texture + backgroundNoise: number; + noiseColor: number; + noiseFilter: number; + + // Pops and clicks + popIntensity: number; + popPitchRange: number; + clickAmount: number; + + // Dynamics and variation + dynamicRange: number; + irregularity: number; + + // Stereo field + stereoWidth: number; + + // Global envelope + globalAttack: number; + globalDecay: number; +} + +export class DustNoise implements SynthEngine { + getName(): string { + return 'Pond'; + } + + getDescription(): string { + return 'Vinyl dust, crackle, and particle noise generator'; + } + + getType() { + return 'generative' as const; + } + + randomParams(pitchLock?: PitchLock): DustNoiseParams { + const characterBias = Math.random(); + + let dustDensity: number; + let crackleAmount: number; + let popDensity: number; + let backgroundNoise: number; + + if (characterBias < 0.5) { + // Very sparse, minimal particles + dustDensity = 0.01 + Math.random() * 0.08; + crackleAmount = Math.random() * 0.12; + popDensity = 0.01 + Math.random() * 0.05; + backgroundNoise = Math.random() * 0.08; + } else if (characterBias < 0.8) { + // Sparse, clean with occasional pops + dustDensity = 0.1 + Math.random() * 0.15; + crackleAmount = Math.random() * 0.25; + popDensity = 0.06 + Math.random() * 0.1; + backgroundNoise = 0.05 + Math.random() * 0.15; + } else { + // Medium vinyl character (was heavy) + dustDensity = 0.3 + Math.random() * 0.25; + crackleAmount = 0.25 + Math.random() * 0.3; + popDensity = 0.18 + Math.random() * 0.15; + backgroundNoise = 0.15 + Math.random() * 0.25; + } + + const particleDecay = 0.3 + Math.random() * 0.6; + const particlePitchRange = 0.2 + Math.random() * 0.7; + const particleResonance = Math.random() * 0.6; + + const noiseColor = Math.random(); + const noiseFilter = Math.random() * 0.8; + + const popIntensity = 0.3 + Math.random() * 0.6; + const popPitchRange = 0.2 + Math.random() * 0.7; + const clickAmount = Math.random() * 0.7; + + const dynamicRange = 0.3 + Math.random() * 0.6; + const irregularity = Math.random() * 0.7; + + const stereoWidth = Math.random() * 0.8; + + const globalAttack = Math.random() * 0.08; + const globalDecay = 0.3 + Math.random() * 0.5; + + return { + dustDensity, + crackleAmount, + popDensity, + particleDecay, + particlePitchRange, + particleResonance, + backgroundNoise, + noiseColor, + noiseFilter, + popIntensity, + popPitchRange, + clickAmount, + dynamicRange, + irregularity, + stereoWidth, + globalAttack, + globalDecay + }; + } + + mutateParams(params: DustNoiseParams, mutationAmount: number = 0.15, pitchLock?: PitchLock): DustNoiseParams { + const mutate = (value: number, amount: number = 0.15): number => { + return Math.max(0, Math.min(1, value + (Math.random() - 0.5) * amount)); + }; + + return { + dustDensity: mutate(params.dustDensity, 0.2), + crackleAmount: mutate(params.crackleAmount, 0.25), + popDensity: mutate(params.popDensity, 0.2), + particleDecay: mutate(params.particleDecay, 0.2), + particlePitchRange: pitchLock?.enabled ? params.particlePitchRange : mutate(params.particlePitchRange, 0.25), + particleResonance: mutate(params.particleResonance, 0.2), + backgroundNoise: mutate(params.backgroundNoise, 0.2), + noiseColor: mutate(params.noiseColor, 0.25), + noiseFilter: mutate(params.noiseFilter, 0.2), + popIntensity: mutate(params.popIntensity, 0.2), + popPitchRange: pitchLock?.enabled ? params.popPitchRange : mutate(params.popPitchRange, 0.25), + clickAmount: mutate(params.clickAmount, 0.2), + dynamicRange: mutate(params.dynamicRange, 0.2), + irregularity: mutate(params.irregularity, 0.2), + stereoWidth: mutate(params.stereoWidth, 0.2), + globalAttack: mutate(params.globalAttack, 0.15), + globalDecay: mutate(params.globalDecay, 0.2) + }; + } + + generate(params: DustNoiseParams, sampleRate: number, duration: number, pitchLock?: PitchLock): [Float32Array, Float32Array] { + const numSamples = Math.floor(sampleRate * duration); + const left = new Float32Array(numSamples); + const right = new Float32Array(numSamples); + + // Generate dust particles + const avgDustPerSecond = 5 + params.dustDensity * 120; + const totalDust = Math.floor(avgDustPerSecond * duration); + + // Generate pops + const avgPopsPerSecond = 0.5 + params.popDensity * 12; + const totalPops = Math.floor(avgPopsPerSecond * duration); + + // Create dust particles + const dustParticles: Array<{ + startTime: number; + decay: number; + pitch: number; + amplitude: number; + resonance: number; + stereoOffset: number; + }> = []; + + const baseDustPitch = pitchLock?.enabled ? pitchLock.frequency : 800 + params.particlePitchRange * 2000; + const basePopPitch = pitchLock?.enabled ? pitchLock.frequency : 200 + params.popPitchRange * 1000; + + for (let i = 0; i < totalDust; i++) { + const startTime = Math.random() * duration; + const decay = (0.001 + params.particleDecay * 0.02) * (0.5 + Math.random() * 0.5); + const pitchVariation = pitchLock?.enabled ? 0.2 : params.particlePitchRange; + const pitchFreq = baseDustPitch + (Math.random() - 0.5) * pitchVariation * baseDustPitch; + const amplitude = (0.3 + Math.random() * 0.7) * (0.5 + params.dynamicRange * 0.5); + const resonance = params.particleResonance * (0.5 + Math.random() * 0.5); + const stereoOffset = (Math.random() - 0.5) * params.stereoWidth * 0.3; + + dustParticles.push({ + startTime, + decay, + pitch: pitchFreq, + amplitude, + resonance, + stereoOffset + }); + } + + // Create pops + const pops: Array<{ + startTime: number; + intensity: number; + pitch: number; + isClick: boolean; + stereoOffset: number; + }> = []; + + for (let i = 0; i < totalPops; i++) { + const startTime = Math.random() * duration; + const intensity = params.popIntensity * (0.5 + Math.random() * 0.5); + const pitchVariation = pitchLock?.enabled ? 0.2 : params.popPitchRange; + const pitchFreq = basePopPitch + (Math.random() - 0.5) * pitchVariation * basePopPitch; + const isClick = Math.random() < params.clickAmount; + const stereoOffset = (Math.random() - 0.5) * params.stereoWidth * 0.5; + + pops.push({ + startTime, + intensity, + pitch: pitchFreq, + isClick, + stereoOffset + }); + } + + // Sort events by time + dustParticles.sort((a, b) => a.startTime - b.startTime); + pops.sort((a, b) => a.startTime - b.startTime); + + // Noise state + const pinkStateL = new Float32Array(7); + const pinkStateR = new Float32Array(7); + let brownStateL = 0; + let brownStateR = 0; + + // Filter state for background noise + let bgFilterStateL1 = 0; + let bgFilterStateL2 = 0; + let bgFilterStateR1 = 0; + let bgFilterStateR2 = 0; + + // Active particles + let dustIndex = 0; + let popIndex = 0; + const activeDust: Array<{ + particle: typeof dustParticles[0]; + startSample: number; + phase: number; + }> = []; + const activePops: Array<{ + pop: typeof pops[0]; + startSample: number; + phase: number; + }> = []; + + // Crackle state (for vinyl crackle texture) + let cracklePhase = 0; + const crackleFreq = 20 + params.crackleAmount * 80; + + for (let i = 0; i < numSamples; i++) { + const t = i / sampleRate; + + // Add new dust particles + while (dustIndex < dustParticles.length && dustParticles[dustIndex].startTime <= t) { + activeDust.push({ + particle: dustParticles[dustIndex], + startSample: i, + phase: Math.random() * Math.PI * 2 + }); + dustIndex++; + } + + // Add new pops + while (popIndex < pops.length && pops[popIndex].startTime <= t) { + activePops.push({ + pop: pops[popIndex], + startSample: i, + phase: Math.random() * Math.PI * 2 + }); + popIndex++; + } + + // Global envelope + const globalEnv = this.globalEnvelope( + i, + numSamples, + params.globalAttack, + params.globalDecay, + duration, + sampleRate + ); + + // Background noise + const whiteL = Math.random() * 2 - 1; + const whiteR = Math.random() * 2 - 1; + + brownStateL = this.updateBrownState(brownStateL, whiteL); + brownStateR = this.updateBrownState(brownStateR, whiteR); + + let bgNoiseL = this.selectNoiseColor(params.noiseColor, whiteL, pinkStateL, brownStateL); + let bgNoiseR = this.selectNoiseColor(params.noiseColor, whiteR, pinkStateR, brownStateR); + + // Filter background noise + if (params.noiseFilter > 0.1) { + const filterFreq = 500 + params.noiseFilter * 3000; + const filtered = this.stateVariableFilter( + bgNoiseL, + filterFreq, + 1, + sampleRate, + bgFilterStateL1, + bgFilterStateL2 + ); + bgFilterStateL1 = filtered.state1; + bgFilterStateL2 = filtered.state2; + bgNoiseL = filtered.output; + + const filteredR = this.stateVariableFilter( + bgNoiseR, + filterFreq, + 1, + sampleRate, + bgFilterStateR1, + bgFilterStateR2 + ); + bgFilterStateR1 = filteredR.state1; + bgFilterStateR2 = filteredR.state2; + bgNoiseR = filteredR.output; + } + + // Crackle modulation + cracklePhase += (2 * Math.PI * crackleFreq) / sampleRate; + const crackleMod = Math.sin(cracklePhase) * 0.5 + 0.5; + const crackleEnv = Math.pow(crackleMod, 3) * params.crackleAmount; + + bgNoiseL *= params.backgroundNoise * (1 + crackleEnv); + bgNoiseR *= params.backgroundNoise * (1 + crackleEnv); + + // Render dust particles + let dustL = 0; + let dustR = 0; + + for (let d = activeDust.length - 1; d >= 0; d--) { + const active = activeDust[d]; + const particle = active.particle; + const elapsed = (i - active.startSample) / sampleRate; + + if (elapsed > particle.decay * 5) { + activeDust.splice(d, 1); + continue; + } + + const env = Math.exp(-elapsed / particle.decay); + const phaseInc = (2 * Math.PI * particle.pitch) / sampleRate; + active.phase += phaseInc; + + let signal = Math.sin(active.phase); + + // Add resonance (filter-like character) + if (particle.resonance > 0.1) { + signal = signal * (1 - particle.resonance) + + Math.sin(active.phase * 2) * particle.resonance * 0.3 + + Math.sin(active.phase * 3) * particle.resonance * 0.15; + } + + const output = signal * env * particle.amplitude; + + const panL = 0.5 - particle.stereoOffset; + const panR = 0.5 + particle.stereoOffset; + + dustL += output * panL; + dustR += output * panR; + } + + // Render pops and clicks + let popL = 0; + let popR = 0; + + for (let p = activePops.length - 1; p >= 0; p--) { + const active = activePops[p]; + const pop = active.pop; + const elapsed = (i - active.startSample) / sampleRate; + + const maxDuration = pop.isClick ? 0.001 : 0.008; + + if (elapsed > maxDuration) { + activePops.splice(p, 1); + continue; + } + + const env = Math.exp(-elapsed * (pop.isClick ? 2000 : 300)); + + let signal: number; + if (pop.isClick) { + // Sharp click (very short impulse) + signal = (Math.random() * 2 - 1) * (elapsed < 0.0003 ? 1 : 0.3); + } else { + // Pop with pitch + const phaseInc = (2 * Math.PI * pop.pitch) / sampleRate; + active.phase += phaseInc; + signal = Math.sin(active.phase) * 0.7 + (Math.random() * 2 - 1) * 0.3; + } + + const output = signal * env * pop.intensity; + + const panL = 0.5 - pop.stereoOffset; + const panR = 0.5 + pop.stereoOffset; + + popL += output * panL; + popR += output * panR; + } + + // Combine all elements + let sampleL = bgNoiseL + dustL + popL; + let sampleR = bgNoiseR + dustR + popR; + + // Apply irregularity (random amplitude modulation) + if (params.irregularity > 0.1) { + const irregMod = 1 + (Math.random() - 0.5) * params.irregularity * 0.3; + sampleL *= irregMod; + sampleR *= irregMod; + } + + // Apply global envelope + sampleL *= globalEnv; + sampleR *= globalEnv; + + // Soft clipping + left[i] = this.softClip(sampleL * 0.6); + right[i] = this.softClip(sampleR * 0.6); + } + + // Normalize + let peak = 0; + for (let i = 0; i < numSamples; i++) { + peak = Math.max(peak, Math.abs(left[i]), Math.abs(right[i])); + } + + if (peak > 0.001) { + const normGain = 0.95 / peak; + for (let i = 0; i < numSamples; i++) { + left[i] *= normGain; + right[i] *= normGain; + } + } + + return [left, right]; + } + + private globalEnvelope( + sample: number, + totalSamples: number, + attack: number, + decay: number, + duration: number, + sampleRate: number + ): number { + const attackSamples = Math.floor(attack * duration * sampleRate); + const phase = sample / totalSamples; + + if (sample < attackSamples && attackSamples > 0) { + const attackPhase = sample / attackSamples; + return attackPhase * attackPhase * (3 - 2 * attackPhase); + } + + const decayRate = Math.max(decay, 0.1); + const decayPhase = (sample - attackSamples) / (totalSamples - attackSamples); + return Math.exp(-decayPhase / decayRate); + } + + private updateBrownState(brownState: number, whiteNoise: number): number { + return (brownState + whiteNoise * 0.02) * 0.98; + } + + private selectNoiseColor( + colorParam: number, + whiteNoise: number, + pinkState: Float32Array, + brownState: number + ): number { + if (colorParam < 0.33) { + return whiteNoise; + } else if (colorParam < 0.66) { + pinkState[0] = 0.99886 * pinkState[0] + whiteNoise * 0.0555179; + pinkState[1] = 0.99332 * pinkState[1] + whiteNoise * 0.0750759; + pinkState[2] = 0.96900 * pinkState[2] + whiteNoise * 0.1538520; + pinkState[3] = 0.86650 * pinkState[3] + whiteNoise * 0.3104856; + pinkState[4] = 0.55000 * pinkState[4] + whiteNoise * 0.5329522; + pinkState[5] = -0.7616 * pinkState[5] - whiteNoise * 0.0168980; + + const pink = pinkState[0] + pinkState[1] + pinkState[2] + pinkState[3] + + pinkState[4] + pinkState[5] + pinkState[6] + whiteNoise * 0.5362; + pinkState[6] = whiteNoise * 0.115926; + + return pink * 0.11; + } else { + return brownState * 2.5; + } + } + + private stateVariableFilter( + input: number, + cutoff: number, + resonance: number, + sampleRate: number, + state1: number, + state2: number + ): { output: number; state1: number; state2: number } { + const normalizedFreq = Math.min(cutoff / sampleRate, 0.48); + const f = 2 * Math.sin(Math.PI * normalizedFreq); + const q = Math.max(1 / Math.min(resonance, 10), 0.02); + + const lowpass = state2 + f * state1; + const highpass = input - lowpass - q * state1; + const bandpass = f * highpass + state1; + + const newState1 = Math.max(-2, Math.min(2, Math.abs(bandpass) > 1e-10 ? bandpass : 0)); + const newState2 = Math.max(-2, Math.min(2, Math.abs(lowpass) > 1e-10 ? lowpass : 0)); + + return { + output: bandpass, + state1: newState1, + state2: newState2 + }; + } + + private softClip(x: number): number { + if (x > 1) { + return 1; + } else if (x < -1) { + return -1; + } else if (x > 0.66) { + return (3 - (2 - 3 * x) ** 2) / 3; + } else if (x < -0.66) { + return -(3 - (2 - 3 * -x) ** 2) / 3; + } else { + return x; + } + } +} diff --git a/src/lib/audio/engines/ParticleNoise.ts b/src/lib/audio/engines/ParticleNoise.ts new file mode 100644 index 0000000..c8ed482 --- /dev/null +++ b/src/lib/audio/engines/ParticleNoise.ts @@ -0,0 +1,390 @@ +import type { SynthEngine, PitchLock } from './SynthEngine'; + +interface ParticleNoiseParams { + // Particle characteristics + density: number; + impulseLength: number; + impulseLengthVariation: number; + + // Pitch characteristics (affects filter frequency) + basePitch: number; + pitchVariation: number; + + // Texture + noiseColor: number; + filterResonance: number; + clickiness: number; + + // Spatial + stereoSpread: number; + panSpeed: number; + + // Dynamics + globalEnvAttack: number; + globalEnvDecay: number; + velocity: number; +} + +export class ParticleNoise implements SynthEngine { + getName(): string { + return 'Particle'; + } + + getDescription(): string { + return 'Very short noise impulses and clicks generator'; + } + + getType() { + return 'generative' as const; + } + + randomParams(pitchLock?: PitchLock): ParticleNoiseParams { + const densityBias = Math.random(); + + let density: number; + let impulseLength: number; + let impulseLengthVariation: number; + + if (densityBias < 0.5) { + // Very sparse particles + density = 0.01 + Math.random() * 0.08; + impulseLength = 0.01 + Math.random() * 0.04; + impulseLengthVariation = 0.5 + Math.random() * 0.4; + } else if (densityBias < 0.8) { + // Sparse particles + density = 0.1 + Math.random() * 0.15; + impulseLength = 0.008 + Math.random() * 0.03; + impulseLengthVariation = 0.3 + Math.random() * 0.4; + } else { + // Medium density + density = 0.3 + Math.random() * 0.25; + impulseLength = 0.005 + Math.random() * 0.02; + impulseLengthVariation = 0.15 + Math.random() * 0.35; + } + + let basePitch: number; + if (pitchLock?.enabled) { + basePitch = Math.max(0, Math.min(1, (pitchLock.frequency - 100) / 2000)); + } else { + basePitch = 0.2 + Math.random() * 0.7; + } + + const pitchVariation = Math.random() * 0.6; + + const noiseColor = Math.random(); + const filterResonance = Math.random() * 0.5; + const clickiness = Math.random() * 0.8; + + const stereoSpread = Math.random() * 0.8; + const panSpeed = Math.random() * 0.6; + + const globalEnvAttack = Math.random() * 0.12; + const globalEnvDecay = 0.25 + Math.random() * 0.5; + const velocity = 0.6 + Math.random() * 0.4; + + return { + density, + impulseLength, + impulseLengthVariation, + basePitch, + pitchVariation, + noiseColor, + filterResonance, + clickiness, + stereoSpread, + panSpeed, + globalEnvAttack, + globalEnvDecay, + velocity + }; + } + + mutateParams(params: ParticleNoiseParams, mutationAmount: number = 0.15, pitchLock?: PitchLock): ParticleNoiseParams { + const mutate = (value: number, amount: number = 0.15): number => { + return Math.max(0, Math.min(1, value + (Math.random() - 0.5) * amount)); + }; + + return { + density: mutate(params.density, 0.2), + impulseLength: mutate(params.impulseLength, 0.2), + impulseLengthVariation: mutate(params.impulseLengthVariation, 0.2), + basePitch: pitchLock?.enabled ? params.basePitch : mutate(params.basePitch, 0.25), + pitchVariation: mutate(params.pitchVariation, 0.2), + noiseColor: mutate(params.noiseColor, 0.25), + filterResonance: mutate(params.filterResonance, 0.2), + clickiness: mutate(params.clickiness, 0.2), + stereoSpread: mutate(params.stereoSpread, 0.2), + panSpeed: mutate(params.panSpeed, 0.2), + globalEnvAttack: mutate(params.globalEnvAttack, 0.15), + globalEnvDecay: mutate(params.globalEnvDecay, 0.2), + velocity: mutate(params.velocity, 0.15) + }; + } + + generate(params: ParticleNoiseParams, sampleRate: number, duration: number, pitchLock?: PitchLock): [Float32Array, Float32Array] { + const numSamples = Math.floor(sampleRate * duration); + const left = new Float32Array(numSamples); + const right = new Float32Array(numSamples); + + // Calculate number of grains based on density + const avgGrainsPerSecond = 2 + params.density * 80; + const totalGrains = Math.floor(avgGrainsPerSecond * duration); + + // Pre-generate impulse timings and parameters + const impulses: Array<{ + startTime: number; + duration: number; + filterFreq: number; + pan: number; + amplitude: number; + isClick: boolean; + }> = []; + + const baseFilterFreq = pitchLock?.enabled ? pitchLock.frequency : 200 + params.basePitch * 3000; + + for (let i = 0; i < totalGrains; i++) { + const startTime = Math.random() * duration; + + const baseImpulseDuration = 0.0005 + params.impulseLength * 0.003; + const impulseDuration = baseImpulseDuration * (0.5 + Math.random() * params.impulseLengthVariation); + + const filterOffset = (Math.random() - 0.5) * params.pitchVariation * baseFilterFreq * 2; + const filterFreq = Math.max(100, baseFilterFreq + filterOffset); + + const pan = Math.random(); + const amplitude = 0.5 + Math.random() * 0.5; + const isClick = Math.random() < params.clickiness; + + impulses.push({ + startTime, + duration: impulseDuration, + filterFreq, + pan, + amplitude, + isClick + }); + } + + // Sort impulses by start time for efficient processing + impulses.sort((a, b) => a.startTime - b.startTime); + + // Noise state for colored noise generation + const pinkStateL = new Float32Array(7); + const pinkStateR = new Float32Array(7); + let brownStateL = 0; + let brownStateR = 0; + + let impulseIndex = 0; + const activeImpulses: Array<{ + impulse: typeof impulses[0]; + startSample: number; + filterState1: number; + filterState2: number; + }> = []; + + for (let i = 0; i < numSamples; i++) { + const t = i / sampleRate; + + // Add new impulses that should start at this sample + while (impulseIndex < impulses.length && impulses[impulseIndex].startTime <= t) { + activeImpulses.push({ + impulse: impulses[impulseIndex], + startSample: i, + filterState1: 0, + filterState2: 0 + }); + impulseIndex++; + } + + // Global envelope + const globalEnv = this.globalEnvelope( + i, + numSamples, + params.globalEnvAttack, + params.globalEnvDecay, + duration, + sampleRate + ); + + // Pan modulation + const panLFO = Math.sin(2 * Math.PI * (0.1 + params.panSpeed * 2) * t); + + let sampleL = 0; + let sampleR = 0; + + // Render all active impulses + for (let g = activeImpulses.length - 1; g >= 0; g--) { + const active = activeImpulses[g]; + const impulse = active.impulse; + const impulseSample = i - active.startSample; + const impulseTime = impulseSample / sampleRate; + + if (impulseTime >= impulse.duration) { + activeImpulses.splice(g, 1); + continue; + } + + const impulsePhase = impulseTime / impulse.duration; + + // Very fast exponential decay envelope + const impulseEnv = Math.exp(-impulsePhase * 15); + + // Generate noise burst + const whiteL = Math.random() * 2 - 1; + const whiteR = Math.random() * 2 - 1; + + brownStateL = this.updateBrownState(brownStateL, whiteL); + brownStateR = this.updateBrownState(brownStateR, whiteR); + + let noiseL = this.selectNoiseColor(params.noiseColor, whiteL, pinkStateL, brownStateL); + let noiseR = this.selectNoiseColor(params.noiseColor, whiteR, pinkStateR, brownStateR); + + // For clicks, use pure white noise burst + if (impulse.isClick) { + noiseL = whiteL; + noiseR = whiteR; + } else if (params.filterResonance > 0.1) { + // Apply resonant filter for tonal color + const resonance = 2 + params.filterResonance * 8; + const filtered = this.stateVariableFilter( + noiseL, + impulse.filterFreq, + resonance, + active.filterState1, + active.filterState2 + ); + active.filterState1 = filtered.state1; + active.filterState2 = filtered.state2; + noiseL = filtered.output; + noiseR = filtered.output; + } + + // Apply impulse envelope and amplitude + const impulseOutput = noiseL * impulseEnv * impulse.amplitude * params.velocity; + + // Apply panning with modulation + const panMod = impulse.pan + panLFO * params.stereoSpread * 0.15; + const panClamp = Math.max(0, Math.min(1, panMod)); + const panL = Math.cos(panClamp * Math.PI * 0.5); + const panR = Math.sin(panClamp * Math.PI * 0.5); + + sampleL += impulseOutput * panL; + sampleR += impulseOutput * panR * (impulse.isClick ? 1 : 1 + (Math.random() - 0.5) * params.stereoSpread * 0.2); + } + + // Apply global envelope + sampleL *= globalEnv; + sampleR *= globalEnv; + + // Soft clipping + left[i] = this.softClip(sampleL * 0.7); + right[i] = this.softClip(sampleR * 0.7); + } + + // Normalize + let peak = 0; + for (let i = 0; i < numSamples; i++) { + peak = Math.max(peak, Math.abs(left[i]), Math.abs(right[i])); + } + + if (peak > 0.001) { + const normGain = 0.95 / peak; + for (let i = 0; i < numSamples; i++) { + left[i] *= normGain; + right[i] *= normGain; + } + } + + return [left, right]; + } + + private globalEnvelope( + sample: number, + totalSamples: number, + attack: number, + decay: number, + duration: number, + sampleRate: number + ): number { + const attackSamples = Math.floor(attack * duration * sampleRate); + const phase = sample / totalSamples; + + if (sample < attackSamples && attackSamples > 0) { + const attackPhase = sample / attackSamples; + return attackPhase * attackPhase * (3 - 2 * attackPhase); + } + + const decayRate = Math.max(decay, 0.1); + const decayPhase = (sample - attackSamples) / (totalSamples - attackSamples); + return Math.exp(-decayPhase / decayRate); + } + + private stateVariableFilter( + input: number, + cutoff: number, + resonance: number, + state1: number, + state2: number + ): { output: number; state1: number; state2: number } { + const normalizedFreq = Math.min(cutoff / 44100, 0.48); + const f = 2 * Math.sin(Math.PI * normalizedFreq); + const q = Math.max(1 / Math.min(resonance, 20), 0.01); + + const lowpass = state2 + f * state1; + const highpass = input - lowpass - q * state1; + const bandpass = f * highpass + state1; + + const newState1 = Math.max(-3, Math.min(3, Math.abs(bandpass) > 1e-10 ? bandpass : 0)); + const newState2 = Math.max(-3, Math.min(3, Math.abs(lowpass) > 1e-10 ? lowpass : 0)); + + return { + output: bandpass, + state1: newState1, + state2: newState2 + }; + } + + private updateBrownState(brownState: number, whiteNoise: number): number { + return (brownState + whiteNoise * 0.02) * 0.98; + } + + private selectNoiseColor( + colorParam: number, + whiteNoise: number, + pinkState: Float32Array, + brownState: number + ): number { + if (colorParam < 0.33) { + return whiteNoise; + } else if (colorParam < 0.66) { + pinkState[0] = 0.99886 * pinkState[0] + whiteNoise * 0.0555179; + pinkState[1] = 0.99332 * pinkState[1] + whiteNoise * 0.0750759; + pinkState[2] = 0.96900 * pinkState[2] + whiteNoise * 0.1538520; + pinkState[3] = 0.86650 * pinkState[3] + whiteNoise * 0.3104856; + pinkState[4] = 0.55000 * pinkState[4] + whiteNoise * 0.5329522; + pinkState[5] = -0.7616 * pinkState[5] - whiteNoise * 0.0168980; + + const pink = pinkState[0] + pinkState[1] + pinkState[2] + pinkState[3] + + pinkState[4] + pinkState[5] + pinkState[6] + whiteNoise * 0.5362; + pinkState[6] = whiteNoise * 0.115926; + + return pink * 0.11; + } else { + return brownState * 2.5; + } + } + + private softClip(x: number): number { + if (x > 1) { + return 1; + } else if (x < -1) { + return -1; + } else if (x > 0.66) { + return (3 - (2 - 3 * x) ** 2) / 3; + } else if (x < -0.66) { + return -(3 - (2 - 3 * -x) ** 2) / 3; + } else { + return x; + } + } +} diff --git a/src/lib/audio/engines/registry.ts b/src/lib/audio/engines/registry.ts index bacf770..5507f74 100644 --- a/src/lib/audio/engines/registry.ts +++ b/src/lib/audio/engines/registry.ts @@ -14,6 +14,8 @@ import { AdditiveEngine } from './AdditiveEngine'; import { Snare } from './Snare'; import { BassDrum } from './BassDrum'; import { HiHat } from './HiHat'; +import { ParticleNoise } from './ParticleNoise'; +import { DustNoise } from './DustNoise'; export const engines: SynthEngine[] = [ new Sample(), @@ -31,4 +33,6 @@ export const engines: SynthEngine[] = [ new Ring(), new KarplusStrong(), new AdditiveEngine(), + new ParticleNoise(), + new DustNoise(), ]; diff --git a/src/lib/audio/processors/Resonator.ts b/src/lib/audio/processors/Resonator.ts new file mode 100644 index 0000000..5cc3e13 --- /dev/null +++ b/src/lib/audio/processors/Resonator.ts @@ -0,0 +1,205 @@ +import type { AudioProcessor } from './AudioProcessor'; + +export class Resonator implements AudioProcessor { + private readonly sampleRate = 44100; + + getName(): string { + return 'Resonator'; + } + + getDescription(): string { + return 'Multi-band resonant filter bank that adds tonal character through resonance'; + } + + process( + leftChannel: Float32Array, + rightChannel: Float32Array + ): [Float32Array, Float32Array] { + const length = leftChannel.length; + + const numResonators = Math.floor(Math.random() * 3) + 2; // 2-4 resonators + const baseFreq = Math.random() * 200 + 100; // 100-300 Hz base frequency + const spread = Math.random() * 0.6 + 0.4; // 0.4-1.0 harmonic spread + const resonance = Math.random() * 8 + 4; // Q factor 4-12 + const mix = Math.random() * 0.6 + 0.3; // 30-90% wet + const stereoSpread = Math.random() * 0.2; // 0-20% stereo detuning + const modulationRate = Math.random() * 0.8 + 0.1; // 0.1-0.9 Hz modulation + const modulationDepth = Math.random() * 0.3 + 0.1; // 10-40% pitch modulation + const drive = Math.random() * 0.5; // 0-50% input drive + + const leftOut = new Float32Array(length); + const rightOut = new Float32Array(length); + + const leftResonators: Array<{ + freq: number; + state1: number; + state2: number; + }> = []; + + const rightResonators: Array<{ + freq: number; + state1: number; + state2: number; + }> = []; + + // Create resonator banks with harmonic or inharmonic relationships + const isHarmonic = Math.random() < 0.6; + + for (let i = 0; i < numResonators; i++) { + let freqMultiplier: number; + if (isHarmonic) { + // Harmonic series + freqMultiplier = Math.pow(2, i * spread); + } else { + // Inharmonic/stretched partials + freqMultiplier = Math.pow(2, i * spread * (1 + Math.random() * 0.4)); + } + + const leftFreq = baseFreq * freqMultiplier; + const rightFreq = leftFreq * (1 + (Math.random() - 0.5) * stereoSpread); + + leftResonators.push({ + freq: leftFreq, + state1: 0, + state2: 0 + }); + + rightResonators.push({ + freq: rightFreq, + state1: 0, + state2: 0 + }); + } + + for (let i = 0; i < length; i++) { + const t = i / this.sampleRate; + + // LFO for frequency modulation + const lfo = Math.sin(2 * Math.PI * modulationRate * t); + + // Apply input drive + let leftInput = leftChannel[i]; + let rightInput = rightChannel[i]; + + if (drive > 0.1) { + const driveAmount = 1 + drive * 2; + leftInput = this.softSaturation(leftInput * driveAmount); + rightInput = this.softSaturation(rightInput * driveAmount); + } + + // Process through all resonators + let leftResonant = 0; + let rightResonant = 0; + + for (let r = 0; r < numResonators; r++) { + const leftRes = leftResonators[r]; + const rightRes = rightResonators[r]; + + // Modulate frequency + const freqMod = 1 + lfo * modulationDepth; + const leftModFreq = Math.min(leftRes.freq * freqMod, this.sampleRate * 0.45); + const rightModFreq = Math.min(rightRes.freq * freqMod, this.sampleRate * 0.45); + + // Apply resonant filter + const leftFiltered = this.stateVariableFilter( + leftInput, + leftModFreq, + resonance, + leftRes.state1, + leftRes.state2 + ); + + leftRes.state1 = leftFiltered.state1; + leftRes.state2 = leftFiltered.state2; + + const rightFiltered = this.stateVariableFilter( + rightInput, + rightModFreq, + resonance, + rightRes.state1, + rightRes.state2 + ); + + rightRes.state1 = rightFiltered.state1; + rightRes.state2 = rightFiltered.state2; + + // Amplitude compensation for number of resonators + const ampScale = 1 / Math.sqrt(numResonators); + leftResonant += leftFiltered.output * ampScale; + rightResonant += rightFiltered.output * ampScale; + } + + // Mix dry and wet signals + const dryGain = Math.sqrt(1 - mix); + const wetGain = Math.sqrt(mix); + + leftOut[i] = leftChannel[i] * dryGain + leftResonant * wetGain; + rightOut[i] = rightChannel[i] * dryGain + rightResonant * wetGain; + + // Soft clipping + leftOut[i] = this.softClip(leftOut[i]); + rightOut[i] = this.softClip(rightOut[i]); + } + + this.normalizeOutput(leftOut, rightOut); + + return [leftOut, rightOut]; + } + + private stateVariableFilter( + input: number, + cutoff: number, + resonance: number, + state1: number, + state2: number + ): { output: number; state1: number; state2: number } { + const normalizedFreq = Math.min(cutoff / this.sampleRate, 0.48); + const f = 2 * Math.sin(Math.PI * normalizedFreq); + const q = Math.max(1 / Math.min(resonance, 20), 0.01); + + const lowpass = state2 + f * state1; + const highpass = input - lowpass - q * state1; + const bandpass = f * highpass + state1; + + // Clamp states to prevent instability + const newState1 = Math.max(-3, Math.min(3, Math.abs(bandpass) > 1e-10 ? bandpass : 0)); + const newState2 = Math.max(-3, Math.min(3, Math.abs(lowpass) > 1e-10 ? lowpass : 0)); + + return { + output: bandpass, + state1: newState1, + state2: newState2 + }; + } + + private softSaturation(x: number): number { + return x / (1 + Math.abs(x)); + } + + private softClip(sample: number): number { + const threshold = 0.95; + if (Math.abs(sample) < threshold) { + return sample; + } + const sign = sample < 0 ? -1 : 1; + const abs = Math.abs(sample); + return sign * (threshold + (1 - threshold) * Math.tanh((abs - threshold) / (1 - threshold))); + } + + private normalizeOutput(leftOut: Float32Array, rightOut: Float32Array): void { + let maxPeak = 0; + for (let i = 0; i < leftOut.length; i++) { + maxPeak = Math.max(maxPeak, Math.abs(leftOut[i]), Math.abs(rightOut[i])); + } + + if (maxPeak > 0.01) { + const targetPeak = 0.95; + const normalizeGain = Math.min(1.0, targetPeak / maxPeak); + + for (let i = 0; i < leftOut.length; i++) { + leftOut[i] *= normalizeGain; + rightOut[i] *= normalizeGain; + } + } + } +} diff --git a/src/lib/audio/processors/registry.ts b/src/lib/audio/processors/registry.ts index 58c3075..3e9f1ee 100644 --- a/src/lib/audio/processors/registry.ts +++ b/src/lib/audio/processors/registry.ts @@ -24,6 +24,7 @@ import { RingModulator } from './RingModulator'; import { Waveshaper } from './Waveshaper'; import { DCOffsetRemover } from './DCOffsetRemover'; import { TrimSilence } from './TrimSilence'; +import { Resonator } from './Resonator'; const processors: AudioProcessor[] = [ new SegmentShuffler(), @@ -51,6 +52,7 @@ const processors: AudioProcessor[] = [ new Waveshaper(), new DCOffsetRemover(), new TrimSilence(), + new Resonator(), ]; export function getRandomProcessor(): AudioProcessor {