Use sample playback for SNES test model.
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user