import type { SynthEngine } from './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 { 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'; } 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; } 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(): 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 = 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): FourOpFMParams { return { baseFreq: params.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(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)); } }