diff --git a/src/synthesis/TestModelSynthesisEngine.ts b/src/synthesis/TestModelSynthesisEngine.ts index daacceb..e5c319d 100644 --- a/src/synthesis/TestModelSynthesisEngine.ts +++ b/src/synthesis/TestModelSynthesisEngine.ts @@ -2,6 +2,23 @@ import type { RoleAssignment, Role, SynthLayer, NoteEvent, ChordEvent } from '.. export type SynthModel = 'pre8bit' | 'nes_gb' | 'snes'; +type SnesSampleDef = { + buffer: AudioBuffer; + baseMidi: number; + loop: boolean; + loopStart: number; + loopEnd: number; + pan: number; +}; + +type SnesSampleBank = { + lead: SnesSampleDef; + bass: SnesSampleDef; + pad: SnesSampleDef; + pluck: SnesSampleDef; + noise: SnesSampleDef; +}; + /** * TestModelSynthesisEngine * ----------------------- @@ -34,6 +51,9 @@ export class TestModelSynthesisEngine { private scheduleInterval = 25; private fadeTime = 0.05; + // SNES-only sample bank (generated in-memory; no downloads) + private snesBank: SnesSampleBank | null = null; + constructor(audioContext: AudioContext, model: SynthModel) { this.audioContext = audioContext; this.model = model; @@ -48,6 +68,10 @@ export class TestModelSynthesisEngine { // Voice count: SNES = 8 voices. Pre-8bit = ~2. NES/GB = “few”, but keep 8 for fun. this.maxVoices = model === 'pre8bit' ? 2 : model === 'snes' ? 8 : 8; + if (model === 'snes') { + this.snesBank = this.createSnesSampleBank(); + } + this.configureMasterChain(); } @@ -329,48 +353,74 @@ export class TestModelSynthesisEngine { if (this.activeVoiceCount >= this.maxVoices) return; - const osc = this.audioContext.createOscillator(); const envelope = this.audioContext.createGain(); - osc.frequency.value = this.midiToFrequency(pitch); if (this.model === 'pre8bit') { + const osc = this.audioContext.createOscillator(); + osc.frequency.value = this.midiToFrequency(pitch); osc.type = role === 'bass' ? 'triangle' : 'square'; + osc.connect(envelope); + osc.start(when); + osc.stop(when + duration + 0.5); } else if (this.model === 'snes') { - if (role === 'bass') osc.type = 'triangle'; - else if (role === 'melody') osc.type = 'sawtooth'; - else if (role === 'drone') osc.type = 'sawtooth'; - else if (role === 'ostinato') osc.type = 'triangle'; - else osc.type = 'sine'; - osc.detune.value = (Math.random() - 0.5) * 8; + const sample = this.getSnesSampleForRole(role); + if (!sample || !sample.buffer) return; + + const src = this.audioContext.createBufferSource(); + src.buffer = sample.buffer; + src.loop = sample.loop; + if (sample.loop) { + src.loopStart = sample.loopStart; + src.loopEnd = sample.loopEnd; + } + + const base = sample.baseMidi; + const rate = Math.pow(2, (pitch - base) / 12); + src.playbackRate.setValueAtTime(rate, when); + src.detune.value = (Math.random() - 0.5) * 6; // small “sample pitch” drift + + // Optional stereo panning (SNES output was stereo) + const pan = this.audioContext.createStereoPanner(); + pan.pan.value = sample.pan; + + src.connect(pan); + pan.connect(envelope); + + // Start and stop; for looped samples, stop after note + release tail + src.start(when); + src.stop(when + duration + 0.5); } else { + const osc = this.audioContext.createOscillator(); + osc.frequency.value = this.midiToFrequency(pitch); if (role === 'bass') osc.type = 'square'; else if (role === 'drone') osc.type = 'sawtooth'; else if (role === 'ostinato') osc.type = 'triangle'; else if (role === 'melody') osc.type = 'triangle'; else osc.type = 'sine'; + osc.connect(envelope); + osc.start(when); + osc.stop(when + duration + 0.5); } - - osc.connect(envelope); envelope.connect(layer.filterNode); const gainValue = velocity * 0.5; - const attackBase = this.model === 'pre8bit' ? 0.003 : this.model === 'snes' ? 0.01 : 0.005; - const releaseBase = this.model === 'pre8bit' ? 0.008 : this.model === 'snes' ? 0.14 : 0.01; + const attackBase = this.model === 'pre8bit' ? 0.003 : this.model === 'snes' ? 0.008 : 0.005; + const releaseBase = this.model === 'pre8bit' ? 0.008 : this.model === 'snes' ? 0.18 : 0.01; + const sustainLevel = this.model === 'snes' ? 0.65 : 1.0; const attackTime = Math.max(attackBase, Math.min(0.06, duration * 0.1)); - const releaseTime = Math.max(releaseBase, Math.min(this.model === 'snes' ? 0.22 : 0.12, duration * 0.3)); + const releaseTime = Math.max(releaseBase, Math.min(this.model === 'snes' ? 0.28 : 0.12, duration * 0.3)); + const decayTime = this.model === 'snes' ? Math.max(0.01, Math.min(0.12, duration * 0.15)) : 0.02; envelope.gain.setValueAtTime(0, when); envelope.gain.linearRampToValueAtTime(gainValue, when + attackTime); - envelope.gain.setValueAtTime(gainValue, when + Math.max(attackTime, duration - releaseTime)); + envelope.gain.linearRampToValueAtTime(gainValue * sustainLevel, when + attackTime + decayTime); + envelope.gain.setValueAtTime(gainValue * sustainLevel, when + Math.max(attackTime + decayTime, duration - releaseTime)); envelope.gain.exponentialRampToValueAtTime(0.001, when + duration + releaseTime); - osc.start(when); - osc.stop(when + duration + releaseTime + 0.01); this.activeVoiceCount++; setTimeout(() => { try { - osc.disconnect(); envelope.disconnect(); } catch {} this.activeVoiceCount = Math.max(0, this.activeVoiceCount - 1); @@ -386,18 +436,41 @@ export class TestModelSynthesisEngine { for (const pitch of chordPitches) { if (this.activeVoiceCount >= this.maxVoices) break; - const osc = this.audioContext.createOscillator(); const envelope = this.audioContext.createGain(); - osc.frequency.value = this.midiToFrequency(pitch); if (this.model === 'snes') { - osc.type = role === 'bass' ? 'triangle' : role === 'melody' ? 'sawtooth' : 'sine'; - osc.detune.value = (Math.random() - 0.5) * 8; + const sample = this.getSnesSampleForRole(role); + if (!sample || !sample.buffer) continue; + + const src = this.audioContext.createBufferSource(); + src.buffer = sample.buffer; + src.loop = sample.loop; + if (sample.loop) { + src.loopStart = sample.loopStart; + src.loopEnd = sample.loopEnd; + } + + const base = sample.baseMidi; + const rate = Math.pow(2, (pitch - base) / 12); + src.playbackRate.setValueAtTime(rate, when); + src.detune.value = (Math.random() - 0.5) * 6; + + const pan = this.audioContext.createStereoPanner(); + pan.pan.value = sample.pan; + src.connect(pan); + pan.connect(envelope); + + src.start(when); + src.stop(when + duration + 0.5); } else { + const osc = this.audioContext.createOscillator(); + osc.frequency.value = this.midiToFrequency(pitch); osc.type = role === 'bass' ? 'square' : role === 'drone' ? 'sawtooth' : 'triangle'; + osc.connect(envelope); + osc.start(when); + osc.stop(when + duration + 0.5); } - osc.connect(envelope); envelope.connect(layer.filterNode); const gainValue = (velocity * 0.3) / Math.max(chordPitches.length * 0.5, 1); @@ -405,19 +478,19 @@ export class TestModelSynthesisEngine { const releaseBase = this.model === 'snes' ? 0.16 : 0.01; const attackTime = Math.max(attackBase, Math.min(0.06, duration * 0.1)); const releaseTime = Math.max(releaseBase, Math.min(this.model === 'snes' ? 0.24 : 0.12, duration * 0.3)); + const sustainLevel = this.model === 'snes' ? 0.65 : 1.0; + const decayTime = this.model === 'snes' ? Math.max(0.01, Math.min(0.12, duration * 0.15)) : 0.02; envelope.gain.setValueAtTime(0, when); envelope.gain.linearRampToValueAtTime(gainValue, when + attackTime); - envelope.gain.setValueAtTime(gainValue, when + Math.max(attackTime, duration - releaseTime)); + envelope.gain.linearRampToValueAtTime(gainValue * sustainLevel, when + attackTime + decayTime); + envelope.gain.setValueAtTime(gainValue * sustainLevel, when + Math.max(attackTime + decayTime, duration - releaseTime)); envelope.gain.exponentialRampToValueAtTime(0.001, when + duration + releaseTime); - osc.start(when); - osc.stop(when + duration + releaseTime + 0.01); this.activeVoiceCount++; setTimeout(() => { try { - osc.disconnect(); envelope.disconnect(); } catch {} this.activeVoiceCount = Math.max(0, this.activeVoiceCount - 1); @@ -425,6 +498,115 @@ export class TestModelSynthesisEngine { } } + private getSnesSampleForRole(role: Role): SnesSampleDef | null { + if (!this.snesBank) return null; + switch (role) { + case 'bass': return this.snesBank.bass; + case 'melody': return this.snesBank.lead; + case 'drone': return this.snesBank.pad; + case 'ostinato': return this.snesBank.pluck; + case 'texture': return this.snesBank.pad; + case 'accents': return this.snesBank.noise; + default: return this.snesBank.lead; + } + } + + private createSnesSampleBank(): SnesSampleBank { + // Generate small “instrument” buffers that loop cleanly. + // This approximates SNES sample playback without shipping any assets. + const sr = this.audioContext.sampleRate; + + const lead = this.makeHarmonicSample({ seconds: 0.7, sr, baseMidi: 69, harmonics: [1, 0.55, 0.28, 0.14], attack: 0.008, decay: 0.12, sustain: 0.55, release: 0.22, loopStart: 0.10, loopEnd: 0.62 }); + const bass = this.makeHarmonicSample({ seconds: 0.8, sr, baseMidi: 45, harmonics: [1, 0.35, 0.12], attack: 0.010, decay: 0.14, sustain: 0.60, release: 0.28, loopStart: 0.12, loopEnd: 0.70 }); + const pad = this.makeHarmonicSample({ seconds: 1.2, sr, baseMidi: 60, harmonics: [1, 0.75, 0.45, 0.22, 0.12], attack: 0.04, decay: 0.30, sustain: 0.75, release: 0.45, loopStart: 0.20, loopEnd: 1.05 }); + const pluck = this.makeHarmonicSample({ seconds: 0.6, sr, baseMidi: 72, harmonics: [1, 0.4, 0.2], attack: 0.003, decay: 0.18, sustain: 0.0, release: 0.12, loopStart: 0, loopEnd: 0 }); + const noise = this.makeNoiseHit({ seconds: 0.22, sr, baseMidi: 60 }); + + return { + lead: { ...lead, pan: 0.15 }, + bass: { ...bass, pan: -0.08 }, + pad: { ...pad, pan: -0.18 }, + pluck:{ ...pluck,pan: 0.22 }, + noise:{ ...noise,pan: 0.0 }, + }; + } + + private makeHarmonicSample(args: { + seconds: number; + sr: number; + baseMidi: number; + harmonics: number[]; + attack: number; + decay: number; + sustain: number; + release: number; + loopStart: number; + loopEnd: number; + }): SnesSampleDef { + const length = Math.max(1, Math.floor(args.seconds * args.sr)); + const buf = this.audioContext.createBuffer(1, length, args.sr); + const data = buf.getChannelData(0); + + // Base frequency for buffer generation + const f0 = this.midiToFrequency(args.baseMidi); + const twoPi = Math.PI * 2; + + for (let i = 0; i < length; i++) { + const t = i / args.sr; + let s = 0; + for (let h = 0; h < args.harmonics.length; h++) { + const amp = args.harmonics[h]; + const n = h + 1; + s += amp * Math.sin(twoPi * f0 * n * t); + } + + // Simple “sample-like” smoothing + slight nonlinearity + s = Math.tanh(s * 1.2); + + // Apply baked-in amp envelope so the raw sample already feels “instrumental” + const env = this.adsrAt(t, args.attack, args.decay, args.sustain, args.release, args.seconds); + data[i] = s * env * 0.9; + } + + // Loop region + const loop = args.loopEnd > args.loopStart && args.loopEnd > 0; + return { + buffer: buf, + baseMidi: args.baseMidi, + loop, + loopStart: loop ? args.loopStart : 0, + loopEnd: loop ? args.loopEnd : 0, + pan: 0, + }; + } + + private makeNoiseHit(args: { seconds: number; sr: number; baseMidi: number }): SnesSampleDef { + const length = Math.max(1, Math.floor(args.seconds * args.sr)); + const buf = this.audioContext.createBuffer(1, length, args.sr); + const data = buf.getChannelData(0); + for (let i = 0; i < length; i++) { + const t = i / args.sr; + // White noise with fast decay + const n = (Math.random() * 2 - 1); + const env = Math.exp(-t * 18); + data[i] = n * env * 0.8; + } + return { buffer: buf, baseMidi: args.baseMidi, loop: false, loopStart: 0, loopEnd: 0, pan: 0 }; + } + + private adsrAt(t: number, a: number, d: number, s: number, r: number, total: number): number { + const endSustain = Math.max(0, total - r); + if (t < 0) return 0; + if (t < a) return a > 0 ? (t / a) : 1; + if (t < a + d) { + const u = (t - a) / Math.max(d, 1e-6); + return 1 + (s - 1) * u; + } + if (t < endSustain) return s; + const u = (t - endSustain) / Math.max(r, 1e-6); + return s * Math.max(0, 1 - u); + } + private makeSoftClipCurve(amount: number): Float32Array { // amount: 0..1 (higher = more distortion) const k = 1 + amount * 30;