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 { 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 { 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), }; } }