Files
rsgp/src/lib/audio/engines/AdditiveEngine.ts
2025-10-12 11:32:36 +02:00

595 lines
20 KiB
TypeScript

import type { SynthEngine, PitchLock } from './SynthEngine';
enum EnvCurve {
Linear,
Exponential,
Logarithmic,
SCurve,
}
enum HarmonicSeriesType {
All,
OddOnly,
EvenOnly,
PrimeOnly,
}
enum DistributionStrategy {
FundamentalFocused,
OctaveStack,
FifthBased,
LowMidCluster,
HighShimmer,
SparseScatter,
DenseLow,
SubharmonicEmphasis,
BellCurve,
RandomWalk,
}
interface PartialParams {
ratio: number;
level: number;
attack: number;
decay: number;
sustain: number;
release: number;
attackCurve: EnvCurve;
decayCurve: EnvCurve;
releaseCurve: EnvCurve;
vibratoRate: number;
vibratoDepth: number;
tremoloRate: number;
tremoloDepth: number;
amplitudeLFORate: number;
amplitudeLFODepth: number;
frequencyDrift: number;
}
interface PrecomputedPartial {
partial: PartialParams;
evolutionOffsetSamples: number;
rolloff: number;
leftGain: number;
rightGain: number;
inharmonicRatio: number;
phaseIncrementL: number;
phaseIncrementR: number;
vibratoPhaseIncrement: number;
tremoloPhaseIncrement: number;
amplitudeLFOPhaseIncrement: number;
attackSamples: number;
decaySamples: number;
releaseSamples: number;
sustainStartSamples: number;
releaseStartSamples: number;
}
export interface AdditiveParams {
baseFreq: number;
partials: PartialParams[];
stereoWidth: number;
brightness: number;
inharmonicity: number;
harmonicSeriesType: HarmonicSeriesType;
distributionStrategy: DistributionStrategy;
phaseCoherence: number;
evolutionAmount: number;
panSpread: number;
}
export class AdditiveEngine implements SynthEngine<AdditiveParams> {
getName(): string {
return 'Prism';
}
getDescription(): string {
return 'Spectral synthesis with up to 50 harmonics, individual envelopes, amplitude LFOs, modulation, inharmonicity, and harmonic filtering';
}
getType() {
return 'generative' as const;
}
generate(params: AdditiveParams, 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 detune = 1 + (params.stereoWidth * 0.001);
const leftFreq = params.baseFreq / detune;
const rightFreq = params.baseFreq * detune;
const TAU_DIV_SR = TAU / sampleRate;
const precomputed: PrecomputedPartial[] = [];
const partialPhasesL: number[] = [];
const partialPhasesR: number[] = [];
const vibratoPhasesL: number[] = [];
const vibratoPhasesR: number[] = [];
const tremoloPhasesL: number[] = [];
const tremoloPhasesR: number[] = [];
const amplitudeLFOPhases: number[] = [];
const driftOffsetsL: number[] = [];
const driftOffsetsR: number[] = [];
for (let p = 0; p < params.partials.length; p++) {
const partial = params.partials[p];
if (!this.shouldIncludePartial(partial.ratio, params.harmonicSeriesType)) {
continue;
}
const partialPosition = p / Math.max(1, params.partials.length - 1);
const panPosition = (partialPosition * 2 - 1) * params.panSpread;
const inharmonicRatio = partial.ratio * (1 + params.inharmonicity * partial.ratio * partial.ratio * 0.001);
const maxFreqL = leftFreq * inharmonicRatio;
const maxFreqR = rightFreq * inharmonicRatio;
if (maxFreqL > 16000 || maxFreqR > 16000) {
continue;
}
const randomPhaseL = Math.random() * TAU;
const coherentPhaseL = (p * TAU * 0.1) % TAU;
partialPhasesL.push(randomPhaseL * (1 - params.phaseCoherence) + coherentPhaseL * params.phaseCoherence);
const randomPhaseR = Math.random() * TAU;
const coherentPhaseR = (p * TAU * 0.1) % TAU;
partialPhasesR.push(randomPhaseR * (1 - params.phaseCoherence) + coherentPhaseR * params.phaseCoherence);
vibratoPhasesL.push(0);
vibratoPhasesR.push(Math.random() * TAU * 0.1);
tremoloPhasesL.push(0);
tremoloPhasesR.push(Math.random() * TAU * 0.15);
amplitudeLFOPhases.push(Math.random() * TAU);
driftOffsetsL.push(0);
driftOffsetsR.push(0);
const position = p / Math.max(1, params.partials.length - 1);
const rolloff = 1 - (position * (1 - params.brightness));
precomputed.push({
partial,
evolutionOffsetSamples: partialPosition * params.evolutionAmount * duration * 0.3 * sampleRate,
rolloff,
leftGain: Math.cos((panPosition + 1) * Math.PI / 4),
rightGain: Math.sin((panPosition + 1) * Math.PI / 4),
inharmonicRatio,
phaseIncrementL: TAU_DIV_SR * leftFreq,
phaseIncrementR: TAU_DIV_SR * rightFreq,
vibratoPhaseIncrement: TAU_DIV_SR * partial.vibratoRate,
tremoloPhaseIncrement: TAU_DIV_SR * partial.tremoloRate,
amplitudeLFOPhaseIncrement: TAU_DIV_SR * partial.amplitudeLFORate,
attackSamples: partial.attack * duration * sampleRate,
decaySamples: partial.decay * duration * sampleRate,
releaseSamples: partial.release * duration * sampleRate,
sustainStartSamples: (partial.attack + partial.decay) * duration * sampleRate,
releaseStartSamples: (duration - partial.release * duration) * sampleRate,
});
}
const invNormFactor = 1.0 / Math.sqrt(precomputed.length);
let peakL = 0;
let peakR = 0;
for (let i = 0; i < numSamples; i++) {
let sumL = 0;
let sumR = 0;
for (let p = 0; p < precomputed.length; p++) {
const pc = precomputed[p];
const partial = pc.partial;
const adjustedSample = Math.max(0, i - pc.evolutionOffsetSamples);
const envelope = this.calculateEnvelopeFast(
adjustedSample,
pc.attackSamples,
pc.decaySamples,
pc.sustainStartSamples,
pc.releaseStartSamples,
pc.releaseSamples,
partial.sustain,
partial.attackCurve,
partial.decayCurve,
partial.releaseCurve
);
const amplitudeLFO = 1 + Math.sin(amplitudeLFOPhases[p]) * partial.amplitudeLFODepth;
driftOffsetsL[p] += (Math.random() * 2 - 1) * partial.frequencyDrift * 0.00001;
driftOffsetsR[p] += (Math.random() * 2 - 1) * partial.frequencyDrift * 0.00001;
driftOffsetsL[p] = Math.max(-partial.frequencyDrift * 0.005, Math.min(partial.frequencyDrift * 0.005, driftOffsetsL[p]));
driftOffsetsR[p] = Math.max(-partial.frequencyDrift * 0.005, Math.min(partial.frequencyDrift * 0.005, driftOffsetsR[p]));
const vibratoL = Math.sin(vibratoPhasesL[p]) * partial.vibratoDepth;
const vibratoR = Math.sin(vibratoPhasesR[p]) * partial.vibratoDepth;
const tremoloL = 1 + Math.sin(tremoloPhasesL[p]) * partial.tremoloDepth;
const tremoloR = 1 + Math.sin(tremoloPhasesR[p]) * partial.tremoloDepth;
const freqL = pc.inharmonicRatio * (1 + vibratoL + driftOffsetsL[p]);
const freqR = pc.inharmonicRatio * (1 + vibratoR + driftOffsetsR[p]);
const levelEnvRolloffL = partial.level * envelope * pc.rolloff * pc.leftGain * amplitudeLFO;
const levelEnvRolloffR = partial.level * envelope * pc.rolloff * pc.rightGain * amplitudeLFO;
const sampleL = Math.sin(partialPhasesL[p]) * levelEnvRolloffL * tremoloL;
const sampleR = Math.sin(partialPhasesR[p]) * levelEnvRolloffR * tremoloR;
sumL += sampleL;
sumR += sampleR;
partialPhasesL[p] += pc.phaseIncrementL * freqL;
partialPhasesR[p] += pc.phaseIncrementR * freqR;
vibratoPhasesL[p] += pc.vibratoPhaseIncrement;
vibratoPhasesR[p] += pc.vibratoPhaseIncrement;
tremoloPhasesL[p] += pc.tremoloPhaseIncrement;
tremoloPhasesR[p] += pc.tremoloPhaseIncrement;
amplitudeLFOPhases[p] += pc.amplitudeLFOPhaseIncrement;
if (partialPhasesL[p] > TAU) partialPhasesL[p] %= TAU;
if (partialPhasesR[p] > TAU) partialPhasesR[p] %= TAU;
}
const sampL = sumL * invNormFactor;
const sampR = sumR * invNormFactor;
leftBuffer[i] = sampL;
rightBuffer[i] = sampR;
const absL = Math.abs(sampL);
const absR = Math.abs(sampR);
if (absL > peakL) peakL = absL;
if (absR > peakR) peakR = absR;
}
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 calculateRolloff(partialIndex: number, totalPartials: number, brightness: number): number {
const position = partialIndex / (totalPartials - 1);
const rolloffAmount = 1 - brightness;
return 1 - (position * rolloffAmount);
}
private shouldIncludePartial(ratio: number, seriesType: HarmonicSeriesType): boolean {
if (seriesType === HarmonicSeriesType.All) {
return true;
}
if (ratio < 1) {
return true;
}
const roundedRatio = Math.round(ratio);
switch (seriesType) {
case HarmonicSeriesType.OddOnly:
return roundedRatio % 2 === 1 && roundedRatio > 0;
case HarmonicSeriesType.EvenOnly:
return roundedRatio % 2 === 0 && roundedRatio > 0;
case HarmonicSeriesType.PrimeOnly:
return this.isPrime(roundedRatio);
default:
return true;
}
}
private isPrime(n: number): boolean {
if (n < 2) return false;
if (n === 2) return true;
if (n % 2 === 0) return false;
for (let i = 3; i <= Math.sqrt(n); i += 2) {
if (n % i === 0) return false;
}
return true;
}
private calculateEnvelope(t: number, duration: number, partial: PartialParams): number {
const attackTime = partial.attack * duration;
const decayTime = partial.decay * duration;
const releaseTime = partial.release * duration;
const sustainStart = attackTime + decayTime;
const releaseStart = duration - releaseTime;
if (t < attackTime) {
const progress = t / attackTime;
return this.applyCurve(progress, partial.attackCurve);
} else if (t < sustainStart) {
const progress = (t - attackTime) / decayTime;
const curvedProgress = this.applyCurve(progress, partial.decayCurve);
return 1 - curvedProgress * (1 - partial.sustain);
} else if (t < releaseStart) {
return partial.sustain;
} else {
const progress = (t - releaseStart) / releaseTime;
const curvedProgress = this.applyCurve(progress, partial.releaseCurve);
return partial.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 applyCurveFast(progress: number, curve: EnvCurve): number {
switch (curve) {
case EnvCurve.Linear:
return progress;
case EnvCurve.Exponential:
return progress * progress * progress;
case EnvCurve.Logarithmic:
return Math.cbrt(progress);
case EnvCurve.SCurve:
const x = (progress - 0.5) * Math.PI;
return (Math.sin(x) + 1) * 0.5;
default:
return progress;
}
}
private calculateEnvelopeFast(
sample: number,
attackSamples: number,
decaySamples: number,
sustainStartSamples: number,
releaseStartSamples: number,
releaseSamples: number,
sustain: number,
attackCurve: EnvCurve,
decayCurve: EnvCurve,
releaseCurve: EnvCurve
): number {
if (sample < attackSamples) {
const progress = sample / attackSamples;
return this.applyCurveFast(progress, attackCurve);
} else if (sample < sustainStartSamples) {
const progress = (sample - attackSamples) / decaySamples;
const curvedProgress = this.applyCurveFast(progress, decayCurve);
return 1 - curvedProgress * (1 - sustain);
} else if (sample < releaseStartSamples) {
return sustain;
} else {
const progress = (sample - releaseStartSamples) / releaseSamples;
const curvedProgress = this.applyCurveFast(progress, releaseCurve);
return sustain * (1 - curvedProgress);
}
}
randomParams(pitchLock?: PitchLock): AdditiveParams {
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.95, 1.05);
const harmonicSeriesType = this.randomInt(0, 3) as HarmonicSeriesType;
const distributionStrategy = this.randomInt(0, 9) as DistributionStrategy;
const ratios = this.generateRatiosForStrategy(distributionStrategy);
const numPartials = Math.min(ratios.length, this.randomInt(Math.min(4, ratios.length), ratios.length));
const partials: PartialParams[] = [];
for (let i = 0; i < numPartials; i++) {
partials.push(this.randomPartial(i, ratios, distributionStrategy));
}
return {
baseFreq,
partials,
stereoWidth: this.randomRange(0.2, 0.8),
brightness: this.randomRange(0.3, 0.9),
inharmonicity: this.randomRange(0, 0.7),
harmonicSeriesType,
distributionStrategy,
phaseCoherence: this.randomRange(0, 1),
evolutionAmount: this.randomRange(0, 0.8),
panSpread: this.randomRange(0, 0.7),
};
}
private generateRatiosForStrategy(strategy: DistributionStrategy): number[] {
switch (strategy) {
case DistributionStrategy.FundamentalFocused:
return [1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5, 6, 7, 8];
case DistributionStrategy.OctaveStack:
return [0.5, 1, 2, 3, 4, 5, 6, 8, 10, 12, 16, 20, 24];
case DistributionStrategy.FifthBased: {
const ratios = [1];
let current = 1;
for (let i = 0; i < 12; i++) {
current *= 1.5;
if (current > 32) current /= 2;
ratios.push(current);
}
return ratios.sort((a, b) => a - b);
}
case DistributionStrategy.LowMidCluster:
return [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14];
case DistributionStrategy.HighShimmer:
return [8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 28, 30, 32];
case DistributionStrategy.SparseScatter:
return [1, 3, 7, 11, 17, 23, 29];
case DistributionStrategy.DenseLow:
return [0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6];
case DistributionStrategy.SubharmonicEmphasis:
return [0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4];
case DistributionStrategy.BellCurve:
return [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14];
case DistributionStrategy.RandomWalk: {
const start = this.randomInt(1, 8);
const ratios = [];
let current = start;
for (let i = 0; i < 15; i++) {
ratios.push(current);
current += Math.random() < 0.5 ? 1 : 2;
if (current > 32) break;
}
return ratios;
}
default:
return [1, 2, 3, 4, 5, 6, 7, 8];
}
}
private randomPartial(index: number, ratios: number[], strategy: DistributionStrategy): PartialParams {
const ratio = this.randomChoice(ratios) * this.randomRange(0.998, 1.002);
let levelDecay: number;
switch (strategy) {
case DistributionStrategy.FundamentalFocused:
levelDecay = Math.pow(0.65, index * 0.6);
break;
case DistributionStrategy.HighShimmer:
levelDecay = Math.pow(0.88, index * 0.2);
break;
case DistributionStrategy.SparseScatter:
levelDecay = Math.pow(0.85, index * 0.3);
break;
case DistributionStrategy.DenseLow:
levelDecay = Math.pow(0.92, index * 0.4);
break;
case DistributionStrategy.SubharmonicEmphasis:
levelDecay = Math.pow(0.82, Math.abs(index - 3) * 0.5);
break;
case DistributionStrategy.BellCurve:
const centerDist = Math.abs(index - ratios.length / 2);
levelDecay = Math.pow(0.75, centerDist * 0.5);
break;
default:
levelDecay = Math.pow(0.8, index * 0.4);
}
const baseLevel = this.randomRange(0.3, 1.0);
const level = baseLevel * levelDecay;
return {
ratio,
level,
attack: this.randomRange(0.001, 0.15),
decay: this.randomRange(0.02, 0.25),
sustain: this.randomRange(0.2, 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,
vibratoRate: this.randomRange(2, 8),
vibratoDepth: this.randomRange(0, 0.015),
tremoloRate: this.randomRange(1, 6),
tremoloDepth: this.randomRange(0, 0.25),
amplitudeLFORate: this.randomRange(0.1, 2),
amplitudeLFODepth: this.randomRange(0, 0.6),
frequencyDrift: this.randomRange(0, 1),
};
}
mutateParams(params: AdditiveParams, mutationAmount: number = 0.15, pitchLock?: PitchLock): AdditiveParams {
const newHarmonicSeriesType = Math.random() < 0.08 ? this.randomInt(0, 3) as HarmonicSeriesType : params.harmonicSeriesType;
const newDistributionStrategy = Math.random() < 0.08 ? this.randomInt(0, 9) as DistributionStrategy : params.distributionStrategy;
const ratios = this.generateRatiosForStrategy(newDistributionStrategy);
const shouldChangePartialCount = Math.random() < 0.1;
let newPartials = params.partials.map(p => this.mutatePartial(p, mutationAmount, ratios));
if (shouldChangePartialCount) {
if (Math.random() < 0.5 && newPartials.length < 50) {
newPartials.push(this.randomPartial(newPartials.length, ratios, newDistributionStrategy));
} else if (newPartials.length > 3) {
newPartials = newPartials.slice(0, -1);
}
}
const baseFreq = pitchLock?.enabled ? pitchLock.frequency : params.baseFreq;
return {
baseFreq,
partials: newPartials,
stereoWidth: this.mutateValue(params.stereoWidth, mutationAmount, 0, 1),
brightness: this.mutateValue(params.brightness, mutationAmount, 0, 1),
inharmonicity: this.mutateValue(params.inharmonicity, mutationAmount, 0, 1),
harmonicSeriesType: newHarmonicSeriesType,
distributionStrategy: newDistributionStrategy,
phaseCoherence: this.mutateValue(params.phaseCoherence, mutationAmount, 0, 1),
evolutionAmount: this.mutateValue(params.evolutionAmount, mutationAmount, 0, 1),
panSpread: this.mutateValue(params.panSpread, mutationAmount, 0, 1),
};
}
private mutatePartial(partial: PartialParams, amount: number, ratios: number[]): PartialParams {
let newRatio = partial.ratio;
if (Math.random() < 0.1) {
newRatio = this.randomChoice(ratios) * this.randomRange(0.998, 1.002);
}
return {
ratio: newRatio,
level: this.mutateValue(partial.level, amount, 0.05, 1.0),
attack: this.mutateValue(partial.attack, amount, 0.001, 0.25),
decay: this.mutateValue(partial.decay, amount, 0.01, 0.4),
sustain: this.mutateValue(partial.sustain, amount, 0.1, 0.9),
release: this.mutateValue(partial.release, amount, 0.02, 0.6),
attackCurve: Math.random() < 0.08 ? this.randomInt(0, 3) as EnvCurve : partial.attackCurve,
decayCurve: Math.random() < 0.08 ? this.randomInt(0, 3) as EnvCurve : partial.decayCurve,
releaseCurve: Math.random() < 0.08 ? this.randomInt(0, 3) as EnvCurve : partial.releaseCurve,
vibratoRate: this.mutateValue(partial.vibratoRate, amount, 1, 15),
vibratoDepth: this.mutateValue(partial.vibratoDepth, amount, 0, 0.03),
tremoloRate: this.mutateValue(partial.tremoloRate, amount, 0.5, 10),
tremoloDepth: this.mutateValue(partial.tremoloDepth, amount, 0, 0.4),
amplitudeLFORate: this.mutateValue(partial.amplitudeLFORate, amount, 0.1, 3),
amplitudeLFODepth: this.mutateValue(partial.amplitudeLFODepth, amount, 0, 0.8),
frequencyDrift: this.mutateValue(partial.frequencyDrift, amount, 0, 1),
};
}
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));
}
}