diff --git a/models.html b/models.html
new file mode 100644
index 0000000..a2ea993
--- /dev/null
+++ b/models.html
@@ -0,0 +1,503 @@
+
+
+
+
+
+ MOTIF — Models (lab)
+
+
+
+
+
+
MOTIF — Models
+
Home
+
+
Lab page. Choose a synthesis model, then click Generate. This page does not modify the main engine.
+
+
+
+
+
+
+
Ready.
+
+
+
+
+ Search results
+ Pick one MIDI, then compare models.
+
+
+
+
+ | Title | Source | Duration |
+
+
+
+
+
+
+
+
+
No MIDI selected
+
Select a result to enable playback.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Motif (model test)
+ Choose a model, then Generate.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ MOTIF — Test models
+
+
+
+
+
+
Test models: Pre‑8bit → NES/GB → SNES‑ish
+
+
+
+
+
+
+
Ready. Enter a song name to search for MIDI files.
+
+
+
+
+
+
+
+ | Title |
+ Source |
+ Duration |
+
+
+
+
+
+
+
+
+
+
No MIDI selected
+
Select a MIDI file from search results to enable playback
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Chip: square/triangle/saw flavor, tight envelope, crisp attacks.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ iOS Safari: tap once to enable audio, then press Generate & Play.
+
+
+
+
Audio: …
+
+
+
+
+
+
+
+
+
diff --git a/src/models.ts b/src/models.ts
new file mode 100644
index 0000000..9d06ac3
--- /dev/null
+++ b/src/models.ts
@@ -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'
+ ? 'Pre‑8bit: 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 {
+ const ctx = await unlockAudio();
+ if (!this.soundfontPlayer) this.soundfontPlayer = new SoundfontMIDIPlayer(ctx);
+ return this.soundfontPlayer;
+ }
+
+ private async handleSearch(): Promise {
+ 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 = `${r.title} | ${r.source} | ${r.parsed ? Math.round(r.parsed.durationSec) + 's' : '?'} | `;
+ tr.addEventListener('click', () => void this.selectResult(idx));
+ this.resultsBody.appendChild(tr);
+ });
+ void this.selectResult(0);
+ }
+
+ private async selectResult(index: number): Promise {
+ 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 =
+ `Source: ${result.source} | Duration: ${Math.round(duration)}s | ` +
+ `Tracks: ${metadata.trackCount} | Notes: ${events.length} | Tempo: ${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 {
+ 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 {
+ 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();
+
diff --git a/src/synthesis/TestModelSynthesisEngine.ts b/src/synthesis/TestModelSynthesisEngine.ts
new file mode 100644
index 0000000..d4654ed
--- /dev/null
+++ b/src/synthesis/TestModelSynthesisEngine.ts
@@ -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 = new Map();
+ private roleAssignments: Map = new Map();
+
+ private isPlaying = false;
+ private schedulerIntervalId: number | null = null;
+ private startTime = 0;
+ private nextEventIndex = new Map();
+
+ 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();
+ }
+}
+
diff --git a/vercel.json b/vercel.json
index cffc83d..c402c1f 100644
--- a/vercel.json
+++ b/vercel.json
@@ -26,6 +26,10 @@
"src": "/embed",
"dest": "/embed.html"
},
+ {
+ "src": "/models",
+ "dest": "/models.html"
+ },
{ "handle": "filesystem" },
{
"src": "/(.*)",
diff --git a/vite.config.ts b/vite.config.ts
index 1b65634..2658b45 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -12,6 +12,7 @@ export default defineConfig({
input: {
main: resolve(__dirname, 'index.html'),
embed: resolve(__dirname, 'embed.html'),
+ models: resolve(__dirname, 'models.html'),
},
},
},