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

552 lines
20 KiB
TypeScript

import type { SynthEngine, PitchLock } from './base/SynthEngine';
enum EnvCurve {
Linear,
Exponential,
Logarithmic,
SCurve,
}
enum LFOWaveform {
Sine,
Triangle,
Square,
Saw,
SampleHold,
RandomWalk,
}
enum Algorithm {
Cascade, // 1→2→3→4 (deep modulation)
DualStack, // (1→2) + (3→4) (two independent stacks)
Parallel, // 1+2+3+4 (additive)
TripleMod, // 1→2→3 + 4 (complex modulation + pure carrier)
Bell, // (1→3, 2→3) + 4 (two modulators converge)
Feedback, // 1→1→2→3→4 (self-modulation)
}
interface OperatorParams {
ratio: number;
level: number;
attack: number;
decay: number;
sustain: number;
release: number;
attackCurve: EnvCurve;
decayCurve: EnvCurve;
releaseCurve: EnvCurve;
}
interface LFOParams {
rate: number;
depth: number;
waveform: LFOWaveform;
target: 'pitch' | 'amplitude' | 'modIndex';
}
export interface FourOpFMParams {
baseFreq: number;
algorithm: Algorithm;
operators: [OperatorParams, OperatorParams, OperatorParams, OperatorParams];
lfo: LFOParams;
feedback: number;
stereoWidth: number;
}
export class FourOpFM implements SynthEngine<FourOpFMParams> {
private lfoSampleHoldValue = 0;
private lfoSampleHoldPhase = 0;
private lfoRandomWalkCurrent = 0;
private lfoRandomWalkTarget = 0;
// DC blocking filters for each channel
private dcBlockerL = 0;
private dcBlockerR = 0;
private readonly dcBlockerCutoff = 0.995;
getName(): string {
return '4-OP FM';
}
getDescription(): string {
return 'Four-operator FM synthesis with multiple algorithms, envelope curves, and LFO waveforms';
}
getType() {
return 'generative' as const;
}
generate(params: FourOpFMParams, 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;
// More subtle stereo detuning
const detune = 1 + (params.stereoWidth * 0.001);
const leftFreq = params.baseFreq / detune;
const rightFreq = params.baseFreq * detune;
// Initialize operator phases for stereo with more musical offsets
const opPhasesL = [0, Math.PI * params.stereoWidth * 0.05, 0, 0];
const opPhasesR = [0, Math.PI * params.stereoWidth * 0.08, 0, 0];
let lfoPhaseL = 0;
let lfoPhaseR = Math.PI * params.stereoWidth * 0.25;
// Reset non-periodic LFO state
this.lfoSampleHoldValue = Math.random() * 2 - 1;
this.lfoSampleHoldPhase = 0;
this.lfoRandomWalkCurrent = Math.random() * 2 - 1;
this.lfoRandomWalkTarget = Math.random() * 2 - 1;
let feedbackSampleL = 0;
let feedbackSampleR = 0;
// Get algorithm-specific gain compensation
const gainCompensation = this.getAlgorithmGainCompensation(params.algorithm);
for (let i = 0; i < numSamples; i++) {
const t = i / sampleRate;
// Calculate envelopes for each operator
const env1 = this.calculateEnvelope(t, duration, params.operators[0]);
const env2 = this.calculateEnvelope(t, duration, params.operators[1]);
const env3 = this.calculateEnvelope(t, duration, params.operators[2]);
const env4 = this.calculateEnvelope(t, duration, params.operators[3]);
// Generate LFO modulation
const lfoL = this.generateLFO(lfoPhaseL, params.lfo.waveform, params.lfo.rate, sampleRate);
const lfoR = this.generateLFO(lfoPhaseR, params.lfo.waveform, params.lfo.rate, sampleRate);
const lfoModL = lfoL * params.lfo.depth;
const lfoModR = lfoR * params.lfo.depth;
// Apply LFO to target parameter
let pitchModL = 0, pitchModR = 0;
let ampModL = 1, ampModR = 1;
let modIndexMod = 0;
if (params.lfo.target === 'pitch') {
// More musical pitch modulation range
pitchModL = lfoModL * 0.02;
pitchModR = lfoModR * 0.02;
} else if (params.lfo.target === 'amplitude') {
// Tremolo effect
ampModL = 1 + lfoModL * 0.5;
ampModR = 1 + lfoModR * 0.5;
} else {
// Modulation index modulation
modIndexMod = lfoModL;
}
// Process algorithm - generate left and right samples
const [sampleL, sampleR] = this.processAlgorithm(
params.algorithm,
params.operators,
opPhasesL,
opPhasesR,
[env1, env2, env3, env4],
feedbackSampleL,
feedbackSampleR,
params.feedback,
modIndexMod
);
// Apply gain compensation and amplitude modulation
let outL = sampleL * gainCompensation * ampModL;
let outR = sampleR * gainCompensation * ampModR;
// Soft clipping for musical saturation
outL = this.softClip(outL);
outR = this.softClip(outR);
// DC blocking filter
const dcFilteredL = outL - this.dcBlockerL;
this.dcBlockerL += (1 - this.dcBlockerCutoff) * dcFilteredL;
const dcFilteredR = outR - this.dcBlockerR;
this.dcBlockerR += (1 - this.dcBlockerCutoff) * dcFilteredR;
leftBuffer[i] = dcFilteredL;
rightBuffer[i] = dcFilteredR;
// Store feedback samples (after soft clipping)
feedbackSampleL = outL;
feedbackSampleR = outR;
// Advance operator phases
for (let op = 0; op < 4; op++) {
const opFreqL = leftFreq * params.operators[op].ratio * (1 + pitchModL);
const opFreqR = rightFreq * params.operators[op].ratio * (1 + pitchModR);
opPhasesL[op] += (TAU * opFreqL) / sampleRate;
opPhasesR[op] += (TAU * opFreqR) / sampleRate;
// Wrap phases to prevent numerical issues
if (opPhasesL[op] > TAU * 1000) opPhasesL[op] -= TAU * 1000;
if (opPhasesR[op] > TAU * 1000) opPhasesR[op] -= TAU * 1000;
}
// Advance LFO phase
lfoPhaseL += (TAU * params.lfo.rate) / sampleRate;
lfoPhaseR += (TAU * params.lfo.rate) / sampleRate;
}
// 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 processAlgorithm(
algorithm: Algorithm,
operators: [OperatorParams, OperatorParams, OperatorParams, OperatorParams],
phasesL: number[],
phasesR: number[],
envelopes: number[],
feedbackL: number,
feedbackR: number,
feedbackAmount: number,
modIndexMod: number
): [number, number] {
// More musical modulation scaling
const baseModIndex = 2.5;
const modScale = baseModIndex * (1 + modIndexMod * 2);
switch (algorithm) {
case Algorithm.Cascade: {
// 1→2→3→4 - Deep FM chain
const fbAmountScaled = feedbackAmount * 0.8;
const mod1L = Math.sin(phasesL[0] + fbAmountScaled * feedbackL) * envelopes[0] * operators[0].level;
const mod1R = Math.sin(phasesR[0] + fbAmountScaled * feedbackR) * envelopes[0] * operators[0].level;
const mod2L = Math.sin(phasesL[1] + modScale * mod1L) * envelopes[1] * operators[1].level;
const mod2R = Math.sin(phasesR[1] + modScale * mod1R) * envelopes[1] * operators[1].level;
const mod3L = Math.sin(phasesL[2] + modScale * 0.7 * mod2L) * envelopes[2] * operators[2].level;
const mod3R = Math.sin(phasesR[2] + modScale * 0.7 * mod2R) * envelopes[2] * operators[2].level;
const outL = Math.sin(phasesL[3] + modScale * 0.5 * mod3L) * envelopes[3] * operators[3].level;
const outR = Math.sin(phasesR[3] + modScale * 0.5 * mod3R) * envelopes[3] * operators[3].level;
return [outL, outR];
}
case Algorithm.DualStack: {
// (1→2) + (3→4) - Two parallel FM pairs
const mod1L = Math.sin(phasesL[0]) * envelopes[0] * operators[0].level;
const mod1R = Math.sin(phasesR[0]) * envelopes[0] * operators[0].level;
const car1L = Math.sin(phasesL[1] + modScale * mod1L) * envelopes[1] * operators[1].level;
const car1R = Math.sin(phasesR[1] + modScale * mod1R) * envelopes[1] * operators[1].level;
const mod2L = Math.sin(phasesL[2]) * envelopes[2] * operators[2].level;
const mod2R = Math.sin(phasesR[2]) * envelopes[2] * operators[2].level;
const car2L = Math.sin(phasesL[3] + modScale * mod2L) * envelopes[3] * operators[3].level;
const car2R = Math.sin(phasesR[3] + modScale * mod2R) * envelopes[3] * operators[3].level;
// Mix with proper gain staging
return [(car1L + car2L) * 0.5, (car1R + car2R) * 0.5];
}
case Algorithm.Parallel: {
// 1+2+3+4 - Additive synthesis
let sumL = 0, sumR = 0;
for (let i = 0; i < 4; i++) {
sumL += Math.sin(phasesL[i]) * envelopes[i] * operators[i].level;
sumR += Math.sin(phasesR[i]) * envelopes[i] * operators[i].level;
}
// Scale by 1/sqrt(4) for constant power mixing
return [sumL * 0.5, sumR * 0.5];
}
case Algorithm.TripleMod: {
// 1→2→3 + 4 - Complex mod chain plus carrier
const mod1L = Math.sin(phasesL[0]) * envelopes[0] * operators[0].level;
const mod1R = Math.sin(phasesR[0]) * envelopes[0] * operators[0].level;
const mod2L = Math.sin(phasesL[1] + modScale * mod1L) * envelopes[1] * operators[1].level;
const mod2R = Math.sin(phasesR[1] + modScale * mod1R) * envelopes[1] * operators[1].level;
const car1L = Math.sin(phasesL[2] + modScale * 0.7 * mod2L) * envelopes[2] * operators[2].level;
const car1R = Math.sin(phasesR[2] + modScale * 0.7 * mod2R) * envelopes[2] * operators[2].level;
const car2L = Math.sin(phasesL[3]) * envelopes[3] * operators[3].level;
const car2R = Math.sin(phasesR[3]) * envelopes[3] * operators[3].level;
return [(car1L * 0.7 + car2L * 0.3), (car1R * 0.7 + car2R * 0.3)];
}
case Algorithm.Bell: {
// (1→3, 2→3) + 4 - Bell-like tones
const mod1L = Math.sin(phasesL[0]) * envelopes[0] * operators[0].level;
const mod1R = Math.sin(phasesR[0]) * envelopes[0] * operators[0].level;
const mod2L = Math.sin(phasesL[1]) * envelopes[1] * operators[1].level;
const mod2R = Math.sin(phasesR[1]) * envelopes[1] * operators[1].level;
const car1L = Math.sin(phasesL[2] + modScale * 0.6 * (mod1L + mod2L)) * envelopes[2] * operators[2].level;
const car1R = Math.sin(phasesR[2] + modScale * 0.6 * (mod1R + mod2R)) * envelopes[2] * operators[2].level;
const car2L = Math.sin(phasesL[3]) * envelopes[3] * operators[3].level;
const car2R = Math.sin(phasesR[3]) * envelopes[3] * operators[3].level;
return [(car1L + car2L) * 0.5, (car1R + car2R) * 0.5];
}
case Algorithm.Feedback: {
// 1→1→2→3→4 - Self-modulating cascade
const fbAmountScaled = Math.min(feedbackAmount * 0.7, 1.5);
const mod1L = Math.sin(phasesL[0] + fbAmountScaled * this.softClip(feedbackL * 2)) * envelopes[0] * operators[0].level;
const mod1R = Math.sin(phasesR[0] + fbAmountScaled * this.softClip(feedbackR * 2)) * envelopes[0] * operators[0].level;
const mod2L = Math.sin(phasesL[1] + modScale * mod1L) * envelopes[1] * operators[1].level;
const mod2R = Math.sin(phasesR[1] + modScale * mod1R) * envelopes[1] * operators[1].level;
const mod3L = Math.sin(phasesL[2] + modScale * 0.7 * mod2L) * envelopes[2] * operators[2].level;
const mod3R = Math.sin(phasesR[2] + modScale * 0.7 * mod2R) * envelopes[2] * operators[2].level;
const outL = Math.sin(phasesL[3] + modScale * 0.5 * mod3L) * envelopes[3] * operators[3].level;
const outR = Math.sin(phasesR[3] + modScale * 0.5 * mod3R) * envelopes[3] * operators[3].level;
return [outL, outR];
}
default:
return [0, 0];
}
}
private getAlgorithmGainCompensation(algorithm: Algorithm): number {
// Compensate for different algorithm output levels
switch (algorithm) {
case Algorithm.Cascade:
case Algorithm.Feedback:
return 0.7;
case Algorithm.DualStack:
case Algorithm.TripleMod:
case Algorithm.Bell:
return 0.8;
case Algorithm.Parallel:
return 0.6;
default:
return 0.75;
}
}
private softClip(x: number): number {
// Musical soft clipping using tanh approximation
const absX = Math.abs(x);
if (absX < 0.7) return x;
if (absX > 3) return Math.sign(x) * 0.98;
// Fast tanh approximation for soft saturation
const x2 = x * x;
return x * (27 + x2) / (27 + 9 * x2);
}
private calculateEnvelope(t: number, duration: number, op: OperatorParams): number {
const attackTime = op.attack * duration;
const decayTime = op.decay * duration;
const releaseTime = op.release * duration;
const sustainStart = attackTime + decayTime;
const releaseStart = duration - releaseTime;
if (t < attackTime) {
const progress = t / attackTime;
return this.applyCurve(progress, op.attackCurve);
} else if (t < sustainStart) {
const progress = (t - attackTime) / decayTime;
const curvedProgress = this.applyCurve(progress, op.decayCurve);
return 1 - curvedProgress * (1 - op.sustain);
} else if (t < releaseStart) {
return op.sustain;
} else {
const progress = (t - releaseStart) / releaseTime;
const curvedProgress = this.applyCurve(progress, op.releaseCurve);
return op.sustain * (1 - curvedProgress);
}
}
private applyCurve(progress: number, curve: EnvCurve): number {
switch (curve) {
case EnvCurve.Linear:
return progress;
case EnvCurve.Exponential:
return Math.pow(progress, 3);
case EnvCurve.Logarithmic:
return Math.pow(progress, 0.33);
case EnvCurve.SCurve:
return (Math.sin((progress - 0.5) * Math.PI) + 1) / 2;
default:
return progress;
}
}
private generateLFO(phase: number, waveform: LFOWaveform, rate: number, sampleRate: number): number {
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;
}
}
randomParams(pitchLock?: PitchLock): FourOpFMParams {
const algorithm = this.randomInt(0, 5) as Algorithm;
// More musical frequency ratios including inharmonic ones
const baseFreqChoices = [55, 82.4, 110, 146.8, 220, 293.7, 440, 587.3, 880];
const baseFreq = pitchLock?.enabled
? pitchLock.frequency
: this.randomChoice(baseFreqChoices) * this.randomRange(0.9, 1.1);
return {
baseFreq,
algorithm,
operators: [
this.randomOperator(true, algorithm),
this.randomOperator(false, algorithm),
this.randomOperator(false, algorithm),
this.randomOperator(false, algorithm),
],
lfo: {
rate: this.randomRange(0.1, 12),
depth: this.randomRange(0, 0.5),
waveform: this.randomInt(0, 5) as LFOWaveform,
target: this.randomChoice(['pitch', 'amplitude', 'modIndex'] as const),
},
feedback: this.randomRange(0, 1.5),
stereoWidth: this.randomRange(0.2, 0.8),
};
}
private randomOperator(isCarrier: boolean, algorithm: Algorithm): OperatorParams {
// More musical ratio choices including inharmonic ones
const harmonicRatios = [0.5, 1, 2, 3, 4, 5, 6, 7, 8];
const inharmonicRatios = [1.414, 1.732, 2.236, 3.14, 4.19, 5.13, 6.28];
const bellRatios = [0.56, 0.92, 1.19, 1.71, 2, 2.74, 3, 3.76, 4.07];
let ratio: number;
if (algorithm === Algorithm.Bell && Math.random() < 0.7) {
ratio = this.randomChoice(bellRatios);
} else if (Math.random() < 0.3) {
ratio = this.randomChoice(inharmonicRatios);
} else {
ratio = this.randomChoice(harmonicRatios);
}
// Add slight detuning for richness
ratio *= this.randomRange(0.998, 1.002);
// Carriers typically have lower levels in FM
const levelRange = isCarrier ? [0.3, 0.7] : [0.2, 0.8];
return {
ratio,
level: this.randomRange(levelRange[0], levelRange[1]),
attack: this.randomRange(0.001, 0.15),
decay: this.randomRange(0.02, 0.25),
sustain: this.randomRange(0.1, 0.8),
release: this.randomRange(0.05, 0.4),
attackCurve: this.randomInt(0, 3) as EnvCurve,
decayCurve: this.randomInt(0, 3) as EnvCurve,
releaseCurve: this.randomInt(0, 3) as EnvCurve,
};
}
mutateParams(params: FourOpFMParams, mutationAmount: number = 0.15, pitchLock?: PitchLock): FourOpFMParams {
const baseFreq = pitchLock?.enabled ? pitchLock.frequency : params.baseFreq;
return {
baseFreq,
algorithm: Math.random() < 0.08 ? this.randomInt(0, 5) as Algorithm : params.algorithm,
operators: params.operators.map((op, i) =>
this.mutateOperator(op, mutationAmount, i === 3, params.algorithm)
) as [OperatorParams, OperatorParams, OperatorParams, OperatorParams],
lfo: {
rate: this.mutateValue(params.lfo.rate, mutationAmount, 0.1, 20),
depth: this.mutateValue(params.lfo.depth, mutationAmount, 0, 0.7),
waveform: Math.random() < 0.08 ? this.randomInt(0, 5) as LFOWaveform : params.lfo.waveform,
target: Math.random() < 0.08 ? this.randomChoice(['pitch', 'amplitude', 'modIndex'] as const) : params.lfo.target,
},
feedback: this.mutateValue(params.feedback, mutationAmount, 0, 2),
stereoWidth: this.mutateValue(params.stereoWidth, mutationAmount, 0, 1),
};
}
private mutateOperator(op: OperatorParams, amount: number, isCarrier: boolean, algorithm: Algorithm): OperatorParams {
const harmonicRatios = [0.5, 1, 2, 3, 4, 5, 6, 7, 8];
const inharmonicRatios = [1.414, 1.732, 2.236, 3.14, 4.19, 5.13, 6.28];
const bellRatios = [0.56, 0.92, 1.19, 1.71, 2, 2.74, 3, 3.76, 4.07];
let newRatio = op.ratio;
if (Math.random() < 0.12) {
if (algorithm === Algorithm.Bell && Math.random() < 0.7) {
newRatio = this.randomChoice(bellRatios);
} else if (Math.random() < 0.3) {
newRatio = this.randomChoice(inharmonicRatios);
} else {
newRatio = this.randomChoice(harmonicRatios);
}
newRatio *= this.randomRange(0.998, 1.002);
}
return {
ratio: newRatio,
level: this.mutateValue(op.level, amount, 0.1, isCarrier ? 0.8 : 1.0),
attack: this.mutateValue(op.attack, amount, 0.001, 0.25),
decay: this.mutateValue(op.decay, amount, 0.01, 0.4),
sustain: this.mutateValue(op.sustain, amount, 0.05, 0.95),
release: this.mutateValue(op.release, amount, 0.02, 0.6),
attackCurve: Math.random() < 0.08 ? this.randomInt(0, 3) as EnvCurve : op.attackCurve,
decayCurve: Math.random() < 0.08 ? this.randomInt(0, 3) as EnvCurve : op.decayCurve,
releaseCurve: Math.random() < 0.08 ? this.randomInt(0, 3) as EnvCurve : op.releaseCurve,
};
}
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));
}
}