Compare commits
3 Commits
b422d253d3
...
c1f7cc02fd
| Author | SHA1 | Date | |
|---|---|---|---|
| c1f7cc02fd | |||
| d118d3a52b | |||
| 57fb8a93dc |
@ -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.
|
||||
|
||||
604
src/lib/audio/engines/BassDrum.ts
Normal file
604
src/lib/audio/engines/BassDrum.ts
Normal file
@ -0,0 +1,604 @@
|
||||
import type { PitchLock, SynthEngine } from './SynthEngine';
|
||||
|
||||
interface BassDrumParams {
|
||||
// Core frequency (base pitch of the kick)
|
||||
baseFreq: number;
|
||||
|
||||
// Pitch envelope (how much frequency sweeps down)
|
||||
pitchMod: number;
|
||||
|
||||
// Pitch envelope speed
|
||||
pitchDecay: number;
|
||||
|
||||
// Amplitude decay time
|
||||
decay: number;
|
||||
|
||||
// Click/snap amount (high freq transient)
|
||||
click: number;
|
||||
|
||||
// Attack noise burst (sharp transient)
|
||||
attackNoise: number;
|
||||
attackNoiseFreq: number; // Filter frequency for attack noise
|
||||
|
||||
// Body resonance
|
||||
bodyResonance: number;
|
||||
bodyFreq: number;
|
||||
|
||||
// Body tone variation
|
||||
bodyDecay: number;
|
||||
|
||||
// Noise amount
|
||||
noise: number;
|
||||
|
||||
// Noise decay
|
||||
noiseDecay: number;
|
||||
|
||||
// Harmonic content
|
||||
harmonics: number;
|
||||
|
||||
// Wave shape (0 = sine, 0.5 = triangle, 1 = square)
|
||||
waveShape: number;
|
||||
|
||||
// Phase distortion amount
|
||||
phaseDistortion: number;
|
||||
|
||||
// Distortion amount (0 = clean, 1 = heavy)
|
||||
distortion: number;
|
||||
|
||||
// Tuning offset
|
||||
tuning: number;
|
||||
|
||||
// Envelope curve parameters
|
||||
attackTime: number; // Attack duration (0 = 0.5ms, 1 = 50ms)
|
||||
attackCurve: number; // Attack curve shape (0 = soft/slow, 1 = sharp/fast)
|
||||
ampDecayCurve: number; // Amplitude decay curve (0 = loose/boomy, 1 = tight/punchy)
|
||||
pitchDecayCurve: number; // Pitch envelope curve (0 = loose, 1 = tight)
|
||||
}
|
||||
|
||||
export class BassDrum implements SynthEngine {
|
||||
getName(): string {
|
||||
return 'Kick';
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return 'Versatile kick drum synthesizer with varied styles from sub to tom';
|
||||
}
|
||||
|
||||
getType() {
|
||||
return 'generative' as const;
|
||||
}
|
||||
|
||||
randomParams(pitchLock?: PitchLock): BassDrumParams {
|
||||
// Choose a kick character/style
|
||||
const styleRoll = Math.random();
|
||||
|
||||
let baseFreq: number, pitchMod: number, pitchDecay: number, decay: number;
|
||||
let click: number, attackNoise: number, attackNoiseFreq: number;
|
||||
let bodyResonance: number, bodyFreq: number, bodyDecay: number;
|
||||
let noise: number, noiseDecay: number, harmonics: number;
|
||||
let waveShape: number, phaseDistortion: number, distortion: number;
|
||||
let attackTime: number, attackCurve: number, ampDecayCurve: number, pitchDecayCurve: number;
|
||||
|
||||
if (styleRoll < 0.08) {
|
||||
// Tom/bongo style (starts HIGH, decays VERY fast - rare!)
|
||||
baseFreq = 0.6 + Math.random() * 0.4; // 83-115 Hz (very high!)
|
||||
pitchMod = 0.8 + Math.random() * 0.2; // Extreme pitch sweep
|
||||
pitchDecay = 0.01 + Math.random() * 0.08; // Ultra fast pitch decay (10-90ms)
|
||||
decay = 0.05 + Math.random() * 0.25; // Very short decay
|
||||
click = 0.3 + Math.random() * 0.5; // Good click
|
||||
attackNoise = 0.4 + Math.random() * 0.6; // Strong attack
|
||||
attackNoiseFreq = 0.7 + Math.random() * 0.3; // High filtered
|
||||
bodyResonance = 0.1 + Math.random() * 0.3;
|
||||
bodyFreq = 0.5 + Math.random() * 0.5;
|
||||
bodyDecay = 0.05 + Math.random() * 0.25; // Short body decay
|
||||
noise = Math.random() * 0.08;
|
||||
noiseDecay = 0.02 + Math.random() * 0.15; // Fast noise decay
|
||||
harmonics = 0.2 + Math.random() * 0.5; // Lots of harmonics
|
||||
waveShape = 0.3 + Math.random() * 0.4; // Triangle-ish
|
||||
phaseDistortion = 0.1 + Math.random() * 0.4; // Some phase distortion
|
||||
distortion = 0; // Clean
|
||||
attackTime = 0.0 + Math.random() * 0.2; // Very fast attack (0.5-10ms)
|
||||
attackCurve = 0.6 + Math.random() * 0.3; // Sharp attack (0.6-0.9)
|
||||
ampDecayCurve = 0.7 + Math.random() * 0.3; // Very tight decay (0.7-1.0)
|
||||
pitchDecayCurve = 0.6 + Math.random() * 0.3; // Fast pitch sweep (0.6-0.9)
|
||||
} else if (styleRoll < 0.25) {
|
||||
// Tight, punchy 808-style kick (short, lots of pitch, CLEAN)
|
||||
baseFreq = 0.2 + Math.random() * 0.4; // 56-88 Hz (mid-high kicks)
|
||||
pitchMod = 0.7 + Math.random() * 0.3; // High pitch sweep
|
||||
pitchDecay = 0.05 + Math.random() * 0.4; // Fast to medium pitch decay
|
||||
decay = 0.1 + Math.random() * 0.35; // Short to medium decay
|
||||
click = 0.2 + Math.random() * 0.4; // Good click
|
||||
attackNoise = 0.3 + Math.random() * 0.5; // Sharp attack
|
||||
attackNoiseFreq = 0.5 + Math.random() * 0.5; // Mid-high filtered
|
||||
bodyResonance = 0.05 + Math.random() * 0.2;
|
||||
bodyFreq = 0.3 + Math.random() * 0.5;
|
||||
bodyDecay = 0.1 + Math.random() * 0.5; // Varied body decay
|
||||
noise = Math.random() * 0.05; // Very minimal
|
||||
noiseDecay = 0.05 + Math.random() * 0.4; // Varied noise decay
|
||||
harmonics = 0.1 + Math.random() * 0.3;
|
||||
waveShape = 0.2 + Math.random() * 0.5; // Sine to triangle
|
||||
phaseDistortion = Math.random() * 0.3; // Light phase distortion
|
||||
distortion = 0; // Clean
|
||||
attackTime = 0.0 + Math.random() * 0.3; // Fast attack (0.5-15ms)
|
||||
attackCurve = 0.3 + Math.random() * 0.3; // Medium-sharp attack (0.3-0.6)
|
||||
ampDecayCurve = 0.5 + Math.random() * 0.3; // Medium-tight decay (0.5-0.8)
|
||||
pitchDecayCurve = 0.4 + Math.random() * 0.3; // Medium pitch sweep (0.4-0.7)
|
||||
} else if (styleRoll < 0.43) {
|
||||
// Deep, smooth kick (long, clean, lots of body)
|
||||
baseFreq = 0.0 + Math.random() * 0.35; // 40-68 Hz (low to mid kicks)
|
||||
pitchMod = 0.3 + Math.random() * 0.4; // Moderate pitch sweep
|
||||
pitchDecay = 0.2 + Math.random() * 0.6; // Medium to slow pitch decay
|
||||
decay = 0.4 + Math.random() * 0.6; // Long decay
|
||||
click = Math.random() * 0.2; // Minimal click
|
||||
attackNoise = 0.1 + Math.random() * 0.4; // Some attack
|
||||
attackNoiseFreq = 0.3 + Math.random() * 0.4; // Mid filtered
|
||||
bodyResonance = 0.2 + Math.random() * 0.5; // Lots of body
|
||||
bodyFreq = 0.1 + Math.random() * 0.4;
|
||||
bodyDecay = 0.3 + Math.random() * 0.7; // Long body decay
|
||||
noise = Math.random() * 0.08;
|
||||
noiseDecay = 0.2 + Math.random() * 0.6; // Varied noise decay
|
||||
harmonics = Math.random() * 0.2;
|
||||
waveShape = Math.random() * 0.4; // Sine to triangle
|
||||
phaseDistortion = Math.random() * 0.25; // Subtle phase distortion
|
||||
distortion = 0; // Clean
|
||||
attackTime = 0.1 + Math.random() * 0.4; // Slower attack (5-25ms)
|
||||
attackCurve = 0.2 + Math.random() * 0.3; // Soft attack (0.2-0.5)
|
||||
ampDecayCurve = 0.2 + Math.random() * 0.3; // Loose, boomy decay (0.2-0.5)
|
||||
pitchDecayCurve = 0.2 + Math.random() * 0.3; // Slow pitch sweep (0.2-0.5)
|
||||
} else if (styleRoll < 0.63) {
|
||||
// Sub/electronic kick (very low, pure sine-like)
|
||||
baseFreq = 0.0 + Math.random() * 0.25; // 40-60 Hz (sub bass kicks)
|
||||
pitchMod = 0.1 + Math.random() * 0.4; // Low to moderate pitch sweep
|
||||
pitchDecay = 0.15 + Math.random() * 0.5; // Varied pitch decay
|
||||
decay = 0.4 + Math.random() * 0.6; // Long decay
|
||||
click = Math.random() * 0.15; // Almost no click
|
||||
attackNoise = Math.random() * 0.3; // Variable attack
|
||||
attackNoiseFreq = 0.2 + Math.random() * 0.3; // Low filtered
|
||||
bodyResonance = 0.3 + Math.random() * 0.6; // Strong body
|
||||
bodyFreq = 0.05 + Math.random() * 0.3;
|
||||
bodyDecay = 0.4 + Math.random() * 0.6; // Long body decay
|
||||
noise = Math.random() * 0.03; // Almost no noise
|
||||
noiseDecay = 0.1 + Math.random() * 0.4; // Varied noise decay
|
||||
harmonics = Math.random() * 0.15;
|
||||
waveShape = Math.random() * 0.3; // Mostly sine
|
||||
phaseDistortion = Math.random() * 0.2; // Very subtle
|
||||
distortion = 0; // Clean
|
||||
attackTime = 0.2 + Math.random() * 0.4; // Medium attack (10-30ms)
|
||||
attackCurve = 0.3 + Math.random() * 0.3; // Medium attack (0.3-0.6)
|
||||
ampDecayCurve = 0.1 + Math.random() * 0.3; // Very loose, sustaining (0.1-0.4)
|
||||
pitchDecayCurve = 0.1 + Math.random() * 0.3; // Gentle pitch sweep (0.1-0.4)
|
||||
} else if (styleRoll < 0.78) {
|
||||
// Snappy, clicky kick (fast attack, short)
|
||||
baseFreq = 0.3 + Math.random() * 0.5; // 64-100 Hz (high kicks)
|
||||
pitchMod = 0.5 + Math.random() * 0.5; // High pitch sweep
|
||||
pitchDecay = 0.02 + Math.random() * 0.25; // Very fast pitch
|
||||
decay = 0.08 + Math.random() * 0.35; // Very short to short
|
||||
click = 0.4 + Math.random() * 0.6; // Lots of click
|
||||
attackNoise = 0.5 + Math.random() * 0.5; // Strong attack burst
|
||||
attackNoiseFreq = 0.6 + Math.random() * 0.4; // High filtered
|
||||
bodyResonance = Math.random() * 0.2; // Minimal body
|
||||
bodyFreq = 0.4 + Math.random() * 0.6;
|
||||
bodyDecay = 0.05 + Math.random() * 0.3; // Short body decay
|
||||
noise = Math.random() * 0.1;
|
||||
noiseDecay = 0.02 + Math.random() * 0.2; // Fast noise decay
|
||||
harmonics = 0.2 + Math.random() * 0.5;
|
||||
waveShape = 0.4 + Math.random() * 0.6; // Triangle to square
|
||||
phaseDistortion = 0.2 + Math.random() * 0.5; // More phase distortion
|
||||
distortion = 0; // Clean
|
||||
attackTime = 0.0 + Math.random() * 0.15; // Instant attack (0.5-8ms)
|
||||
attackCurve = 0.7 + Math.random() * 0.3; // Very sharp attack (0.7-1.0)
|
||||
ampDecayCurve = 0.7 + Math.random() * 0.3; // Very tight (0.7-1.0)
|
||||
pitchDecayCurve = 0.7 + Math.random() * 0.3; // Fast pitch sweep (0.7-1.0)
|
||||
} else if (styleRoll < 0.88) {
|
||||
// Weird/experimental kick
|
||||
baseFreq = Math.random() * 0.7; // Full range 35-96 Hz
|
||||
pitchMod = Math.random();
|
||||
pitchDecay = 0.05 + Math.random() * 0.8; // Any decay
|
||||
decay = 0.1 + Math.random() * 0.8; // Any decay
|
||||
click = Math.random() * 0.5;
|
||||
attackNoise = Math.random() * 0.7;
|
||||
attackNoiseFreq = Math.random();
|
||||
bodyResonance = Math.random() * 0.7;
|
||||
bodyFreq = Math.random();
|
||||
bodyDecay = 0.1 + Math.random() * 0.9; // Any body decay
|
||||
noise = Math.random() * 0.15;
|
||||
noiseDecay = 0.05 + Math.random() * 0.8; // Any noise decay
|
||||
harmonics = Math.random() * 0.6;
|
||||
waveShape = Math.random(); // Any shape
|
||||
phaseDistortion = Math.random() * 0.7; // Any amount
|
||||
distortion = 0; // Clean
|
||||
attackTime = Math.random(); // Full range (0.5-50ms)
|
||||
attackCurve = Math.random(); // Full range (0.0-1.0)
|
||||
ampDecayCurve = Math.random(); // Full range (0.0-1.0)
|
||||
pitchDecayCurve = Math.random(); // Full range (0.0-1.0)
|
||||
} else {
|
||||
// Dirty/distorted kick (only 15% of the time!)
|
||||
baseFreq = 0.1 + Math.random() * 0.5; // 44-84 Hz (low to mid)
|
||||
pitchMod = 0.4 + Math.random() * 0.5;
|
||||
pitchDecay = 0.1 + Math.random() * 0.6; // Varied pitch decay
|
||||
decay = 0.2 + Math.random() * 0.6; // Varied decay
|
||||
click = 0.2 + Math.random() * 0.5;
|
||||
attackNoise = 0.3 + Math.random() * 0.5;
|
||||
attackNoiseFreq = 0.4 + Math.random() * 0.5;
|
||||
bodyResonance = 0.1 + Math.random() * 0.5;
|
||||
bodyFreq = 0.2 + Math.random() * 0.6;
|
||||
bodyDecay = 0.15 + Math.random() * 0.7; // Varied body decay
|
||||
noise = 0.05 + Math.random() * 0.2;
|
||||
noiseDecay = 0.15 + Math.random() * 0.6; // Varied noise decay
|
||||
harmonics = 0.1 + Math.random() * 0.4;
|
||||
waveShape = 0.3 + Math.random() * 0.5; // Varied shapes
|
||||
phaseDistortion = 0.2 + Math.random() * 0.6; // Good amount
|
||||
distortion = 0.3 + Math.random() * 0.5; // Only this style has distortion
|
||||
attackTime = 0.1 + Math.random() * 0.4; // Medium attack (5-25ms)
|
||||
attackCurve = 0.4 + Math.random() * 0.4; // Medium-sharp attack (0.4-0.8)
|
||||
ampDecayCurve = 0.3 + Math.random() * 0.4; // Medium varied (0.3-0.7)
|
||||
pitchDecayCurve = 0.3 + Math.random() * 0.4; // Medium varied (0.3-0.7)
|
||||
}
|
||||
|
||||
return {
|
||||
baseFreq: pitchLock ? this.freqToParam(pitchLock.frequency) : baseFreq,
|
||||
pitchMod,
|
||||
pitchDecay,
|
||||
decay,
|
||||
click,
|
||||
attackNoise,
|
||||
attackNoiseFreq,
|
||||
bodyResonance,
|
||||
bodyFreq,
|
||||
bodyDecay,
|
||||
noise,
|
||||
noiseDecay,
|
||||
harmonics,
|
||||
waveShape,
|
||||
phaseDistortion,
|
||||
distortion,
|
||||
tuning: pitchLock ? 0.5 : 0.4 + Math.random() * 0.2,
|
||||
attackTime,
|
||||
attackCurve,
|
||||
ampDecayCurve,
|
||||
pitchDecayCurve,
|
||||
};
|
||||
}
|
||||
|
||||
mutateParams(params: BassDrumParams, mutationAmount: number = 0.15, pitchLock?: PitchLock): BassDrumParams {
|
||||
const mutate = (value: number, amount: number = mutationAmount): number => {
|
||||
return Math.max(0, Math.min(1, value + (Math.random() - 0.5) * amount));
|
||||
};
|
||||
|
||||
return {
|
||||
baseFreq: pitchLock ? params.baseFreq : mutate(params.baseFreq, 0.2),
|
||||
pitchMod: mutate(params.pitchMod, 0.2),
|
||||
pitchDecay: mutate(params.pitchDecay, 0.2),
|
||||
decay: mutate(params.decay, 0.25),
|
||||
click: mutate(params.click, 0.2),
|
||||
attackNoise: mutate(params.attackNoise, 0.25),
|
||||
attackNoiseFreq: mutate(params.attackNoiseFreq, 0.25),
|
||||
bodyResonance: mutate(params.bodyResonance, 0.2),
|
||||
bodyFreq: mutate(params.bodyFreq, 0.25),
|
||||
bodyDecay: mutate(params.bodyDecay, 0.2),
|
||||
noise: mutate(params.noise, 0.15),
|
||||
noiseDecay: mutate(params.noiseDecay, 0.2),
|
||||
harmonics: mutate(params.harmonics, 0.2),
|
||||
waveShape: mutate(params.waveShape, 0.25),
|
||||
phaseDistortion: mutate(params.phaseDistortion, 0.25),
|
||||
distortion: mutate(params.distortion, 0.2),
|
||||
tuning: pitchLock ? params.tuning : mutate(params.tuning, 0.15),
|
||||
attackTime: mutate(params.attackTime, 0.15),
|
||||
attackCurve: mutate(params.attackCurve, 0.2),
|
||||
ampDecayCurve: mutate(params.ampDecayCurve, 0.2),
|
||||
pitchDecayCurve: mutate(params.pitchDecayCurve, 0.2),
|
||||
};
|
||||
}
|
||||
|
||||
generate(
|
||||
params: BassDrumParams,
|
||||
sampleRate: number,
|
||||
duration: number,
|
||||
pitchLock?: PitchLock
|
||||
): [Float32Array, Float32Array] {
|
||||
const numSamples = Math.floor(sampleRate * duration);
|
||||
const left = new Float32Array(numSamples);
|
||||
const right = new Float32Array(numSamples);
|
||||
|
||||
// Base frequency: 35Hz to 115Hz (full kick range from sub to high)
|
||||
const baseFreq = pitchLock ? pitchLock.frequency : 35 + params.baseFreq * 80;
|
||||
|
||||
// Tuning offset: -12% to +12%
|
||||
const tuningFactor = 0.88 + params.tuning * 0.24;
|
||||
const tunedFreq = baseFreq * tuningFactor;
|
||||
|
||||
// Pitch modulation: when locked, starts at locked freq and decays down
|
||||
// when not locked, starts higher and decays to base
|
||||
const pitchMultiplier = 1 + params.pitchMod * 1.5; // 1x to 2.5x
|
||||
const pitchDecayRatio = pitchLock ? 1 / pitchMultiplier : pitchMultiplier; // Invert for pitch lock
|
||||
|
||||
// Pitch decay time scaled by duration (50ms to 200ms)
|
||||
const pitchDecayTime = (0.05 + params.pitchDecay * 0.15) * duration;
|
||||
|
||||
// Amplitude decay time scaled by duration
|
||||
const ampDecayTime = (0.2 + params.decay * 1.8) * duration;
|
||||
|
||||
// Noise decay time
|
||||
const noiseDecayTime = (0.02 + params.noiseDecay * 0.15) * duration; // 20ms to 170ms
|
||||
|
||||
// Body resonance frequency (40Hz to 200Hz for low body resonances)
|
||||
const bodyFreq = 40 + params.bodyFreq * 160;
|
||||
const bodyDecayTime = (0.1 + params.bodyDecay * 0.4) * duration; // 100ms to 500ms
|
||||
|
||||
// Attack time (0.5ms to 50ms, scaled by duration)
|
||||
const attackTime = (0.0005 + params.attackTime * 0.0495) * duration;
|
||||
|
||||
// Click decay (very fast for transient)
|
||||
const clickDecayTime = 0.003 + params.click * 0.007; // 3ms to 10ms
|
||||
|
||||
// Attack noise burst decay (super fast: 1-5ms)
|
||||
const attackNoiseDecayTime = 0.001 + params.attackNoise * 0.004; // 1ms to 5ms
|
||||
|
||||
// Attack noise filter frequency (500Hz to 8000Hz)
|
||||
const attackNoiseFilterFreq = 500 + params.attackNoiseFreq * 7500;
|
||||
|
||||
// Low-pass filter at 3000Hz
|
||||
const filterFreq = 3000;
|
||||
|
||||
for (let channel = 0; channel < 2; channel++) {
|
||||
const output = channel === 0 ? left : right;
|
||||
|
||||
// Triangle oscillator phase
|
||||
let phase = 0;
|
||||
|
||||
// Low-pass filter state
|
||||
let filterState = 0;
|
||||
|
||||
// Body resonance filter state
|
||||
let bodyState1 = 0;
|
||||
let bodyState2 = 0;
|
||||
|
||||
// Attack noise filter state (highpass for snap)
|
||||
let attackNoiseState1 = 0;
|
||||
let attackNoiseState2 = 0;
|
||||
|
||||
// Stereo variation
|
||||
const stereoDetune = channel === 0 ? 0.999 : 1.001;
|
||||
const stereoPhaseOffset = channel === 0 ? 0 : 0.05;
|
||||
|
||||
for (let i = 0; i < numSamples; i++) {
|
||||
const t = i / sampleRate;
|
||||
|
||||
// Pitch envelope: curved decay
|
||||
const pitchEnv = this.applyDecayCurve(t, pitchDecayTime, params.pitchDecayCurve);
|
||||
|
||||
// When pitch locked: start at locked freq, decay down
|
||||
// When not locked: start higher, decay to base
|
||||
const currentFreq = pitchLock
|
||||
? tunedFreq * stereoDetune * (pitchDecayRatio + (1 - pitchDecayRatio) * pitchEnv)
|
||||
: tunedFreq * stereoDetune * (1 + (pitchMultiplier - 1) * pitchEnv);
|
||||
|
||||
// Generate oscillator with wave shape morphing and phase distortion
|
||||
phase += (2 * Math.PI * currentFreq) / sampleRate;
|
||||
if (phase > 2 * Math.PI) phase -= 2 * Math.PI;
|
||||
|
||||
// Apply phase distortion (modulate the phase itself)
|
||||
let distortedPhase = phase;
|
||||
if (params.phaseDistortion > 0.01) {
|
||||
// Phase distortion creates harmonic richness
|
||||
const phaseMod = Math.sin(phase * 2) * params.phaseDistortion * 0.5;
|
||||
distortedPhase = phase + phaseMod;
|
||||
if (distortedPhase < 0) distortedPhase += 2 * Math.PI;
|
||||
if (distortedPhase > 2 * Math.PI) distortedPhase -= 2 * Math.PI;
|
||||
}
|
||||
|
||||
// Wave shape morphing: 0=sine, 0.5=triangle, 1=square
|
||||
let waveform: number;
|
||||
|
||||
// Generate sine wave
|
||||
const sine = Math.sin(distortedPhase);
|
||||
|
||||
// Generate triangle wave
|
||||
const triangle = distortedPhase < Math.PI
|
||||
? -1 + (2 * distortedPhase) / Math.PI
|
||||
: 3 - (2 * distortedPhase) / Math.PI;
|
||||
|
||||
// Generate square wave
|
||||
const square = distortedPhase < Math.PI ? 1 : -1;
|
||||
|
||||
// Morph between shapes
|
||||
if (params.waveShape < 0.5) {
|
||||
// Morph between sine and triangle
|
||||
const mix = params.waveShape * 2; // 0 to 1
|
||||
waveform = sine * (1 - mix) + triangle * mix;
|
||||
} else {
|
||||
// Morph between triangle and square
|
||||
const mix = (params.waveShape - 0.5) * 2; // 0 to 1
|
||||
waveform = triangle * (1 - mix) + square * mix;
|
||||
}
|
||||
|
||||
// Attack envelope (curved)
|
||||
const attackEnv = t < attackTime
|
||||
? this.applyAttackCurve(t, attackTime, params.attackCurve)
|
||||
: 1.0;
|
||||
|
||||
// Amplitude envelope: curved decay with attack
|
||||
const ampEnv = this.applyDecayCurve(t, ampDecayTime, params.ampDecayCurve) * attackEnv;
|
||||
|
||||
// 1. Morphed oscillator (main tone)
|
||||
let signal = waveform * ampEnv * 0.6;
|
||||
|
||||
// 1b. Add harmonics (2nd and 3rd) for more character
|
||||
if (params.harmonics > 0.01) {
|
||||
const harmonic2 = Math.sin(2 * distortedPhase) * params.harmonics * 0.3;
|
||||
const harmonic3 = Math.sin(3 * distortedPhase) * params.harmonics * 0.15;
|
||||
signal += (harmonic2 + harmonic3) * ampEnv;
|
||||
}
|
||||
|
||||
// 2. Attack noise burst (super sharp transient, 1-5ms)
|
||||
if (params.attackNoise > 0.01) {
|
||||
const attackEnv = Math.exp(-t / attackNoiseDecayTime);
|
||||
const attackNoise = Math.random() * 2 - 1;
|
||||
|
||||
// Highpass filter the attack noise for snap
|
||||
const attackNoiseFiltered = this.highpassFilter(
|
||||
attackNoise,
|
||||
attackNoiseFilterFreq,
|
||||
2.0,
|
||||
sampleRate,
|
||||
attackNoiseState1,
|
||||
attackNoiseState2
|
||||
);
|
||||
attackNoiseState1 = attackNoiseFiltered.state1;
|
||||
attackNoiseState2 = attackNoiseFiltered.state2;
|
||||
|
||||
signal += attackNoiseFiltered.output * params.attackNoise * attackEnv * 0.4;
|
||||
}
|
||||
|
||||
// 3. Click/snap transient (fast decaying high-pitched sine)
|
||||
if (params.click > 0.01) {
|
||||
const clickEnv = Math.exp(-t / clickDecayTime);
|
||||
const clickFreq = tunedFreq * 4; // High frequency for click
|
||||
const clickOsc = Math.sin(2 * Math.PI * clickFreq * t);
|
||||
signal += clickOsc * params.click * clickEnv * 0.15;
|
||||
}
|
||||
|
||||
// 4. Body resonance (bandpass filtered feedback with decay)
|
||||
if (params.bodyResonance > 0.05) {
|
||||
const bodyEnv = Math.exp(-t / bodyDecayTime);
|
||||
const bodyFiltered = this.bandpassFilter(
|
||||
signal,
|
||||
bodyFreq,
|
||||
5 + params.bodyResonance * 10,
|
||||
sampleRate,
|
||||
bodyState1,
|
||||
bodyState2
|
||||
);
|
||||
bodyState1 = bodyFiltered.state1;
|
||||
bodyState2 = bodyFiltered.state2;
|
||||
signal += bodyFiltered.output * params.bodyResonance * bodyEnv * 0.2;
|
||||
}
|
||||
|
||||
// 5. Add sustaining noise
|
||||
if (params.noise > 0.01) {
|
||||
const noise = Math.random() * 2 - 1;
|
||||
const noiseEnv = Math.exp(-t / noiseDecayTime);
|
||||
signal += noise * params.noise * noiseEnv * 0.1;
|
||||
}
|
||||
|
||||
// 6. Optional distortion/saturation (only if distortion > 0.2)
|
||||
if (params.distortion > 0.2) {
|
||||
const distAmount = 1 + params.distortion * 3;
|
||||
signal = this.waveshaper(signal * distAmount, distAmount) / distAmount;
|
||||
}
|
||||
|
||||
// 7. Low-pass filter at 3000Hz
|
||||
const normalizedFreq = Math.min(filterFreq / sampleRate, 0.48);
|
||||
const a = 2 * Math.PI * normalizedFreq;
|
||||
filterState += a * (signal - filterState);
|
||||
signal = filterState;
|
||||
|
||||
output[i] = signal;
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize output to consistent level
|
||||
let peak = 0;
|
||||
for (let i = 0; i < numSamples; i++) {
|
||||
peak = Math.max(peak, Math.abs(left[i]), Math.abs(right[i]));
|
||||
}
|
||||
|
||||
if (peak > 0.001) {
|
||||
const normGain = 0.5 / peak; // Normalize to 0.5 peak for more headroom
|
||||
for (let i = 0; i < numSamples; i++) {
|
||||
left[i] *= normGain;
|
||||
right[i] *= normGain;
|
||||
}
|
||||
}
|
||||
|
||||
return [left, right];
|
||||
}
|
||||
|
||||
private applyDecayCurve(t: number, decayTime: number, curveParam: number): number {
|
||||
const normalized = Math.min(t / decayTime, 5);
|
||||
const baseEnv = Math.exp(-normalized);
|
||||
const exponent = 0.4 + curveParam * 2.1;
|
||||
return Math.pow(baseEnv, exponent);
|
||||
}
|
||||
|
||||
private applyAttackCurve(t: number, attackTime: number, curveParam: number): number {
|
||||
const normalized = Math.min(t / attackTime, 1);
|
||||
const exponent = 0.4 + curveParam * 2.1;
|
||||
return Math.pow(normalized, exponent);
|
||||
}
|
||||
|
||||
private bandpassFilter(
|
||||
input: number,
|
||||
freq: number,
|
||||
q: number,
|
||||
sampleRate: number,
|
||||
state1: number,
|
||||
state2: number
|
||||
): { output: number; state1: number; state2: number } {
|
||||
// State variable filter in bandpass mode
|
||||
const normalizedFreq = Math.min(freq / sampleRate, 0.48);
|
||||
const f = 2 * Math.sin(Math.PI * normalizedFreq);
|
||||
const qRecip = 1 / Math.max(q, 0.5);
|
||||
|
||||
const lowpass = state2 + f * state1;
|
||||
const highpass = input - lowpass - qRecip * state1;
|
||||
const bandpass = f * highpass + state1;
|
||||
|
||||
// Update states with clamping
|
||||
const newState1 = Math.max(-2, Math.min(2, bandpass));
|
||||
const newState2 = Math.max(-2, Math.min(2, lowpass));
|
||||
|
||||
return {
|
||||
output: bandpass,
|
||||
state1: newState1,
|
||||
state2: newState2,
|
||||
};
|
||||
}
|
||||
|
||||
private highpassFilter(
|
||||
input: number,
|
||||
freq: number,
|
||||
q: number,
|
||||
sampleRate: number,
|
||||
state1: number,
|
||||
state2: number
|
||||
): { output: number; state1: number; state2: number } {
|
||||
// State variable filter in highpass mode
|
||||
const normalizedFreq = Math.min(freq / sampleRate, 0.48);
|
||||
const f = 2 * Math.sin(Math.PI * normalizedFreq);
|
||||
const qRecip = 1 / Math.max(q, 0.5);
|
||||
|
||||
const lowpass = state2 + f * state1;
|
||||
const highpass = input - lowpass - qRecip * state1;
|
||||
const bandpass = f * highpass + state1;
|
||||
|
||||
// Update states with clamping
|
||||
const newState1 = Math.max(-2, Math.min(2, bandpass));
|
||||
const newState2 = Math.max(-2, Math.min(2, lowpass));
|
||||
|
||||
return {
|
||||
output: highpass,
|
||||
state1: newState1,
|
||||
state2: newState2,
|
||||
};
|
||||
}
|
||||
|
||||
private waveshaper(x: number, amount: number): number {
|
||||
const PI = Math.PI;
|
||||
if (Math.abs(x) < 0.001) return x;
|
||||
return ((PI + amount) * x) / (PI + amount * Math.abs(x));
|
||||
}
|
||||
|
||||
private softClip(x: number): number {
|
||||
if (x > 1) return 1;
|
||||
if (x < -1) return -1;
|
||||
if (x > 0.66) return (3 - (2 - 3 * x) ** 2) / 3;
|
||||
if (x < -0.66) return -(3 - (2 - 3 * -x) ** 2) / 3;
|
||||
return x;
|
||||
}
|
||||
|
||||
private freqToParam(freq: number): number {
|
||||
// Map frequency to 0-1 range (35-115 Hz)
|
||||
return Math.max(0, Math.min(1, (freq - 35) / 80));
|
||||
}
|
||||
}
|
||||
179
src/lib/audio/engines/HiHat.ts
Normal file
179
src/lib/audio/engines/HiHat.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
@ -39,7 +39,7 @@ interface NoiseDrumParams {
|
||||
|
||||
export class NoiseDrum implements SynthEngine {
|
||||
getName(): string {
|
||||
return 'Noise Drum';
|
||||
return 'NPerc';
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
|
||||
588
src/lib/audio/engines/PhaseDistortionFM.ts
Normal file
588
src/lib/audio/engines/PhaseDistortionFM.ts
Normal file
@ -0,0 +1,588 @@
|
||||
import type { SynthEngine, PitchLock } from './SynthEngine';
|
||||
|
||||
enum PDWaveform {
|
||||
Sine,
|
||||
SawUp,
|
||||
SawDown,
|
||||
Pulse,
|
||||
DoubleSine,
|
||||
ResonantSaw,
|
||||
}
|
||||
|
||||
enum PDAlgorithm {
|
||||
Single, // Single oscillator with PD
|
||||
Dual, // Two oscillators, different waveforms
|
||||
Detune, // Two slightly detuned oscillators
|
||||
Octave, // Oscillator + octave up
|
||||
Fifth, // Base + fifth up (1.5x frequency)
|
||||
Sub, // Base + octave down
|
||||
Stack, // Base + fifth + octave
|
||||
Wide, // Heavily detuned oscillators
|
||||
}
|
||||
|
||||
enum LFOWaveform {
|
||||
Sine,
|
||||
Triangle,
|
||||
Square,
|
||||
Saw,
|
||||
SampleHold,
|
||||
RandomWalk,
|
||||
}
|
||||
|
||||
interface PDOscillatorParams {
|
||||
waveform: PDWaveform;
|
||||
level: number;
|
||||
detune: number; // in cents
|
||||
dcw: number; // Digitally Controlled Waveshaping (0-1, controls brightness)
|
||||
pulseWidth: number; // 0-1, controls pulse wave width
|
||||
attack: number;
|
||||
decay: number;
|
||||
sustain: number;
|
||||
release: number;
|
||||
dcwAttack: number;
|
||||
dcwDecay: number;
|
||||
dcwSustain: number;
|
||||
dcwRelease: number;
|
||||
}
|
||||
|
||||
interface LFOParams {
|
||||
rate: number;
|
||||
depth: number;
|
||||
waveform: LFOWaveform;
|
||||
target: 'pitch' | 'dcw';
|
||||
}
|
||||
|
||||
export interface PhaseDistortionFMParams {
|
||||
baseFreq: number;
|
||||
algorithm: PDAlgorithm;
|
||||
oscillators: [PDOscillatorParams, PDOscillatorParams];
|
||||
lfo: LFOParams;
|
||||
stereoWidth: number;
|
||||
}
|
||||
|
||||
export class PhaseDistortionFM implements SynthEngine<PhaseDistortionFMParams> {
|
||||
private static workletLoaded = false;
|
||||
private static workletURL: string | null = null;
|
||||
|
||||
getName(): string {
|
||||
return 'PD';
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return 'Casio CZ-style Phase Distortion synthesis with DCW envelopes';
|
||||
}
|
||||
|
||||
getType() {
|
||||
return 'generative' as const;
|
||||
}
|
||||
|
||||
generate(params: PhaseDistortionFMParams, sampleRate: number, duration: number): [Float32Array, Float32Array] {
|
||||
const numSamples = Math.floor(sampleRate * duration);
|
||||
const leftBuffer = new Float32Array(numSamples);
|
||||
const rightBuffer = new Float32Array(numSamples);
|
||||
const TAU = Math.PI * 2;
|
||||
|
||||
const detune = 1 + (params.stereoWidth * 0.001);
|
||||
const leftFreq = params.baseFreq / detune;
|
||||
const rightFreq = params.baseFreq * detune;
|
||||
|
||||
let osc1PhaseL = 0;
|
||||
let osc1PhaseR = 0;
|
||||
let osc2PhaseL = 0;
|
||||
let osc2PhaseR = 0;
|
||||
|
||||
let lfoPhaseL = 0;
|
||||
let lfoPhaseR = Math.PI * params.stereoWidth * 0.3;
|
||||
|
||||
let lfoSampleHoldValue = Math.random() * 2 - 1;
|
||||
let lfoSampleHoldCounter = 0;
|
||||
const lfoSampleHoldInterval = Math.max(1, Math.floor(sampleRate / (params.lfo.rate * 4)));
|
||||
let lfoRandomWalkCurrent = Math.random() * 2 - 1;
|
||||
let lfoRandomWalkTarget = Math.random() * 2 - 1;
|
||||
let lfoRandomWalkCounter = 0;
|
||||
const lfoRandomWalkInterval = Math.max(1, Math.floor(sampleRate / (params.lfo.rate * 2)));
|
||||
|
||||
let dcBlockerPrevL = 0;
|
||||
let dcBlockerAccL = 0;
|
||||
let dcBlockerPrevR = 0;
|
||||
let dcBlockerAccR = 0;
|
||||
const dcBlockerCutoff = 0.995;
|
||||
|
||||
for (let i = 0; i < numSamples; i++) {
|
||||
const t = i / sampleRate;
|
||||
|
||||
const env1 = this.calculateEnvelope(t, duration, params.oscillators[0]);
|
||||
const env2 = this.calculateEnvelope(t, duration, params.oscillators[1]);
|
||||
|
||||
const dcw1 = this.calculateDCWEnvelope(t, duration, params.oscillators[0]);
|
||||
const dcw2 = this.calculateDCWEnvelope(t, duration, params.oscillators[1]);
|
||||
|
||||
// Update LFO states
|
||||
lfoSampleHoldCounter++;
|
||||
if (lfoSampleHoldCounter >= lfoSampleHoldInterval) {
|
||||
lfoSampleHoldValue = Math.random() * 2 - 1;
|
||||
lfoSampleHoldCounter = 0;
|
||||
}
|
||||
|
||||
lfoRandomWalkCounter++;
|
||||
if (lfoRandomWalkCounter >= lfoRandomWalkInterval) {
|
||||
lfoRandomWalkTarget = Math.random() * 2 - 1;
|
||||
lfoRandomWalkCounter = 0;
|
||||
}
|
||||
const walkSpeed = 0.01;
|
||||
lfoRandomWalkCurrent += (lfoRandomWalkTarget - lfoRandomWalkCurrent) * walkSpeed;
|
||||
|
||||
const lfoL = this.generateLFO(lfoPhaseL, params.lfo.waveform, lfoSampleHoldValue, lfoRandomWalkCurrent);
|
||||
const lfoR = this.generateLFO(lfoPhaseR, params.lfo.waveform, lfoSampleHoldValue, lfoRandomWalkCurrent);
|
||||
|
||||
const lfoModL = lfoL * params.lfo.depth;
|
||||
const lfoModR = lfoR * params.lfo.depth;
|
||||
|
||||
let pitchModL = 0, pitchModR = 0;
|
||||
let dcwMod1 = 0, dcwMod2 = 0;
|
||||
|
||||
if (params.lfo.target === 'pitch') {
|
||||
pitchModL = lfoModL * 0.02;
|
||||
pitchModR = lfoModR * 0.02;
|
||||
} else {
|
||||
dcwMod1 = lfoModL * 0.3;
|
||||
dcwMod2 = lfoModL * 0.3;
|
||||
}
|
||||
|
||||
const [sampleL, sampleR] = this.processAlgorithm(
|
||||
params.algorithm,
|
||||
params.oscillators,
|
||||
[osc1PhaseL, osc2PhaseL],
|
||||
[osc1PhaseR, osc2PhaseR],
|
||||
[env1, env2],
|
||||
[Math.max(0, Math.min(1, dcw1 + dcwMod1)), Math.max(0, Math.min(1, dcw2 + dcwMod2))]
|
||||
);
|
||||
|
||||
let outL = sampleL;
|
||||
let outR = sampleR;
|
||||
|
||||
outL = this.softClip(outL);
|
||||
outR = this.softClip(outR);
|
||||
|
||||
// DC blocker
|
||||
const dcFilteredL = outL - dcBlockerPrevL + dcBlockerCutoff * dcBlockerAccL;
|
||||
dcBlockerPrevL = outL;
|
||||
dcBlockerAccL = dcFilteredL;
|
||||
|
||||
const dcFilteredR = outR - dcBlockerPrevR + dcBlockerCutoff * dcBlockerAccR;
|
||||
dcBlockerPrevR = outR;
|
||||
dcBlockerAccR = dcFilteredR;
|
||||
|
||||
leftBuffer[i] = dcFilteredL;
|
||||
rightBuffer[i] = dcFilteredR;
|
||||
|
||||
// Advance oscillator phases
|
||||
const detune1 = Math.pow(2, params.oscillators[0].detune / 1200);
|
||||
const detune2 = Math.pow(2, params.oscillators[1].detune / 1200);
|
||||
|
||||
const osc1FreqL = leftFreq * detune1 * (1 + pitchModL);
|
||||
const osc1FreqR = rightFreq * detune1 * (1 + pitchModR);
|
||||
const osc2FreqL = leftFreq * detune2 * (1 + pitchModL);
|
||||
const osc2FreqR = rightFreq * detune2 * (1 + pitchModR);
|
||||
|
||||
osc1PhaseL += (TAU * osc1FreqL) / sampleRate;
|
||||
osc1PhaseR += (TAU * osc1FreqR) / sampleRate;
|
||||
osc2PhaseL += (TAU * osc2FreqL) / sampleRate;
|
||||
osc2PhaseR += (TAU * osc2FreqR) / sampleRate;
|
||||
|
||||
while (osc1PhaseL >= TAU) osc1PhaseL -= TAU;
|
||||
while (osc1PhaseR >= TAU) osc1PhaseR -= TAU;
|
||||
while (osc2PhaseL >= TAU) osc2PhaseL -= TAU;
|
||||
while (osc2PhaseR >= TAU) osc2PhaseR -= TAU;
|
||||
|
||||
lfoPhaseL += (TAU * params.lfo.rate) / sampleRate;
|
||||
lfoPhaseR += (TAU * params.lfo.rate) / sampleRate;
|
||||
while (lfoPhaseL >= TAU) lfoPhaseL -= TAU;
|
||||
while (lfoPhaseR >= TAU) lfoPhaseR -= TAU;
|
||||
}
|
||||
|
||||
// Normalize
|
||||
let peakL = 0;
|
||||
let peakR = 0;
|
||||
for (let i = 0; i < numSamples; i++) {
|
||||
peakL = Math.max(peakL, Math.abs(leftBuffer[i]));
|
||||
peakR = Math.max(peakR, Math.abs(rightBuffer[i]));
|
||||
}
|
||||
const peak = Math.max(peakL, peakR);
|
||||
|
||||
if (peak > 0.001) {
|
||||
const normalizeGain = 0.85 / peak;
|
||||
for (let i = 0; i < numSamples; i++) {
|
||||
leftBuffer[i] *= normalizeGain;
|
||||
rightBuffer[i] *= normalizeGain;
|
||||
}
|
||||
}
|
||||
|
||||
return [leftBuffer, rightBuffer];
|
||||
}
|
||||
|
||||
private processAlgorithm(
|
||||
algorithm: PDAlgorithm,
|
||||
oscillators: [PDOscillatorParams, PDOscillatorParams],
|
||||
phasesL: number[],
|
||||
phasesR: number[],
|
||||
envelopes: number[],
|
||||
dcws: number[]
|
||||
): [number, number] {
|
||||
switch (algorithm) {
|
||||
case PDAlgorithm.Single: {
|
||||
// Single oscillator
|
||||
const outL = this.generatePDWaveform(phasesL[0], oscillators[0].waveform, dcws[0], oscillators[0].pulseWidth)
|
||||
* envelopes[0] * oscillators[0].level;
|
||||
const outR = this.generatePDWaveform(phasesR[0], oscillators[0].waveform, dcws[0], oscillators[0].pulseWidth)
|
||||
* envelopes[0] * oscillators[0].level;
|
||||
return [outL, outR];
|
||||
}
|
||||
|
||||
case PDAlgorithm.Dual: {
|
||||
// Two oscillators with different waveforms
|
||||
const osc1L = this.generatePDWaveform(phasesL[0], oscillators[0].waveform, dcws[0], oscillators[0].pulseWidth)
|
||||
* envelopes[0] * oscillators[0].level;
|
||||
const osc1R = this.generatePDWaveform(phasesR[0], oscillators[0].waveform, dcws[0], oscillators[0].pulseWidth)
|
||||
* envelopes[0] * oscillators[0].level;
|
||||
const osc2L = this.generatePDWaveform(phasesL[1], oscillators[1].waveform, dcws[1], oscillators[1].pulseWidth)
|
||||
* envelopes[1] * oscillators[1].level;
|
||||
const osc2R = this.generatePDWaveform(phasesR[1], oscillators[1].waveform, dcws[1], oscillators[1].pulseWidth)
|
||||
* envelopes[1] * oscillators[1].level;
|
||||
return [(osc1L + osc2L) * 0.707, (osc1R + osc2R) * 0.707];
|
||||
}
|
||||
|
||||
case PDAlgorithm.Detune: {
|
||||
// Two slightly detuned oscillators (chorus effect)
|
||||
const osc1L = this.generatePDWaveform(phasesL[0], oscillators[0].waveform, dcws[0], oscillators[0].pulseWidth)
|
||||
* envelopes[0] * oscillators[0].level;
|
||||
const osc1R = this.generatePDWaveform(phasesR[0], oscillators[0].waveform, dcws[0], oscillators[0].pulseWidth)
|
||||
* envelopes[0] * oscillators[0].level;
|
||||
const osc2L = this.generatePDWaveform(phasesL[1], oscillators[0].waveform, dcws[0], oscillators[0].pulseWidth)
|
||||
* envelopes[0] * oscillators[1].level;
|
||||
const osc2R = this.generatePDWaveform(phasesR[1], oscillators[0].waveform, dcws[0], oscillators[0].pulseWidth)
|
||||
* envelopes[0] * oscillators[1].level;
|
||||
return [(osc1L + osc2L) * 0.707, (osc1R + osc2R) * 0.707];
|
||||
}
|
||||
|
||||
case PDAlgorithm.Octave: {
|
||||
// Oscillator + octave up
|
||||
const osc1L = this.generatePDWaveform(phasesL[0], oscillators[0].waveform, dcws[0], oscillators[0].pulseWidth)
|
||||
* envelopes[0] * oscillators[0].level;
|
||||
const osc1R = this.generatePDWaveform(phasesR[0], oscillators[0].waveform, dcws[0], oscillators[0].pulseWidth)
|
||||
* envelopes[0] * oscillators[0].level;
|
||||
const osc2L = this.generatePDWaveform(phasesL[1] * 2, oscillators[1].waveform, dcws[1], oscillators[1].pulseWidth)
|
||||
* envelopes[1] * oscillators[1].level;
|
||||
const osc2R = this.generatePDWaveform(phasesR[1] * 2, oscillators[1].waveform, dcws[1], oscillators[1].pulseWidth)
|
||||
* envelopes[1] * oscillators[1].level;
|
||||
return [(osc1L + osc2L) * 0.707, (osc1R + osc2R) * 0.707];
|
||||
}
|
||||
|
||||
case PDAlgorithm.Fifth: {
|
||||
// Base + fifth up (1.5x frequency)
|
||||
const osc1L = this.generatePDWaveform(phasesL[0], oscillators[0].waveform, dcws[0], oscillators[0].pulseWidth)
|
||||
* envelopes[0] * oscillators[0].level;
|
||||
const osc1R = this.generatePDWaveform(phasesR[0], oscillators[0].waveform, dcws[0], oscillators[0].pulseWidth)
|
||||
* envelopes[0] * oscillators[0].level;
|
||||
const osc2L = this.generatePDWaveform(phasesL[1] * 1.5, oscillators[1].waveform, dcws[1], oscillators[1].pulseWidth)
|
||||
* envelopes[1] * oscillators[1].level;
|
||||
const osc2R = this.generatePDWaveform(phasesR[1] * 1.5, oscillators[1].waveform, dcws[1], oscillators[1].pulseWidth)
|
||||
* envelopes[1] * oscillators[1].level;
|
||||
return [(osc1L + osc2L) * 0.707, (osc1R + osc2R) * 0.707];
|
||||
}
|
||||
|
||||
case PDAlgorithm.Sub: {
|
||||
// Base + octave down
|
||||
const osc1L = this.generatePDWaveform(phasesL[0], oscillators[0].waveform, dcws[0], oscillators[0].pulseWidth)
|
||||
* envelopes[0] * oscillators[0].level;
|
||||
const osc1R = this.generatePDWaveform(phasesR[0], oscillators[0].waveform, dcws[0], oscillators[0].pulseWidth)
|
||||
* envelopes[0] * oscillators[0].level;
|
||||
const osc2L = this.generatePDWaveform(phasesL[1] * 0.5, oscillators[1].waveform, dcws[1], oscillators[1].pulseWidth)
|
||||
* envelopes[1] * oscillators[1].level;
|
||||
const osc2R = this.generatePDWaveform(phasesR[1] * 0.5, oscillators[1].waveform, dcws[1], oscillators[1].pulseWidth)
|
||||
* envelopes[1] * oscillators[1].level;
|
||||
return [(osc1L + osc2L) * 0.707, (osc1R + osc2R) * 0.707];
|
||||
}
|
||||
|
||||
case PDAlgorithm.Stack: {
|
||||
// Base + fifth + octave - three oscillators!
|
||||
const osc1L = this.generatePDWaveform(phasesL[0], oscillators[0].waveform, dcws[0], oscillators[0].pulseWidth)
|
||||
* envelopes[0] * oscillators[0].level;
|
||||
const osc1R = this.generatePDWaveform(phasesR[0], oscillators[0].waveform, dcws[0], oscillators[0].pulseWidth)
|
||||
* envelopes[0] * oscillators[0].level;
|
||||
const osc2L = this.generatePDWaveform(phasesL[1] * 1.5, oscillators[1].waveform, dcws[1], oscillators[1].pulseWidth)
|
||||
* envelopes[1] * oscillators[1].level;
|
||||
const osc2R = this.generatePDWaveform(phasesR[1] * 1.5, oscillators[1].waveform, dcws[1], oscillators[1].pulseWidth)
|
||||
* envelopes[1] * oscillators[1].level;
|
||||
const osc3L = this.generatePDWaveform(phasesL[1] * 2, oscillators[1].waveform, dcws[1], oscillators[1].pulseWidth)
|
||||
* envelopes[1] * oscillators[1].level * 0.7;
|
||||
const osc3R = this.generatePDWaveform(phasesR[1] * 2, oscillators[1].waveform, dcws[1], oscillators[1].pulseWidth)
|
||||
* envelopes[1] * oscillators[1].level * 0.7;
|
||||
return [(osc1L + osc2L + osc3L) * 0.577, (osc1R + osc2R + osc3R) * 0.577];
|
||||
}
|
||||
|
||||
case PDAlgorithm.Wide: {
|
||||
// Heavily detuned oscillators for super wide chorus
|
||||
const osc1L = this.generatePDWaveform(phasesL[0], oscillators[0].waveform, dcws[0], oscillators[0].pulseWidth)
|
||||
* envelopes[0] * oscillators[0].level;
|
||||
const osc1R = this.generatePDWaveform(phasesR[0], oscillators[0].waveform, dcws[0], oscillators[0].pulseWidth)
|
||||
* envelopes[0] * oscillators[0].level;
|
||||
const osc2L = this.generatePDWaveform(phasesL[1], oscillators[1].waveform, dcws[1], oscillators[1].pulseWidth)
|
||||
* envelopes[1] * oscillators[1].level;
|
||||
const osc2R = this.generatePDWaveform(phasesR[1], oscillators[1].waveform, dcws[1], oscillators[1].pulseWidth)
|
||||
* envelopes[1] * oscillators[1].level;
|
||||
return [(osc1L + osc2L) * 0.707, (osc1R + osc2R) * 0.707];
|
||||
}
|
||||
|
||||
default:
|
||||
return [0, 0];
|
||||
}
|
||||
}
|
||||
|
||||
private generatePDWaveform(phase: number, waveform: PDWaveform, dcw: number, pulseWidth: number): number {
|
||||
const TAU = Math.PI * 2;
|
||||
let normalizedPhase = phase / TAU;
|
||||
normalizedPhase = normalizedPhase - Math.floor(normalizedPhase);
|
||||
|
||||
// Apply phase distortion based on DCW
|
||||
const distortedPhase = this.applyPhaseDistortion(normalizedPhase, dcw);
|
||||
|
||||
switch (waveform) {
|
||||
case PDWaveform.Sine:
|
||||
return Math.sin(distortedPhase * TAU);
|
||||
|
||||
case PDWaveform.SawUp:
|
||||
return distortedPhase * 2 - 1;
|
||||
|
||||
case PDWaveform.SawDown:
|
||||
return 1 - distortedPhase * 2;
|
||||
|
||||
case PDWaveform.Pulse:
|
||||
return distortedPhase < pulseWidth ? 1 : -1;
|
||||
|
||||
case PDWaveform.DoubleSine:
|
||||
return Math.sin(distortedPhase * TAU * 2);
|
||||
|
||||
case PDWaveform.ResonantSaw:
|
||||
return this.resonantSaw(distortedPhase, dcw);
|
||||
|
||||
default:
|
||||
return Math.sin(distortedPhase * TAU);
|
||||
}
|
||||
}
|
||||
|
||||
private applyPhaseDistortion(phase: number, dcw: number): number {
|
||||
// Casio CZ-style phase distortion
|
||||
// DCW = 0: no distortion (linear phase)
|
||||
// DCW = 1: maximum distortion (transforms waveform)
|
||||
|
||||
if (dcw < 0.01) return phase;
|
||||
|
||||
// Add a triangle wave to the phase - this is the classic CZ method
|
||||
// The triangle modulates how fast we read through the waveform
|
||||
const trianglePhase = phase < 0.5 ? phase * 2 : 2 - phase * 2;
|
||||
|
||||
// Mix between linear and distorted phase
|
||||
return phase + (trianglePhase - 0.5) * dcw;
|
||||
}
|
||||
|
||||
private resonantSaw(phase: number, brightness: number): number {
|
||||
// Band-limited sawtooth with fewer harmonics for cleaner sound
|
||||
const maxHarmonics = Math.min(4, Math.floor(1 + brightness * 3));
|
||||
let sum = 0;
|
||||
let normalization = 0;
|
||||
for (let n = 1; n <= maxHarmonics; n++) {
|
||||
sum += Math.sin(Math.PI * 2 * phase * n) / n;
|
||||
normalization += 1 / n;
|
||||
}
|
||||
return normalization > 0 ? (sum / normalization) * 0.7 : 0;
|
||||
}
|
||||
|
||||
private calculateEnvelope(t: number, duration: number, osc: PDOscillatorParams): number {
|
||||
const attackTime = Math.max(0.0001, osc.attack * duration);
|
||||
const decayTime = Math.max(0.0001, osc.decay * duration);
|
||||
const releaseTime = Math.max(0.0001, osc.release * duration);
|
||||
const sustainStart = attackTime + decayTime;
|
||||
const releaseStart = duration - releaseTime;
|
||||
|
||||
if (t < attackTime) {
|
||||
return t / attackTime;
|
||||
} else if (t < sustainStart) {
|
||||
const progress = (t - attackTime) / decayTime;
|
||||
return 1 - progress * (1 - osc.sustain);
|
||||
} else if (t < releaseStart) {
|
||||
return osc.sustain;
|
||||
} else {
|
||||
const progress = (t - releaseStart) / releaseTime;
|
||||
return osc.sustain * (1 - progress);
|
||||
}
|
||||
}
|
||||
|
||||
private calculateDCWEnvelope(t: number, duration: number, osc: PDOscillatorParams): number {
|
||||
const attackTime = Math.max(0.0001, osc.dcwAttack * duration);
|
||||
const decayTime = Math.max(0.0001, osc.dcwDecay * duration);
|
||||
const releaseTime = Math.max(0.0001, osc.dcwRelease * duration);
|
||||
const sustainStart = attackTime + decayTime;
|
||||
const releaseStart = duration - releaseTime;
|
||||
|
||||
if (t < attackTime) {
|
||||
return (t / attackTime) * osc.dcw;
|
||||
} else if (t < sustainStart) {
|
||||
const progress = (t - attackTime) / decayTime;
|
||||
return osc.dcw * (1 - progress * (1 - osc.dcwSustain));
|
||||
} else if (t < releaseStart) {
|
||||
return osc.dcw * osc.dcwSustain;
|
||||
} else {
|
||||
const progress = (t - releaseStart) / releaseTime;
|
||||
return osc.dcw * osc.dcwSustain * (1 - progress);
|
||||
}
|
||||
}
|
||||
|
||||
private generateLFO(phase: number, waveform: LFOWaveform, sampleHoldValue: number, randomWalkValue: number): number {
|
||||
const TAU = Math.PI * 2;
|
||||
let normalizedPhase = phase / TAU;
|
||||
normalizedPhase = normalizedPhase - Math.floor(normalizedPhase);
|
||||
|
||||
switch (waveform) {
|
||||
case LFOWaveform.Sine:
|
||||
return Math.sin(phase);
|
||||
|
||||
case LFOWaveform.Triangle:
|
||||
return normalizedPhase < 0.5
|
||||
? normalizedPhase * 4 - 1
|
||||
: 3 - normalizedPhase * 4;
|
||||
|
||||
case LFOWaveform.Square:
|
||||
return normalizedPhase < 0.5 ? 1 : -1;
|
||||
|
||||
case LFOWaveform.Saw:
|
||||
return normalizedPhase * 2 - 1;
|
||||
|
||||
case LFOWaveform.SampleHold:
|
||||
return sampleHoldValue;
|
||||
|
||||
case LFOWaveform.RandomWalk:
|
||||
return randomWalkValue;
|
||||
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private softClip(x: number): number {
|
||||
const absX = Math.abs(x);
|
||||
if (absX < 0.7) return x;
|
||||
if (absX > 3) return Math.sign(x) * 0.98;
|
||||
const x2 = x * x;
|
||||
return x * (27 + x2) / (27 + 9 * x2);
|
||||
}
|
||||
|
||||
randomParams(pitchLock?: PitchLock): PhaseDistortionFMParams {
|
||||
const algorithm = this.randomInt(0, 7) as PDAlgorithm;
|
||||
|
||||
let baseFreq: number;
|
||||
if (pitchLock?.enabled) {
|
||||
baseFreq = pitchLock.frequency;
|
||||
} else {
|
||||
const baseFreqChoices = [55, 82.4, 110, 146.8, 220, 293.7, 440, 587.3, 880];
|
||||
baseFreq = this.randomChoice(baseFreqChoices);
|
||||
}
|
||||
|
||||
return {
|
||||
baseFreq,
|
||||
algorithm,
|
||||
oscillators: [
|
||||
this.randomOscillator(algorithm),
|
||||
this.randomOscillator(algorithm),
|
||||
],
|
||||
lfo: {
|
||||
rate: this.randomRange(0.5, 8),
|
||||
depth: this.randomRange(0, 0.4),
|
||||
waveform: this.randomInt(0, 5) as LFOWaveform,
|
||||
target: this.randomChoice(['pitch', 'dcw'] as const),
|
||||
},
|
||||
stereoWidth: this.randomRange(0.1, 0.5),
|
||||
};
|
||||
}
|
||||
|
||||
private randomOscillator(algorithm: PDAlgorithm): PDOscillatorParams {
|
||||
const waveform = this.randomInt(0, 5) as PDWaveform;
|
||||
|
||||
let detune = 0;
|
||||
if (algorithm === PDAlgorithm.Detune) {
|
||||
detune = this.randomRange(-10, 10);
|
||||
} else if (algorithm === PDAlgorithm.Wide) {
|
||||
detune = this.randomRange(-30, 30);
|
||||
}
|
||||
|
||||
return {
|
||||
waveform,
|
||||
level: this.randomRange(0.5, 0.9),
|
||||
detune,
|
||||
dcw: this.randomRange(0.2, 0.8),
|
||||
pulseWidth: this.randomRange(0.2, 0.8),
|
||||
attack: this.randomRange(0.001, 0.1),
|
||||
decay: this.randomRange(0.02, 0.2),
|
||||
sustain: this.randomRange(0.3, 0.8),
|
||||
release: this.randomRange(0.05, 0.3),
|
||||
dcwAttack: this.randomRange(0.001, 0.08),
|
||||
dcwDecay: this.randomRange(0.02, 0.15),
|
||||
dcwSustain: this.randomRange(0.2, 0.7),
|
||||
dcwRelease: this.randomRange(0.05, 0.25),
|
||||
};
|
||||
}
|
||||
|
||||
mutateParams(params: PhaseDistortionFMParams, mutationAmount: number = 0.15, pitchLock?: PitchLock): PhaseDistortionFMParams {
|
||||
const baseFreq = pitchLock?.enabled ? pitchLock.frequency : params.baseFreq;
|
||||
|
||||
return {
|
||||
baseFreq,
|
||||
algorithm: Math.random() < 0.1 ? this.randomInt(0, 7) as PDAlgorithm : params.algorithm,
|
||||
oscillators: params.oscillators.map(osc =>
|
||||
this.mutateOscillator(osc, mutationAmount)
|
||||
) as [PDOscillatorParams, PDOscillatorParams],
|
||||
lfo: {
|
||||
rate: this.mutateValue(params.lfo.rate, mutationAmount, 0.1, 15),
|
||||
depth: this.mutateValue(params.lfo.depth, mutationAmount, 0, 0.6),
|
||||
waveform: Math.random() < 0.08 ? this.randomInt(0, 5) as LFOWaveform : params.lfo.waveform,
|
||||
target: Math.random() < 0.08 ? this.randomChoice(['pitch', 'dcw'] as const) : params.lfo.target,
|
||||
},
|
||||
stereoWidth: this.mutateValue(params.stereoWidth, mutationAmount, 0, 0.7),
|
||||
};
|
||||
}
|
||||
|
||||
private mutateOscillator(osc: PDOscillatorParams, amount: number): PDOscillatorParams {
|
||||
return {
|
||||
waveform: Math.random() < 0.1 ? this.randomInt(0, 5) as PDWaveform : osc.waveform,
|
||||
level: this.mutateValue(osc.level, amount, 0.3, 1.0),
|
||||
detune: this.mutateValue(osc.detune, amount, -20, 20),
|
||||
dcw: this.mutateValue(osc.dcw, amount, 0, 1),
|
||||
pulseWidth: this.mutateValue(osc.pulseWidth, amount, 0.1, 0.9),
|
||||
attack: this.mutateValue(osc.attack, amount, 0.001, 0.2),
|
||||
decay: this.mutateValue(osc.decay, amount, 0.01, 0.3),
|
||||
sustain: this.mutateValue(osc.sustain, amount, 0.1, 0.95),
|
||||
release: this.mutateValue(osc.release, amount, 0.02, 0.5),
|
||||
dcwAttack: this.mutateValue(osc.dcwAttack, amount, 0.001, 0.15),
|
||||
dcwDecay: this.mutateValue(osc.dcwDecay, amount, 0.01, 0.25),
|
||||
dcwSustain: this.mutateValue(osc.dcwSustain, amount, 0.1, 0.9),
|
||||
dcwRelease: this.mutateValue(osc.dcwRelease, amount, 0.02, 0.4),
|
||||
};
|
||||
}
|
||||
|
||||
private randomRange(min: number, max: number): number {
|
||||
return min + Math.random() * (max - min);
|
||||
}
|
||||
|
||||
private randomInt(min: number, max: number): number {
|
||||
return Math.floor(this.randomRange(min, max + 1));
|
||||
}
|
||||
|
||||
private randomChoice<T>(choices: readonly T[]): T {
|
||||
return choices[Math.floor(Math.random() * choices.length)];
|
||||
}
|
||||
|
||||
private mutateValue(value: number, amount: number, min: number, max: number): number {
|
||||
const variation = value * amount * (Math.random() * 2 - 1);
|
||||
return Math.max(min, Math.min(max, value + variation));
|
||||
}
|
||||
}
|
||||
@ -248,6 +248,22 @@ export class Ring implements SynthEngine<RingParams> {
|
||||
if (secondModPhaseR > TAU * 1000) secondModPhaseR -= TAU * 1000;
|
||||
}
|
||||
|
||||
let peakL = 0;
|
||||
let peakR = 0;
|
||||
for (let i = 0; i < numSamples; i++) {
|
||||
peakL = Math.max(peakL, Math.abs(leftBuffer[i]));
|
||||
peakR = Math.max(peakR, Math.abs(rightBuffer[i]));
|
||||
}
|
||||
const peak = Math.max(peakL, peakR);
|
||||
|
||||
if (peak > 0.001) {
|
||||
const normalizeGain = 0.85 / peak;
|
||||
for (let i = 0; i < numSamples; i++) {
|
||||
leftBuffer[i] *= normalizeGain;
|
||||
rightBuffer[i] *= normalizeGain;
|
||||
}
|
||||
}
|
||||
|
||||
return [leftBuffer, rightBuffer];
|
||||
}
|
||||
|
||||
|
||||
234
src/lib/audio/engines/Snare.ts
Normal file
234
src/lib/audio/engines/Snare.ts
Normal file
@ -0,0 +1,234 @@
|
||||
import type { PitchLock, SynthEngine } from './SynthEngine';
|
||||
|
||||
interface SnareParams {
|
||||
// Core frequency (base pitch of the snare)
|
||||
baseFreq: number;
|
||||
|
||||
// Tone control (0 = more tonal, 1 = more noise)
|
||||
tone: number;
|
||||
|
||||
// Snap amount (pitch modulation intensity)
|
||||
snap: number;
|
||||
|
||||
// Decay times
|
||||
tonalDecay: number;
|
||||
noiseDecay: number;
|
||||
|
||||
// Accent (volume boost)
|
||||
accent: number;
|
||||
|
||||
// Tuning offset
|
||||
tuning: number;
|
||||
}
|
||||
|
||||
export class Snare implements SynthEngine {
|
||||
getName(): string {
|
||||
return 'Snare';
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return 'Classic snare drum with triangle wave and noise';
|
||||
}
|
||||
|
||||
getType() {
|
||||
return 'generative' as const;
|
||||
}
|
||||
|
||||
randomParams(pitchLock?: PitchLock): SnareParams {
|
||||
return {
|
||||
baseFreq: pitchLock ? this.freqToParam(pitchLock.frequency) : 0.3 + Math.random() * 0.4,
|
||||
tone: 0.4 + Math.random() * 0.4,
|
||||
snap: 0.5 + Math.random() * 0.5,
|
||||
tonalDecay: 0.15 + Math.random() * 0.35,
|
||||
noiseDecay: 0.4 + Math.random() * 0.4,
|
||||
accent: 0.5 + Math.random() * 0.5,
|
||||
tuning: pitchLock ? 0.5 : 0.4 + Math.random() * 0.2,
|
||||
};
|
||||
}
|
||||
|
||||
mutateParams(params: SnareParams, mutationAmount: number = 0.15, pitchLock?: PitchLock): SnareParams {
|
||||
const mutate = (value: number, amount: number = mutationAmount): number => {
|
||||
return Math.max(0, Math.min(1, value + (Math.random() - 0.5) * amount));
|
||||
};
|
||||
|
||||
return {
|
||||
baseFreq: pitchLock ? params.baseFreq : mutate(params.baseFreq, 0.2),
|
||||
tone: mutate(params.tone, 0.25),
|
||||
snap: mutate(params.snap, 0.2),
|
||||
tonalDecay: mutate(params.tonalDecay, 0.2),
|
||||
noiseDecay: mutate(params.noiseDecay, 0.2),
|
||||
accent: mutate(params.accent, 0.2),
|
||||
tuning: pitchLock ? params.tuning : mutate(params.tuning, 0.15),
|
||||
};
|
||||
}
|
||||
|
||||
generate(
|
||||
params: SnareParams,
|
||||
sampleRate: number,
|
||||
duration: number,
|
||||
pitchLock?: PitchLock
|
||||
): [Float32Array, Float32Array] {
|
||||
const numSamples = Math.floor(sampleRate * duration);
|
||||
const left = new Float32Array(numSamples);
|
||||
const right = new Float32Array(numSamples);
|
||||
|
||||
// Base frequency: 100Hz to 400Hz
|
||||
const baseFreq = pitchLock ? pitchLock.frequency : 100 + params.baseFreq * 300;
|
||||
|
||||
// Tuning offset: -12% to +12%
|
||||
const tuningFactor = 0.88 + params.tuning * 0.24;
|
||||
const tunedFreq = baseFreq * tuningFactor;
|
||||
|
||||
// Pitch modulation parameters (starts 4x higher, drops over 10ms)
|
||||
const pitchMultiplier = 1 + params.snap * 3; // 1x to 4x
|
||||
const modDuration = 0.008 + params.snap * 0.007; // 8ms to 15ms
|
||||
|
||||
// Decay times scaled by duration
|
||||
const tonalDecayTime = (0.05 + params.tonalDecay * 0.15) * duration; // 50ms to 200ms
|
||||
const noiseDecayTime = (0.15 + params.noiseDecay * 0.35) * duration; // 150ms to 500ms
|
||||
|
||||
// Volume and gain staging
|
||||
const baseVolume = 0.5;
|
||||
const accentGain = baseVolume * (1 + params.accent);
|
||||
|
||||
// Attack time (5ms ramp)
|
||||
const attackTime = 0.005;
|
||||
|
||||
// Notch filter parameters for noise (fixed at 1000Hz)
|
||||
const notchFreq = 1000;
|
||||
const notchQ = 5;
|
||||
|
||||
for (let channel = 0; channel < 2; channel++) {
|
||||
const output = channel === 0 ? left : right;
|
||||
|
||||
// Triangle oscillator phase
|
||||
let phase = Math.random() * Math.PI * 2;
|
||||
|
||||
// Notch filter state variables
|
||||
let notchState1 = 0;
|
||||
let notchState2 = 0;
|
||||
|
||||
// Stereo variation (slight detune and phase offset)
|
||||
const stereoDetune = channel === 0 ? 0.998 : 1.002;
|
||||
const stereoPhaseOffset = channel === 0 ? 0 : 0.1;
|
||||
|
||||
for (let i = 0; i < numSamples; i++) {
|
||||
const t = i / sampleRate;
|
||||
const normPhase = i / numSamples;
|
||||
|
||||
// Pitch envelope: exponential ramp from pitchMultiplier*freq down to base freq
|
||||
const pitchEnv = Math.exp(-t / modDuration);
|
||||
const currentFreq = tunedFreq * stereoDetune * (1 + (pitchMultiplier - 1) * pitchEnv);
|
||||
|
||||
// Generate triangle wave
|
||||
phase += (2 * Math.PI * currentFreq) / sampleRate;
|
||||
if (phase > 2 * Math.PI) phase -= 2 * Math.PI;
|
||||
|
||||
// Triangle wave from phase
|
||||
const triangle = phase < Math.PI
|
||||
? -1 + (2 * phase) / Math.PI
|
||||
: 3 - (2 * phase) / Math.PI;
|
||||
|
||||
// Attack envelope (5ms linear ramp)
|
||||
const attackEnv = t < attackTime ? t / attackTime : 1.0;
|
||||
|
||||
// Tonal envelope: exponential decay
|
||||
const tonalEnv = Math.exp(-t / tonalDecayTime) * attackEnv;
|
||||
const tonalSignal = triangle * tonalEnv;
|
||||
|
||||
// Generate white noise
|
||||
const noise = Math.random() * 2 - 1;
|
||||
|
||||
// Apply notch filter to noise (removes 1000Hz component)
|
||||
const notchFiltered = this.notchFilter(
|
||||
noise,
|
||||
notchFreq,
|
||||
notchQ,
|
||||
sampleRate,
|
||||
notchState1,
|
||||
notchState2
|
||||
);
|
||||
notchState1 = notchFiltered.state1;
|
||||
notchState2 = notchFiltered.state2;
|
||||
|
||||
// Noise envelope: exponential decay (longer than tonal)
|
||||
const noiseEnv = Math.exp(-t / noiseDecayTime);
|
||||
const noiseSignal = notchFiltered.output * noiseEnv;
|
||||
|
||||
// Mix tonal and noise
|
||||
const tonalGain = baseVolume * (1 - params.tone * 0.5); // 0.5 to 0.25
|
||||
const noiseGain = 0.15 + params.tone * 0.2; // 0.15 to 0.35
|
||||
|
||||
let sample = tonalSignal * tonalGain + noiseSignal * noiseGain;
|
||||
|
||||
// Apply accent
|
||||
sample *= (0.5 + params.accent * 0.5);
|
||||
|
||||
// Soft clipping
|
||||
sample = this.softClip(sample);
|
||||
|
||||
output[i] = sample;
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize output to consistent level
|
||||
let peak = 0;
|
||||
for (let i = 0; i < numSamples; i++) {
|
||||
peak = Math.max(peak, Math.abs(left[i]), Math.abs(right[i]));
|
||||
}
|
||||
|
||||
if (peak > 0.001) {
|
||||
const normGain = 0.5 / peak; // Normalize to 0.5 peak for more headroom
|
||||
for (let i = 0; i < numSamples; i++) {
|
||||
left[i] *= normGain;
|
||||
right[i] *= normGain;
|
||||
}
|
||||
}
|
||||
|
||||
return [left, right];
|
||||
}
|
||||
|
||||
private notchFilter(
|
||||
input: number,
|
||||
frequency: number,
|
||||
q: number,
|
||||
sampleRate: number,
|
||||
state1: number,
|
||||
state2: number
|
||||
): { output: number; state1: number; state2: number } {
|
||||
// State variable filter configured as notch
|
||||
const normalizedFreq = Math.min(frequency / sampleRate, 0.48);
|
||||
const f = 2 * Math.sin(Math.PI * normalizedFreq);
|
||||
const qRecip = 1 / Math.max(q, 0.5);
|
||||
|
||||
const lowpass = state2 + f * state1;
|
||||
const highpass = input - lowpass - qRecip * state1;
|
||||
const bandpass = f * highpass + state1;
|
||||
|
||||
// Notch = input - bandpass
|
||||
const notch = input - bandpass;
|
||||
|
||||
// Update states with clamping
|
||||
const newState1 = Math.max(-2, Math.min(2, bandpass));
|
||||
const newState2 = Math.max(-2, Math.min(2, lowpass));
|
||||
|
||||
return {
|
||||
output: notch,
|
||||
state1: newState1,
|
||||
state2: newState2,
|
||||
};
|
||||
}
|
||||
|
||||
private softClip(x: number): number {
|
||||
if (x > 1) return 1;
|
||||
if (x < -1) return -1;
|
||||
if (x > 0.66) return (3 - (2 - 3 * x) ** 2) / 3;
|
||||
if (x < -0.66) return -(3 - (2 - 3 * -x) ** 2) / 3;
|
||||
return x;
|
||||
}
|
||||
|
||||
private freqToParam(freq: number): number {
|
||||
// Map frequency to 0-1 range (100-400 Hz)
|
||||
return Math.max(0, Math.min(1, (freq - 100) / 300));
|
||||
}
|
||||
}
|
||||
@ -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,16 +11,23 @@ import { Sample } from './Sample';
|
||||
import { Input } from './Input';
|
||||
import { KarplusStrong } from './KarplusStrong';
|
||||
import { AdditiveEngine } from './AdditiveEngine';
|
||||
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 Snare(),
|
||||
new BassDrum(),
|
||||
new HiHat(),
|
||||
new Ring(),
|
||||
new KarplusStrong(),
|
||||
new AdditiveEngine(),
|
||||
|
||||
449
src/lib/audio/engines/worklets/phase-distortion-fm-processor.js
Normal file
449
src/lib/audio/engines/worklets/phase-distortion-fm-processor.js
Normal 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);
|
||||
Reference in New Issue
Block a user