251 lines
7.6 KiB
TypeScript
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),
|
|
};
|
|
}
|
|
}
|