From d118d3a52b8108add7abfb74677903078cb1112f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Forment?= Date: Sun, 12 Oct 2025 18:25:44 +0200 Subject: [PATCH] phase distortion --- README.md | 3 +- .../engines/{BassDrum909.ts => BassDrum.ts} | 93 +++- src/lib/audio/engines/HiHat.ts | 179 ++++++ src/lib/audio/engines/NoiseDrum.ts | 2 +- src/lib/audio/engines/PhaseDistortionFM.ts | 523 ++++++++++++++++++ .../audio/engines/{Snare909.ts => Snare.ts} | 32 +- src/lib/audio/engines/registry.ts | 12 +- .../worklets/phase-distortion-fm-processor.js | 449 +++++++++++++++ 8 files changed, 1252 insertions(+), 41 deletions(-) rename src/lib/audio/engines/{BassDrum909.ts => BassDrum.ts} (81%) create mode 100644 src/lib/audio/engines/HiHat.ts create mode 100644 src/lib/audio/engines/PhaseDistortionFM.ts rename src/lib/audio/engines/{Snare909.ts => Snare.ts} (85%) create mode 100644 src/lib/audio/engines/worklets/phase-distortion-fm-processor.js diff --git a/README.md b/README.md index 62e0b65..e88516b 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/lib/audio/engines/BassDrum909.ts b/src/lib/audio/engines/BassDrum.ts similarity index 81% rename from src/lib/audio/engines/BassDrum909.ts rename to src/lib/audio/engines/BassDrum.ts index 143e064..5113f6c 100644 --- a/src/lib/audio/engines/BassDrum909.ts +++ b/src/lib/audio/engines/BassDrum.ts @@ -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)); } diff --git a/src/lib/audio/engines/HiHat.ts b/src/lib/audio/engines/HiHat.ts new file mode 100644 index 0000000..8b6ea72 --- /dev/null +++ b/src/lib/audio/engines/HiHat.ts @@ -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)); + } +} diff --git a/src/lib/audio/engines/NoiseDrum.ts b/src/lib/audio/engines/NoiseDrum.ts index 9df597b..215d7bf 100644 --- a/src/lib/audio/engines/NoiseDrum.ts +++ b/src/lib/audio/engines/NoiseDrum.ts @@ -39,7 +39,7 @@ interface NoiseDrumParams { export class NoiseDrum implements SynthEngine { getName(): string { - return 'Noise Drum'; + return 'NPerc'; } getDescription(): string { diff --git a/src/lib/audio/engines/PhaseDistortionFM.ts b/src/lib/audio/engines/PhaseDistortionFM.ts new file mode 100644 index 0000000..69c7edf --- /dev/null +++ b/src/lib/audio/engines/PhaseDistortionFM.ts @@ -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 { + 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(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)); + } +} diff --git a/src/lib/audio/engines/Snare909.ts b/src/lib/audio/engines/Snare.ts similarity index 85% rename from src/lib/audio/engines/Snare909.ts rename to src/lib/audio/engines/Snare.ts index 1f903a1..937d6f7 100644 --- a/src/lib/audio/engines/Snare909.ts +++ b/src/lib/audio/engines/Snare.ts @@ -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); diff --git a/src/lib/audio/engines/registry.ts b/src/lib/audio/engines/registry.ts index 27283a3..bacf770 100644 --- a/src/lib/audio/engines/registry.ts +++ b/src/lib/audio/engines/registry.ts @@ -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(), diff --git a/src/lib/audio/engines/worklets/phase-distortion-fm-processor.js b/src/lib/audio/engines/worklets/phase-distortion-fm-processor.js new file mode 100644 index 0000000..53a7a6c --- /dev/null +++ b/src/lib/audio/engines/worklets/phase-distortion-fm-processor.js @@ -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);