Compare commits

...

3 Commits

Author SHA1 Message Date
c1f7cc02fd new engine 2025-10-12 23:39:14 +02:00
d118d3a52b phase distortion 2025-10-12 18:25:44 +02:00
57fb8a93dc current kick generator 2025-10-12 16:35:00 +02:00
9 changed files with 2081 additions and 2 deletions

View File

@ -65,4 +65,5 @@ Opens on http://localhost:8080
## Credits ## Credits
Wavetables from [Adventure Kid Waveforms](https://www.adventurekid.se/akrt/waveforms/adventure-kid-waveforms/) by Kristoffer Ekstrand - Wavetables from [Adventure Kid Waveforms](https://www.adventurekid.se/akrt/waveforms/adventure-kid-waveforms/) by Kristoffer Ekstrand
- [Garten Salat](https://garten.salat.dev/) by Felix Roos for drum synthesis inspiration.

View File

@ -0,0 +1,604 @@
import type { PitchLock, SynthEngine } from './SynthEngine';
interface BassDrumParams {
// 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;
// Envelope curve parameters
attackTime: number; // Attack duration (0 = 0.5ms, 1 = 50ms)
attackCurve: number; // Attack curve shape (0 = soft/slow, 1 = sharp/fast)
ampDecayCurve: number; // Amplitude decay curve (0 = loose/boomy, 1 = tight/punchy)
pitchDecayCurve: number; // Pitch envelope curve (0 = loose, 1 = tight)
}
export class BassDrum 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): BassDrumParams {
// 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;
let attackTime: number, attackCurve: number, ampDecayCurve: number, pitchDecayCurve: 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
attackTime = 0.0 + Math.random() * 0.2; // Very fast attack (0.5-10ms)
attackCurve = 0.6 + Math.random() * 0.3; // Sharp attack (0.6-0.9)
ampDecayCurve = 0.7 + Math.random() * 0.3; // Very tight decay (0.7-1.0)
pitchDecayCurve = 0.6 + Math.random() * 0.3; // Fast pitch sweep (0.6-0.9)
} 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
attackTime = 0.0 + Math.random() * 0.3; // Fast attack (0.5-15ms)
attackCurve = 0.3 + Math.random() * 0.3; // Medium-sharp attack (0.3-0.6)
ampDecayCurve = 0.5 + Math.random() * 0.3; // Medium-tight decay (0.5-0.8)
pitchDecayCurve = 0.4 + Math.random() * 0.3; // Medium pitch sweep (0.4-0.7)
} 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
attackTime = 0.1 + Math.random() * 0.4; // Slower attack (5-25ms)
attackCurve = 0.2 + Math.random() * 0.3; // Soft attack (0.2-0.5)
ampDecayCurve = 0.2 + Math.random() * 0.3; // Loose, boomy decay (0.2-0.5)
pitchDecayCurve = 0.2 + Math.random() * 0.3; // Slow pitch sweep (0.2-0.5)
} 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
attackTime = 0.2 + Math.random() * 0.4; // Medium attack (10-30ms)
attackCurve = 0.3 + Math.random() * 0.3; // Medium attack (0.3-0.6)
ampDecayCurve = 0.1 + Math.random() * 0.3; // Very loose, sustaining (0.1-0.4)
pitchDecayCurve = 0.1 + Math.random() * 0.3; // Gentle pitch sweep (0.1-0.4)
} 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
attackTime = 0.0 + Math.random() * 0.15; // Instant attack (0.5-8ms)
attackCurve = 0.7 + Math.random() * 0.3; // Very sharp attack (0.7-1.0)
ampDecayCurve = 0.7 + Math.random() * 0.3; // Very tight (0.7-1.0)
pitchDecayCurve = 0.7 + Math.random() * 0.3; // Fast pitch sweep (0.7-1.0)
} 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
attackTime = Math.random(); // Full range (0.5-50ms)
attackCurve = Math.random(); // Full range (0.0-1.0)
ampDecayCurve = Math.random(); // Full range (0.0-1.0)
pitchDecayCurve = Math.random(); // Full range (0.0-1.0)
} 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
attackTime = 0.1 + Math.random() * 0.4; // Medium attack (5-25ms)
attackCurve = 0.4 + Math.random() * 0.4; // Medium-sharp attack (0.4-0.8)
ampDecayCurve = 0.3 + Math.random() * 0.4; // Medium varied (0.3-0.7)
pitchDecayCurve = 0.3 + Math.random() * 0.4; // Medium varied (0.3-0.7)
}
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,
attackTime,
attackCurve,
ampDecayCurve,
pitchDecayCurve,
};
}
mutateParams(params: BassDrumParams, mutationAmount: number = 0.15, pitchLock?: PitchLock): BassDrumParams {
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),
attackTime: mutate(params.attackTime, 0.15),
attackCurve: mutate(params.attackCurve, 0.2),
ampDecayCurve: mutate(params.ampDecayCurve, 0.2),
pitchDecayCurve: mutate(params.pitchDecayCurve, 0.2),
};
}
generate(
params: BassDrumParams,
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
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 (0.5ms to 50ms, scaled by duration)
const attackTime = (0.0005 + params.attackTime * 0.0495) * duration;
// 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: curved decay
const pitchEnv = this.applyDecayCurve(t, pitchDecayTime, params.pitchDecayCurve);
// 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 (curved)
const attackEnv = t < attackTime
? this.applyAttackCurve(t, attackTime, params.attackCurve)
: 1.0;
// Amplitude envelope: curved decay with attack
const ampEnv = this.applyDecayCurve(t, ampDecayTime, params.ampDecayCurve) * attackEnv;
// 1. Morphed oscillator (main tone)
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;
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 applyDecayCurve(t: number, decayTime: number, curveParam: number): number {
const normalized = Math.min(t / decayTime, 5);
const baseEnv = Math.exp(-normalized);
const exponent = 0.4 + curveParam * 2.1;
return Math.pow(baseEnv, exponent);
}
private applyAttackCurve(t: number, attackTime: number, curveParam: number): number {
const normalized = Math.min(t / attackTime, 1);
const exponent = 0.4 + curveParam * 2.1;
return Math.pow(normalized, exponent);
}
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 {
const PI = Math.PI;
if (Math.abs(x) < 0.001) return x;
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

@ -0,0 +1,179 @@
import type { PitchLock, SynthEngine } from './SynthEngine';
interface HiHatParams {
// Decay time (0 = closed/tight, 1 = open/long)
decay: number;
// Tone/brightness (filter cutoff frequency)
tone: number;
// Noise mix (0 = pure metallic, 1 = more noise)
noise: number;
// Base pitch
pitch: number;
// Timbre (shifts frequency ratios for different metallic characters)
timbre: number;
}
export class HiHat implements SynthEngine {
getName(): string {
return 'Hi-Hat';
}
getDescription(): string {
return 'Metallic hi-hat from square wave oscillators and noise';
}
getType() {
return 'generative' as const;
}
randomParams(pitchLock?: PitchLock): HiHatParams {
return {
decay: Math.random(),
tone: 0.4 + Math.random() * 0.6,
noise: 0.3 + Math.random() * 0.5,
pitch: pitchLock ? this.freqToParam(pitchLock.frequency) : 0.4 + Math.random() * 0.2,
timbre: Math.random(),
};
}
mutateParams(params: HiHatParams, mutationAmount: number = 0.15, pitchLock?: PitchLock): HiHatParams {
const mutate = (value: number, amount: number = mutationAmount): number => {
return Math.max(0, Math.min(1, value + (Math.random() - 0.5) * amount));
};
return {
decay: mutate(params.decay, 0.25),
tone: mutate(params.tone, 0.25),
noise: mutate(params.noise, 0.2),
pitch: pitchLock ? params.pitch : mutate(params.pitch, 0.15),
timbre: mutate(params.timbre, 0.25),
};
}
generate(
params: HiHatParams,
sampleRate: number,
duration: number,
pitchLock?: PitchLock
): [Float32Array, Float32Array] {
const numSamples = Math.floor(sampleRate * duration);
const left = new Float32Array(numSamples);
const right = new Float32Array(numSamples);
// Decay time: 30ms (tight closed) to 800ms (long open)
const decayTime = (0.03 + params.decay * 0.77) * duration;
// Bandpass filter frequencies for metallic character (based on classic hi-hat synthesis)
// Pitch shifts all frequencies, timbre changes the spread
const basePitch = pitchLock ? pitchLock.frequency : 600 + params.pitch * 800;
const pitchScale = basePitch / 1000; // normalize to ~1000Hz center
// Timbre controls frequency spread: 0 = tight/focused, 1 = wide/trashy
const spread = 0.7 + params.timbre * 0.6;
const filterFreqs = [
4500 * pitchScale * spread,
6200 * pitchScale * spread,
7800 * pitchScale * spread,
9600 * pitchScale * spread,
11400 * pitchScale * spread,
13200 * pitchScale * spread,
];
// Tone controls overall brightness via highpass cutoff
const hpFreq = 4000 + params.tone * 6000; // 4kHz to 10kHz
for (let channel = 0; channel < 2; channel++) {
const output = channel === 0 ? left : right;
// Bandpass filter states (6 filters, 2 states each)
const bpStates1 = new Array(6).fill(0);
const bpStates2 = new Array(6).fill(0);
// Highpass filter state
let hp1 = 0, hp2 = 0;
// Stereo variation in filter frequencies
const stereoShift = channel === 0 ? 0.995 : 1.005;
for (let i = 0; i < numSamples; i++) {
const t = i / sampleRate;
// Exponential decay envelope
const env = Math.exp(-t / decayTime);
// White noise source
const noise = Math.random() * 2 - 1;
// Pass noise through 6 bandpass filters at different frequencies
let filtered = 0;
for (let j = 0; j < filterFreqs.length; j++) {
const freq = filterFreqs[j] * stereoShift;
const q = 8 + j * 2; // Higher Q for higher frequencies
const freqNorm = Math.min(freq / sampleRate, 0.48);
const f = 2 * Math.sin(Math.PI * freqNorm);
const qRecip = 1 / q;
const lp = bpStates2[j] + f * bpStates1[j];
const hp = noise - lp - qRecip * bpStates1[j];
const bp = f * hp + bpStates1[j];
bpStates1[j] = Math.max(-2, Math.min(2, bp));
bpStates2[j] = Math.max(-2, Math.min(2, lp));
filtered += bp;
}
// Average the bandpass outputs
let sample = filtered / filterFreqs.length;
// Add raw noise for more character (controlled by noise param)
sample = sample * (1 - params.noise * 0.5) + noise * params.noise * 0.3;
// Apply envelope
sample *= env;
// Highpass filter for brightness control
const hpNorm = Math.min(hpFreq / sampleRate, 0.48);
const hpA = 1 - hpNorm * 2;
const hpFiltered = hpA * (hp1 + sample - hp2);
hp2 = sample;
hp1 = hpFiltered;
sample = hpFiltered;
// Soft clip
if (sample > 0.7) sample = 0.7 + (sample - 0.7) * 0.3;
if (sample < -0.7) sample = -0.7 + (sample + 0.7) * 0.3;
output[i] = sample;
}
}
// 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.5 / peak;
for (let i = 0; i < numSamples; i++) {
left[i] *= normGain;
right[i] *= normGain;
}
}
return [left, right];
}
private freqToParam(freq: number): number {
// Map frequency to 0-1 range (600-1400 Hz)
return Math.max(0, Math.min(1, (freq - 600) / 800));
}
}

View File

@ -39,7 +39,7 @@ interface NoiseDrumParams {
export class NoiseDrum implements SynthEngine { export class NoiseDrum implements SynthEngine {
getName(): string { getName(): string {
return 'Noise Drum'; return 'NPerc';
} }
getDescription(): string { getDescription(): string {

View File

@ -0,0 +1,588 @@
import type { SynthEngine, PitchLock } from './SynthEngine';
enum PDWaveform {
Sine,
SawUp,
SawDown,
Pulse,
DoubleSine,
ResonantSaw,
}
enum PDAlgorithm {
Single, // Single oscillator with PD
Dual, // Two oscillators, different waveforms
Detune, // Two slightly detuned oscillators
Octave, // Oscillator + octave up
Fifth, // Base + fifth up (1.5x frequency)
Sub, // Base + octave down
Stack, // Base + fifth + octave
Wide, // Heavily detuned oscillators
}
enum LFOWaveform {
Sine,
Triangle,
Square,
Saw,
SampleHold,
RandomWalk,
}
interface PDOscillatorParams {
waveform: PDWaveform;
level: number;
detune: number; // in cents
dcw: number; // Digitally Controlled Waveshaping (0-1, controls brightness)
pulseWidth: number; // 0-1, controls pulse wave width
attack: number;
decay: number;
sustain: number;
release: number;
dcwAttack: number;
dcwDecay: number;
dcwSustain: number;
dcwRelease: number;
}
interface LFOParams {
rate: number;
depth: number;
waveform: LFOWaveform;
target: 'pitch' | 'dcw';
}
export interface PhaseDistortionFMParams {
baseFreq: number;
algorithm: PDAlgorithm;
oscillators: [PDOscillatorParams, PDOscillatorParams];
lfo: LFOParams;
stereoWidth: number;
}
export class PhaseDistortionFM implements SynthEngine<PhaseDistortionFMParams> {
private static workletLoaded = false;
private static workletURL: string | null = null;
getName(): string {
return 'PD';
}
getDescription(): string {
return 'Casio CZ-style Phase Distortion synthesis with DCW envelopes';
}
getType() {
return 'generative' as const;
}
generate(params: PhaseDistortionFMParams, sampleRate: number, duration: number): [Float32Array, Float32Array] {
const numSamples = Math.floor(sampleRate * duration);
const leftBuffer = new Float32Array(numSamples);
const rightBuffer = new Float32Array(numSamples);
const TAU = Math.PI * 2;
const detune = 1 + (params.stereoWidth * 0.001);
const leftFreq = params.baseFreq / detune;
const rightFreq = params.baseFreq * detune;
let osc1PhaseL = 0;
let osc1PhaseR = 0;
let osc2PhaseL = 0;
let osc2PhaseR = 0;
let lfoPhaseL = 0;
let lfoPhaseR = Math.PI * params.stereoWidth * 0.3;
let lfoSampleHoldValue = Math.random() * 2 - 1;
let lfoSampleHoldCounter = 0;
const lfoSampleHoldInterval = Math.max(1, Math.floor(sampleRate / (params.lfo.rate * 4)));
let lfoRandomWalkCurrent = Math.random() * 2 - 1;
let lfoRandomWalkTarget = Math.random() * 2 - 1;
let lfoRandomWalkCounter = 0;
const lfoRandomWalkInterval = Math.max(1, Math.floor(sampleRate / (params.lfo.rate * 2)));
let dcBlockerPrevL = 0;
let dcBlockerAccL = 0;
let dcBlockerPrevR = 0;
let dcBlockerAccR = 0;
const dcBlockerCutoff = 0.995;
for (let i = 0; i < numSamples; i++) {
const t = i / sampleRate;
const env1 = this.calculateEnvelope(t, duration, params.oscillators[0]);
const env2 = this.calculateEnvelope(t, duration, params.oscillators[1]);
const dcw1 = this.calculateDCWEnvelope(t, duration, params.oscillators[0]);
const dcw2 = this.calculateDCWEnvelope(t, duration, params.oscillators[1]);
// Update LFO states
lfoSampleHoldCounter++;
if (lfoSampleHoldCounter >= lfoSampleHoldInterval) {
lfoSampleHoldValue = Math.random() * 2 - 1;
lfoSampleHoldCounter = 0;
}
lfoRandomWalkCounter++;
if (lfoRandomWalkCounter >= lfoRandomWalkInterval) {
lfoRandomWalkTarget = Math.random() * 2 - 1;
lfoRandomWalkCounter = 0;
}
const walkSpeed = 0.01;
lfoRandomWalkCurrent += (lfoRandomWalkTarget - lfoRandomWalkCurrent) * walkSpeed;
const lfoL = this.generateLFO(lfoPhaseL, params.lfo.waveform, lfoSampleHoldValue, lfoRandomWalkCurrent);
const lfoR = this.generateLFO(lfoPhaseR, params.lfo.waveform, lfoSampleHoldValue, lfoRandomWalkCurrent);
const lfoModL = lfoL * params.lfo.depth;
const lfoModR = lfoR * params.lfo.depth;
let pitchModL = 0, pitchModR = 0;
let dcwMod1 = 0, dcwMod2 = 0;
if (params.lfo.target === 'pitch') {
pitchModL = lfoModL * 0.02;
pitchModR = lfoModR * 0.02;
} else {
dcwMod1 = lfoModL * 0.3;
dcwMod2 = lfoModL * 0.3;
}
const [sampleL, sampleR] = this.processAlgorithm(
params.algorithm,
params.oscillators,
[osc1PhaseL, osc2PhaseL],
[osc1PhaseR, osc2PhaseR],
[env1, env2],
[Math.max(0, Math.min(1, dcw1 + dcwMod1)), Math.max(0, Math.min(1, dcw2 + dcwMod2))]
);
let outL = sampleL;
let outR = sampleR;
outL = this.softClip(outL);
outR = this.softClip(outR);
// DC blocker
const dcFilteredL = outL - dcBlockerPrevL + dcBlockerCutoff * dcBlockerAccL;
dcBlockerPrevL = outL;
dcBlockerAccL = dcFilteredL;
const dcFilteredR = outR - dcBlockerPrevR + dcBlockerCutoff * dcBlockerAccR;
dcBlockerPrevR = outR;
dcBlockerAccR = dcFilteredR;
leftBuffer[i] = dcFilteredL;
rightBuffer[i] = dcFilteredR;
// Advance oscillator phases
const detune1 = Math.pow(2, params.oscillators[0].detune / 1200);
const detune2 = Math.pow(2, params.oscillators[1].detune / 1200);
const osc1FreqL = leftFreq * detune1 * (1 + pitchModL);
const osc1FreqR = rightFreq * detune1 * (1 + pitchModR);
const osc2FreqL = leftFreq * detune2 * (1 + pitchModL);
const osc2FreqR = rightFreq * detune2 * (1 + pitchModR);
osc1PhaseL += (TAU * osc1FreqL) / sampleRate;
osc1PhaseR += (TAU * osc1FreqR) / sampleRate;
osc2PhaseL += (TAU * osc2FreqL) / sampleRate;
osc2PhaseR += (TAU * osc2FreqR) / sampleRate;
while (osc1PhaseL >= TAU) osc1PhaseL -= TAU;
while (osc1PhaseR >= TAU) osc1PhaseR -= TAU;
while (osc2PhaseL >= TAU) osc2PhaseL -= TAU;
while (osc2PhaseR >= TAU) osc2PhaseR -= TAU;
lfoPhaseL += (TAU * params.lfo.rate) / sampleRate;
lfoPhaseR += (TAU * params.lfo.rate) / sampleRate;
while (lfoPhaseL >= TAU) lfoPhaseL -= TAU;
while (lfoPhaseR >= TAU) lfoPhaseR -= TAU;
}
// Normalize
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];
}
private processAlgorithm(
algorithm: PDAlgorithm,
oscillators: [PDOscillatorParams, PDOscillatorParams],
phasesL: number[],
phasesR: number[],
envelopes: number[],
dcws: number[]
): [number, number] {
switch (algorithm) {
case PDAlgorithm.Single: {
// Single oscillator
const outL = this.generatePDWaveform(phasesL[0], oscillators[0].waveform, dcws[0], oscillators[0].pulseWidth)
* envelopes[0] * oscillators[0].level;
const outR = this.generatePDWaveform(phasesR[0], oscillators[0].waveform, dcws[0], oscillators[0].pulseWidth)
* envelopes[0] * oscillators[0].level;
return [outL, outR];
}
case PDAlgorithm.Dual: {
// Two oscillators with different waveforms
const osc1L = this.generatePDWaveform(phasesL[0], oscillators[0].waveform, dcws[0], oscillators[0].pulseWidth)
* envelopes[0] * oscillators[0].level;
const osc1R = this.generatePDWaveform(phasesR[0], oscillators[0].waveform, dcws[0], oscillators[0].pulseWidth)
* envelopes[0] * oscillators[0].level;
const osc2L = this.generatePDWaveform(phasesL[1], oscillators[1].waveform, dcws[1], oscillators[1].pulseWidth)
* envelopes[1] * oscillators[1].level;
const osc2R = this.generatePDWaveform(phasesR[1], oscillators[1].waveform, dcws[1], oscillators[1].pulseWidth)
* envelopes[1] * oscillators[1].level;
return [(osc1L + osc2L) * 0.707, (osc1R + osc2R) * 0.707];
}
case PDAlgorithm.Detune: {
// Two slightly detuned oscillators (chorus effect)
const osc1L = this.generatePDWaveform(phasesL[0], oscillators[0].waveform, dcws[0], oscillators[0].pulseWidth)
* envelopes[0] * oscillators[0].level;
const osc1R = this.generatePDWaveform(phasesR[0], oscillators[0].waveform, dcws[0], oscillators[0].pulseWidth)
* envelopes[0] * oscillators[0].level;
const osc2L = this.generatePDWaveform(phasesL[1], oscillators[0].waveform, dcws[0], oscillators[0].pulseWidth)
* envelopes[0] * oscillators[1].level;
const osc2R = this.generatePDWaveform(phasesR[1], oscillators[0].waveform, dcws[0], oscillators[0].pulseWidth)
* envelopes[0] * oscillators[1].level;
return [(osc1L + osc2L) * 0.707, (osc1R + osc2R) * 0.707];
}
case PDAlgorithm.Octave: {
// Oscillator + octave up
const osc1L = this.generatePDWaveform(phasesL[0], oscillators[0].waveform, dcws[0], oscillators[0].pulseWidth)
* envelopes[0] * oscillators[0].level;
const osc1R = this.generatePDWaveform(phasesR[0], oscillators[0].waveform, dcws[0], oscillators[0].pulseWidth)
* envelopes[0] * oscillators[0].level;
const osc2L = this.generatePDWaveform(phasesL[1] * 2, oscillators[1].waveform, dcws[1], oscillators[1].pulseWidth)
* envelopes[1] * oscillators[1].level;
const osc2R = this.generatePDWaveform(phasesR[1] * 2, oscillators[1].waveform, dcws[1], oscillators[1].pulseWidth)
* envelopes[1] * oscillators[1].level;
return [(osc1L + osc2L) * 0.707, (osc1R + osc2R) * 0.707];
}
case PDAlgorithm.Fifth: {
// Base + fifth up (1.5x frequency)
const osc1L = this.generatePDWaveform(phasesL[0], oscillators[0].waveform, dcws[0], oscillators[0].pulseWidth)
* envelopes[0] * oscillators[0].level;
const osc1R = this.generatePDWaveform(phasesR[0], oscillators[0].waveform, dcws[0], oscillators[0].pulseWidth)
* envelopes[0] * oscillators[0].level;
const osc2L = this.generatePDWaveform(phasesL[1] * 1.5, oscillators[1].waveform, dcws[1], oscillators[1].pulseWidth)
* envelopes[1] * oscillators[1].level;
const osc2R = this.generatePDWaveform(phasesR[1] * 1.5, oscillators[1].waveform, dcws[1], oscillators[1].pulseWidth)
* envelopes[1] * oscillators[1].level;
return [(osc1L + osc2L) * 0.707, (osc1R + osc2R) * 0.707];
}
case PDAlgorithm.Sub: {
// Base + octave down
const osc1L = this.generatePDWaveform(phasesL[0], oscillators[0].waveform, dcws[0], oscillators[0].pulseWidth)
* envelopes[0] * oscillators[0].level;
const osc1R = this.generatePDWaveform(phasesR[0], oscillators[0].waveform, dcws[0], oscillators[0].pulseWidth)
* envelopes[0] * oscillators[0].level;
const osc2L = this.generatePDWaveform(phasesL[1] * 0.5, oscillators[1].waveform, dcws[1], oscillators[1].pulseWidth)
* envelopes[1] * oscillators[1].level;
const osc2R = this.generatePDWaveform(phasesR[1] * 0.5, oscillators[1].waveform, dcws[1], oscillators[1].pulseWidth)
* envelopes[1] * oscillators[1].level;
return [(osc1L + osc2L) * 0.707, (osc1R + osc2R) * 0.707];
}
case PDAlgorithm.Stack: {
// Base + fifth + octave - three oscillators!
const osc1L = this.generatePDWaveform(phasesL[0], oscillators[0].waveform, dcws[0], oscillators[0].pulseWidth)
* envelopes[0] * oscillators[0].level;
const osc1R = this.generatePDWaveform(phasesR[0], oscillators[0].waveform, dcws[0], oscillators[0].pulseWidth)
* envelopes[0] * oscillators[0].level;
const osc2L = this.generatePDWaveform(phasesL[1] * 1.5, oscillators[1].waveform, dcws[1], oscillators[1].pulseWidth)
* envelopes[1] * oscillators[1].level;
const osc2R = this.generatePDWaveform(phasesR[1] * 1.5, oscillators[1].waveform, dcws[1], oscillators[1].pulseWidth)
* envelopes[1] * oscillators[1].level;
const osc3L = this.generatePDWaveform(phasesL[1] * 2, oscillators[1].waveform, dcws[1], oscillators[1].pulseWidth)
* envelopes[1] * oscillators[1].level * 0.7;
const osc3R = this.generatePDWaveform(phasesR[1] * 2, oscillators[1].waveform, dcws[1], oscillators[1].pulseWidth)
* envelopes[1] * oscillators[1].level * 0.7;
return [(osc1L + osc2L + osc3L) * 0.577, (osc1R + osc2R + osc3R) * 0.577];
}
case PDAlgorithm.Wide: {
// Heavily detuned oscillators for super wide chorus
const osc1L = this.generatePDWaveform(phasesL[0], oscillators[0].waveform, dcws[0], oscillators[0].pulseWidth)
* envelopes[0] * oscillators[0].level;
const osc1R = this.generatePDWaveform(phasesR[0], oscillators[0].waveform, dcws[0], oscillators[0].pulseWidth)
* envelopes[0] * oscillators[0].level;
const osc2L = this.generatePDWaveform(phasesL[1], oscillators[1].waveform, dcws[1], oscillators[1].pulseWidth)
* envelopes[1] * oscillators[1].level;
const osc2R = this.generatePDWaveform(phasesR[1], oscillators[1].waveform, dcws[1], oscillators[1].pulseWidth)
* envelopes[1] * oscillators[1].level;
return [(osc1L + osc2L) * 0.707, (osc1R + osc2R) * 0.707];
}
default:
return [0, 0];
}
}
private generatePDWaveform(phase: number, waveform: PDWaveform, dcw: number, pulseWidth: number): number {
const TAU = Math.PI * 2;
let normalizedPhase = phase / TAU;
normalizedPhase = normalizedPhase - Math.floor(normalizedPhase);
// Apply phase distortion based on DCW
const distortedPhase = this.applyPhaseDistortion(normalizedPhase, dcw);
switch (waveform) {
case PDWaveform.Sine:
return Math.sin(distortedPhase * TAU);
case PDWaveform.SawUp:
return distortedPhase * 2 - 1;
case PDWaveform.SawDown:
return 1 - distortedPhase * 2;
case PDWaveform.Pulse:
return distortedPhase < pulseWidth ? 1 : -1;
case PDWaveform.DoubleSine:
return Math.sin(distortedPhase * TAU * 2);
case PDWaveform.ResonantSaw:
return this.resonantSaw(distortedPhase, dcw);
default:
return Math.sin(distortedPhase * TAU);
}
}
private applyPhaseDistortion(phase: number, dcw: number): number {
// Casio CZ-style phase distortion
// DCW = 0: no distortion (linear phase)
// DCW = 1: maximum distortion (transforms waveform)
if (dcw < 0.01) return phase;
// Add a triangle wave to the phase - this is the classic CZ method
// The triangle modulates how fast we read through the waveform
const trianglePhase = phase < 0.5 ? phase * 2 : 2 - phase * 2;
// Mix between linear and distorted phase
return phase + (trianglePhase - 0.5) * dcw;
}
private resonantSaw(phase: number, brightness: number): number {
// Band-limited sawtooth with fewer harmonics for cleaner sound
const maxHarmonics = Math.min(4, Math.floor(1 + brightness * 3));
let sum = 0;
let normalization = 0;
for (let n = 1; n <= maxHarmonics; n++) {
sum += Math.sin(Math.PI * 2 * phase * n) / n;
normalization += 1 / n;
}
return normalization > 0 ? (sum / normalization) * 0.7 : 0;
}
private calculateEnvelope(t: number, duration: number, osc: PDOscillatorParams): number {
const attackTime = Math.max(0.0001, osc.attack * duration);
const decayTime = Math.max(0.0001, osc.decay * duration);
const releaseTime = Math.max(0.0001, osc.release * duration);
const sustainStart = attackTime + decayTime;
const releaseStart = duration - releaseTime;
if (t < attackTime) {
return t / attackTime;
} else if (t < sustainStart) {
const progress = (t - attackTime) / decayTime;
return 1 - progress * (1 - osc.sustain);
} else if (t < releaseStart) {
return osc.sustain;
} else {
const progress = (t - releaseStart) / releaseTime;
return osc.sustain * (1 - progress);
}
}
private calculateDCWEnvelope(t: number, duration: number, osc: PDOscillatorParams): number {
const attackTime = Math.max(0.0001, osc.dcwAttack * duration);
const decayTime = Math.max(0.0001, osc.dcwDecay * duration);
const releaseTime = Math.max(0.0001, osc.dcwRelease * duration);
const sustainStart = attackTime + decayTime;
const releaseStart = duration - releaseTime;
if (t < attackTime) {
return (t / attackTime) * osc.dcw;
} else if (t < sustainStart) {
const progress = (t - attackTime) / decayTime;
return osc.dcw * (1 - progress * (1 - osc.dcwSustain));
} else if (t < releaseStart) {
return osc.dcw * osc.dcwSustain;
} else {
const progress = (t - releaseStart) / releaseTime;
return osc.dcw * osc.dcwSustain * (1 - progress);
}
}
private generateLFO(phase: number, waveform: LFOWaveform, sampleHoldValue: number, randomWalkValue: number): number {
const TAU = Math.PI * 2;
let normalizedPhase = phase / TAU;
normalizedPhase = normalizedPhase - Math.floor(normalizedPhase);
switch (waveform) {
case LFOWaveform.Sine:
return Math.sin(phase);
case LFOWaveform.Triangle:
return normalizedPhase < 0.5
? normalizedPhase * 4 - 1
: 3 - normalizedPhase * 4;
case LFOWaveform.Square:
return normalizedPhase < 0.5 ? 1 : -1;
case LFOWaveform.Saw:
return normalizedPhase * 2 - 1;
case LFOWaveform.SampleHold:
return sampleHoldValue;
case LFOWaveform.RandomWalk:
return randomWalkValue;
default:
return 0;
}
}
private softClip(x: number): number {
const absX = Math.abs(x);
if (absX < 0.7) return x;
if (absX > 3) return Math.sign(x) * 0.98;
const x2 = x * x;
return x * (27 + x2) / (27 + 9 * x2);
}
randomParams(pitchLock?: PitchLock): PhaseDistortionFMParams {
const algorithm = this.randomInt(0, 7) as PDAlgorithm;
let baseFreq: number;
if (pitchLock?.enabled) {
baseFreq = pitchLock.frequency;
} else {
const baseFreqChoices = [55, 82.4, 110, 146.8, 220, 293.7, 440, 587.3, 880];
baseFreq = this.randomChoice(baseFreqChoices);
}
return {
baseFreq,
algorithm,
oscillators: [
this.randomOscillator(algorithm),
this.randomOscillator(algorithm),
],
lfo: {
rate: this.randomRange(0.5, 8),
depth: this.randomRange(0, 0.4),
waveform: this.randomInt(0, 5) as LFOWaveform,
target: this.randomChoice(['pitch', 'dcw'] as const),
},
stereoWidth: this.randomRange(0.1, 0.5),
};
}
private randomOscillator(algorithm: PDAlgorithm): PDOscillatorParams {
const waveform = this.randomInt(0, 5) as PDWaveform;
let detune = 0;
if (algorithm === PDAlgorithm.Detune) {
detune = this.randomRange(-10, 10);
} else if (algorithm === PDAlgorithm.Wide) {
detune = this.randomRange(-30, 30);
}
return {
waveform,
level: this.randomRange(0.5, 0.9),
detune,
dcw: this.randomRange(0.2, 0.8),
pulseWidth: this.randomRange(0.2, 0.8),
attack: this.randomRange(0.001, 0.1),
decay: this.randomRange(0.02, 0.2),
sustain: this.randomRange(0.3, 0.8),
release: this.randomRange(0.05, 0.3),
dcwAttack: this.randomRange(0.001, 0.08),
dcwDecay: this.randomRange(0.02, 0.15),
dcwSustain: this.randomRange(0.2, 0.7),
dcwRelease: this.randomRange(0.05, 0.25),
};
}
mutateParams(params: PhaseDistortionFMParams, mutationAmount: number = 0.15, pitchLock?: PitchLock): PhaseDistortionFMParams {
const baseFreq = pitchLock?.enabled ? pitchLock.frequency : params.baseFreq;
return {
baseFreq,
algorithm: Math.random() < 0.1 ? this.randomInt(0, 7) as PDAlgorithm : params.algorithm,
oscillators: params.oscillators.map(osc =>
this.mutateOscillator(osc, mutationAmount)
) as [PDOscillatorParams, PDOscillatorParams],
lfo: {
rate: this.mutateValue(params.lfo.rate, mutationAmount, 0.1, 15),
depth: this.mutateValue(params.lfo.depth, mutationAmount, 0, 0.6),
waveform: Math.random() < 0.08 ? this.randomInt(0, 5) as LFOWaveform : params.lfo.waveform,
target: Math.random() < 0.08 ? this.randomChoice(['pitch', 'dcw'] as const) : params.lfo.target,
},
stereoWidth: this.mutateValue(params.stereoWidth, mutationAmount, 0, 0.7),
};
}
private mutateOscillator(osc: PDOscillatorParams, amount: number): PDOscillatorParams {
return {
waveform: Math.random() < 0.1 ? this.randomInt(0, 5) as PDWaveform : osc.waveform,
level: this.mutateValue(osc.level, amount, 0.3, 1.0),
detune: this.mutateValue(osc.detune, amount, -20, 20),
dcw: this.mutateValue(osc.dcw, amount, 0, 1),
pulseWidth: this.mutateValue(osc.pulseWidth, amount, 0.1, 0.9),
attack: this.mutateValue(osc.attack, amount, 0.001, 0.2),
decay: this.mutateValue(osc.decay, amount, 0.01, 0.3),
sustain: this.mutateValue(osc.sustain, amount, 0.1, 0.95),
release: this.mutateValue(osc.release, amount, 0.02, 0.5),
dcwAttack: this.mutateValue(osc.dcwAttack, amount, 0.001, 0.15),
dcwDecay: this.mutateValue(osc.dcwDecay, amount, 0.01, 0.25),
dcwSustain: this.mutateValue(osc.dcwSustain, amount, 0.1, 0.9),
dcwRelease: this.mutateValue(osc.dcwRelease, amount, 0.02, 0.4),
};
}
private randomRange(min: number, max: number): number {
return min + Math.random() * (max - min);
}
private randomInt(min: number, max: number): number {
return Math.floor(this.randomRange(min, max + 1));
}
private randomChoice<T>(choices: readonly T[]): T {
return choices[Math.floor(Math.random() * choices.length)];
}
private mutateValue(value: number, amount: number, min: number, max: number): number {
const variation = value * amount * (Math.random() * 2 - 1);
return Math.max(min, Math.min(max, value + variation));
}
}

View File

@ -248,6 +248,22 @@ export class Ring implements SynthEngine<RingParams> {
if (secondModPhaseR > TAU * 1000) secondModPhaseR -= TAU * 1000; 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]; return [leftBuffer, rightBuffer];
} }

View File

@ -0,0 +1,234 @@
import type { PitchLock, SynthEngine } from './SynthEngine';
interface SnareParams {
// 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 Snare implements SynthEngine {
getName(): string {
return 'Snare';
}
getDescription(): string {
return 'Classic snare drum with triangle wave and noise';
}
getType() {
return 'generative' as const;
}
randomParams(pitchLock?: PitchLock): SnareParams {
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: SnareParams, mutationAmount: number = 0.15, pitchLock?: PitchLock): SnareParams {
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: SnareParams,
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 (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
const baseVolume = 0.5;
const accentGain = baseVolume * (1 + params.accent);
// Attack time (5ms ramp)
const attackTime = 0.005;
// Notch filter parameters for noise (fixed at 1000Hz)
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)
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
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);
// 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

@ -1,6 +1,7 @@
import type { SynthEngine } from './SynthEngine'; import type { SynthEngine } from './SynthEngine';
import { FourOpFM } from './FourOpFM'; import { FourOpFM } from './FourOpFM';
import { TwoOpFM } from './TwoOpFM'; import { TwoOpFM } from './TwoOpFM';
import { PhaseDistortionFM } from './PhaseDistortionFM';
import { DubSiren } from './DubSiren'; import { DubSiren } from './DubSiren';
import { Benjolin } from './Benjolin'; import { Benjolin } from './Benjolin';
import { ZzfxEngine } from './ZzfxEngine'; import { ZzfxEngine } from './ZzfxEngine';
@ -10,16 +11,23 @@ import { Sample } from './Sample';
import { Input } from './Input'; import { Input } from './Input';
import { KarplusStrong } from './KarplusStrong'; import { KarplusStrong } from './KarplusStrong';
import { AdditiveEngine } from './AdditiveEngine'; import { AdditiveEngine } from './AdditiveEngine';
import { Snare } from './Snare';
import { BassDrum } from './BassDrum';
import { HiHat } from './HiHat';
export const engines: SynthEngine[] = [ export const engines: SynthEngine[] = [
new Sample(), new Sample(),
new Input(), new Input(),
new FourOpFM(), new FourOpFM(),
new TwoOpFM(), new TwoOpFM(),
new PhaseDistortionFM(),
new DubSiren(), new DubSiren(),
new Benjolin(), new Benjolin(),
new ZzfxEngine(), new ZzfxEngine(),
new NoiseDrum(), new NoiseDrum(),
new Snare(),
new BassDrum(),
new HiHat(),
new Ring(), new Ring(),
new KarplusStrong(), new KarplusStrong(),
new AdditiveEngine(), new AdditiveEngine(),

View File

@ -0,0 +1,449 @@
// Phase Distortion FM AudioWorklet Processor
// 3-operator FM synthesis with phase distortion and DCW envelopes
const PDWaveform = {
Sawtooth: 0,
Square: 1,
Resonant1: 2,
Resonant2: 3,
Resonant3: 4,
};
const PDAlgorithm = {
Stack: 0, // 1→2→3
Split: 1, // 1→(2+3)
Ring: 2, // (1×2)→3
Parallel: 3, // 1+2+3
HarmonicStack: 4 // 1→2, 1→3
};
const LFOWaveform = {
Sine: 0,
Triangle: 1,
Square: 2,
Saw: 3,
SampleHold: 4,
RandomWalk: 5,
};
class PhaseDistortionFMProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.opPhasesL = [0, 0, 0];
this.opPhasesR = [0, 0, 0];
this.lfoPhaseL = 0;
this.lfoPhaseR = 0;
this.lfoSampleHoldValue = 0;
this.lfoSampleHoldPhase = 0;
this.lfoRandomWalkCurrent = 0;
this.lfoRandomWalkTarget = 0;
this.dcBlockerL = 0;
this.dcBlockerR = 0;
this.dcBlockerCutoff = 0.995;
this.sampleCount = 0;
this.totalSamples = 0;
this.port.onmessage = (e) => {
if (e.data.type === 'init') {
this.params = e.data.params;
this.duration = e.data.duration;
this.totalSamples = Math.floor(sampleRate * this.duration);
this.lfoSampleHoldValue = Math.random() * 2 - 1;
this.lfoSampleHoldPhase = 0;
this.lfoRandomWalkCurrent = Math.random() * 2 - 1;
this.lfoRandomWalkTarget = Math.random() * 2 - 1;
const stereoOffset = this.params.stereoWidth * 0.1;
this.opPhasesL = [0, Math.PI * stereoOffset, 0];
this.opPhasesR = [0, Math.PI * stereoOffset * 1.5, 0];
this.lfoPhaseL = 0;
this.lfoPhaseR = Math.PI * this.params.stereoWidth * 0.3;
}
};
}
process(inputs, outputs, parameters) {
if (!this.params || this.sampleCount >= this.totalSamples) {
return false;
}
const output = outputs[0];
const leftChannel = output[0];
const rightChannel = output[1];
const TAU = Math.PI * 2;
const detune = 1 + (this.params.stereoWidth * 0.001);
const leftFreq = this.params.baseFreq / detune;
const rightFreq = this.params.baseFreq * detune;
for (let i = 0; i < leftChannel.length; i++) {
if (this.sampleCount >= this.totalSamples) {
leftChannel[i] = 0;
rightChannel[i] = 0;
continue;
}
const t = this.sampleCount / sampleRate;
const env1 = this.calculateEnvelope(t, this.params.operators[0]);
const env2 = this.calculateEnvelope(t, this.params.operators[1]);
const env3 = this.calculateEnvelope(t, this.params.operators[2]);
const dcw1 = this.calculateDCWEnvelope(t, this.params.operators[0]);
const dcw2 = this.calculateDCWEnvelope(t, this.params.operators[1]);
const dcw3 = this.calculateDCWEnvelope(t, this.params.operators[2]);
const lfoL = this.generateLFO(this.lfoPhaseL, this.params.lfo.waveform, this.params.lfo.rate);
const lfoR = this.generateLFO(this.lfoPhaseR, this.params.lfo.waveform, this.params.lfo.rate);
const lfoModL = lfoL * this.params.lfo.depth;
const lfoModR = lfoR * this.params.lfo.depth;
let pitchModL = 0, pitchModR = 0;
let ampModL = 1, ampModR = 1;
let modIndexMod = 0;
if (this.params.lfo.target === 'pitch') {
pitchModL = lfoModL * 0.02;
pitchModR = lfoModR * 0.02;
} else if (this.params.lfo.target === 'amplitude') {
ampModL = 1 + lfoModL * 0.5;
ampModR = 1 + lfoModR * 0.5;
} else {
modIndexMod = lfoModL;
}
const [sampleL, sampleR] = this.processAlgorithm(
this.params.algorithm,
this.opPhasesL,
this.opPhasesR,
[env1, env2, env3],
[dcw1, dcw2, dcw3],
modIndexMod
);
const gainCompensation = this.getAlgorithmGainCompensation(this.params.algorithm);
let outL = sampleL * gainCompensation * ampModL;
let outR = sampleR * gainCompensation * ampModR;
outL = this.softClip(outL);
outR = this.softClip(outR);
const dcFilteredL = outL - this.dcBlockerL;
this.dcBlockerL += (1 - this.dcBlockerCutoff) * dcFilteredL;
const dcFilteredR = outR - this.dcBlockerR;
this.dcBlockerR += (1 - this.dcBlockerCutoff) * dcFilteredR;
leftChannel[i] = dcFilteredL * 0.85;
rightChannel[i] = dcFilteredR * 0.85;
for (let op = 0; op < 3; op++) {
const opFreqL = leftFreq * this.params.operators[op].ratio * (1 + pitchModL);
const opFreqR = rightFreq * this.params.operators[op].ratio * (1 + pitchModR);
this.opPhasesL[op] += (TAU * opFreqL) / sampleRate;
this.opPhasesR[op] += (TAU * opFreqR) / sampleRate;
if (this.opPhasesL[op] > TAU * 1000) this.opPhasesL[op] -= TAU * 1000;
if (this.opPhasesR[op] > TAU * 1000) this.opPhasesR[op] -= TAU * 1000;
}
this.lfoPhaseL += (TAU * this.params.lfo.rate) / sampleRate;
this.lfoPhaseR += (TAU * this.params.lfo.rate) / sampleRate;
this.sampleCount++;
}
return true;
}
processAlgorithm(algorithm, phasesL, phasesR, envelopes, dcwEnvs, modIndexMod) {
const baseModIndex = 3.0;
const modScale = baseModIndex * (1 + modIndexMod * 2);
const ops = this.params.operators;
switch (algorithm) {
case PDAlgorithm.Stack: {
// 1→2→3
const mod1L = this.generateWaveform(phasesL[0], ops[0].waveform, ops[0].distortion, dcwEnvs[0])
* envelopes[0] * ops[0].level;
const mod1R = this.generateWaveform(phasesR[0], ops[0].waveform, ops[0].distortion, dcwEnvs[0])
* envelopes[0] * ops[0].level;
const mod2PhaseL = phasesL[1] + modScale * mod1L;
const mod2PhaseR = phasesR[1] + modScale * mod1R;
const mod2L = this.generateWaveform(mod2PhaseL, ops[1].waveform, ops[1].distortion, dcwEnvs[1])
* envelopes[1] * ops[1].level;
const mod2R = this.generateWaveform(mod2PhaseR, ops[1].waveform, ops[1].distortion, dcwEnvs[1])
* envelopes[1] * ops[1].level;
const outPhaseL = phasesL[2] + modScale * 0.7 * mod2L;
const outPhaseR = phasesR[2] + modScale * 0.7 * mod2R;
const outL = this.generateWaveform(outPhaseL, ops[2].waveform, ops[2].distortion, dcwEnvs[2])
* envelopes[2] * ops[2].level;
const outR = this.generateWaveform(outPhaseR, ops[2].waveform, ops[2].distortion, dcwEnvs[2])
* envelopes[2] * ops[2].level;
return [outL, outR];
}
case PDAlgorithm.Split: {
// 1→(2+3)
const mod1L = this.generateWaveform(phasesL[0], ops[0].waveform, ops[0].distortion, dcwEnvs[0])
* envelopes[0] * ops[0].level;
const mod1R = this.generateWaveform(phasesR[0], ops[0].waveform, ops[0].distortion, dcwEnvs[0])
* envelopes[0] * ops[0].level;
const car1PhaseL = phasesL[1] + modScale * mod1L;
const car1PhaseR = phasesR[1] + modScale * mod1R;
const car1L = this.generateWaveform(car1PhaseL, ops[1].waveform, ops[1].distortion, dcwEnvs[1])
* envelopes[1] * ops[1].level;
const car1R = this.generateWaveform(car1PhaseR, ops[1].waveform, ops[1].distortion, dcwEnvs[1])
* envelopes[1] * ops[1].level;
const car2PhaseL = phasesL[2] + modScale * mod1L;
const car2PhaseR = phasesR[2] + modScale * mod1R;
const car2L = this.generateWaveform(car2PhaseL, ops[2].waveform, ops[2].distortion, dcwEnvs[2])
* envelopes[2] * ops[2].level;
const car2R = this.generateWaveform(car2PhaseR, ops[2].waveform, ops[2].distortion, dcwEnvs[2])
* envelopes[2] * ops[2].level;
return [(car1L + car2L) * 0.707, (car1R + car2R) * 0.707];
}
case PDAlgorithm.Ring: {
// (1×2)→3
const osc1L = this.generateWaveform(phasesL[0], ops[0].waveform, ops[0].distortion, dcwEnvs[0])
* envelopes[0] * ops[0].level;
const osc1R = this.generateWaveform(phasesR[0], ops[0].waveform, ops[0].distortion, dcwEnvs[0])
* envelopes[0] * ops[0].level;
const osc2L = this.generateWaveform(phasesL[1], ops[1].waveform, ops[1].distortion, dcwEnvs[1])
* envelopes[1] * ops[1].level;
const osc2R = this.generateWaveform(phasesR[1], ops[1].waveform, ops[1].distortion, dcwEnvs[1])
* envelopes[1] * ops[1].level;
const ringModL = osc1L * osc2L * this.params.ringModAmount;
const ringModR = osc1R * osc2R * this.params.ringModAmount;
const carPhaseL = phasesL[2] + modScale * ringModL;
const carPhaseR = phasesR[2] + modScale * ringModR;
const outL = this.generateWaveform(carPhaseL, ops[2].waveform, ops[2].distortion, dcwEnvs[2])
* envelopes[2] * ops[2].level;
const outR = this.generateWaveform(carPhaseR, ops[2].waveform, ops[2].distortion, dcwEnvs[2])
* envelopes[2] * ops[2].level;
return [outL, outR];
}
case PDAlgorithm.Parallel: {
// 1+2+3
let sumL = 0, sumR = 0;
for (let i = 0; i < 3; i++) {
sumL += this.generateWaveform(phasesL[i], ops[i].waveform, ops[i].distortion, dcwEnvs[i])
* envelopes[i] * ops[i].level;
sumR += this.generateWaveform(phasesR[i], ops[i].waveform, ops[i].distortion, dcwEnvs[i])
* envelopes[i] * ops[i].level;
}
return [sumL * 0.577, sumR * 0.577];
}
case PDAlgorithm.HarmonicStack: {
// 1→2, 1→3
const mod1L = this.generateWaveform(phasesL[0], ops[0].waveform, ops[0].distortion, dcwEnvs[0])
* envelopes[0] * ops[0].level;
const mod1R = this.generateWaveform(phasesR[0], ops[0].waveform, ops[0].distortion, dcwEnvs[0])
* envelopes[0] * ops[0].level;
const car1PhaseL = phasesL[1] + modScale * mod1L;
const car1PhaseR = phasesR[1] + modScale * mod1R;
const car1L = this.generateWaveform(car1PhaseL, ops[1].waveform, ops[1].distortion, dcwEnvs[1])
* envelopes[1] * ops[1].level;
const car1R = this.generateWaveform(car1PhaseR, ops[1].waveform, ops[1].distortion, dcwEnvs[1])
* envelopes[1] * ops[1].level;
const car2PhaseL = phasesL[2] + modScale * 0.8 * mod1L;
const car2PhaseR = phasesR[2] + modScale * 0.8 * mod1R;
const car2L = this.generateWaveform(car2PhaseL, ops[2].waveform, ops[2].distortion, dcwEnvs[2])
* envelopes[2] * ops[2].level;
const car2R = this.generateWaveform(car2PhaseR, ops[2].waveform, ops[2].distortion, dcwEnvs[2])
* envelopes[2] * ops[2].level;
return [(car1L + car2L) * 0.707, (car1R + car2R) * 0.707];
}
default:
return [0, 0];
}
}
generateWaveform(phase, waveform, distortion, dcw) {
const normalizedPhase = (phase % (Math.PI * 2)) / (Math.PI * 2);
const warpedPhase = this.applyPhaseDistortion(normalizedPhase, distortion, dcw);
switch (waveform) {
case PDWaveform.Sawtooth:
return warpedPhase * 2 - 1;
case PDWaveform.Square:
return warpedPhase < 0.5 ? 1 : -1;
case PDWaveform.Resonant1:
return this.resonantSaw(warpedPhase, dcw);
case PDWaveform.Resonant2:
return this.resonantPulse(warpedPhase, dcw);
case PDWaveform.Resonant3:
return this.doubleResonant(warpedPhase, dcw);
default:
return warpedPhase * 2 - 1;
}
}
applyPhaseDistortion(phase, distortion, dcw) {
const warpAmount = distortion * dcw;
if (phase < 0.5) {
return phase * (1 + warpAmount * (1 - 2 * phase));
} else {
return 0.5 + (phase - 0.5) * (1 + warpAmount * (2 * phase - 1));
}
}
resonantSaw(phase, brightness) {
const harmonics = Math.floor(1 + brightness * 8);
let sum = 0;
for (let n = 1; n <= harmonics; n++) {
sum += Math.sin(Math.PI * 2 * phase * n) / n;
}
return sum * 0.5;
}
resonantPulse(phase, brightness) {
const pulseWidth = 0.5 - brightness * 0.3;
const harmonics = Math.floor(1 + brightness * 6);
let sum = 0;
for (let n = 1; n <= harmonics; n += 2) {
sum += Math.sin(Math.PI * 2 * phase * n) * Math.cos(Math.PI * n * pulseWidth) / n;
}
return sum * 0.6;
}
doubleResonant(phase, brightness) {
const harm1 = this.resonantSaw(phase, brightness);
const harm2 = this.resonantSaw((phase + 0.5) % 1, brightness * 0.7);
return (harm1 + harm2 * 0.5) * 0.6;
}
calculateEnvelope(t, op) {
const attackTime = op.attack * this.duration;
const decayTime = op.decay * this.duration;
const releaseTime = op.release * this.duration;
const sustainStart = attackTime + decayTime;
const releaseStart = this.duration - releaseTime;
if (t < attackTime) {
return t / attackTime;
} else if (t < sustainStart) {
const progress = (t - attackTime) / decayTime;
return 1 - progress * (1 - op.sustain);
} else if (t < releaseStart) {
return op.sustain;
} else {
const progress = (t - releaseStart) / releaseTime;
return op.sustain * (1 - progress);
}
}
calculateDCWEnvelope(t, op) {
const attackTime = op.dcwAttack * this.duration;
const decayTime = op.dcwDecay * this.duration;
const releaseTime = op.dcwRelease * this.duration;
const sustainStart = attackTime + decayTime;
const releaseStart = this.duration - releaseTime;
if (t < attackTime) {
return t / attackTime;
} else if (t < sustainStart) {
const progress = (t - attackTime) / decayTime;
return 1 - progress * (1 - op.dcwSustain);
} else if (t < releaseStart) {
return op.dcwSustain;
} else {
const progress = (t - releaseStart) / releaseTime;
return op.dcwSustain * (1 - progress);
}
}
generateLFO(phase, waveform, rate) {
const normalizedPhase = (phase % (Math.PI * 2)) / (Math.PI * 2);
switch (waveform) {
case LFOWaveform.Sine:
return Math.sin(phase);
case LFOWaveform.Triangle:
return normalizedPhase < 0.5
? normalizedPhase * 4 - 1
: 3 - normalizedPhase * 4;
case LFOWaveform.Square:
return normalizedPhase < 0.5 ? 1 : -1;
case LFOWaveform.Saw:
return normalizedPhase * 2 - 1;
case LFOWaveform.SampleHold: {
const cyclesSinceLastHold = phase - this.lfoSampleHoldPhase;
if (cyclesSinceLastHold >= Math.PI * 2) {
this.lfoSampleHoldValue = Math.random() * 2 - 1;
this.lfoSampleHoldPhase = phase;
}
return this.lfoSampleHoldValue;
}
case LFOWaveform.RandomWalk: {
const interpolationSpeed = rate / sampleRate * 20;
const diff = this.lfoRandomWalkTarget - this.lfoRandomWalkCurrent;
this.lfoRandomWalkCurrent += diff * interpolationSpeed;
if (Math.abs(diff) < 0.01) {
this.lfoRandomWalkTarget = Math.random() * 2 - 1;
}
return this.lfoRandomWalkCurrent;
}
default:
return 0;
}
}
softClip(x) {
const absX = Math.abs(x);
if (absX < 0.7) return x;
if (absX > 3) return Math.sign(x) * 0.98;
const x2 = x * x;
return x * (27 + x2) / (27 + 9 * x2);
}
getAlgorithmGainCompensation(algorithm) {
switch (algorithm) {
case PDAlgorithm.Stack:
return 0.75;
case PDAlgorithm.Split:
case PDAlgorithm.HarmonicStack:
return 0.8;
case PDAlgorithm.Ring:
return 0.7;
case PDAlgorithm.Parallel:
return 0.65;
default:
return 0.75;
}
}
}
registerProcessor('phase-distortion-fm-processor', PhaseDistortionFMProcessor);