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

251 lines
7.6 KiB
TypeScript

import type { SynthEngine, PitchLock } from './base/SynthEngine';
interface WavetableParams {
bankIndex: number;
position: number;
baseFreq: number;
filterCutoff: number;
filterResonance: number;
attack: number;
decay: number;
sustain: number;
release: number;
}
interface Wavetable {
name: string;
samples: Float32Array;
}
export class WavetableEngine implements SynthEngine<WavetableParams> {
private wavetables: Wavetable[] = [];
private isLoaded = false;
constructor() {
this.generateBasicWavetables();
this.loadWavetablesAsync();
}
getName(): string {
return 'Wavetable';
}
getDescription(): string {
return 'Classic wavetable synthesis';
}
getType() {
return 'generative' as const;
}
private generateBasicWavetables(): void {
const size = 2048;
const tables = [
{ name: 'Sine', gen: (i: number) => Math.sin((i / size) * Math.PI * 2) },
{ name: 'Triangle', gen: (i: number) => {
const t = i / size;
return t < 0.25 ? t * 4 : t < 0.75 ? 2 - t * 4 : t * 4 - 4;
}},
{ name: 'Saw', gen: (i: number) => (i / size) * 2 - 1 },
{ name: 'Square', gen: (i: number) => i < size / 2 ? 1 : -1 },
{ name: 'Pulse25', gen: (i: number) => i < size / 4 ? 1 : -1 },
];
this.wavetables = tables.map(({ name, gen }) => ({
name,
samples: Float32Array.from({ length: size }, (_, i) => gen(i)),
}));
}
private async loadWavetablesAsync(): Promise<void> {
if (this.isLoaded) return;
try {
const manifestResponse = await fetch('/wavetables/manifest.txt');
const manifestText = await manifestResponse.text();
const filenames = manifestText.trim().split('\n').filter(line => line.endsWith('.wav'));
const loadPromises = filenames.slice(0, 50).map(async (filename) => {
try {
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
const response = await fetch(`/wavetables/${filename.trim()}`);
const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
const samples = audioBuffer.getChannelData(0);
let sum = 0;
for (let i = 0; i < samples.length; i++) {
sum += samples[i];
}
const dc = sum / samples.length;
const cleaned = new Float32Array(samples.length);
let peak = 0;
for (let i = 0; i < samples.length; i++) {
cleaned[i] = samples[i] - dc;
peak = Math.max(peak, Math.abs(cleaned[i]));
}
if (peak > 0) {
for (let i = 0; i < cleaned.length; i++) {
cleaned[i] /= peak;
}
}
return {
name: filename.replace('.wav', ''),
samples: cleaned,
};
} catch (error) {
return null;
}
});
const results = await Promise.all(loadPromises);
const loaded = results.filter((wt) => wt !== null) as Wavetable[];
if (loaded.length > 0) {
this.wavetables = loaded;
console.log(`Loaded ${this.wavetables.length} wavetables`);
}
this.isLoaded = true;
} catch (error) {
console.error('Failed to load wavetables:', error);
this.isLoaded = true;
}
}
generate(params: WavetableParams, sampleRate: number, duration: number): [Float32Array, Float32Array] {
const numSamples = Math.floor(sampleRate * duration);
const left = new Float32Array(numSamples);
const right = new Float32Array(numSamples);
if (this.wavetables.length < 3) {
return [left, right];
}
const bankSize = 4;
const numBanks = Math.max(1, this.wavetables.length - bankSize + 1);
const bankStart = Math.floor(params.bankIndex * numBanks) % numBanks;
const bank: Wavetable[] = [];
for (let i = 0; i < bankSize; i++) {
bank.push(this.wavetables[(bankStart + i) % this.wavetables.length]);
}
const tableSize = bank[0].samples.length;
const phaseIncrement = params.baseFreq / sampleRate;
let phase = 0;
const attackSamples = params.attack * duration * sampleRate;
const decaySamples = params.decay * duration * sampleRate;
const releaseSamples = params.release * duration * sampleRate;
let filterLP = 0;
let filterBP = 0;
const tablePos = params.position * (bankSize - 1);
const table1Index = Math.floor(tablePos);
const table2Index = Math.min(table1Index + 1, bankSize - 1);
const tableFade = tablePos - table1Index;
const cutoffNorm = Math.min(params.filterCutoff * 0.45, 0.45);
const f = 2 * Math.sin(Math.PI * cutoffNorm);
const q = 1 / Math.max(params.filterResonance * 5 + 0.7, 0.7);
for (let i = 0; i < numSamples; i++) {
let env = 1;
if (i < attackSamples) {
env = i / attackSamples;
} else if (i < attackSamples + decaySamples) {
const t = (i - attackSamples) / decaySamples;
env = 1 - t * (1 - params.sustain);
} else if (i < numSamples - releaseSamples) {
env = params.sustain;
} else {
const t = (i - (numSamples - releaseSamples)) / releaseSamples;
env = params.sustain * (1 - t);
}
const index1 = phase * tableSize;
const i1 = Math.floor(index1);
const i2 = (i1 + 1) % tableSize;
const frac = index1 - i1;
const sample1 = bank[table1Index].samples[i1] * (1 - frac) + bank[table1Index].samples[i2] * frac;
const sample2 = bank[table2Index].samples[i1] * (1 - frac) + bank[table2Index].samples[i2] * frac;
let sample = sample1 * (1 - tableFade) + sample2 * tableFade;
const hp = sample - filterLP - q * filterBP;
filterBP = filterBP + f * hp;
filterLP = filterLP + f * filterBP;
filterBP = Math.max(-2, Math.min(2, filterBP));
filterLP = Math.max(-2, Math.min(2, filterLP));
sample = filterLP * env;
left[i] = sample;
right[i] = sample;
phase += phaseIncrement;
if (phase >= 1) phase -= 1;
}
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 gain = 0.9 / peak;
for (let i = 0; i < numSamples; i++) {
left[i] *= gain;
right[i] *= gain;
}
}
return [left, right];
}
randomParams(pitchLock?: PitchLock): WavetableParams {
const freqs = [110, 146.8, 220, 293.7, 440];
return {
bankIndex: Math.random(),
position: 0.2 + Math.random() * 0.6,
baseFreq: pitchLock?.enabled
? pitchLock.frequency
: freqs[Math.floor(Math.random() * freqs.length)],
filterCutoff: 0.5 + Math.random() * 0.4,
filterResonance: Math.random() * 0.5,
attack: 0.001 + Math.random() * 0.05,
decay: 0.05 + Math.random() * 0.2,
sustain: 0.5 + Math.random() * 0.4,
release: 0.1 + Math.random() * 0.3,
};
}
mutateParams(params: WavetableParams, mutationAmount?: number, pitchLock?: PitchLock): WavetableParams {
const mutate = (v: number, amount: number = 0.1) => {
return Math.max(0, Math.min(1, v + (Math.random() - 0.5) * amount));
};
const baseFreq = pitchLock?.enabled ? pitchLock.frequency : params.baseFreq;
return {
bankIndex: Math.random() < 0.2 ? Math.random() : params.bankIndex,
position: mutate(params.position, 0.2),
baseFreq,
filterCutoff: mutate(params.filterCutoff, 0.2),
filterResonance: mutate(params.filterResonance, 0.15),
attack: mutate(params.attack, 0.1),
decay: mutate(params.decay, 0.15),
sustain: mutate(params.sustain, 0.15),
release: mutate(params.release, 0.15),
};
}
}