Files
rsgp/src/lib/audio/engines/DubSiren.ts

536 lines
17 KiB
TypeScript

import type { SynthEngine, PitchLock } from './base/SynthEngine';
enum OscillatorWaveform {
Sine,
Triangle,
Square,
Saw,
Pulse,
}
enum SweepCurve {
Linear,
Exponential,
Logarithmic,
Bounce,
Elastic,
}
enum FilterType {
None,
LowPass,
HighPass,
BandPass,
}
interface DubSirenParams {
startFreq: number;
endFreq: number;
sweepCurve: SweepCurve;
waveform: OscillatorWaveform;
pulseWidth: number;
harmonics: number;
harmonicSpread: number;
lfoRate: number;
lfoDepth: number;
filterType: FilterType;
filterFreq: number;
filterResonance: number;
filterSweepAmount: number;
attack: number;
decay: number;
sustain: number;
release: number;
feedback: number;
stereoWidth: number;
distortion: number;
}
export class DubSiren implements SynthEngine<DubSirenParams> {
private filterHistoryL1 = 0;
private filterHistoryL2 = 0;
private filterHistoryR1 = 0;
private filterHistoryR2 = 0;
private dcBlockerL = 0;
private dcBlockerR = 0;
private readonly DENORMAL_OFFSET = 1e-24;
getName(): string {
return 'Siren';
}
getDescription(): string {
return 'Siren generator with pitch sweeps, anti-aliased oscillators and stable filtering';
}
getType() {
return 'generative' as const;
}
generate(params: DubSirenParams, 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 invSampleRate = 1 / sampleRate;
// Initialize phases for oscillators
const numOscillators = 1 + params.harmonics;
const phasesL: number[] = new Array(numOscillators).fill(0);
const phasesR: number[] = new Array(numOscillators).fill(0);
// Stereo phase offset
const stereoPhaseOffset = Math.PI * params.stereoWidth * 0.1;
for (let i = 0; i < numOscillators; i++) {
phasesR[i] = stereoPhaseOffset * (i + 1);
}
// LFO setup
let lfoPhaseL = 0;
let lfoPhaseR = Math.PI * params.stereoWidth * 0.25;
const lfoIncrement = TAU * params.lfoRate * invSampleRate;
// Feedback buffers with slight delay for richness
const feedbackDelaySize = 64;
const feedbackBufferL = new Float32Array(feedbackDelaySize);
const feedbackBufferR = new Float32Array(feedbackDelaySize);
let feedbackIndex = 0;
// Reset filter state
this.filterHistoryL1 = 0;
this.filterHistoryL2 = 0;
this.filterHistoryR1 = 0;
this.filterHistoryR2 = 0;
this.dcBlockerL = 0;
this.dcBlockerR = 0;
// Envelope smoothing
let lastEnv = 0;
const envSmoothCoeff = 0.001;
for (let i = 0; i < numSamples; i++) {
const t = i / numSamples;
// Calculate and smooth envelope
const targetEnv = this.calculateEnvelope(
t * duration,
duration,
params.attack,
params.decay,
params.sustain,
params.release
);
const env = lastEnv + (targetEnv - lastEnv) * envSmoothCoeff;
lastEnv = env;
// Calculate pitch sweep
const sweepProgress = this.calculateSweepCurve(t, params.sweepCurve);
const currentFreq = params.startFreq + (params.endFreq - params.startFreq) * sweepProgress;
// LFO modulation (using fast approximation)
const lfoL = this.fastSin(lfoPhaseL);
const lfoR = this.fastSin(lfoPhaseR);
const pitchModL = 1 + lfoL * params.lfoDepth * 0.1;
const pitchModR = 1 + lfoR * params.lfoDepth * 0.1;
// Generate oscillators with harmonics
let sampleL = 0;
let sampleR = 0;
for (let osc = 0; osc < numOscillators; osc++) {
const harmonicMultiplier = 1 + osc * params.harmonicSpread;
const harmonicLevel = 1 / (osc + 1);
const freqL = currentFreq * harmonicMultiplier * pitchModL;
const freqR = currentFreq * harmonicMultiplier * pitchModR;
// Add delayed feedback to first oscillator
const fbL = osc === 0 ? feedbackBufferL[feedbackIndex] * params.feedback * 0.3 : 0;
const fbR = osc === 0 ? feedbackBufferR[feedbackIndex] * params.feedback * 0.3 : 0;
// Use bandlimited oscillators
const oscL = this.generateBandlimitedWaveform(
phasesL[osc],
params.waveform,
params.pulseWidth,
freqL,
sampleRate
);
const oscR = this.generateBandlimitedWaveform(
phasesR[osc],
params.waveform,
params.pulseWidth,
freqR,
sampleRate
);
sampleL += (oscL + fbL) * harmonicLevel;
sampleR += (oscR + fbR) * harmonicLevel;
// Update phases with proper wrapping
const phaseIncrementL = TAU * freqL * invSampleRate;
const phaseIncrementR = TAU * freqR * invSampleRate;
phasesL[osc] = (phasesL[osc] + phaseIncrementL) % TAU;
phasesR[osc] = (phasesR[osc] + phaseIncrementR) % TAU;
}
// Normalize with headroom
const normFactor = 0.5 / Math.sqrt(numOscillators);
sampleL *= normFactor;
sampleR *= normFactor;
// Apply soft saturation if needed
if (params.distortion > 0) {
sampleL = this.fastTanh(sampleL * (1 + params.distortion * 3)) * 0.8;
sampleR = this.fastTanh(sampleR * (1 + params.distortion * 3)) * 0.8;
}
// Apply filter with stability checks
if (params.filterType !== FilterType.None) {
const filterFreqMod = params.filterFreq * (1 + params.filterSweepAmount * sweepProgress * 2);
const filterFreqWithLFO = Math.min(filterFreqMod * (1 + lfoL * params.lfoDepth * 0.2), sampleRate * 0.45);
const safeResonance = Math.min(params.filterResonance, 0.98);
[sampleL, this.filterHistoryL1, this.filterHistoryL2] = this.applyStableFilter(
sampleL,
params.filterType,
filterFreqWithLFO,
safeResonance,
sampleRate,
this.filterHistoryL1,
this.filterHistoryL2
);
[sampleR, this.filterHistoryR1, this.filterHistoryR2] = this.applyStableFilter(
sampleR,
params.filterType,
filterFreqWithLFO,
safeResonance,
sampleRate,
this.filterHistoryR1,
this.filterHistoryR2
);
}
// Update feedback delay buffer
feedbackBufferL[feedbackIndex] = sampleL;
feedbackBufferR[feedbackIndex] = sampleR;
feedbackIndex = (feedbackIndex + 1) % feedbackDelaySize;
// DC blocking
const dcCutoff = 0.995;
const blockedL = sampleL - this.dcBlockerL;
const blockedR = sampleR - this.dcBlockerR;
this.dcBlockerL = sampleL - blockedL * dcCutoff;
this.dcBlockerR = sampleR - blockedR * dcCutoff;
// Apply envelope and final limiting
leftBuffer[i] = Math.max(-1, Math.min(1, blockedL * env));
rightBuffer[i] = Math.max(-1, Math.min(1, blockedR * env));
// Update LFO phases
lfoPhaseL = (lfoPhaseL + lfoIncrement) % TAU;
lfoPhaseR = (lfoPhaseR + lfoIncrement) % TAU;
}
// Peak normalization with headroom
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 generateBandlimitedWaveform(
phase: number,
waveform: OscillatorWaveform,
pulseWidth: number,
frequency: number,
sampleRate: number
): number {
const nyquist = sampleRate / 2;
const maxHarmonic = Math.floor(nyquist / frequency);
switch (waveform) {
case OscillatorWaveform.Sine:
return Math.sin(phase);
case OscillatorWaveform.Triangle:
// Bandlimited triangle using additive synthesis
let tri = 0;
const harmonics = Math.min(maxHarmonic, 32);
for (let h = 1; h <= harmonics; h += 2) {
const sign = ((h - 1) / 2) % 2 === 0 ? 1 : -1;
tri += sign * Math.sin(phase * h) / (h * h);
}
return tri * (8 / (Math.PI * Math.PI));
case OscillatorWaveform.Square:
// Bandlimited square using additive synthesis
let square = 0;
const squareHarmonics = Math.min(maxHarmonic, 32);
for (let h = 1; h <= squareHarmonics; h += 2) {
square += Math.sin(phase * h) / h;
}
return square * (4 / Math.PI);
case OscillatorWaveform.Saw:
// Bandlimited saw using additive synthesis
let saw = 0;
const sawHarmonics = Math.min(maxHarmonic, 32);
for (let h = 1; h <= sawHarmonics; h++) {
saw += Math.sin(phase * h) / h;
}
return -saw * (2 / Math.PI);
case OscillatorWaveform.Pulse:
// Bandlimited pulse as difference of two saws
let pulse1 = 0;
let pulse2 = 0;
const pulseHarmonics = Math.min(maxHarmonic, 32);
const phaseShift = phase + Math.PI * 2 * pulseWidth;
for (let h = 1; h <= pulseHarmonics; h++) {
pulse1 += Math.sin(phase * h) / h;
pulse2 += Math.sin(phaseShift * h) / h;
}
return (pulse1 - pulse2) * (2 / Math.PI);
default:
return 0;
}
}
private calculateSweepCurve(t: number, curve: SweepCurve): number {
switch (curve) {
case SweepCurve.Linear:
return t;
case SweepCurve.Exponential:
return t * t;
case SweepCurve.Logarithmic:
return Math.sqrt(t);
case SweepCurve.Bounce:
return t < 0.5 ? t * 2 : 2 - t * 2;
case SweepCurve.Elastic:
const p = 0.3;
const s = p / 4;
if (t <= 0.001) return 0;
if (t >= 0.999) return 1;
return Math.pow(2, -10 * t) * Math.sin((t - s) * (2 * Math.PI) / p) + 1;
default:
return t;
}
}
private calculateEnvelope(
t: number,
duration: number,
attack: number,
decay: number,
sustain: number,
release: number
): number {
const attackTime = attack * duration;
const decayTime = decay * duration;
const releaseTime = release * duration;
const sustainStart = attackTime + decayTime;
const releaseStart = duration - releaseTime;
if (t < attackTime) {
// Exponential attack for smoother onset
const progress = t / attackTime;
return progress * progress;
} else if (t < sustainStart) {
const decayProgress = (t - attackTime) / decayTime;
return 1 - decayProgress * (1 - sustain);
} else if (t < releaseStart) {
return sustain;
} else {
const releaseProgress = (t - releaseStart) / releaseTime;
// Exponential release for smoother tail
return sustain * Math.pow(1 - releaseProgress, 2);
}
}
private fastSin(phase: number): number {
// Fast sine approximation using parabolic approximation
const x = (phase % (Math.PI * 2)) / Math.PI - 1;
const x2 = x * x;
return x * (1 - x2 * (0.16666 - x2 * 0.00833));
}
private fastTanh(x: number): number {
// Fast tanh approximation for soft clipping
const x2 = x * x;
return x * (27 + x2) / (27 + 9 * x2);
}
private applyStableFilter(
input: number,
filterType: FilterType,
freq: number,
resonance: number,
sampleRate: number,
history1: number,
history2: number
): [number, number, number] {
// Add denormal prevention
input += this.DENORMAL_OFFSET;
// Improved state-variable filter with pre-warping
const w = Math.tan((Math.PI * freq) / sampleRate);
const g = w / (1 + w);
const k = 2 - 2 * resonance; // Stability-safe resonance scaling
// State variable filter equations
const v0 = input;
const v1 = history1;
const v2 = history2;
const v3 = v0 - v2;
const v1Next = v1 + g * (v3 - k * v1);
const v2Next = v2 + g * v1Next;
let output: number;
switch (filterType) {
case FilterType.LowPass:
output = v2Next;
break;
case FilterType.HighPass:
output = v0 - k * v1Next - v2Next;
break;
case FilterType.BandPass:
output = v1Next;
break;
default:
output = input;
}
// Remove denormal offset
output -= this.DENORMAL_OFFSET;
return [output, v1Next, v2Next];
}
randomParams(pitchLock?: PitchLock): DubSirenParams {
let startFreq: number;
let endFreq: number;
if (pitchLock?.enabled) {
// When pitch locked, sweep around the locked frequency
startFreq = pitchLock.frequency;
endFreq = pitchLock.frequency * (Math.random() < 0.5 ? 0.5 : 2);
} else {
const freqPairs = [
[100, 1200],
[200, 800],
[300, 2000],
[50, 400],
[500, 3000],
[150, 600],
];
const [freq1, freq2] = this.randomChoice(freqPairs);
const shouldReverse = Math.random() < 0.3;
startFreq = shouldReverse ? freq2 : freq1;
endFreq = shouldReverse ? freq1 : freq2;
}
return {
startFreq,
endFreq,
sweepCurve: this.randomInt(0, 4) as SweepCurve,
waveform: this.randomInt(0, 4) as OscillatorWaveform,
pulseWidth: this.randomRange(0.1, 0.9),
harmonics: this.randomInt(0, 3),
harmonicSpread: this.randomRange(1.5, 3.0),
lfoRate: this.randomRange(0.5, 8),
lfoDepth: this.randomRange(0, 0.5),
filterType: this.randomInt(0, 3) as FilterType,
filterFreq: this.randomRange(200, 4000),
filterResonance: this.randomRange(0.1, 0.85), // Safer maximum
filterSweepAmount: this.randomRange(0, 1),
attack: this.randomRange(0.005, 0.1), // Minimum 220 samples
decay: this.randomRange(0.01, 0.2),
sustain: this.randomRange(0.3, 0.9),
release: this.randomRange(0.1, 0.4),
feedback: this.randomRange(0, 0.5),
stereoWidth: this.randomRange(0.2, 0.8),
distortion: this.randomRange(0, 0.3),
};
}
mutateParams(params: DubSirenParams, mutationAmount: number = 0.15, pitchLock?: PitchLock): DubSirenParams {
let startFreq: number;
let endFreq: number;
if (pitchLock?.enabled) {
// When pitch locked, keep one frequency at the locked value
if (Math.random() < 0.5) {
startFreq = pitchLock.frequency;
endFreq = this.mutateValue(params.endFreq, mutationAmount, 20, 5000);
} else {
startFreq = this.mutateValue(params.startFreq, mutationAmount, 20, 5000);
endFreq = pitchLock.frequency;
}
} else {
startFreq = this.mutateValue(params.startFreq, mutationAmount, 20, 5000);
endFreq = this.mutateValue(params.endFreq, mutationAmount, 20, 5000);
}
return {
startFreq,
endFreq,
sweepCurve: Math.random() < 0.1 ? this.randomInt(0, 4) as SweepCurve : params.sweepCurve,
waveform: Math.random() < 0.1 ? this.randomInt(0, 4) as OscillatorWaveform : params.waveform,
pulseWidth: this.mutateValue(params.pulseWidth, mutationAmount, 0.05, 0.95),
harmonics: Math.random() < 0.15 ? this.randomInt(0, 3) : params.harmonics,
harmonicSpread: this.mutateValue(params.harmonicSpread, mutationAmount, 1, 4),
lfoRate: this.mutateValue(params.lfoRate, mutationAmount, 0.1, 12),
lfoDepth: this.mutateValue(params.lfoDepth, mutationAmount, 0, 0.7),
filterType: Math.random() < 0.1 ? this.randomInt(0, 3) as FilterType : params.filterType,
filterFreq: this.mutateValue(params.filterFreq, mutationAmount, 100, 8000),
filterResonance: this.mutateValue(params.filterResonance, mutationAmount, 0, 0.85),
filterSweepAmount: this.mutateValue(params.filterSweepAmount, mutationAmount, 0, 1),
attack: this.mutateValue(params.attack, mutationAmount, 0.005, 0.2),
decay: this.mutateValue(params.decay, mutationAmount, 0.01, 0.3),
sustain: this.mutateValue(params.sustain, mutationAmount, 0.1, 1),
release: this.mutateValue(params.release, mutationAmount, 0.05, 0.5),
feedback: this.mutateValue(params.feedback, mutationAmount, 0, 0.7),
stereoWidth: this.mutateValue(params.stereoWidth, mutationAmount, 0, 1),
distortion: this.mutateValue(params.distortion, mutationAmount, 0, 0.5),
};
}
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 = (max - min) * amount * (Math.random() * 2 - 1);
return Math.max(min, Math.min(max, value + variation));
}
}