Add isolated /models lab page for synth comparisons.

This commit is contained in:
b1rdmania
2025-12-20 12:46:23 +00:00
parent b3426f5ccd
commit 25a34b3519
5 changed files with 1250 additions and 0 deletions
+282
View File
@@ -0,0 +1,282 @@
import { MIDIService } from './services/MIDIService';
import { MIDIParser } from './midi/MIDIParser';
import { SoundfontMIDIPlayer } from './synthesis/SoundfontMIDIPlayer';
import { getAudioContext, unlockAudio } from './utils/audioUnlock';
import { MIDIProcessor } from './midi/MIDIProcessor';
import { RoleMapper } from './core/RoleMapper';
import { TestModelSynthesisEngine, type SynthModel } from './synthesis/TestModelSynthesisEngine';
import type { NoteEvent } from './types';
class ModelsApp {
private midiService = new MIDIService();
private midiProcessor = new MIDIProcessor();
private roleMapper = new RoleMapper();
private soundfontPlayer: SoundfontMIDIPlayer | null = null;
private testEngine: TestModelSynthesisEngine | null = null;
private songInput!: HTMLInputElement;
private searchBtn!: HTMLButtonElement;
private status!: HTMLElement;
private resultsCard!: HTMLElement;
private resultsBody!: HTMLElement;
private playerCard!: HTMLElement;
private selectedTitle!: HTMLElement;
private selectedMeta!: HTMLElement;
private previewPlayBtn!: HTMLButtonElement;
private previewStopBtn!: HTMLButtonElement;
private previewVol!: HTMLInputElement;
private nextResultBtn!: HTMLButtonElement;
private motifPlayBtn!: HTMLButtonElement;
private motifStopBtn!: HTMLButtonElement;
private motifVol!: HTMLInputElement;
private modelHint!: HTMLElement;
private modelButtons: HTMLButtonElement[] = [];
private currentModel: SynthModel = 'nes_gb';
private searchResults: any[] = [];
private selectedResultIndex = 0;
private currentMIDI: { events: NoteEvent[]; metadata: any } | null = null;
constructor() {
this.bindUI();
this.bindEvents();
this.setModel('nes_gb');
this.updateStatus('Ready.');
}
private bindUI(): void {
this.songInput = document.getElementById('songInput') as HTMLInputElement;
this.searchBtn = document.getElementById('searchBtn') as HTMLButtonElement;
this.status = document.getElementById('status')!;
this.resultsCard = document.getElementById('resultsCard')!;
this.resultsBody = document.getElementById('resultsBody')!;
this.playerCard = document.getElementById('playerCard')!;
this.selectedTitle = document.getElementById('selectedTitle')!;
this.selectedMeta = document.getElementById('selectedMeta')!;
this.previewPlayBtn = document.getElementById('previewPlayBtn') as HTMLButtonElement;
this.previewStopBtn = document.getElementById('previewStopBtn') as HTMLButtonElement;
this.previewVol = document.getElementById('previewVol') as HTMLInputElement;
this.nextResultBtn = document.getElementById('nextResultBtn') as HTMLButtonElement;
this.motifPlayBtn = document.getElementById('motifPlayBtn') as HTMLButtonElement;
this.motifStopBtn = document.getElementById('motifStopBtn') as HTMLButtonElement;
this.motifVol = document.getElementById('motifVol') as HTMLInputElement;
this.modelHint = document.getElementById('modelHint')!;
this.modelButtons = [
document.getElementById('modelPre8') as HTMLButtonElement,
document.getElementById('modelNesGb') as HTMLButtonElement,
document.getElementById('modelSnes') as HTMLButtonElement,
];
}
private bindEvents(): void {
this.searchBtn.addEventListener('click', () => void this.handleSearch());
this.songInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') void this.handleSearch();
});
this.previewPlayBtn.addEventListener('click', () => void this.handlePreviewPlay());
this.previewStopBtn.addEventListener('click', () => this.handlePreviewStop());
this.previewVol.addEventListener('input', () => this.soundfontPlayer?.setVolume(parseFloat(this.previewVol.value)));
this.nextResultBtn.addEventListener('click', () => void this.selectResult((this.selectedResultIndex + 1) % this.searchResults.length));
for (const btn of this.modelButtons) {
btn.addEventListener('click', () => this.setModel((btn.dataset.model as SynthModel) || 'nes_gb'));
}
this.motifPlayBtn.addEventListener('click', () => void this.handleMotifGeneratePlay());
this.motifStopBtn.addEventListener('click', () => this.handleMotifStop());
this.motifVol.addEventListener('input', () => this.testEngine?.setVolume(parseFloat(this.motifVol.value)));
}
private updateStatus(message: string): void {
this.status.textContent = message;
}
private setModel(model: SynthModel): void {
this.currentModel = model;
for (const btn of this.modelButtons) {
btn.setAttribute('aria-pressed', btn.dataset.model === model ? 'true' : 'false');
}
const hint =
model === 'pre8bit'
? 'Pre8bit: very limited voices, hard gates, square/triangle + noise feel.'
: model === 'snes'
? 'SNES: sample-voice feel, 8 voices, ADSR, echo/reverb + downsample vibe.'
: 'NES/GB: classic chip oscillators (square/triangle/saw), tighter envelopes.';
this.modelHint.textContent = hint;
}
private async ensureAudioReady(): Promise<SoundfontMIDIPlayer> {
const ctx = await unlockAudio();
if (!this.soundfontPlayer) this.soundfontPlayer = new SoundfontMIDIPlayer(ctx);
return this.soundfontPlayer;
}
private async handleSearch(): Promise<void> {
const q = this.songInput.value.trim();
if (!q) return;
this.handleMotifStop();
this.handlePreviewStop();
this.updateStatus('Searching…');
this.searchBtn.disabled = true;
this.resultsCard.style.display = 'none';
this.playerCard.style.display = 'none';
try {
const results = await this.midiService.search(q);
if (!results.length) {
this.updateStatus('No MIDI found. Try another query.');
return;
}
for (let i = 0; i < Math.min(3, results.length); i++) {
const meta = await this.midiService.parseMIDI(results[i].midiUrl);
if (meta) results[i].parsed = meta;
}
this.searchResults = results;
this.selectedResultIndex = 0;
this.renderResults();
this.resultsCard.style.display = 'block';
this.updateStatus(`Found ${results.length}. Select one to compare models.`);
} catch (e) {
this.updateStatus(`Search error: ${e instanceof Error ? e.message : 'Unknown error'}`);
} finally {
this.searchBtn.disabled = false;
}
}
private renderResults(): void {
this.resultsBody.innerHTML = '';
this.searchResults.forEach((r, idx) => {
const tr = document.createElement('tr');
if (idx === this.selectedResultIndex) tr.classList.add('selected');
tr.innerHTML = `<td>${r.title}</td><td>${r.source}</td><td>${r.parsed ? Math.round(r.parsed.durationSec) + 's' : '?'}</td>`;
tr.addEventListener('click', () => void this.selectResult(idx));
this.resultsBody.appendChild(tr);
});
void this.selectResult(0);
}
private async selectResult(index: number): Promise<void> {
if (index < 0 || index >= this.searchResults.length) return;
this.handleMotifStop();
this.handlePreviewStop();
this.selectedResultIndex = index;
const rows = this.resultsBody.querySelectorAll('tr');
rows.forEach((row, i) => row.classList.toggle('selected', i === index));
const result = this.searchResults[index];
this.updateStatus('Loading MIDI…');
this.disablePlayback();
try {
const buf = await this.midiService.fetchMIDI(result.midiUrl);
if (!buf) throw new Error('Failed to fetch MIDI');
const events = MIDIParser.parseMIDI(buf);
const metadata = result.parsed || MIDIParser.getMIDIInfo(buf);
const duration = metadata.duration || metadata.durationSec || (events.length ? Math.max(...events.map(e => e.time + e.duration)) : 0);
this.currentMIDI = { events, metadata: { ...metadata, duration } };
const player = await this.ensureAudioReady();
await player.load(events);
player.setVolume(parseFloat(this.previewVol.value));
this.selectedTitle.textContent = result.title;
this.selectedMeta.innerHTML =
`<strong>Source:</strong> ${result.source} | <strong>Duration:</strong> ${Math.round(duration)}s | ` +
`<strong>Tracks:</strong> ${metadata.trackCount} | <strong>Notes:</strong> ${events.length} | <strong>Tempo:</strong> ${metadata.tempo}bpm`;
this.playerCard.style.display = 'block';
this.enablePlayback();
this.updateStatus('Ready. Preview or generate with a chosen model.');
} catch (e) {
this.updateStatus(`Load error: ${e instanceof Error ? e.message : 'Unknown error'}`);
}
}
private disablePlayback(): void {
this.previewPlayBtn.disabled = true;
this.previewStopBtn.disabled = true;
this.nextResultBtn.disabled = true;
this.motifPlayBtn.disabled = true;
this.motifStopBtn.disabled = true;
}
private enablePlayback(): void {
this.previewPlayBtn.disabled = false;
this.nextResultBtn.disabled = this.searchResults.length <= 1;
this.motifPlayBtn.disabled = false;
}
private async handlePreviewPlay(): Promise<void> {
if (!this.currentMIDI) return;
try {
const player = await this.ensureAudioReady();
await player.play();
this.previewPlayBtn.disabled = true;
this.previewStopBtn.disabled = false;
this.updateStatus('Preview playing…');
} catch (e) {
this.updateStatus(`Preview error: ${e instanceof Error ? e.message : 'Unknown error'}`);
}
}
private handlePreviewStop(): void {
this.soundfontPlayer?.stop();
this.previewPlayBtn.disabled = false;
this.previewStopBtn.disabled = true;
}
private async handleMotifGeneratePlay(): Promise<void> {
if (!this.currentMIDI) return;
try {
await unlockAudio();
this.handleMotifStop(); // isolate each run
const ctx = getAudioContext();
const features = this.midiProcessor.extractFeatures(this.currentMIDI.events);
const assignments = this.roleMapper.assignRoles(features, this.currentMIDI.events);
this.testEngine = new TestModelSynthesisEngine(ctx, this.currentModel);
this.testEngine.setupLayers(assignments);
this.testEngine.setVolume(parseFloat(this.motifVol.value));
this.testEngine.start();
this.motifPlayBtn.disabled = true;
this.motifStopBtn.disabled = false;
this.updateStatus(`Motif playing (${this.currentModel})…`);
} catch (e) {
this.updateStatus(`Motif error: ${e instanceof Error ? e.message : 'Unknown error'}`);
this.motifPlayBtn.disabled = false;
}
}
private handleMotifStop(): void {
this.testEngine?.stop();
this.testEngine = null;
this.motifPlayBtn.disabled = false;
this.motifStopBtn.disabled = true;
}
}
new ModelsApp();
+460
View File
@@ -0,0 +1,460 @@
import type { RoleAssignment, Role, SynthLayer, NoteEvent, ChordEvent } from '../types';
export type SynthModel = 'pre8bit' | 'nes_gb' | 'snes';
/**
* TestModelSynthesisEngine
* -----------------------
* Used ONLY by /models to compare different synthesis models.
*
* IMPORTANT:
* - This is intentionally isolated from the main `SynthesisEngine`.
* - Do not import this into the main app.
*/
export class TestModelSynthesisEngine {
private audioContext: AudioContext;
private model: SynthModel;
private masterGain: GainNode;
private postGain: GainNode;
private layers: Map<Role, SynthLayer> = new Map();
private roleAssignments: Map<Role, RoleAssignment> = new Map();
private isPlaying = false;
private schedulerIntervalId: number | null = null;
private startTime = 0;
private nextEventIndex = new Map<Role, number>();
private activeVoiceCount = 0;
private maxVoices: number;
// Simplified scheduling (fine for the lab page)
private lookaheadTime = 0.12;
private scheduleInterval = 25;
private fadeTime = 0.05;
// SNES-ish “APU constraints”
private snesSampleRate = 32000;
private snesBitDepth = 12;
constructor(audioContext: AudioContext, model: SynthModel) {
this.audioContext = audioContext;
this.model = model;
this.masterGain = audioContext.createGain();
this.postGain = audioContext.createGain();
this.postGain.connect(audioContext.destination);
this.masterGain.gain.value = 0.3;
this.postGain.gain.value = 1.0;
// 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;
this.configureMasterChain();
}
setVolume(volume: number): void {
this.masterGain.gain.value = Math.max(0, Math.min(1, volume));
}
setupLayers(assignments: RoleAssignment[]): void {
this.cleanupLayers();
this.activeVoiceCount = 0;
const filtered = this.filterAssignments(assignments);
let earliestTime = Infinity;
for (const assignment of filtered) {
if (assignment.events.length > 0) earliestTime = Math.min(earliestTime, assignment.events[0].time);
if (assignment.chords.length > 0) earliestTime = Math.min(earliestTime, assignment.chords[0].time);
}
if (earliestTime !== Infinity && earliestTime > 0) {
for (const assignment of filtered) {
for (const e of assignment.events) e.time -= earliestTime;
for (const c of assignment.chords) c.time -= earliestTime;
}
}
for (const assignment of filtered) {
const layer = this.createSynthLayer(assignment.role);
this.layers.set(assignment.role, layer);
this.roleAssignments.set(assignment.role, assignment);
this.nextEventIndex.set(assignment.role, 0);
}
}
start(): void {
if (this.isPlaying) return;
this.isPlaying = true;
this.startTime = this.audioContext.currentTime;
for (const role of this.roleAssignments.keys()) {
this.nextEventIndex.set(role, 0);
}
this.schedulerIntervalId = window.setInterval(() => this.scheduleEvents(), this.scheduleInterval);
}
stop(): void {
if (!this.isPlaying) return;
this.isPlaying = false;
if (this.schedulerIntervalId) {
clearInterval(this.schedulerIntervalId);
this.schedulerIntervalId = null;
}
this.fadeOutAllLayers();
}
private configureMasterChain(): void {
try { this.masterGain.disconnect(); } catch {}
// dry always
const dry = this.audioContext.createGain();
dry.gain.value = 1.0;
this.masterGain.connect(dry);
dry.connect(this.postGain);
if (this.model !== 'snes') return;
// SNES-ish echo (APU has echo buffer + 8-tap FIR). Approx with feedback delay + lowpass.
const wet = this.audioContext.createGain();
const delay = this.audioContext.createDelay(1.0);
const feedback = this.audioContext.createGain();
const fbFilter = this.audioContext.createBiquadFilter();
wet.gain.value = 0.28;
delay.delayTime.value = 0.165;
feedback.gain.value = 0.32;
fbFilter.type = 'lowpass';
fbFilter.frequency.value = 1800;
fbFilter.Q.value = 0.7;
this.masterGain.connect(delay);
delay.connect(wet);
wet.connect(this.postGain);
delay.connect(fbFilter);
fbFilter.connect(feedback);
feedback.connect(delay);
}
private filterAssignments(assignments: RoleAssignment[]): RoleAssignment[] {
if (this.model !== 'pre8bit') return assignments;
const keep: Role[] = ['melody', 'bass'];
const kept = assignments.filter(a => keep.includes(a.role));
if (kept.length > 0) return kept.slice(0, 2);
return assignments.slice(0, 1);
}
private createSynthLayer(role: Role): SynthLayer {
const gainNode = this.audioContext.createGain();
const filterNode = this.audioContext.createBiquadFilter();
if (this.model === 'snes') {
filterNode.type = 'lowpass';
filterNode.frequency.value = 2600;
filterNode.Q.value = 0.9;
} else {
filterNode.type = 'lowpass';
filterNode.frequency.value = 5200;
filterNode.Q.value = 0.8;
}
filterNode.connect(gainNode);
gainNode.connect(this.masterGain);
this.configureLayerForRole(gainNode, filterNode, role);
return { role, oscillators: [], gainNode, filterNode };
}
private configureLayerForRole(gain: GainNode, filter: BiquadFilterNode, role: Role): void {
switch (role) {
case 'bass':
gain.gain.value = this.model === 'pre8bit' ? 0.55 : 0.4;
filter.type = 'lowpass';
filter.frequency.value = this.model === 'snes' ? 260 : 220;
break;
case 'drone':
gain.gain.value = this.model === 'pre8bit' ? 0.0 : 0.2;
filter.type = 'bandpass';
filter.frequency.value = this.model === 'snes' ? 520 : 400;
break;
case 'ostinato':
gain.gain.value = this.model === 'pre8bit' ? 0.25 : 0.3;
filter.type = 'highpass';
filter.frequency.value = this.model === 'snes' ? 220 : 300;
break;
case 'texture':
gain.gain.value = this.model === 'pre8bit' ? 0.0 : 0.1;
filter.type = 'bandpass';
filter.frequency.value = this.model === 'snes' ? 900 : 800;
break;
case 'accents':
gain.gain.value = this.model === 'pre8bit' ? 0.35 : 0.5;
filter.type = 'peaking';
filter.frequency.value = this.model === 'snes' ? 1200 : 1000;
break;
case 'melody':
gain.gain.value = this.model === 'pre8bit' ? 0.42 : 0.35;
filter.type = 'lowpass';
filter.frequency.value = this.model === 'snes' ? 3200 : 4200;
break;
}
}
private midiToFrequency(midiNote: number): number {
return 440 * Math.pow(2, (midiNote - 69) / 12);
}
private scheduleEvents(): void {
if (!this.isPlaying) return;
const currentTime = this.audioContext.currentTime;
const scheduleUntil = currentTime + this.lookaheadTime;
for (const [role, assignment] of this.roleAssignments) {
this.scheduleRoleEvents(role, assignment, scheduleUntil);
}
}
private scheduleRoleEvents(role: Role, assignment: RoleAssignment, scheduleUntil: number): void {
const events = assignment.events;
const chords = assignment.chords;
if (!events.length && !chords.length) return;
if (this.model === 'pre8bit') {
this.scheduleSingleEvents(role, events, scheduleUntil);
return;
}
if ((role === 'drone' || role === 'texture') && chords.length > 0) {
this.scheduleChordEvents(role, chords, scheduleUntil);
} else {
this.scheduleSingleEvents(role, events, scheduleUntil);
}
}
private scheduleChordEvents(role: Role, chords: ChordEvent[], scheduleUntil: number): void {
let chordIndex = this.nextEventIndex.get(role) || 0;
while (chordIndex < chords.length) {
const chord = chords[chordIndex];
const eventTime = this.startTime + chord.time;
if (eventTime > scheduleUntil) break;
if (eventTime >= this.audioContext.currentTime) {
this.scheduleChord(role, chord.pitches, Math.max(0.05, chord.duration), chord.velocity, eventTime);
}
chordIndex++;
}
this.nextEventIndex.set(role, chordIndex);
if (chordIndex >= chords.length) this.loopIfEnded(chords.length);
}
private scheduleSingleEvents(role: Role, events: NoteEvent[], scheduleUntil: number): void {
let eventIndex = this.nextEventIndex.get(role) || 0;
while (eventIndex < events.length) {
const event = events[eventIndex];
const eventTime = this.startTime + event.time;
if (eventTime > scheduleUntil) break;
if (eventTime >= this.audioContext.currentTime) {
this.scheduleNote(role, event.pitch, Math.max(0.05, event.duration), event.velocity, eventTime);
}
eventIndex++;
}
this.nextEventIndex.set(role, eventIndex);
if (eventIndex >= events.length) this.loopIfEnded(events.length);
}
private loopIfEnded(length: number): void {
if (length <= 0) return;
if (Array.from(this.nextEventIndex.values()).every(idx => idx === 0 || idx >= length)) {
for (const role of this.nextEventIndex.keys()) this.nextEventIndex.set(role, 0);
this.startTime = this.audioContext.currentTime;
}
}
private scheduleNote(role: Role, pitch: number, duration: number, velocity: number, when: number): void {
const layer = this.layers.get(role);
if (!layer) return;
if (this.model === 'pre8bit') {
if (role !== 'bass' && role !== 'melody') return;
}
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') {
osc.type = role === 'bass' ? 'triangle' : 'square';
} 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;
} else {
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);
// Per-voice crunch for SNES-ish
const out: AudioNode = this.model === 'snes' ? this.makeSnesCrunchNode(envelope) : envelope;
out.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 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));
envelope.gain.setValueAtTime(0, when);
envelope.gain.linearRampToValueAtTime(gainValue, when + attackTime);
envelope.gain.setValueAtTime(gainValue, when + Math.max(attackTime, 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();
if (out !== envelope) out.disconnect();
} catch {}
this.activeVoiceCount = Math.max(0, this.activeVoiceCount - 1);
}, (duration + releaseTime + 0.1) * 1000);
}
private scheduleChord(role: Role, pitches: number[], duration: number, velocity: number, when: number): void {
const layer = this.layers.get(role);
if (!layer) return;
if (this.activeVoiceCount >= this.maxVoices) return;
const chordPitches = this.model === 'snes' ? pitches.slice(0, 4) : pitches.slice(0, 3);
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;
} else {
osc.type = role === 'bass' ? 'square' : role === 'drone' ? 'sawtooth' : 'triangle';
}
osc.connect(envelope);
const out: AudioNode = this.model === 'snes' ? this.makeSnesCrunchNode(envelope) : envelope;
out.connect(layer.filterNode);
const gainValue = (velocity * 0.3) / Math.max(chordPitches.length * 0.5, 1);
const attackBase = this.model === 'snes' ? 0.01 : 0.005;
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));
envelope.gain.setValueAtTime(0, when);
envelope.gain.linearRampToValueAtTime(gainValue, when + attackTime);
envelope.gain.setValueAtTime(gainValue, when + Math.max(attackTime, 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();
if (out !== envelope) out.disconnect();
} catch {}
this.activeVoiceCount = Math.max(0, this.activeVoiceCount - 1);
}, (duration + releaseTime + 0.1) * 1000);
}
}
/**
* SNES “crunch” approximation:
* - downsample to ~32kHz
* - quantize to ~12-bit
*
* Implemented via ScriptProcessorNode for broad compatibility (lab-only).
*/
private makeSnesCrunchNode(input: AudioNode): AudioNode {
const sp = this.audioContext.createScriptProcessor(1024, 1, 1);
const gain = this.audioContext.createGain();
gain.gain.value = 1.0;
const targetRate = this.snesSampleRate;
const ratio = this.audioContext.sampleRate / targetRate;
const step = Math.max(1, Math.round(ratio));
const levels = Math.pow(2, this.snesBitDepth);
let hold = 0;
let last = 0;
sp.onaudioprocess = (e) => {
const inp = e.inputBuffer.getChannelData(0);
const out = e.outputBuffer.getChannelData(0);
for (let i = 0; i < inp.length; i++) {
if (hold-- <= 0) {
hold = step;
const q = Math.max(-1, Math.min(1, inp[i]));
last = Math.round(q * (levels / 2)) / (levels / 2);
}
out[i] = last;
}
};
input.connect(gain);
gain.connect(sp);
return sp;
}
private fadeOutAllLayers(): void {
const when = this.audioContext.currentTime;
for (const layer of this.layers.values()) {
layer.gainNode.gain.linearRampToValueAtTime(0, when + this.fadeTime);
}
setTimeout(() => this.cleanupLayers(), this.fadeTime * 1000 + 100);
}
private cleanupLayers(): void {
for (const layer of this.layers.values()) {
for (const osc of layer.oscillators) {
try {
osc.stop();
osc.disconnect();
} catch {}
}
try { layer.gainNode.disconnect(); } catch {}
try { layer.filterNode.disconnect(); } catch {}
}
this.layers.clear();
this.roleAssignments.clear();
this.nextEventIndex.clear();
}
}