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.
+
+ + + + +
+ + + + + + + + + + + MOTIF — Test models + + + +
+
+

🎵 MOTIF

+
+ Home +
+
+

Test models: Pre‑8bit → NES/GB → SNES‑ish

+ +
+ + +
+ +
Ready. Enter a song name to search for MIDI files.
+ +
+
+

Search Results

+

Pick a MIDI, then compare models.

+
+
+ + + + + + + + + +
TitleSourceDuration
+
+ +
+
+
+

No MIDI selected

+

Select a MIDI file from search results to enable playback

+
+
+ +
+
+

Preview

+

Soundfont MIDI preview (baseline).

+
+
+
+ + +
+ + +
+ +
+
+ +
+
+

Motif generation

+

Same MIDI → different synth models.

+
+
+ +
+ +
+ + + +
+ 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'), }, }, },