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; } } }