phase distortion

This commit is contained in:
2025-10-12 18:25:44 +02:00
parent 57fb8a93dc
commit d118d3a52b
8 changed files with 1252 additions and 41 deletions

View File

@ -65,4 +65,5 @@ Opens on http://localhost:8080
## 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

@ -1,6 +1,6 @@
import type { PitchLock, SynthEngine } from './SynthEngine';
interface BassDrum909Params {
interface BassDrumParams {
// Core frequency (base pitch of the kick)
baseFreq: number;
@ -47,9 +47,15 @@ interface BassDrum909Params {
// 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 BassDrum909 implements SynthEngine {
export class BassDrum implements SynthEngine {
getName(): string {
return 'Kick';
}
@ -62,7 +68,7 @@ export class BassDrum909 implements SynthEngine {
return 'generative' as const;
}
randomParams(pitchLock?: PitchLock): BassDrum909Params {
randomParams(pitchLock?: PitchLock): BassDrumParams {
// Choose a kick character/style
const styleRoll = Math.random();
@ -71,6 +77,7 @@ export class BassDrum909 implements SynthEngine {
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!)
@ -90,6 +97,10 @@ export class BassDrum909 implements SynthEngine {
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)
@ -108,6 +119,10 @@ export class BassDrum909 implements SynthEngine {
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)
@ -126,6 +141,10 @@ export class BassDrum909 implements SynthEngine {
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)
@ -144,6 +163,10 @@ export class BassDrum909 implements SynthEngine {
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)
@ -162,6 +185,10 @@ export class BassDrum909 implements SynthEngine {
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
@ -180,6 +207,10 @@ export class BassDrum909 implements SynthEngine {
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)
@ -198,6 +229,10 @@ export class BassDrum909 implements SynthEngine {
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 {
@ -218,10 +253,14 @@ export class BassDrum909 implements SynthEngine {
phaseDistortion,
distortion,
tuning: pitchLock ? 0.5 : 0.4 + Math.random() * 0.2,
attackTime,
attackCurve,
ampDecayCurve,
pitchDecayCurve,
};
}
mutateParams(params: BassDrum909Params, mutationAmount: number = 0.15, pitchLock?: PitchLock): BassDrum909Params {
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));
};
@ -244,11 +283,15 @@ export class BassDrum909 implements SynthEngine {
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: BassDrum909Params,
params: BassDrumParams,
sampleRate: number,
duration: number,
pitchLock?: PitchLock
@ -272,7 +315,7 @@ export class BassDrum909 implements SynthEngine {
// 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)
// Amplitude decay time scaled by duration
const ampDecayTime = (0.2 + params.decay * 1.8) * duration;
// Noise decay time
@ -282,8 +325,8 @@ export class BassDrum909 implements SynthEngine {
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;
// 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
@ -321,8 +364,8 @@ export class BassDrum909 implements SynthEngine {
for (let i = 0; i < numSamples; i++) {
const t = i / sampleRate;
// Pitch envelope: exponential decay
const pitchEnv = Math.exp(-t / pitchDecayTime);
// 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
@ -369,13 +412,15 @@ export class BassDrum909 implements SynthEngine {
waveform = triangle * (1 - mix) + square * mix;
}
// Attack envelope (5ms linear ramp)
const attackEnv = t < attackTime ? t / attackTime : 1.0;
// Attack envelope (curved)
const attackEnv = t < attackTime
? this.applyAttackCurve(t, attackTime, params.attackCurve)
: 1.0;
// Amplitude envelope: exponential decay with attack
const ampEnv = Math.exp(-t / ampDecayTime) * attackEnv;
// Amplitude envelope: curved decay with attack
const ampEnv = this.applyDecayCurve(t, ampDecayTime, params.ampDecayCurve) * attackEnv;
// 1. Morphed oscillator (main tone) - reduced level
// 1. Morphed oscillator (main tone)
let signal = waveform * ampEnv * 0.6;
// 1b. Add harmonics (2nd and 3rd) for more character
@ -438,7 +483,7 @@ export class BassDrum909 implements SynthEngine {
// 6. Optional distortion/saturation (only if distortion > 0.2)
if (params.distortion > 0.2) {
const distAmount = 1 + params.distortion * 3; // Reduced range
const distAmount = 1 + params.distortion * 3;
signal = this.waveshaper(signal * distAmount, distAmount) / distAmount;
}
@ -469,6 +514,19 @@ export class BassDrum909 implements SynthEngine {
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,
@ -526,9 +584,8 @@ export class BassDrum909 implements SynthEngine {
}
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
if (Math.abs(x) < 0.001) return x;
return ((PI + amount) * x) / (PI + amount * Math.abs(x));
}

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 {
getName(): string {
return 'Noise Drum';
return 'NPerc';
}
getDescription(): string {

View File

@ -0,0 +1,523 @@
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
}
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)
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])
* envelopes[0] * oscillators[0].level;
const outR = this.generatePDWaveform(phasesR[0], oscillators[0].waveform, dcws[0])
* 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])
* envelopes[0] * oscillators[0].level;
const osc1R = this.generatePDWaveform(phasesR[0], oscillators[0].waveform, dcws[0])
* envelopes[0] * oscillators[0].level;
const osc2L = this.generatePDWaveform(phasesL[1], oscillators[1].waveform, dcws[1])
* envelopes[1] * oscillators[1].level;
const osc2R = this.generatePDWaveform(phasesR[1], oscillators[1].waveform, dcws[1])
* 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])
* envelopes[0] * oscillators[0].level;
const osc1R = this.generatePDWaveform(phasesR[0], oscillators[0].waveform, dcws[0])
* envelopes[0] * oscillators[0].level;
const osc2L = this.generatePDWaveform(phasesL[1], oscillators[0].waveform, dcws[0])
* envelopes[0] * oscillators[1].level;
const osc2R = this.generatePDWaveform(phasesR[1], oscillators[0].waveform, dcws[0])
* 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])
* envelopes[0] * oscillators[0].level;
const osc1R = this.generatePDWaveform(phasesR[0], oscillators[0].waveform, dcws[0])
* envelopes[0] * oscillators[0].level;
const osc2L = this.generatePDWaveform(phasesL[1] * 2, oscillators[1].waveform, dcws[1])
* envelopes[1] * oscillators[1].level;
const osc2R = this.generatePDWaveform(phasesR[1] * 2, oscillators[1].waveform, dcws[1])
* 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): 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 < 0.5 ? 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, 3) 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);
}
return {
waveform,
level: this.randomRange(0.5, 0.9),
detune,
dcw: 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, 3) 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),
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

@ -1,6 +1,6 @@
import type { PitchLock, SynthEngine } from './SynthEngine';
interface Snare909Params {
interface SnareParams {
// Core frequency (base pitch of the snare)
baseFreq: number;
@ -21,20 +21,20 @@ interface Snare909Params {
tuning: number;
}
export class Snare909 implements SynthEngine {
export class Snare implements SynthEngine {
getName(): string {
return 'Snare';
}
getDescription(): string {
return 'Classic 909-style snare drum with triangle wave and noise';
return 'Classic snare drum with triangle wave and noise';
}
getType() {
return 'generative' as const;
}
randomParams(pitchLock?: PitchLock): Snare909Params {
randomParams(pitchLock?: PitchLock): SnareParams {
return {
baseFreq: pitchLock ? this.freqToParam(pitchLock.frequency) : 0.3 + Math.random() * 0.4,
tone: 0.4 + Math.random() * 0.4,
@ -46,7 +46,7 @@ export class Snare909 implements SynthEngine {
};
}
mutateParams(params: Snare909Params, mutationAmount: number = 0.15, pitchLock?: PitchLock): Snare909Params {
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));
};
@ -63,7 +63,7 @@ export class Snare909 implements SynthEngine {
}
generate(
params: Snare909Params,
params: SnareParams,
sampleRate: number,
duration: number,
pitchLock?: PitchLock
@ -79,7 +79,7 @@ export class Snare909 implements SynthEngine {
const tuningFactor = 0.88 + params.tuning * 0.24;
const tunedFreq = baseFreq * tuningFactor;
// Pitch modulation parameters (classic 909: starts 4x higher, drops over 10ms)
// 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
@ -87,14 +87,14 @@ export class Snare909 implements SynthEngine {
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)
// Volume and gain staging
const baseVolume = 0.5;
const accentGain = baseVolume * (1 + params.accent);
// Attack time (5ms ramp like original)
// Attack time (5ms ramp)
const attackTime = 0.005;
// Notch filter parameters for noise (fixed at 1000Hz like the original)
// Notch filter parameters for noise (fixed at 1000Hz)
const notchFreq = 1000;
const notchQ = 5;
@ -129,7 +129,7 @@ export class Snare909 implements SynthEngine {
? -1 + (2 * phase) / Math.PI
: 3 - (2 * phase) / Math.PI;
// Attack envelope (5ms linear ramp like original)
// Attack envelope (5ms linear ramp)
const attackEnv = t < attackTime ? t / attackTime : 1.0;
// Tonal envelope: exponential decay
@ -155,16 +155,14 @@ export class Snare909 implements SynthEngine {
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
// 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); // additional accent multiplier
sample *= (0.5 + params.accent * 0.5);
// Soft clipping
sample = this.softClip(sample);

View File

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