527 lines
20 KiB
TypeScript
527 lines
20 KiB
TypeScript
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<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';
|
|
}
|
|
|
|
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<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));
|
|
}
|
|
} |