536 lines
17 KiB
TypeScript
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));
|
|
}
|
|
} |