current kick generator

This commit is contained in:
2025-10-12 16:35:00 +02:00
parent b422d253d3
commit 57fb8a93dc
4 changed files with 803 additions and 0 deletions

View File

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

View File

@ -248,6 +248,22 @@ export class Ring implements SynthEngine<RingParams> {
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];
}

View File

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

View File

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