Adding two engines and one processor
This commit is contained in:
528
src/lib/audio/engines/DustNoise.ts
Normal file
528
src/lib/audio/engines/DustNoise.ts
Normal file
@ -0,0 +1,528 @@
|
|||||||
|
import type { SynthEngine, PitchLock } from './SynthEngine';
|
||||||
|
|
||||||
|
interface DustNoiseParams {
|
||||||
|
// Dust density and character
|
||||||
|
dustDensity: number;
|
||||||
|
crackleAmount: number;
|
||||||
|
popDensity: number;
|
||||||
|
|
||||||
|
// Dust particle characteristics
|
||||||
|
particleDecay: number;
|
||||||
|
particlePitchRange: number;
|
||||||
|
particleResonance: number;
|
||||||
|
|
||||||
|
// Background texture
|
||||||
|
backgroundNoise: number;
|
||||||
|
noiseColor: number;
|
||||||
|
noiseFilter: number;
|
||||||
|
|
||||||
|
// Pops and clicks
|
||||||
|
popIntensity: number;
|
||||||
|
popPitchRange: number;
|
||||||
|
clickAmount: number;
|
||||||
|
|
||||||
|
// Dynamics and variation
|
||||||
|
dynamicRange: number;
|
||||||
|
irregularity: number;
|
||||||
|
|
||||||
|
// Stereo field
|
||||||
|
stereoWidth: number;
|
||||||
|
|
||||||
|
// Global envelope
|
||||||
|
globalAttack: number;
|
||||||
|
globalDecay: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DustNoise implements SynthEngine {
|
||||||
|
getName(): string {
|
||||||
|
return 'Pond';
|
||||||
|
}
|
||||||
|
|
||||||
|
getDescription(): string {
|
||||||
|
return 'Vinyl dust, crackle, and particle noise generator';
|
||||||
|
}
|
||||||
|
|
||||||
|
getType() {
|
||||||
|
return 'generative' as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
randomParams(pitchLock?: PitchLock): DustNoiseParams {
|
||||||
|
const characterBias = Math.random();
|
||||||
|
|
||||||
|
let dustDensity: number;
|
||||||
|
let crackleAmount: number;
|
||||||
|
let popDensity: number;
|
||||||
|
let backgroundNoise: number;
|
||||||
|
|
||||||
|
if (characterBias < 0.5) {
|
||||||
|
// Very sparse, minimal particles
|
||||||
|
dustDensity = 0.01 + Math.random() * 0.08;
|
||||||
|
crackleAmount = Math.random() * 0.12;
|
||||||
|
popDensity = 0.01 + Math.random() * 0.05;
|
||||||
|
backgroundNoise = Math.random() * 0.08;
|
||||||
|
} else if (characterBias < 0.8) {
|
||||||
|
// Sparse, clean with occasional pops
|
||||||
|
dustDensity = 0.1 + Math.random() * 0.15;
|
||||||
|
crackleAmount = Math.random() * 0.25;
|
||||||
|
popDensity = 0.06 + Math.random() * 0.1;
|
||||||
|
backgroundNoise = 0.05 + Math.random() * 0.15;
|
||||||
|
} else {
|
||||||
|
// Medium vinyl character (was heavy)
|
||||||
|
dustDensity = 0.3 + Math.random() * 0.25;
|
||||||
|
crackleAmount = 0.25 + Math.random() * 0.3;
|
||||||
|
popDensity = 0.18 + Math.random() * 0.15;
|
||||||
|
backgroundNoise = 0.15 + Math.random() * 0.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
const particleDecay = 0.3 + Math.random() * 0.6;
|
||||||
|
const particlePitchRange = 0.2 + Math.random() * 0.7;
|
||||||
|
const particleResonance = Math.random() * 0.6;
|
||||||
|
|
||||||
|
const noiseColor = Math.random();
|
||||||
|
const noiseFilter = Math.random() * 0.8;
|
||||||
|
|
||||||
|
const popIntensity = 0.3 + Math.random() * 0.6;
|
||||||
|
const popPitchRange = 0.2 + Math.random() * 0.7;
|
||||||
|
const clickAmount = Math.random() * 0.7;
|
||||||
|
|
||||||
|
const dynamicRange = 0.3 + Math.random() * 0.6;
|
||||||
|
const irregularity = Math.random() * 0.7;
|
||||||
|
|
||||||
|
const stereoWidth = Math.random() * 0.8;
|
||||||
|
|
||||||
|
const globalAttack = Math.random() * 0.08;
|
||||||
|
const globalDecay = 0.3 + Math.random() * 0.5;
|
||||||
|
|
||||||
|
return {
|
||||||
|
dustDensity,
|
||||||
|
crackleAmount,
|
||||||
|
popDensity,
|
||||||
|
particleDecay,
|
||||||
|
particlePitchRange,
|
||||||
|
particleResonance,
|
||||||
|
backgroundNoise,
|
||||||
|
noiseColor,
|
||||||
|
noiseFilter,
|
||||||
|
popIntensity,
|
||||||
|
popPitchRange,
|
||||||
|
clickAmount,
|
||||||
|
dynamicRange,
|
||||||
|
irregularity,
|
||||||
|
stereoWidth,
|
||||||
|
globalAttack,
|
||||||
|
globalDecay
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
mutateParams(params: DustNoiseParams, mutationAmount: number = 0.15, pitchLock?: PitchLock): DustNoiseParams {
|
||||||
|
const mutate = (value: number, amount: number = 0.15): number => {
|
||||||
|
return Math.max(0, Math.min(1, value + (Math.random() - 0.5) * amount));
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
dustDensity: mutate(params.dustDensity, 0.2),
|
||||||
|
crackleAmount: mutate(params.crackleAmount, 0.25),
|
||||||
|
popDensity: mutate(params.popDensity, 0.2),
|
||||||
|
particleDecay: mutate(params.particleDecay, 0.2),
|
||||||
|
particlePitchRange: pitchLock?.enabled ? params.particlePitchRange : mutate(params.particlePitchRange, 0.25),
|
||||||
|
particleResonance: mutate(params.particleResonance, 0.2),
|
||||||
|
backgroundNoise: mutate(params.backgroundNoise, 0.2),
|
||||||
|
noiseColor: mutate(params.noiseColor, 0.25),
|
||||||
|
noiseFilter: mutate(params.noiseFilter, 0.2),
|
||||||
|
popIntensity: mutate(params.popIntensity, 0.2),
|
||||||
|
popPitchRange: pitchLock?.enabled ? params.popPitchRange : mutate(params.popPitchRange, 0.25),
|
||||||
|
clickAmount: mutate(params.clickAmount, 0.2),
|
||||||
|
dynamicRange: mutate(params.dynamicRange, 0.2),
|
||||||
|
irregularity: mutate(params.irregularity, 0.2),
|
||||||
|
stereoWidth: mutate(params.stereoWidth, 0.2),
|
||||||
|
globalAttack: mutate(params.globalAttack, 0.15),
|
||||||
|
globalDecay: mutate(params.globalDecay, 0.2)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
generate(params: DustNoiseParams, sampleRate: number, duration: number, pitchLock?: PitchLock): [Float32Array, Float32Array] {
|
||||||
|
const numSamples = Math.floor(sampleRate * duration);
|
||||||
|
const left = new Float32Array(numSamples);
|
||||||
|
const right = new Float32Array(numSamples);
|
||||||
|
|
||||||
|
// Generate dust particles
|
||||||
|
const avgDustPerSecond = 5 + params.dustDensity * 120;
|
||||||
|
const totalDust = Math.floor(avgDustPerSecond * duration);
|
||||||
|
|
||||||
|
// Generate pops
|
||||||
|
const avgPopsPerSecond = 0.5 + params.popDensity * 12;
|
||||||
|
const totalPops = Math.floor(avgPopsPerSecond * duration);
|
||||||
|
|
||||||
|
// Create dust particles
|
||||||
|
const dustParticles: Array<{
|
||||||
|
startTime: number;
|
||||||
|
decay: number;
|
||||||
|
pitch: number;
|
||||||
|
amplitude: number;
|
||||||
|
resonance: number;
|
||||||
|
stereoOffset: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
const baseDustPitch = pitchLock?.enabled ? pitchLock.frequency : 800 + params.particlePitchRange * 2000;
|
||||||
|
const basePopPitch = pitchLock?.enabled ? pitchLock.frequency : 200 + params.popPitchRange * 1000;
|
||||||
|
|
||||||
|
for (let i = 0; i < totalDust; i++) {
|
||||||
|
const startTime = Math.random() * duration;
|
||||||
|
const decay = (0.001 + params.particleDecay * 0.02) * (0.5 + Math.random() * 0.5);
|
||||||
|
const pitchVariation = pitchLock?.enabled ? 0.2 : params.particlePitchRange;
|
||||||
|
const pitchFreq = baseDustPitch + (Math.random() - 0.5) * pitchVariation * baseDustPitch;
|
||||||
|
const amplitude = (0.3 + Math.random() * 0.7) * (0.5 + params.dynamicRange * 0.5);
|
||||||
|
const resonance = params.particleResonance * (0.5 + Math.random() * 0.5);
|
||||||
|
const stereoOffset = (Math.random() - 0.5) * params.stereoWidth * 0.3;
|
||||||
|
|
||||||
|
dustParticles.push({
|
||||||
|
startTime,
|
||||||
|
decay,
|
||||||
|
pitch: pitchFreq,
|
||||||
|
amplitude,
|
||||||
|
resonance,
|
||||||
|
stereoOffset
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create pops
|
||||||
|
const pops: Array<{
|
||||||
|
startTime: number;
|
||||||
|
intensity: number;
|
||||||
|
pitch: number;
|
||||||
|
isClick: boolean;
|
||||||
|
stereoOffset: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < totalPops; i++) {
|
||||||
|
const startTime = Math.random() * duration;
|
||||||
|
const intensity = params.popIntensity * (0.5 + Math.random() * 0.5);
|
||||||
|
const pitchVariation = pitchLock?.enabled ? 0.2 : params.popPitchRange;
|
||||||
|
const pitchFreq = basePopPitch + (Math.random() - 0.5) * pitchVariation * basePopPitch;
|
||||||
|
const isClick = Math.random() < params.clickAmount;
|
||||||
|
const stereoOffset = (Math.random() - 0.5) * params.stereoWidth * 0.5;
|
||||||
|
|
||||||
|
pops.push({
|
||||||
|
startTime,
|
||||||
|
intensity,
|
||||||
|
pitch: pitchFreq,
|
||||||
|
isClick,
|
||||||
|
stereoOffset
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort events by time
|
||||||
|
dustParticles.sort((a, b) => a.startTime - b.startTime);
|
||||||
|
pops.sort((a, b) => a.startTime - b.startTime);
|
||||||
|
|
||||||
|
// Noise state
|
||||||
|
const pinkStateL = new Float32Array(7);
|
||||||
|
const pinkStateR = new Float32Array(7);
|
||||||
|
let brownStateL = 0;
|
||||||
|
let brownStateR = 0;
|
||||||
|
|
||||||
|
// Filter state for background noise
|
||||||
|
let bgFilterStateL1 = 0;
|
||||||
|
let bgFilterStateL2 = 0;
|
||||||
|
let bgFilterStateR1 = 0;
|
||||||
|
let bgFilterStateR2 = 0;
|
||||||
|
|
||||||
|
// Active particles
|
||||||
|
let dustIndex = 0;
|
||||||
|
let popIndex = 0;
|
||||||
|
const activeDust: Array<{
|
||||||
|
particle: typeof dustParticles[0];
|
||||||
|
startSample: number;
|
||||||
|
phase: number;
|
||||||
|
}> = [];
|
||||||
|
const activePops: Array<{
|
||||||
|
pop: typeof pops[0];
|
||||||
|
startSample: number;
|
||||||
|
phase: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
// Crackle state (for vinyl crackle texture)
|
||||||
|
let cracklePhase = 0;
|
||||||
|
const crackleFreq = 20 + params.crackleAmount * 80;
|
||||||
|
|
||||||
|
for (let i = 0; i < numSamples; i++) {
|
||||||
|
const t = i / sampleRate;
|
||||||
|
|
||||||
|
// Add new dust particles
|
||||||
|
while (dustIndex < dustParticles.length && dustParticles[dustIndex].startTime <= t) {
|
||||||
|
activeDust.push({
|
||||||
|
particle: dustParticles[dustIndex],
|
||||||
|
startSample: i,
|
||||||
|
phase: Math.random() * Math.PI * 2
|
||||||
|
});
|
||||||
|
dustIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new pops
|
||||||
|
while (popIndex < pops.length && pops[popIndex].startTime <= t) {
|
||||||
|
activePops.push({
|
||||||
|
pop: pops[popIndex],
|
||||||
|
startSample: i,
|
||||||
|
phase: Math.random() * Math.PI * 2
|
||||||
|
});
|
||||||
|
popIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global envelope
|
||||||
|
const globalEnv = this.globalEnvelope(
|
||||||
|
i,
|
||||||
|
numSamples,
|
||||||
|
params.globalAttack,
|
||||||
|
params.globalDecay,
|
||||||
|
duration,
|
||||||
|
sampleRate
|
||||||
|
);
|
||||||
|
|
||||||
|
// Background noise
|
||||||
|
const whiteL = Math.random() * 2 - 1;
|
||||||
|
const whiteR = Math.random() * 2 - 1;
|
||||||
|
|
||||||
|
brownStateL = this.updateBrownState(brownStateL, whiteL);
|
||||||
|
brownStateR = this.updateBrownState(brownStateR, whiteR);
|
||||||
|
|
||||||
|
let bgNoiseL = this.selectNoiseColor(params.noiseColor, whiteL, pinkStateL, brownStateL);
|
||||||
|
let bgNoiseR = this.selectNoiseColor(params.noiseColor, whiteR, pinkStateR, brownStateR);
|
||||||
|
|
||||||
|
// Filter background noise
|
||||||
|
if (params.noiseFilter > 0.1) {
|
||||||
|
const filterFreq = 500 + params.noiseFilter * 3000;
|
||||||
|
const filtered = this.stateVariableFilter(
|
||||||
|
bgNoiseL,
|
||||||
|
filterFreq,
|
||||||
|
1,
|
||||||
|
sampleRate,
|
||||||
|
bgFilterStateL1,
|
||||||
|
bgFilterStateL2
|
||||||
|
);
|
||||||
|
bgFilterStateL1 = filtered.state1;
|
||||||
|
bgFilterStateL2 = filtered.state2;
|
||||||
|
bgNoiseL = filtered.output;
|
||||||
|
|
||||||
|
const filteredR = this.stateVariableFilter(
|
||||||
|
bgNoiseR,
|
||||||
|
filterFreq,
|
||||||
|
1,
|
||||||
|
sampleRate,
|
||||||
|
bgFilterStateR1,
|
||||||
|
bgFilterStateR2
|
||||||
|
);
|
||||||
|
bgFilterStateR1 = filteredR.state1;
|
||||||
|
bgFilterStateR2 = filteredR.state2;
|
||||||
|
bgNoiseR = filteredR.output;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crackle modulation
|
||||||
|
cracklePhase += (2 * Math.PI * crackleFreq) / sampleRate;
|
||||||
|
const crackleMod = Math.sin(cracklePhase) * 0.5 + 0.5;
|
||||||
|
const crackleEnv = Math.pow(crackleMod, 3) * params.crackleAmount;
|
||||||
|
|
||||||
|
bgNoiseL *= params.backgroundNoise * (1 + crackleEnv);
|
||||||
|
bgNoiseR *= params.backgroundNoise * (1 + crackleEnv);
|
||||||
|
|
||||||
|
// Render dust particles
|
||||||
|
let dustL = 0;
|
||||||
|
let dustR = 0;
|
||||||
|
|
||||||
|
for (let d = activeDust.length - 1; d >= 0; d--) {
|
||||||
|
const active = activeDust[d];
|
||||||
|
const particle = active.particle;
|
||||||
|
const elapsed = (i - active.startSample) / sampleRate;
|
||||||
|
|
||||||
|
if (elapsed > particle.decay * 5) {
|
||||||
|
activeDust.splice(d, 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const env = Math.exp(-elapsed / particle.decay);
|
||||||
|
const phaseInc = (2 * Math.PI * particle.pitch) / sampleRate;
|
||||||
|
active.phase += phaseInc;
|
||||||
|
|
||||||
|
let signal = Math.sin(active.phase);
|
||||||
|
|
||||||
|
// Add resonance (filter-like character)
|
||||||
|
if (particle.resonance > 0.1) {
|
||||||
|
signal = signal * (1 - particle.resonance) +
|
||||||
|
Math.sin(active.phase * 2) * particle.resonance * 0.3 +
|
||||||
|
Math.sin(active.phase * 3) * particle.resonance * 0.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = signal * env * particle.amplitude;
|
||||||
|
|
||||||
|
const panL = 0.5 - particle.stereoOffset;
|
||||||
|
const panR = 0.5 + particle.stereoOffset;
|
||||||
|
|
||||||
|
dustL += output * panL;
|
||||||
|
dustR += output * panR;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render pops and clicks
|
||||||
|
let popL = 0;
|
||||||
|
let popR = 0;
|
||||||
|
|
||||||
|
for (let p = activePops.length - 1; p >= 0; p--) {
|
||||||
|
const active = activePops[p];
|
||||||
|
const pop = active.pop;
|
||||||
|
const elapsed = (i - active.startSample) / sampleRate;
|
||||||
|
|
||||||
|
const maxDuration = pop.isClick ? 0.001 : 0.008;
|
||||||
|
|
||||||
|
if (elapsed > maxDuration) {
|
||||||
|
activePops.splice(p, 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const env = Math.exp(-elapsed * (pop.isClick ? 2000 : 300));
|
||||||
|
|
||||||
|
let signal: number;
|
||||||
|
if (pop.isClick) {
|
||||||
|
// Sharp click (very short impulse)
|
||||||
|
signal = (Math.random() * 2 - 1) * (elapsed < 0.0003 ? 1 : 0.3);
|
||||||
|
} else {
|
||||||
|
// Pop with pitch
|
||||||
|
const phaseInc = (2 * Math.PI * pop.pitch) / sampleRate;
|
||||||
|
active.phase += phaseInc;
|
||||||
|
signal = Math.sin(active.phase) * 0.7 + (Math.random() * 2 - 1) * 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = signal * env * pop.intensity;
|
||||||
|
|
||||||
|
const panL = 0.5 - pop.stereoOffset;
|
||||||
|
const panR = 0.5 + pop.stereoOffset;
|
||||||
|
|
||||||
|
popL += output * panL;
|
||||||
|
popR += output * panR;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine all elements
|
||||||
|
let sampleL = bgNoiseL + dustL + popL;
|
||||||
|
let sampleR = bgNoiseR + dustR + popR;
|
||||||
|
|
||||||
|
// Apply irregularity (random amplitude modulation)
|
||||||
|
if (params.irregularity > 0.1) {
|
||||||
|
const irregMod = 1 + (Math.random() - 0.5) * params.irregularity * 0.3;
|
||||||
|
sampleL *= irregMod;
|
||||||
|
sampleR *= irregMod;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply global envelope
|
||||||
|
sampleL *= globalEnv;
|
||||||
|
sampleR *= globalEnv;
|
||||||
|
|
||||||
|
// Soft clipping
|
||||||
|
left[i] = this.softClip(sampleL * 0.6);
|
||||||
|
right[i] = this.softClip(sampleR * 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize
|
||||||
|
let peak = 0;
|
||||||
|
for (let i = 0; i < numSamples; i++) {
|
||||||
|
peak = Math.max(peak, Math.abs(left[i]), Math.abs(right[i]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (peak > 0.001) {
|
||||||
|
const normGain = 0.95 / peak;
|
||||||
|
for (let i = 0; i < numSamples; i++) {
|
||||||
|
left[i] *= normGain;
|
||||||
|
right[i] *= normGain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [left, right];
|
||||||
|
}
|
||||||
|
|
||||||
|
private globalEnvelope(
|
||||||
|
sample: number,
|
||||||
|
totalSamples: number,
|
||||||
|
attack: number,
|
||||||
|
decay: number,
|
||||||
|
duration: number,
|
||||||
|
sampleRate: number
|
||||||
|
): number {
|
||||||
|
const attackSamples = Math.floor(attack * duration * sampleRate);
|
||||||
|
const phase = sample / totalSamples;
|
||||||
|
|
||||||
|
if (sample < attackSamples && attackSamples > 0) {
|
||||||
|
const attackPhase = sample / attackSamples;
|
||||||
|
return attackPhase * attackPhase * (3 - 2 * attackPhase);
|
||||||
|
}
|
||||||
|
|
||||||
|
const decayRate = Math.max(decay, 0.1);
|
||||||
|
const decayPhase = (sample - attackSamples) / (totalSamples - attackSamples);
|
||||||
|
return Math.exp(-decayPhase / decayRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateBrownState(brownState: number, whiteNoise: number): number {
|
||||||
|
return (brownState + whiteNoise * 0.02) * 0.98;
|
||||||
|
}
|
||||||
|
|
||||||
|
private selectNoiseColor(
|
||||||
|
colorParam: number,
|
||||||
|
whiteNoise: number,
|
||||||
|
pinkState: Float32Array,
|
||||||
|
brownState: number
|
||||||
|
): number {
|
||||||
|
if (colorParam < 0.33) {
|
||||||
|
return whiteNoise;
|
||||||
|
} else if (colorParam < 0.66) {
|
||||||
|
pinkState[0] = 0.99886 * pinkState[0] + whiteNoise * 0.0555179;
|
||||||
|
pinkState[1] = 0.99332 * pinkState[1] + whiteNoise * 0.0750759;
|
||||||
|
pinkState[2] = 0.96900 * pinkState[2] + whiteNoise * 0.1538520;
|
||||||
|
pinkState[3] = 0.86650 * pinkState[3] + whiteNoise * 0.3104856;
|
||||||
|
pinkState[4] = 0.55000 * pinkState[4] + whiteNoise * 0.5329522;
|
||||||
|
pinkState[5] = -0.7616 * pinkState[5] - whiteNoise * 0.0168980;
|
||||||
|
|
||||||
|
const pink = pinkState[0] + pinkState[1] + pinkState[2] + pinkState[3] +
|
||||||
|
pinkState[4] + pinkState[5] + pinkState[6] + whiteNoise * 0.5362;
|
||||||
|
pinkState[6] = whiteNoise * 0.115926;
|
||||||
|
|
||||||
|
return pink * 0.11;
|
||||||
|
} else {
|
||||||
|
return brownState * 2.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private stateVariableFilter(
|
||||||
|
input: number,
|
||||||
|
cutoff: number,
|
||||||
|
resonance: number,
|
||||||
|
sampleRate: number,
|
||||||
|
state1: number,
|
||||||
|
state2: number
|
||||||
|
): { output: number; state1: number; state2: number } {
|
||||||
|
const normalizedFreq = Math.min(cutoff / sampleRate, 0.48);
|
||||||
|
const f = 2 * Math.sin(Math.PI * normalizedFreq);
|
||||||
|
const q = Math.max(1 / Math.min(resonance, 10), 0.02);
|
||||||
|
|
||||||
|
const lowpass = state2 + f * state1;
|
||||||
|
const highpass = input - lowpass - q * state1;
|
||||||
|
const bandpass = f * highpass + state1;
|
||||||
|
|
||||||
|
const newState1 = Math.max(-2, Math.min(2, Math.abs(bandpass) > 1e-10 ? bandpass : 0));
|
||||||
|
const newState2 = Math.max(-2, Math.min(2, Math.abs(lowpass) > 1e-10 ? lowpass : 0));
|
||||||
|
|
||||||
|
return {
|
||||||
|
output: bandpass,
|
||||||
|
state1: newState1,
|
||||||
|
state2: newState2
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private softClip(x: number): number {
|
||||||
|
if (x > 1) {
|
||||||
|
return 1;
|
||||||
|
} else if (x < -1) {
|
||||||
|
return -1;
|
||||||
|
} else if (x > 0.66) {
|
||||||
|
return (3 - (2 - 3 * x) ** 2) / 3;
|
||||||
|
} else if (x < -0.66) {
|
||||||
|
return -(3 - (2 - 3 * -x) ** 2) / 3;
|
||||||
|
} else {
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
390
src/lib/audio/engines/ParticleNoise.ts
Normal file
390
src/lib/audio/engines/ParticleNoise.ts
Normal file
@ -0,0 +1,390 @@
|
|||||||
|
import type { SynthEngine, PitchLock } from './SynthEngine';
|
||||||
|
|
||||||
|
interface ParticleNoiseParams {
|
||||||
|
// Particle characteristics
|
||||||
|
density: number;
|
||||||
|
impulseLength: number;
|
||||||
|
impulseLengthVariation: number;
|
||||||
|
|
||||||
|
// Pitch characteristics (affects filter frequency)
|
||||||
|
basePitch: number;
|
||||||
|
pitchVariation: number;
|
||||||
|
|
||||||
|
// Texture
|
||||||
|
noiseColor: number;
|
||||||
|
filterResonance: number;
|
||||||
|
clickiness: number;
|
||||||
|
|
||||||
|
// Spatial
|
||||||
|
stereoSpread: number;
|
||||||
|
panSpeed: number;
|
||||||
|
|
||||||
|
// Dynamics
|
||||||
|
globalEnvAttack: number;
|
||||||
|
globalEnvDecay: number;
|
||||||
|
velocity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ParticleNoise implements SynthEngine {
|
||||||
|
getName(): string {
|
||||||
|
return 'Particle';
|
||||||
|
}
|
||||||
|
|
||||||
|
getDescription(): string {
|
||||||
|
return 'Very short noise impulses and clicks generator';
|
||||||
|
}
|
||||||
|
|
||||||
|
getType() {
|
||||||
|
return 'generative' as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
randomParams(pitchLock?: PitchLock): ParticleNoiseParams {
|
||||||
|
const densityBias = Math.random();
|
||||||
|
|
||||||
|
let density: number;
|
||||||
|
let impulseLength: number;
|
||||||
|
let impulseLengthVariation: number;
|
||||||
|
|
||||||
|
if (densityBias < 0.5) {
|
||||||
|
// Very sparse particles
|
||||||
|
density = 0.01 + Math.random() * 0.08;
|
||||||
|
impulseLength = 0.01 + Math.random() * 0.04;
|
||||||
|
impulseLengthVariation = 0.5 + Math.random() * 0.4;
|
||||||
|
} else if (densityBias < 0.8) {
|
||||||
|
// Sparse particles
|
||||||
|
density = 0.1 + Math.random() * 0.15;
|
||||||
|
impulseLength = 0.008 + Math.random() * 0.03;
|
||||||
|
impulseLengthVariation = 0.3 + Math.random() * 0.4;
|
||||||
|
} else {
|
||||||
|
// Medium density
|
||||||
|
density = 0.3 + Math.random() * 0.25;
|
||||||
|
impulseLength = 0.005 + Math.random() * 0.02;
|
||||||
|
impulseLengthVariation = 0.15 + Math.random() * 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
let basePitch: number;
|
||||||
|
if (pitchLock?.enabled) {
|
||||||
|
basePitch = Math.max(0, Math.min(1, (pitchLock.frequency - 100) / 2000));
|
||||||
|
} else {
|
||||||
|
basePitch = 0.2 + Math.random() * 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pitchVariation = Math.random() * 0.6;
|
||||||
|
|
||||||
|
const noiseColor = Math.random();
|
||||||
|
const filterResonance = Math.random() * 0.5;
|
||||||
|
const clickiness = Math.random() * 0.8;
|
||||||
|
|
||||||
|
const stereoSpread = Math.random() * 0.8;
|
||||||
|
const panSpeed = Math.random() * 0.6;
|
||||||
|
|
||||||
|
const globalEnvAttack = Math.random() * 0.12;
|
||||||
|
const globalEnvDecay = 0.25 + Math.random() * 0.5;
|
||||||
|
const velocity = 0.6 + Math.random() * 0.4;
|
||||||
|
|
||||||
|
return {
|
||||||
|
density,
|
||||||
|
impulseLength,
|
||||||
|
impulseLengthVariation,
|
||||||
|
basePitch,
|
||||||
|
pitchVariation,
|
||||||
|
noiseColor,
|
||||||
|
filterResonance,
|
||||||
|
clickiness,
|
||||||
|
stereoSpread,
|
||||||
|
panSpeed,
|
||||||
|
globalEnvAttack,
|
||||||
|
globalEnvDecay,
|
||||||
|
velocity
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
mutateParams(params: ParticleNoiseParams, mutationAmount: number = 0.15, pitchLock?: PitchLock): ParticleNoiseParams {
|
||||||
|
const mutate = (value: number, amount: number = 0.15): number => {
|
||||||
|
return Math.max(0, Math.min(1, value + (Math.random() - 0.5) * amount));
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
density: mutate(params.density, 0.2),
|
||||||
|
impulseLength: mutate(params.impulseLength, 0.2),
|
||||||
|
impulseLengthVariation: mutate(params.impulseLengthVariation, 0.2),
|
||||||
|
basePitch: pitchLock?.enabled ? params.basePitch : mutate(params.basePitch, 0.25),
|
||||||
|
pitchVariation: mutate(params.pitchVariation, 0.2),
|
||||||
|
noiseColor: mutate(params.noiseColor, 0.25),
|
||||||
|
filterResonance: mutate(params.filterResonance, 0.2),
|
||||||
|
clickiness: mutate(params.clickiness, 0.2),
|
||||||
|
stereoSpread: mutate(params.stereoSpread, 0.2),
|
||||||
|
panSpeed: mutate(params.panSpeed, 0.2),
|
||||||
|
globalEnvAttack: mutate(params.globalEnvAttack, 0.15),
|
||||||
|
globalEnvDecay: mutate(params.globalEnvDecay, 0.2),
|
||||||
|
velocity: mutate(params.velocity, 0.15)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
generate(params: ParticleNoiseParams, sampleRate: number, duration: number, pitchLock?: PitchLock): [Float32Array, Float32Array] {
|
||||||
|
const numSamples = Math.floor(sampleRate * duration);
|
||||||
|
const left = new Float32Array(numSamples);
|
||||||
|
const right = new Float32Array(numSamples);
|
||||||
|
|
||||||
|
// Calculate number of grains based on density
|
||||||
|
const avgGrainsPerSecond = 2 + params.density * 80;
|
||||||
|
const totalGrains = Math.floor(avgGrainsPerSecond * duration);
|
||||||
|
|
||||||
|
// Pre-generate impulse timings and parameters
|
||||||
|
const impulses: Array<{
|
||||||
|
startTime: number;
|
||||||
|
duration: number;
|
||||||
|
filterFreq: number;
|
||||||
|
pan: number;
|
||||||
|
amplitude: number;
|
||||||
|
isClick: boolean;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
const baseFilterFreq = pitchLock?.enabled ? pitchLock.frequency : 200 + params.basePitch * 3000;
|
||||||
|
|
||||||
|
for (let i = 0; i < totalGrains; i++) {
|
||||||
|
const startTime = Math.random() * duration;
|
||||||
|
|
||||||
|
const baseImpulseDuration = 0.0005 + params.impulseLength * 0.003;
|
||||||
|
const impulseDuration = baseImpulseDuration * (0.5 + Math.random() * params.impulseLengthVariation);
|
||||||
|
|
||||||
|
const filterOffset = (Math.random() - 0.5) * params.pitchVariation * baseFilterFreq * 2;
|
||||||
|
const filterFreq = Math.max(100, baseFilterFreq + filterOffset);
|
||||||
|
|
||||||
|
const pan = Math.random();
|
||||||
|
const amplitude = 0.5 + Math.random() * 0.5;
|
||||||
|
const isClick = Math.random() < params.clickiness;
|
||||||
|
|
||||||
|
impulses.push({
|
||||||
|
startTime,
|
||||||
|
duration: impulseDuration,
|
||||||
|
filterFreq,
|
||||||
|
pan,
|
||||||
|
amplitude,
|
||||||
|
isClick
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort impulses by start time for efficient processing
|
||||||
|
impulses.sort((a, b) => a.startTime - b.startTime);
|
||||||
|
|
||||||
|
// Noise state for colored noise generation
|
||||||
|
const pinkStateL = new Float32Array(7);
|
||||||
|
const pinkStateR = new Float32Array(7);
|
||||||
|
let brownStateL = 0;
|
||||||
|
let brownStateR = 0;
|
||||||
|
|
||||||
|
let impulseIndex = 0;
|
||||||
|
const activeImpulses: Array<{
|
||||||
|
impulse: typeof impulses[0];
|
||||||
|
startSample: number;
|
||||||
|
filterState1: number;
|
||||||
|
filterState2: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < numSamples; i++) {
|
||||||
|
const t = i / sampleRate;
|
||||||
|
|
||||||
|
// Add new impulses that should start at this sample
|
||||||
|
while (impulseIndex < impulses.length && impulses[impulseIndex].startTime <= t) {
|
||||||
|
activeImpulses.push({
|
||||||
|
impulse: impulses[impulseIndex],
|
||||||
|
startSample: i,
|
||||||
|
filterState1: 0,
|
||||||
|
filterState2: 0
|
||||||
|
});
|
||||||
|
impulseIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global envelope
|
||||||
|
const globalEnv = this.globalEnvelope(
|
||||||
|
i,
|
||||||
|
numSamples,
|
||||||
|
params.globalEnvAttack,
|
||||||
|
params.globalEnvDecay,
|
||||||
|
duration,
|
||||||
|
sampleRate
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pan modulation
|
||||||
|
const panLFO = Math.sin(2 * Math.PI * (0.1 + params.panSpeed * 2) * t);
|
||||||
|
|
||||||
|
let sampleL = 0;
|
||||||
|
let sampleR = 0;
|
||||||
|
|
||||||
|
// Render all active impulses
|
||||||
|
for (let g = activeImpulses.length - 1; g >= 0; g--) {
|
||||||
|
const active = activeImpulses[g];
|
||||||
|
const impulse = active.impulse;
|
||||||
|
const impulseSample = i - active.startSample;
|
||||||
|
const impulseTime = impulseSample / sampleRate;
|
||||||
|
|
||||||
|
if (impulseTime >= impulse.duration) {
|
||||||
|
activeImpulses.splice(g, 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const impulsePhase = impulseTime / impulse.duration;
|
||||||
|
|
||||||
|
// Very fast exponential decay envelope
|
||||||
|
const impulseEnv = Math.exp(-impulsePhase * 15);
|
||||||
|
|
||||||
|
// Generate noise burst
|
||||||
|
const whiteL = Math.random() * 2 - 1;
|
||||||
|
const whiteR = Math.random() * 2 - 1;
|
||||||
|
|
||||||
|
brownStateL = this.updateBrownState(brownStateL, whiteL);
|
||||||
|
brownStateR = this.updateBrownState(brownStateR, whiteR);
|
||||||
|
|
||||||
|
let noiseL = this.selectNoiseColor(params.noiseColor, whiteL, pinkStateL, brownStateL);
|
||||||
|
let noiseR = this.selectNoiseColor(params.noiseColor, whiteR, pinkStateR, brownStateR);
|
||||||
|
|
||||||
|
// For clicks, use pure white noise burst
|
||||||
|
if (impulse.isClick) {
|
||||||
|
noiseL = whiteL;
|
||||||
|
noiseR = whiteR;
|
||||||
|
} else if (params.filterResonance > 0.1) {
|
||||||
|
// Apply resonant filter for tonal color
|
||||||
|
const resonance = 2 + params.filterResonance * 8;
|
||||||
|
const filtered = this.stateVariableFilter(
|
||||||
|
noiseL,
|
||||||
|
impulse.filterFreq,
|
||||||
|
resonance,
|
||||||
|
active.filterState1,
|
||||||
|
active.filterState2
|
||||||
|
);
|
||||||
|
active.filterState1 = filtered.state1;
|
||||||
|
active.filterState2 = filtered.state2;
|
||||||
|
noiseL = filtered.output;
|
||||||
|
noiseR = filtered.output;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply impulse envelope and amplitude
|
||||||
|
const impulseOutput = noiseL * impulseEnv * impulse.amplitude * params.velocity;
|
||||||
|
|
||||||
|
// Apply panning with modulation
|
||||||
|
const panMod = impulse.pan + panLFO * params.stereoSpread * 0.15;
|
||||||
|
const panClamp = Math.max(0, Math.min(1, panMod));
|
||||||
|
const panL = Math.cos(panClamp * Math.PI * 0.5);
|
||||||
|
const panR = Math.sin(panClamp * Math.PI * 0.5);
|
||||||
|
|
||||||
|
sampleL += impulseOutput * panL;
|
||||||
|
sampleR += impulseOutput * panR * (impulse.isClick ? 1 : 1 + (Math.random() - 0.5) * params.stereoSpread * 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply global envelope
|
||||||
|
sampleL *= globalEnv;
|
||||||
|
sampleR *= globalEnv;
|
||||||
|
|
||||||
|
// Soft clipping
|
||||||
|
left[i] = this.softClip(sampleL * 0.7);
|
||||||
|
right[i] = this.softClip(sampleR * 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize
|
||||||
|
let peak = 0;
|
||||||
|
for (let i = 0; i < numSamples; i++) {
|
||||||
|
peak = Math.max(peak, Math.abs(left[i]), Math.abs(right[i]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (peak > 0.001) {
|
||||||
|
const normGain = 0.95 / peak;
|
||||||
|
for (let i = 0; i < numSamples; i++) {
|
||||||
|
left[i] *= normGain;
|
||||||
|
right[i] *= normGain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [left, right];
|
||||||
|
}
|
||||||
|
|
||||||
|
private globalEnvelope(
|
||||||
|
sample: number,
|
||||||
|
totalSamples: number,
|
||||||
|
attack: number,
|
||||||
|
decay: number,
|
||||||
|
duration: number,
|
||||||
|
sampleRate: number
|
||||||
|
): number {
|
||||||
|
const attackSamples = Math.floor(attack * duration * sampleRate);
|
||||||
|
const phase = sample / totalSamples;
|
||||||
|
|
||||||
|
if (sample < attackSamples && attackSamples > 0) {
|
||||||
|
const attackPhase = sample / attackSamples;
|
||||||
|
return attackPhase * attackPhase * (3 - 2 * attackPhase);
|
||||||
|
}
|
||||||
|
|
||||||
|
const decayRate = Math.max(decay, 0.1);
|
||||||
|
const decayPhase = (sample - attackSamples) / (totalSamples - attackSamples);
|
||||||
|
return Math.exp(-decayPhase / decayRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stateVariableFilter(
|
||||||
|
input: number,
|
||||||
|
cutoff: number,
|
||||||
|
resonance: number,
|
||||||
|
state1: number,
|
||||||
|
state2: number
|
||||||
|
): { output: number; state1: number; state2: number } {
|
||||||
|
const normalizedFreq = Math.min(cutoff / 44100, 0.48);
|
||||||
|
const f = 2 * Math.sin(Math.PI * normalizedFreq);
|
||||||
|
const q = Math.max(1 / Math.min(resonance, 20), 0.01);
|
||||||
|
|
||||||
|
const lowpass = state2 + f * state1;
|
||||||
|
const highpass = input - lowpass - q * state1;
|
||||||
|
const bandpass = f * highpass + state1;
|
||||||
|
|
||||||
|
const newState1 = Math.max(-3, Math.min(3, Math.abs(bandpass) > 1e-10 ? bandpass : 0));
|
||||||
|
const newState2 = Math.max(-3, Math.min(3, Math.abs(lowpass) > 1e-10 ? lowpass : 0));
|
||||||
|
|
||||||
|
return {
|
||||||
|
output: bandpass,
|
||||||
|
state1: newState1,
|
||||||
|
state2: newState2
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateBrownState(brownState: number, whiteNoise: number): number {
|
||||||
|
return (brownState + whiteNoise * 0.02) * 0.98;
|
||||||
|
}
|
||||||
|
|
||||||
|
private selectNoiseColor(
|
||||||
|
colorParam: number,
|
||||||
|
whiteNoise: number,
|
||||||
|
pinkState: Float32Array,
|
||||||
|
brownState: number
|
||||||
|
): number {
|
||||||
|
if (colorParam < 0.33) {
|
||||||
|
return whiteNoise;
|
||||||
|
} else if (colorParam < 0.66) {
|
||||||
|
pinkState[0] = 0.99886 * pinkState[0] + whiteNoise * 0.0555179;
|
||||||
|
pinkState[1] = 0.99332 * pinkState[1] + whiteNoise * 0.0750759;
|
||||||
|
pinkState[2] = 0.96900 * pinkState[2] + whiteNoise * 0.1538520;
|
||||||
|
pinkState[3] = 0.86650 * pinkState[3] + whiteNoise * 0.3104856;
|
||||||
|
pinkState[4] = 0.55000 * pinkState[4] + whiteNoise * 0.5329522;
|
||||||
|
pinkState[5] = -0.7616 * pinkState[5] - whiteNoise * 0.0168980;
|
||||||
|
|
||||||
|
const pink = pinkState[0] + pinkState[1] + pinkState[2] + pinkState[3] +
|
||||||
|
pinkState[4] + pinkState[5] + pinkState[6] + whiteNoise * 0.5362;
|
||||||
|
pinkState[6] = whiteNoise * 0.115926;
|
||||||
|
|
||||||
|
return pink * 0.11;
|
||||||
|
} else {
|
||||||
|
return brownState * 2.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private softClip(x: number): number {
|
||||||
|
if (x > 1) {
|
||||||
|
return 1;
|
||||||
|
} else if (x < -1) {
|
||||||
|
return -1;
|
||||||
|
} else if (x > 0.66) {
|
||||||
|
return (3 - (2 - 3 * x) ** 2) / 3;
|
||||||
|
} else if (x < -0.66) {
|
||||||
|
return -(3 - (2 - 3 * -x) ** 2) / 3;
|
||||||
|
} else {
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -14,6 +14,8 @@ import { AdditiveEngine } from './AdditiveEngine';
|
|||||||
import { Snare } from './Snare';
|
import { Snare } from './Snare';
|
||||||
import { BassDrum } from './BassDrum';
|
import { BassDrum } from './BassDrum';
|
||||||
import { HiHat } from './HiHat';
|
import { HiHat } from './HiHat';
|
||||||
|
import { ParticleNoise } from './ParticleNoise';
|
||||||
|
import { DustNoise } from './DustNoise';
|
||||||
|
|
||||||
export const engines: SynthEngine[] = [
|
export const engines: SynthEngine[] = [
|
||||||
new Sample(),
|
new Sample(),
|
||||||
@ -31,4 +33,6 @@ export const engines: SynthEngine[] = [
|
|||||||
new Ring(),
|
new Ring(),
|
||||||
new KarplusStrong(),
|
new KarplusStrong(),
|
||||||
new AdditiveEngine(),
|
new AdditiveEngine(),
|
||||||
|
new ParticleNoise(),
|
||||||
|
new DustNoise(),
|
||||||
];
|
];
|
||||||
|
|||||||
205
src/lib/audio/processors/Resonator.ts
Normal file
205
src/lib/audio/processors/Resonator.ts
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
import type { AudioProcessor } from './AudioProcessor';
|
||||||
|
|
||||||
|
export class Resonator implements AudioProcessor {
|
||||||
|
private readonly sampleRate = 44100;
|
||||||
|
|
||||||
|
getName(): string {
|
||||||
|
return 'Resonator';
|
||||||
|
}
|
||||||
|
|
||||||
|
getDescription(): string {
|
||||||
|
return 'Multi-band resonant filter bank that adds tonal character through resonance';
|
||||||
|
}
|
||||||
|
|
||||||
|
process(
|
||||||
|
leftChannel: Float32Array,
|
||||||
|
rightChannel: Float32Array
|
||||||
|
): [Float32Array, Float32Array] {
|
||||||
|
const length = leftChannel.length;
|
||||||
|
|
||||||
|
const numResonators = Math.floor(Math.random() * 3) + 2; // 2-4 resonators
|
||||||
|
const baseFreq = Math.random() * 200 + 100; // 100-300 Hz base frequency
|
||||||
|
const spread = Math.random() * 0.6 + 0.4; // 0.4-1.0 harmonic spread
|
||||||
|
const resonance = Math.random() * 8 + 4; // Q factor 4-12
|
||||||
|
const mix = Math.random() * 0.6 + 0.3; // 30-90% wet
|
||||||
|
const stereoSpread = Math.random() * 0.2; // 0-20% stereo detuning
|
||||||
|
const modulationRate = Math.random() * 0.8 + 0.1; // 0.1-0.9 Hz modulation
|
||||||
|
const modulationDepth = Math.random() * 0.3 + 0.1; // 10-40% pitch modulation
|
||||||
|
const drive = Math.random() * 0.5; // 0-50% input drive
|
||||||
|
|
||||||
|
const leftOut = new Float32Array(length);
|
||||||
|
const rightOut = new Float32Array(length);
|
||||||
|
|
||||||
|
const leftResonators: Array<{
|
||||||
|
freq: number;
|
||||||
|
state1: number;
|
||||||
|
state2: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
const rightResonators: Array<{
|
||||||
|
freq: number;
|
||||||
|
state1: number;
|
||||||
|
state2: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
// Create resonator banks with harmonic or inharmonic relationships
|
||||||
|
const isHarmonic = Math.random() < 0.6;
|
||||||
|
|
||||||
|
for (let i = 0; i < numResonators; i++) {
|
||||||
|
let freqMultiplier: number;
|
||||||
|
if (isHarmonic) {
|
||||||
|
// Harmonic series
|
||||||
|
freqMultiplier = Math.pow(2, i * spread);
|
||||||
|
} else {
|
||||||
|
// Inharmonic/stretched partials
|
||||||
|
freqMultiplier = Math.pow(2, i * spread * (1 + Math.random() * 0.4));
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftFreq = baseFreq * freqMultiplier;
|
||||||
|
const rightFreq = leftFreq * (1 + (Math.random() - 0.5) * stereoSpread);
|
||||||
|
|
||||||
|
leftResonators.push({
|
||||||
|
freq: leftFreq,
|
||||||
|
state1: 0,
|
||||||
|
state2: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
rightResonators.push({
|
||||||
|
freq: rightFreq,
|
||||||
|
state1: 0,
|
||||||
|
state2: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
const t = i / this.sampleRate;
|
||||||
|
|
||||||
|
// LFO for frequency modulation
|
||||||
|
const lfo = Math.sin(2 * Math.PI * modulationRate * t);
|
||||||
|
|
||||||
|
// Apply input drive
|
||||||
|
let leftInput = leftChannel[i];
|
||||||
|
let rightInput = rightChannel[i];
|
||||||
|
|
||||||
|
if (drive > 0.1) {
|
||||||
|
const driveAmount = 1 + drive * 2;
|
||||||
|
leftInput = this.softSaturation(leftInput * driveAmount);
|
||||||
|
rightInput = this.softSaturation(rightInput * driveAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process through all resonators
|
||||||
|
let leftResonant = 0;
|
||||||
|
let rightResonant = 0;
|
||||||
|
|
||||||
|
for (let r = 0; r < numResonators; r++) {
|
||||||
|
const leftRes = leftResonators[r];
|
||||||
|
const rightRes = rightResonators[r];
|
||||||
|
|
||||||
|
// Modulate frequency
|
||||||
|
const freqMod = 1 + lfo * modulationDepth;
|
||||||
|
const leftModFreq = Math.min(leftRes.freq * freqMod, this.sampleRate * 0.45);
|
||||||
|
const rightModFreq = Math.min(rightRes.freq * freqMod, this.sampleRate * 0.45);
|
||||||
|
|
||||||
|
// Apply resonant filter
|
||||||
|
const leftFiltered = this.stateVariableFilter(
|
||||||
|
leftInput,
|
||||||
|
leftModFreq,
|
||||||
|
resonance,
|
||||||
|
leftRes.state1,
|
||||||
|
leftRes.state2
|
||||||
|
);
|
||||||
|
|
||||||
|
leftRes.state1 = leftFiltered.state1;
|
||||||
|
leftRes.state2 = leftFiltered.state2;
|
||||||
|
|
||||||
|
const rightFiltered = this.stateVariableFilter(
|
||||||
|
rightInput,
|
||||||
|
rightModFreq,
|
||||||
|
resonance,
|
||||||
|
rightRes.state1,
|
||||||
|
rightRes.state2
|
||||||
|
);
|
||||||
|
|
||||||
|
rightRes.state1 = rightFiltered.state1;
|
||||||
|
rightRes.state2 = rightFiltered.state2;
|
||||||
|
|
||||||
|
// Amplitude compensation for number of resonators
|
||||||
|
const ampScale = 1 / Math.sqrt(numResonators);
|
||||||
|
leftResonant += leftFiltered.output * ampScale;
|
||||||
|
rightResonant += rightFiltered.output * ampScale;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mix dry and wet signals
|
||||||
|
const dryGain = Math.sqrt(1 - mix);
|
||||||
|
const wetGain = Math.sqrt(mix);
|
||||||
|
|
||||||
|
leftOut[i] = leftChannel[i] * dryGain + leftResonant * wetGain;
|
||||||
|
rightOut[i] = rightChannel[i] * dryGain + rightResonant * wetGain;
|
||||||
|
|
||||||
|
// Soft clipping
|
||||||
|
leftOut[i] = this.softClip(leftOut[i]);
|
||||||
|
rightOut[i] = this.softClip(rightOut[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.normalizeOutput(leftOut, rightOut);
|
||||||
|
|
||||||
|
return [leftOut, rightOut];
|
||||||
|
}
|
||||||
|
|
||||||
|
private stateVariableFilter(
|
||||||
|
input: number,
|
||||||
|
cutoff: number,
|
||||||
|
resonance: number,
|
||||||
|
state1: number,
|
||||||
|
state2: number
|
||||||
|
): { output: number; state1: number; state2: number } {
|
||||||
|
const normalizedFreq = Math.min(cutoff / this.sampleRate, 0.48);
|
||||||
|
const f = 2 * Math.sin(Math.PI * normalizedFreq);
|
||||||
|
const q = Math.max(1 / Math.min(resonance, 20), 0.01);
|
||||||
|
|
||||||
|
const lowpass = state2 + f * state1;
|
||||||
|
const highpass = input - lowpass - q * state1;
|
||||||
|
const bandpass = f * highpass + state1;
|
||||||
|
|
||||||
|
// Clamp states to prevent instability
|
||||||
|
const newState1 = Math.max(-3, Math.min(3, Math.abs(bandpass) > 1e-10 ? bandpass : 0));
|
||||||
|
const newState2 = Math.max(-3, Math.min(3, Math.abs(lowpass) > 1e-10 ? lowpass : 0));
|
||||||
|
|
||||||
|
return {
|
||||||
|
output: bandpass,
|
||||||
|
state1: newState1,
|
||||||
|
state2: newState2
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private softSaturation(x: number): number {
|
||||||
|
return x / (1 + Math.abs(x));
|
||||||
|
}
|
||||||
|
|
||||||
|
private softClip(sample: number): number {
|
||||||
|
const threshold = 0.95;
|
||||||
|
if (Math.abs(sample) < threshold) {
|
||||||
|
return sample;
|
||||||
|
}
|
||||||
|
const sign = sample < 0 ? -1 : 1;
|
||||||
|
const abs = Math.abs(sample);
|
||||||
|
return sign * (threshold + (1 - threshold) * Math.tanh((abs - threshold) / (1 - threshold)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeOutput(leftOut: Float32Array, rightOut: Float32Array): void {
|
||||||
|
let maxPeak = 0;
|
||||||
|
for (let i = 0; i < leftOut.length; i++) {
|
||||||
|
maxPeak = Math.max(maxPeak, Math.abs(leftOut[i]), Math.abs(rightOut[i]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxPeak > 0.01) {
|
||||||
|
const targetPeak = 0.95;
|
||||||
|
const normalizeGain = Math.min(1.0, targetPeak / maxPeak);
|
||||||
|
|
||||||
|
for (let i = 0; i < leftOut.length; i++) {
|
||||||
|
leftOut[i] *= normalizeGain;
|
||||||
|
rightOut[i] *= normalizeGain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -24,6 +24,7 @@ import { RingModulator } from './RingModulator';
|
|||||||
import { Waveshaper } from './Waveshaper';
|
import { Waveshaper } from './Waveshaper';
|
||||||
import { DCOffsetRemover } from './DCOffsetRemover';
|
import { DCOffsetRemover } from './DCOffsetRemover';
|
||||||
import { TrimSilence } from './TrimSilence';
|
import { TrimSilence } from './TrimSilence';
|
||||||
|
import { Resonator } from './Resonator';
|
||||||
|
|
||||||
const processors: AudioProcessor[] = [
|
const processors: AudioProcessor[] = [
|
||||||
new SegmentShuffler(),
|
new SegmentShuffler(),
|
||||||
@ -51,6 +52,7 @@ const processors: AudioProcessor[] = [
|
|||||||
new Waveshaper(),
|
new Waveshaper(),
|
||||||
new DCOffsetRemover(),
|
new DCOffsetRemover(),
|
||||||
new TrimSilence(),
|
new TrimSilence(),
|
||||||
|
new Resonator(),
|
||||||
];
|
];
|
||||||
|
|
||||||
export function getRandomProcessor(): AudioProcessor {
|
export function getRandomProcessor(): AudioProcessor {
|
||||||
|
|||||||
Reference in New Issue
Block a user