From 57fb8a93dc99eb77b32cdbe6643b1a12b8f9ab9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Forment?= Date: Sun, 12 Oct 2025 16:35:00 +0200 Subject: [PATCH] current kick generator --- src/lib/audio/engines/BassDrum909.ts | 547 +++++++++++++++++++++++++++ src/lib/audio/engines/Ring.ts | 16 + src/lib/audio/engines/Snare909.ts | 236 ++++++++++++ src/lib/audio/engines/registry.ts | 4 + 4 files changed, 803 insertions(+) create mode 100644 src/lib/audio/engines/BassDrum909.ts create mode 100644 src/lib/audio/engines/Snare909.ts diff --git a/src/lib/audio/engines/BassDrum909.ts b/src/lib/audio/engines/BassDrum909.ts new file mode 100644 index 0000000..143e064 --- /dev/null +++ b/src/lib/audio/engines/BassDrum909.ts @@ -0,0 +1,547 @@ +import type { PitchLock, SynthEngine } from './SynthEngine'; + +interface BassDrum909Params { + // Core frequency (base pitch of the kick) + baseFreq: number; + + // Pitch envelope (how much frequency sweeps down) + pitchMod: number; + + // Pitch envelope speed + pitchDecay: number; + + // Amplitude decay time + decay: number; + + // Click/snap amount (high freq transient) + click: number; + + // Attack noise burst (sharp transient) + attackNoise: number; + attackNoiseFreq: number; // Filter frequency for attack noise + + // Body resonance + bodyResonance: number; + bodyFreq: number; + + // Body tone variation + bodyDecay: number; + + // Noise amount + noise: number; + + // Noise decay + noiseDecay: number; + + // Harmonic content + harmonics: number; + + // Wave shape (0 = sine, 0.5 = triangle, 1 = square) + waveShape: number; + + // Phase distortion amount + phaseDistortion: number; + + // Distortion amount (0 = clean, 1 = heavy) + distortion: number; + + // Tuning offset + tuning: number; +} + +export class BassDrum909 implements SynthEngine { + getName(): string { + return 'Kick'; + } + + getDescription(): string { + return 'Versatile kick drum synthesizer with varied styles from sub to tom'; + } + + getType() { + return 'generative' as const; + } + + randomParams(pitchLock?: PitchLock): BassDrum909Params { + // Choose a kick character/style + const styleRoll = Math.random(); + + let baseFreq: number, pitchMod: number, pitchDecay: number, decay: number; + let click: number, attackNoise: number, attackNoiseFreq: number; + let bodyResonance: number, bodyFreq: number, bodyDecay: number; + let noise: number, noiseDecay: number, harmonics: number; + let waveShape: number, phaseDistortion: number, distortion: number; + + if (styleRoll < 0.08) { + // Tom/bongo style (starts HIGH, decays VERY fast - rare!) + baseFreq = 0.6 + Math.random() * 0.4; // 83-115 Hz (very high!) + pitchMod = 0.8 + Math.random() * 0.2; // Extreme pitch sweep + pitchDecay = 0.01 + Math.random() * 0.08; // Ultra fast pitch decay (10-90ms) + decay = 0.05 + Math.random() * 0.25; // Very short decay + click = 0.3 + Math.random() * 0.5; // Good click + attackNoise = 0.4 + Math.random() * 0.6; // Strong attack + attackNoiseFreq = 0.7 + Math.random() * 0.3; // High filtered + bodyResonance = 0.1 + Math.random() * 0.3; + bodyFreq = 0.5 + Math.random() * 0.5; + bodyDecay = 0.05 + Math.random() * 0.25; // Short body decay + noise = Math.random() * 0.08; + noiseDecay = 0.02 + Math.random() * 0.15; // Fast noise decay + harmonics = 0.2 + Math.random() * 0.5; // Lots of harmonics + waveShape = 0.3 + Math.random() * 0.4; // Triangle-ish + phaseDistortion = 0.1 + Math.random() * 0.4; // Some phase distortion + distortion = 0; // Clean + } else if (styleRoll < 0.25) { + // Tight, punchy 808-style kick (short, lots of pitch, CLEAN) + baseFreq = 0.2 + Math.random() * 0.4; // 56-88 Hz (mid-high kicks) + pitchMod = 0.7 + Math.random() * 0.3; // High pitch sweep + pitchDecay = 0.05 + Math.random() * 0.4; // Fast to medium pitch decay + decay = 0.1 + Math.random() * 0.35; // Short to medium decay + click = 0.2 + Math.random() * 0.4; // Good click + attackNoise = 0.3 + Math.random() * 0.5; // Sharp attack + attackNoiseFreq = 0.5 + Math.random() * 0.5; // Mid-high filtered + bodyResonance = 0.05 + Math.random() * 0.2; + bodyFreq = 0.3 + Math.random() * 0.5; + bodyDecay = 0.1 + Math.random() * 0.5; // Varied body decay + noise = Math.random() * 0.05; // Very minimal + noiseDecay = 0.05 + Math.random() * 0.4; // Varied noise decay + harmonics = 0.1 + Math.random() * 0.3; + waveShape = 0.2 + Math.random() * 0.5; // Sine to triangle + phaseDistortion = Math.random() * 0.3; // Light phase distortion + distortion = 0; // Clean + } else if (styleRoll < 0.43) { + // Deep, smooth kick (long, clean, lots of body) + baseFreq = 0.0 + Math.random() * 0.35; // 40-68 Hz (low to mid kicks) + pitchMod = 0.3 + Math.random() * 0.4; // Moderate pitch sweep + pitchDecay = 0.2 + Math.random() * 0.6; // Medium to slow pitch decay + decay = 0.4 + Math.random() * 0.6; // Long decay + click = Math.random() * 0.2; // Minimal click + attackNoise = 0.1 + Math.random() * 0.4; // Some attack + attackNoiseFreq = 0.3 + Math.random() * 0.4; // Mid filtered + bodyResonance = 0.2 + Math.random() * 0.5; // Lots of body + bodyFreq = 0.1 + Math.random() * 0.4; + bodyDecay = 0.3 + Math.random() * 0.7; // Long body decay + noise = Math.random() * 0.08; + noiseDecay = 0.2 + Math.random() * 0.6; // Varied noise decay + harmonics = Math.random() * 0.2; + waveShape = Math.random() * 0.4; // Sine to triangle + phaseDistortion = Math.random() * 0.25; // Subtle phase distortion + distortion = 0; // Clean + } else if (styleRoll < 0.63) { + // Sub/electronic kick (very low, pure sine-like) + baseFreq = 0.0 + Math.random() * 0.25; // 40-60 Hz (sub bass kicks) + pitchMod = 0.1 + Math.random() * 0.4; // Low to moderate pitch sweep + pitchDecay = 0.15 + Math.random() * 0.5; // Varied pitch decay + decay = 0.4 + Math.random() * 0.6; // Long decay + click = Math.random() * 0.15; // Almost no click + attackNoise = Math.random() * 0.3; // Variable attack + attackNoiseFreq = 0.2 + Math.random() * 0.3; // Low filtered + bodyResonance = 0.3 + Math.random() * 0.6; // Strong body + bodyFreq = 0.05 + Math.random() * 0.3; + bodyDecay = 0.4 + Math.random() * 0.6; // Long body decay + noise = Math.random() * 0.03; // Almost no noise + noiseDecay = 0.1 + Math.random() * 0.4; // Varied noise decay + harmonics = Math.random() * 0.15; + waveShape = Math.random() * 0.3; // Mostly sine + phaseDistortion = Math.random() * 0.2; // Very subtle + distortion = 0; // Clean + } else if (styleRoll < 0.78) { + // Snappy, clicky kick (fast attack, short) + baseFreq = 0.3 + Math.random() * 0.5; // 64-100 Hz (high kicks) + pitchMod = 0.5 + Math.random() * 0.5; // High pitch sweep + pitchDecay = 0.02 + Math.random() * 0.25; // Very fast pitch + decay = 0.08 + Math.random() * 0.35; // Very short to short + click = 0.4 + Math.random() * 0.6; // Lots of click + attackNoise = 0.5 + Math.random() * 0.5; // Strong attack burst + attackNoiseFreq = 0.6 + Math.random() * 0.4; // High filtered + bodyResonance = Math.random() * 0.2; // Minimal body + bodyFreq = 0.4 + Math.random() * 0.6; + bodyDecay = 0.05 + Math.random() * 0.3; // Short body decay + noise = Math.random() * 0.1; + noiseDecay = 0.02 + Math.random() * 0.2; // Fast noise decay + harmonics = 0.2 + Math.random() * 0.5; + waveShape = 0.4 + Math.random() * 0.6; // Triangle to square + phaseDistortion = 0.2 + Math.random() * 0.5; // More phase distortion + distortion = 0; // Clean + } else if (styleRoll < 0.88) { + // Weird/experimental kick + baseFreq = Math.random() * 0.7; // Full range 35-96 Hz + pitchMod = Math.random(); + pitchDecay = 0.05 + Math.random() * 0.8; // Any decay + decay = 0.1 + Math.random() * 0.8; // Any decay + click = Math.random() * 0.5; + attackNoise = Math.random() * 0.7; + attackNoiseFreq = Math.random(); + bodyResonance = Math.random() * 0.7; + bodyFreq = Math.random(); + bodyDecay = 0.1 + Math.random() * 0.9; // Any body decay + noise = Math.random() * 0.15; + noiseDecay = 0.05 + Math.random() * 0.8; // Any noise decay + harmonics = Math.random() * 0.6; + waveShape = Math.random(); // Any shape + phaseDistortion = Math.random() * 0.7; // Any amount + distortion = 0; // Clean + } else { + // Dirty/distorted kick (only 15% of the time!) + baseFreq = 0.1 + Math.random() * 0.5; // 44-84 Hz (low to mid) + pitchMod = 0.4 + Math.random() * 0.5; + pitchDecay = 0.1 + Math.random() * 0.6; // Varied pitch decay + decay = 0.2 + Math.random() * 0.6; // Varied decay + click = 0.2 + Math.random() * 0.5; + attackNoise = 0.3 + Math.random() * 0.5; + attackNoiseFreq = 0.4 + Math.random() * 0.5; + bodyResonance = 0.1 + Math.random() * 0.5; + bodyFreq = 0.2 + Math.random() * 0.6; + bodyDecay = 0.15 + Math.random() * 0.7; // Varied body decay + noise = 0.05 + Math.random() * 0.2; + noiseDecay = 0.15 + Math.random() * 0.6; // Varied noise decay + harmonics = 0.1 + Math.random() * 0.4; + waveShape = 0.3 + Math.random() * 0.5; // Varied shapes + phaseDistortion = 0.2 + Math.random() * 0.6; // Good amount + distortion = 0.3 + Math.random() * 0.5; // Only this style has distortion + } + + return { + baseFreq: pitchLock ? this.freqToParam(pitchLock.frequency) : baseFreq, + pitchMod, + pitchDecay, + decay, + click, + attackNoise, + attackNoiseFreq, + bodyResonance, + bodyFreq, + bodyDecay, + noise, + noiseDecay, + harmonics, + waveShape, + phaseDistortion, + distortion, + tuning: pitchLock ? 0.5 : 0.4 + Math.random() * 0.2, + }; + } + + mutateParams(params: BassDrum909Params, mutationAmount: number = 0.15, pitchLock?: PitchLock): BassDrum909Params { + const mutate = (value: number, amount: number = mutationAmount): number => { + return Math.max(0, Math.min(1, value + (Math.random() - 0.5) * amount)); + }; + + return { + baseFreq: pitchLock ? params.baseFreq : mutate(params.baseFreq, 0.2), + pitchMod: mutate(params.pitchMod, 0.2), + pitchDecay: mutate(params.pitchDecay, 0.2), + decay: mutate(params.decay, 0.25), + click: mutate(params.click, 0.2), + attackNoise: mutate(params.attackNoise, 0.25), + attackNoiseFreq: mutate(params.attackNoiseFreq, 0.25), + bodyResonance: mutate(params.bodyResonance, 0.2), + bodyFreq: mutate(params.bodyFreq, 0.25), + bodyDecay: mutate(params.bodyDecay, 0.2), + noise: mutate(params.noise, 0.15), + noiseDecay: mutate(params.noiseDecay, 0.2), + harmonics: mutate(params.harmonics, 0.2), + waveShape: mutate(params.waveShape, 0.25), + phaseDistortion: mutate(params.phaseDistortion, 0.25), + distortion: mutate(params.distortion, 0.2), + tuning: pitchLock ? params.tuning : mutate(params.tuning, 0.15), + }; + } + + generate( + params: BassDrum909Params, + sampleRate: number, + duration: number, + pitchLock?: PitchLock + ): [Float32Array, Float32Array] { + const numSamples = Math.floor(sampleRate * duration); + const left = new Float32Array(numSamples); + const right = new Float32Array(numSamples); + + // Base frequency: 35Hz to 115Hz (full kick range from sub to high) + const baseFreq = pitchLock ? pitchLock.frequency : 35 + params.baseFreq * 80; + + // Tuning offset: -12% to +12% + const tuningFactor = 0.88 + params.tuning * 0.24; + const tunedFreq = baseFreq * tuningFactor; + + // Pitch modulation: when locked, starts at locked freq and decays down + // when not locked, starts higher and decays to base + const pitchMultiplier = 1 + params.pitchMod * 1.5; // 1x to 2.5x + const pitchDecayRatio = pitchLock ? 1 / pitchMultiplier : pitchMultiplier; // Invert for pitch lock + + // Pitch decay time scaled by duration (50ms to 200ms) + const pitchDecayTime = (0.05 + params.pitchDecay * 0.15) * duration; + + // Amplitude decay time scaled by duration (300ms default in original) + const ampDecayTime = (0.2 + params.decay * 1.8) * duration; + + // Noise decay time + const noiseDecayTime = (0.02 + params.noiseDecay * 0.15) * duration; // 20ms to 170ms + + // Body resonance frequency (40Hz to 200Hz for low body resonances) + const bodyFreq = 40 + params.bodyFreq * 160; + const bodyDecayTime = (0.1 + params.bodyDecay * 0.4) * duration; // 100ms to 500ms + + // Attack time (5ms ramp like original) + const attackTime = 0.005; + + // Click decay (very fast for transient) + const clickDecayTime = 0.003 + params.click * 0.007; // 3ms to 10ms + + // Attack noise burst decay (super fast: 1-5ms) + const attackNoiseDecayTime = 0.001 + params.attackNoise * 0.004; // 1ms to 5ms + + // Attack noise filter frequency (500Hz to 8000Hz) + const attackNoiseFilterFreq = 500 + params.attackNoiseFreq * 7500; + + // Low-pass filter at 3000Hz + const filterFreq = 3000; + + for (let channel = 0; channel < 2; channel++) { + const output = channel === 0 ? left : right; + + // Triangle oscillator phase + let phase = 0; + + // Low-pass filter state + let filterState = 0; + + // Body resonance filter state + let bodyState1 = 0; + let bodyState2 = 0; + + // Attack noise filter state (highpass for snap) + let attackNoiseState1 = 0; + let attackNoiseState2 = 0; + + // Stereo variation + const stereoDetune = channel === 0 ? 0.999 : 1.001; + const stereoPhaseOffset = channel === 0 ? 0 : 0.05; + + for (let i = 0; i < numSamples; i++) { + const t = i / sampleRate; + + // Pitch envelope: exponential decay + const pitchEnv = Math.exp(-t / pitchDecayTime); + + // When pitch locked: start at locked freq, decay down + // When not locked: start higher, decay to base + const currentFreq = pitchLock + ? tunedFreq * stereoDetune * (pitchDecayRatio + (1 - pitchDecayRatio) * pitchEnv) + : tunedFreq * stereoDetune * (1 + (pitchMultiplier - 1) * pitchEnv); + + // Generate oscillator with wave shape morphing and phase distortion + phase += (2 * Math.PI * currentFreq) / sampleRate; + if (phase > 2 * Math.PI) phase -= 2 * Math.PI; + + // Apply phase distortion (modulate the phase itself) + let distortedPhase = phase; + if (params.phaseDistortion > 0.01) { + // Phase distortion creates harmonic richness + const phaseMod = Math.sin(phase * 2) * params.phaseDistortion * 0.5; + distortedPhase = phase + phaseMod; + if (distortedPhase < 0) distortedPhase += 2 * Math.PI; + if (distortedPhase > 2 * Math.PI) distortedPhase -= 2 * Math.PI; + } + + // Wave shape morphing: 0=sine, 0.5=triangle, 1=square + let waveform: number; + + // Generate sine wave + const sine = Math.sin(distortedPhase); + + // Generate triangle wave + const triangle = distortedPhase < Math.PI + ? -1 + (2 * distortedPhase) / Math.PI + : 3 - (2 * distortedPhase) / Math.PI; + + // Generate square wave + const square = distortedPhase < Math.PI ? 1 : -1; + + // Morph between shapes + if (params.waveShape < 0.5) { + // Morph between sine and triangle + const mix = params.waveShape * 2; // 0 to 1 + waveform = sine * (1 - mix) + triangle * mix; + } else { + // Morph between triangle and square + const mix = (params.waveShape - 0.5) * 2; // 0 to 1 + waveform = triangle * (1 - mix) + square * mix; + } + + // Attack envelope (5ms linear ramp) + const attackEnv = t < attackTime ? t / attackTime : 1.0; + + // Amplitude envelope: exponential decay with attack + const ampEnv = Math.exp(-t / ampDecayTime) * attackEnv; + + // 1. Morphed oscillator (main tone) - reduced level + let signal = waveform * ampEnv * 0.6; + + // 1b. Add harmonics (2nd and 3rd) for more character + if (params.harmonics > 0.01) { + const harmonic2 = Math.sin(2 * distortedPhase) * params.harmonics * 0.3; + const harmonic3 = Math.sin(3 * distortedPhase) * params.harmonics * 0.15; + signal += (harmonic2 + harmonic3) * ampEnv; + } + + // 2. Attack noise burst (super sharp transient, 1-5ms) + if (params.attackNoise > 0.01) { + const attackEnv = Math.exp(-t / attackNoiseDecayTime); + const attackNoise = Math.random() * 2 - 1; + + // Highpass filter the attack noise for snap + const attackNoiseFiltered = this.highpassFilter( + attackNoise, + attackNoiseFilterFreq, + 2.0, + sampleRate, + attackNoiseState1, + attackNoiseState2 + ); + attackNoiseState1 = attackNoiseFiltered.state1; + attackNoiseState2 = attackNoiseFiltered.state2; + + signal += attackNoiseFiltered.output * params.attackNoise * attackEnv * 0.4; + } + + // 3. Click/snap transient (fast decaying high-pitched sine) + if (params.click > 0.01) { + const clickEnv = Math.exp(-t / clickDecayTime); + const clickFreq = tunedFreq * 4; // High frequency for click + const clickOsc = Math.sin(2 * Math.PI * clickFreq * t); + signal += clickOsc * params.click * clickEnv * 0.15; + } + + // 4. Body resonance (bandpass filtered feedback with decay) + if (params.bodyResonance > 0.05) { + const bodyEnv = Math.exp(-t / bodyDecayTime); + const bodyFiltered = this.bandpassFilter( + signal, + bodyFreq, + 5 + params.bodyResonance * 10, + sampleRate, + bodyState1, + bodyState2 + ); + bodyState1 = bodyFiltered.state1; + bodyState2 = bodyFiltered.state2; + signal += bodyFiltered.output * params.bodyResonance * bodyEnv * 0.2; + } + + // 5. Add sustaining noise + if (params.noise > 0.01) { + const noise = Math.random() * 2 - 1; + const noiseEnv = Math.exp(-t / noiseDecayTime); + signal += noise * params.noise * noiseEnv * 0.1; + } + + // 6. Optional distortion/saturation (only if distortion > 0.2) + if (params.distortion > 0.2) { + const distAmount = 1 + params.distortion * 3; // Reduced range + signal = this.waveshaper(signal * distAmount, distAmount) / distAmount; + } + + // 7. Low-pass filter at 3000Hz + const normalizedFreq = Math.min(filterFreq / sampleRate, 0.48); + const a = 2 * Math.PI * normalizedFreq; + filterState += a * (signal - filterState); + signal = filterState; + + output[i] = signal; + } + } + + // Normalize output to consistent level + 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.5 / peak; // Normalize to 0.5 peak for more headroom + for (let i = 0; i < numSamples; i++) { + left[i] *= normGain; + right[i] *= normGain; + } + } + + return [left, right]; + } + + private bandpassFilter( + input: number, + freq: number, + q: number, + sampleRate: number, + state1: number, + state2: number + ): { output: number; state1: number; state2: number } { + // State variable filter in bandpass mode + const normalizedFreq = Math.min(freq / sampleRate, 0.48); + const f = 2 * Math.sin(Math.PI * normalizedFreq); + const qRecip = 1 / Math.max(q, 0.5); + + const lowpass = state2 + f * state1; + const highpass = input - lowpass - qRecip * state1; + const bandpass = f * highpass + state1; + + // Update states with clamping + const newState1 = Math.max(-2, Math.min(2, bandpass)); + const newState2 = Math.max(-2, Math.min(2, lowpass)); + + return { + output: bandpass, + state1: newState1, + state2: newState2, + }; + } + + private highpassFilter( + input: number, + freq: number, + q: number, + sampleRate: number, + state1: number, + state2: number + ): { output: number; state1: number; state2: number } { + // State variable filter in highpass mode + const normalizedFreq = Math.min(freq / sampleRate, 0.48); + const f = 2 * Math.sin(Math.PI * normalizedFreq); + const qRecip = 1 / Math.max(q, 0.5); + + const lowpass = state2 + f * state1; + const highpass = input - lowpass - qRecip * state1; + const bandpass = f * highpass + state1; + + // Update states with clamping + const newState1 = Math.max(-2, Math.min(2, bandpass)); + const newState2 = Math.max(-2, Math.min(2, lowpass)); + + return { + output: highpass, + state1: newState1, + state2: newState2, + }; + } + + private waveshaper(x: number, amount: number): number { + // Original 909 distortion curve: ((π + amount) * x) / (π + amount * |x|) + const PI = Math.PI; + if (Math.abs(x) < 0.001) return x; // Avoid division issues + return ((PI + amount) * x) / (PI + amount * Math.abs(x)); + } + + private softClip(x: number): number { + if (x > 1) return 1; + if (x < -1) return -1; + if (x > 0.66) return (3 - (2 - 3 * x) ** 2) / 3; + if (x < -0.66) return -(3 - (2 - 3 * -x) ** 2) / 3; + return x; + } + + private freqToParam(freq: number): number { + // Map frequency to 0-1 range (35-115 Hz) + return Math.max(0, Math.min(1, (freq - 35) / 80)); + } +} diff --git a/src/lib/audio/engines/Ring.ts b/src/lib/audio/engines/Ring.ts index 70cc844..3dae20d 100644 --- a/src/lib/audio/engines/Ring.ts +++ b/src/lib/audio/engines/Ring.ts @@ -248,6 +248,22 @@ export class Ring implements SynthEngine { if (secondModPhaseR > TAU * 1000) secondModPhaseR -= TAU * 1000; } + let peakL = 0; + let peakR = 0; + for (let i = 0; i < numSamples; i++) { + peakL = Math.max(peakL, Math.abs(leftBuffer[i])); + peakR = Math.max(peakR, Math.abs(rightBuffer[i])); + } + const peak = Math.max(peakL, peakR); + + if (peak > 0.001) { + const normalizeGain = 0.85 / peak; + for (let i = 0; i < numSamples; i++) { + leftBuffer[i] *= normalizeGain; + rightBuffer[i] *= normalizeGain; + } + } + return [leftBuffer, rightBuffer]; } diff --git a/src/lib/audio/engines/Snare909.ts b/src/lib/audio/engines/Snare909.ts new file mode 100644 index 0000000..1f903a1 --- /dev/null +++ b/src/lib/audio/engines/Snare909.ts @@ -0,0 +1,236 @@ +import type { PitchLock, SynthEngine } from './SynthEngine'; + +interface Snare909Params { + // Core frequency (base pitch of the snare) + baseFreq: number; + + // Tone control (0 = more tonal, 1 = more noise) + tone: number; + + // Snap amount (pitch modulation intensity) + snap: number; + + // Decay times + tonalDecay: number; + noiseDecay: number; + + // Accent (volume boost) + accent: number; + + // Tuning offset + tuning: number; +} + +export class Snare909 implements SynthEngine { + getName(): string { + return 'Snare'; + } + + getDescription(): string { + return 'Classic 909-style snare drum with triangle wave and noise'; + } + + getType() { + return 'generative' as const; + } + + randomParams(pitchLock?: PitchLock): Snare909Params { + return { + baseFreq: pitchLock ? this.freqToParam(pitchLock.frequency) : 0.3 + Math.random() * 0.4, + tone: 0.4 + Math.random() * 0.4, + snap: 0.5 + Math.random() * 0.5, + tonalDecay: 0.15 + Math.random() * 0.35, + noiseDecay: 0.4 + Math.random() * 0.4, + accent: 0.5 + Math.random() * 0.5, + tuning: pitchLock ? 0.5 : 0.4 + Math.random() * 0.2, + }; + } + + mutateParams(params: Snare909Params, mutationAmount: number = 0.15, pitchLock?: PitchLock): Snare909Params { + const mutate = (value: number, amount: number = mutationAmount): number => { + return Math.max(0, Math.min(1, value + (Math.random() - 0.5) * amount)); + }; + + return { + baseFreq: pitchLock ? params.baseFreq : mutate(params.baseFreq, 0.2), + tone: mutate(params.tone, 0.25), + snap: mutate(params.snap, 0.2), + tonalDecay: mutate(params.tonalDecay, 0.2), + noiseDecay: mutate(params.noiseDecay, 0.2), + accent: mutate(params.accent, 0.2), + tuning: pitchLock ? params.tuning : mutate(params.tuning, 0.15), + }; + } + + generate( + params: Snare909Params, + sampleRate: number, + duration: number, + pitchLock?: PitchLock + ): [Float32Array, Float32Array] { + const numSamples = Math.floor(sampleRate * duration); + const left = new Float32Array(numSamples); + const right = new Float32Array(numSamples); + + // Base frequency: 100Hz to 400Hz + const baseFreq = pitchLock ? pitchLock.frequency : 100 + params.baseFreq * 300; + + // Tuning offset: -12% to +12% + const tuningFactor = 0.88 + params.tuning * 0.24; + const tunedFreq = baseFreq * tuningFactor; + + // Pitch modulation parameters (classic 909: starts 4x higher, drops over 10ms) + const pitchMultiplier = 1 + params.snap * 3; // 1x to 4x + const modDuration = 0.008 + params.snap * 0.007; // 8ms to 15ms + + // Decay times scaled by duration + const tonalDecayTime = (0.05 + params.tonalDecay * 0.15) * duration; // 50ms to 200ms + const noiseDecayTime = (0.15 + params.noiseDecay * 0.35) * duration; // 150ms to 500ms + + // Volume and gain staging (matching original 909 implementation) + const baseVolume = 0.5; // original default volume + const accentGain = baseVolume * (1 + params.accent); // 0.5 to 1.0 (accent can double) + + // Attack time (5ms ramp like original) + const attackTime = 0.005; + + // Notch filter parameters for noise (fixed at 1000Hz like the original) + const notchFreq = 1000; + const notchQ = 5; + + for (let channel = 0; channel < 2; channel++) { + const output = channel === 0 ? left : right; + + // Triangle oscillator phase + let phase = Math.random() * Math.PI * 2; + + // Notch filter state variables + let notchState1 = 0; + let notchState2 = 0; + + // Stereo variation (slight detune and phase offset) + const stereoDetune = channel === 0 ? 0.998 : 1.002; + const stereoPhaseOffset = channel === 0 ? 0 : 0.1; + + for (let i = 0; i < numSamples; i++) { + const t = i / sampleRate; + const normPhase = i / numSamples; + + // Pitch envelope: exponential ramp from pitchMultiplier*freq down to base freq + const pitchEnv = Math.exp(-t / modDuration); + const currentFreq = tunedFreq * stereoDetune * (1 + (pitchMultiplier - 1) * pitchEnv); + + // Generate triangle wave + phase += (2 * Math.PI * currentFreq) / sampleRate; + if (phase > 2 * Math.PI) phase -= 2 * Math.PI; + + // Triangle wave from phase + const triangle = phase < Math.PI + ? -1 + (2 * phase) / Math.PI + : 3 - (2 * phase) / Math.PI; + + // Attack envelope (5ms linear ramp like original) + const attackEnv = t < attackTime ? t / attackTime : 1.0; + + // Tonal envelope: exponential decay + const tonalEnv = Math.exp(-t / tonalDecayTime) * attackEnv; + const tonalSignal = triangle * tonalEnv; + + // Generate white noise + const noise = Math.random() * 2 - 1; + + // Apply notch filter to noise (removes 1000Hz component) + const notchFiltered = this.notchFilter( + noise, + notchFreq, + notchQ, + sampleRate, + notchState1, + notchState2 + ); + notchState1 = notchFiltered.state1; + notchState2 = notchFiltered.state2; + + // Noise envelope: exponential decay (longer than tonal) + const noiseEnv = Math.exp(-t / noiseDecayTime); + const noiseSignal = notchFiltered.output * noiseEnv; + + // Mix tonal and noise using original 909 gain staging + // Original: input (tonal) at volume (0.5), noise at tone (0.25) + // tone param controls noise amount: 0 = less noise, 1 = more noise + const tonalGain = baseVolume * (1 - params.tone * 0.5); // 0.5 to 0.25 + const noiseGain = 0.15 + params.tone * 0.2; // 0.15 to 0.35 + + let sample = tonalSignal * tonalGain + noiseSignal * noiseGain; + + // Apply accent + sample *= (0.5 + params.accent * 0.5); // additional accent multiplier + + // Soft clipping + sample = this.softClip(sample); + + output[i] = sample; + } + } + + // Normalize output to consistent level + 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.5 / peak; // Normalize to 0.5 peak for more headroom + for (let i = 0; i < numSamples; i++) { + left[i] *= normGain; + right[i] *= normGain; + } + } + + return [left, right]; + } + + private notchFilter( + input: number, + frequency: number, + q: number, + sampleRate: number, + state1: number, + state2: number + ): { output: number; state1: number; state2: number } { + // State variable filter configured as notch + const normalizedFreq = Math.min(frequency / sampleRate, 0.48); + const f = 2 * Math.sin(Math.PI * normalizedFreq); + const qRecip = 1 / Math.max(q, 0.5); + + const lowpass = state2 + f * state1; + const highpass = input - lowpass - qRecip * state1; + const bandpass = f * highpass + state1; + + // Notch = input - bandpass + const notch = input - bandpass; + + // Update states with clamping + const newState1 = Math.max(-2, Math.min(2, bandpass)); + const newState2 = Math.max(-2, Math.min(2, lowpass)); + + return { + output: notch, + state1: newState1, + state2: newState2, + }; + } + + private softClip(x: number): number { + if (x > 1) return 1; + if (x < -1) return -1; + if (x > 0.66) return (3 - (2 - 3 * x) ** 2) / 3; + if (x < -0.66) return -(3 - (2 - 3 * -x) ** 2) / 3; + return x; + } + + private freqToParam(freq: number): number { + // Map frequency to 0-1 range (100-400 Hz) + return Math.max(0, Math.min(1, (freq - 100) / 300)); + } +} diff --git a/src/lib/audio/engines/registry.ts b/src/lib/audio/engines/registry.ts index 2b3e5d8..27283a3 100644 --- a/src/lib/audio/engines/registry.ts +++ b/src/lib/audio/engines/registry.ts @@ -10,6 +10,8 @@ import { Sample } from './Sample'; import { Input } from './Input'; import { KarplusStrong } from './KarplusStrong'; import { AdditiveEngine } from './AdditiveEngine'; +import { Snare909 } from './Snare909'; +import { BassDrum909 } from './BassDrum909'; export const engines: SynthEngine[] = [ new Sample(), @@ -20,6 +22,8 @@ export const engines: SynthEngine[] = [ new Benjolin(), new ZzfxEngine(), new NoiseDrum(), + new Snare909(), + new BassDrum909(), new Ring(), new KarplusStrong(), new AdditiveEngine(),