diff --git a/index.html b/index.html index 99ae1e3..96cde02 100644 --- a/index.html +++ b/index.html @@ -84,8 +84,13 @@ .topbar h1 { margin: 0; } - .faq-link { + .topbar-actions { margin-left: auto; + display: inline-flex; + align-items: center; + gap: 8px; + } + .faq-link { background: transparent; border: 1px solid rgba(255,255,255,0.14); color: rgba(255,255,255,0.85); @@ -820,7 +825,10 @@

🎵 MOTIF

- +
+ Models + +

Procedural Music Synthesis from MIDI Structure

diff --git a/models.html b/models.html new file mode 100644 index 0000000..9b9c807 --- /dev/null +++ b/models.html @@ -0,0 +1,258 @@ + + + + + + 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.

+
+
+ +
+ + + Square/triangle/noise vibe, tight envelope. +
+ +
+ +
+ + +
+ + +
+
+ +
+
+
+ iOS Safari: tap once to enable audio, then press Generate & Play. +
+ +
+
Audio: …
+
+
+
+
+ + + + + diff --git a/src/core/MotifEngine.ts b/src/core/MotifEngine.ts index 357f1e2..63366f1 100644 --- a/src/core/MotifEngine.ts +++ b/src/core/MotifEngine.ts @@ -1,4 +1,4 @@ -import type { NoteEvent, MotifConfig } from '../types'; +import type { NoteEvent, MotifConfig, SynthModel } from '../types'; import { MIDIProcessor } from '../midi/MIDIProcessor'; import { MIDIParser } from '../midi/MIDIParser'; import { MIDIService } from '../services/MIDIService'; @@ -27,13 +27,17 @@ export class MotifEngine { this.roleMapper = new RoleMapper(); } - async generateFromMIDI(events: NoteEvent[], transformMode: 'passthrough' | 'procedural' = 'passthrough'): Promise { + async generateFromMIDI( + events: NoteEvent[], + transformMode: 'passthrough' | 'procedural' = 'passthrough', + model: SynthModel = 'nes_gb' + ): Promise { // Initialize audio context using shared unlock (iOS compatibility) if (!this.audioContext) { this.audioContext = await unlockAudio(); } - this.synthesisEngine = new SynthesisEngine(this.audioContext, this.config); + this.synthesisEngine = new SynthesisEngine(this.audioContext, this.config, model); if (transformMode === 'passthrough') { // Direct playback mode - play MIDI as-is without transformations @@ -107,7 +111,7 @@ export class MotifEngine { this.audioContext = await unlockAudio(); } - this.synthesisEngine = new SynthesisEngine(this.audioContext, this.config); + this.synthesisEngine = new SynthesisEngine(this.audioContext, this.config, 'nes_gb'); this.synthesisEngine.setupLayers(roleAssignments); } diff --git a/src/models.ts b/src/models.ts new file mode 100644 index 0000000..a624680 --- /dev/null +++ b/src/models.ts @@ -0,0 +1,378 @@ +import { MotifEngine } from './core/MotifEngine'; +import { MIDIService } from './services/MIDIService'; +import { MIDIParser } from './midi/MIDIParser'; +import { SoundfontMIDIPlayer } from './synthesis/SoundfontMIDIPlayer'; +import { getAudioContext, isAudioReady, unlockAudio } from './utils/audioUnlock'; +import type { NoteEvent, SynthModel } from './types'; + +class ModelsApp { + private motifEngine: MotifEngine; + private midiService: MIDIService; + + private soundfontPlayer: SoundfontMIDIPlayer | null = null; + + private searchBtn!: HTMLButtonElement; + private songInput!: HTMLInputElement; + private status!: HTMLElement; + + private resultsSection!: HTMLElement; + private resultsBody!: HTMLElement; + private playerSection!: HTMLElement; + + private selectedTitle!: HTMLElement; + private selectedMeta!: HTMLElement; + + private soundfontPlayBtn!: HTMLButtonElement; + private soundfontStopBtn!: HTMLButtonElement; + private soundfontVolumeSlider!: HTMLInputElement; + + private motifBtn!: HTMLButtonElement; + private motifStopBtn!: HTMLButtonElement; + private motifVolumeSlider!: HTMLInputElement; + + private modelSelect!: HTMLSelectElement; + private modelHint!: HTMLElement; + + private iosAudioBanner!: HTMLElement; + private enableAudioBtn!: HTMLButtonElement; + private iosAudioState!: HTMLElement; + + private nextResultBtn!: HTMLButtonElement; + + private searchResults: any[] = []; + private selectedResultIndex = 0; + private currentMIDI: { events: NoteEvent[]; metadata: any } | null = null; + + constructor() { + this.motifEngine = new MotifEngine(); + this.midiService = new MIDIService(); + this.initializeUI(); + this.setupEventListeners(); + this.syncModelHint(); + } + + private async ensureAudioReady(): Promise { + const audioContext = await unlockAudio(); + if (!this.soundfontPlayer) { + this.soundfontPlayer = new SoundfontMIDIPlayer(audioContext); + } + return this.soundfontPlayer; + } + + private initializeUI(): void { + this.searchBtn = document.getElementById('searchBtn') as HTMLButtonElement; + this.songInput = document.getElementById('songInput') as HTMLInputElement; + this.status = document.getElementById('status')!; + + this.resultsSection = document.getElementById('resultsSection')!; + this.resultsBody = document.getElementById('resultsBody')!; + this.playerSection = document.getElementById('playerSection')!; + + this.selectedTitle = document.getElementById('selectedTitle')!; + this.selectedMeta = document.getElementById('selectedMeta')!; + + this.soundfontPlayBtn = document.getElementById('soundfontPlayBtn') as HTMLButtonElement; + this.soundfontStopBtn = document.getElementById('soundfontStopBtn') as HTMLButtonElement; + this.soundfontVolumeSlider = document.getElementById('soundfontVolume') as HTMLInputElement; + + this.motifBtn = document.getElementById('motifBtn') as HTMLButtonElement; + this.motifStopBtn = document.getElementById('motifStopBtn') as HTMLButtonElement; + this.motifVolumeSlider = document.getElementById('motifVolume') as HTMLInputElement; + + this.modelSelect = document.getElementById('modelSelect') as HTMLSelectElement; + this.modelHint = document.getElementById('modelHint')!; + + this.nextResultBtn = document.getElementById('nextResultBtn') as HTMLButtonElement; + + this.iosAudioBanner = document.getElementById('iosAudioBanner')!; + this.enableAudioBtn = document.getElementById('enableAudioBtn') as HTMLButtonElement; + this.iosAudioState = document.getElementById('iosAudioState')!; + } + + private setupEventListeners(): void { + this.searchBtn.addEventListener('click', () => void this.handleSearch()); + this.songInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') void this.handleSearch(); + }); + + this.soundfontPlayBtn.addEventListener('click', () => void this.handleSoundfontPlay()); + this.soundfontStopBtn.addEventListener('click', () => this.handleSoundfontStop()); + this.soundfontVolumeSlider.addEventListener('input', (e) => { + const volume = parseFloat((e.target as HTMLInputElement).value); + this.soundfontPlayer?.setVolume(volume); + }); + + this.motifBtn.addEventListener('click', () => void this.handleMotif()); + this.motifStopBtn.addEventListener('click', () => this.handleMotifStop()); + this.motifVolumeSlider.addEventListener('input', (e) => { + const volume = parseFloat((e.target as HTMLInputElement).value); + this.motifEngine.setVolume(volume); + }); + + this.modelSelect.addEventListener('change', () => this.syncModelHint()); + + this.nextResultBtn.addEventListener('click', () => this.handleNextResult()); + + const enable = () => void this.handleEnableAudio(); + this.enableAudioBtn.addEventListener('click', enable); + this.enableAudioBtn.addEventListener('touchend', enable, { passive: true }); + } + + private getModel(): SynthModel { + const v = (this.modelSelect.value || 'nes_gb') as SynthModel; + if (v === 'pre8bit' || v === 'nes_gb' || v === 'snes_ish') return v; + return 'nes_gb'; + } + + private syncModelHint(): void { + const model = this.getModel(); + const hint = + model === 'pre8bit' + ? 'Ultra-sparse: mostly square/triangle, short gates, minimal polyphony.' + : model === 'snes_ish' + ? 'Warmer: gentler filter + echo. More voices, smoother release.' + : 'Chip: square/triangle/saw flavor, tight envelope, crisp attacks.'; + + this.modelHint.textContent = hint; + } + + private isIOSLike(): boolean { + const ua = navigator.userAgent || ''; + const iOS = /iPad|iPhone|iPod/.test(ua); + const iPadOS13Plus = /Macintosh/.test(ua) && (navigator as any).maxTouchPoints > 1; + return iOS || iPadOS13Plus; + } + + private updateIOSAudioBanner(): void { + if (!this.isIOSLike()) { + this.iosAudioBanner.style.display = 'none'; + return; + } + + const ready = isAudioReady(); + this.iosAudioBanner.style.display = ready ? 'none' : 'block'; + + const ctx = (() => { + try { return getAudioContext(); } catch { return null; } + })(); + if (!ready && ctx) { + this.iosAudioState.style.display = 'block'; + this.iosAudioState.textContent = `Audio: ${ctx.state} @ ${ctx.sampleRate}Hz`; + } else { + this.iosAudioState.style.display = 'none'; + this.iosAudioState.textContent = ''; + } + } + + private async handleEnableAudio(): Promise { + try { + this.enableAudioBtn.disabled = true; + this.iosAudioState.style.display = 'block'; + this.iosAudioState.textContent = 'Audio: enabling…'; + + await unlockAudio(); + + const ctx = getAudioContext(); + if (ctx.state !== 'running') { + this.enableAudioBtn.disabled = false; + this.iosAudioState.textContent = 'Audio still locked. Tap Enable Audio again.'; + return; + } + + this.iosAudioState.textContent = `Audio: running @ ${ctx.sampleRate}Hz`; + window.setTimeout(() => this.updateIOSAudioBanner(), 250); + } catch { + this.enableAudioBtn.disabled = false; + this.iosAudioState.style.display = 'block'; + this.iosAudioState.textContent = 'Audio enable failed. Tap again, or disable Silent Mode.'; + } finally { + this.enableAudioBtn.disabled = false; + } + } + + private updateStatus(message: string): void { + this.status.textContent = message; + } + + private hideResults(): void { + this.resultsSection.classList.remove('visible'); + this.playerSection.classList.remove('visible'); + } + + private enablePlayerControls(): void { + this.soundfontPlayBtn.disabled = false; + this.motifBtn.disabled = false; + this.nextResultBtn.disabled = this.searchResults.length <= 1; + } + + private disablePlayerControls(): void { + this.soundfontPlayBtn.disabled = true; + this.soundfontStopBtn.disabled = true; + this.motifBtn.disabled = true; + this.motifStopBtn.disabled = true; + this.nextResultBtn.disabled = true; + } + + private async handleSearch(): Promise { + const songName = this.songInput.value.trim(); + if (!songName) return; + + this.handleMotifStop(); + this.updateStatus('Searching for MIDI files...'); + this.searchBtn.disabled = true; + this.hideResults(); + + try { + const results = await this.midiService.search(songName); + if (results.length === 0) { + this.updateStatus('No MIDI files found. Try a different search.'); + return; + } + + this.searchResults = results; + this.selectedResultIndex = 0; + + this.updateStatus('Analyzing MIDI files...'); + for (let i = 0; i < Math.min(results.length, 3); i++) { + const metadata = await this.midiService.parseMIDI(results[i].midiUrl); + if (metadata) results[i].parsed = metadata; + } + + this.displayResults(); + this.updateStatus(`Found ${results.length} MIDI files. Select one to compare models.`); + this.updateIOSAudioBanner(); + } catch (error) { + this.updateStatus(`Search error: ${error instanceof Error ? error.message : 'Unknown error'}`); + } finally { + this.searchBtn.disabled = false; + } + } + + private displayResults(): void { + this.resultsBody.innerHTML = ''; + + this.searchResults.forEach((result, index) => { + const row = document.createElement('tr'); + if (index === this.selectedResultIndex) row.classList.add('selected'); + + row.innerHTML = ` + ${result.title} + ${result.source} + ${result.parsed ? Math.round(result.parsed.durationSec) + 's' : '?'} + `; + + row.addEventListener('click', () => void this.selectResult(index)); + this.resultsBody.appendChild(row); + }); + + this.resultsSection.classList.add('visible'); + if (this.searchResults.length > 0) void this.selectResult(0); + } + + public async selectResult(index: number): Promise { + if (index < 0 || index >= this.searchResults.length) return; + + this.handleMotifStop(); + + this.selectedResultIndex = index; + const result = this.searchResults[index]; + + const rows = this.resultsBody.querySelectorAll('tr'); + rows.forEach((row, i) => row.classList.toggle('selected', i === index)); + + this.updateStatus('Loading MIDI file...'); + this.disablePlayerControls(); + + try { + const midiBuffer = await this.midiService.fetchMIDI(result.midiUrl); + if (!midiBuffer) throw new Error('Failed to fetch MIDI file'); + + const events = MIDIParser.parseMIDI(midiBuffer); + const metadata = result.parsed || MIDIParser.getMIDIInfo(midiBuffer); + + let actualDuration = metadata.duration || metadata.durationSec || 0; + if (actualDuration === 0 && events.length > 0) { + actualDuration = Math.max(...events.map(e => e.time + e.duration)); + } + + this.currentMIDI = { events, metadata: { ...metadata, duration: actualDuration } }; + + const player = await this.ensureAudioReady(); + await player.load(events); + + this.selectedTitle.textContent = result.title; + this.selectedMeta.innerHTML = ` + Source: ${result.source} | + Duration: ${Math.round(actualDuration)}s | + Tracks: ${metadata.trackCount} | + Notes: ${events.length} | + Tempo: ${metadata.tempo}bpm + `; + + this.updateIOSAudioBanner(); + this.playerSection.classList.add('visible'); + this.enablePlayerControls(); + this.updateStatus('MIDI loaded. Preview, then switch models and generate.'); + } catch (error) { + this.updateStatus(`Load error: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + private async handleSoundfontPlay(): Promise { + if (!this.currentMIDI) return; + try { + const player = await this.ensureAudioReady(); + await player.play(); + this.soundfontPlayBtn.disabled = true; + this.soundfontStopBtn.disabled = false; + this.updateStatus('Previewing MIDI...'); + } catch (error) { + this.updateStatus(`Preview error: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + this.updateIOSAudioBanner(); + } + + private handleSoundfontStop(): void { + this.soundfontPlayer?.stop(); + this.soundfontPlayBtn.disabled = false; + this.soundfontStopBtn.disabled = true; + this.updateStatus('Preview stopped.'); + } + + private async handleMotif(): Promise { + if (!this.currentMIDI) return; + + try { + await unlockAudio(); + this.updateIOSAudioBanner(); + + const model = this.getModel(); + this.updateStatus(`Generating Motif (${model})...`); + this.motifBtn.disabled = true; + + await this.motifEngine.generateFromMIDI(this.currentMIDI.events, 'procedural', model); + await this.motifEngine.play(); + + this.motifStopBtn.disabled = false; + this.updateStatus(`Playing Motif (${model})...`); + } catch (error) { + this.updateStatus(`Motif error: ${error instanceof Error ? error.message : 'Unknown error'}`); + this.motifBtn.disabled = false; + } + } + + private handleMotifStop(): void { + this.motifEngine.stop(); + this.motifBtn.disabled = false; + this.motifStopBtn.disabled = true; + } + + private handleNextResult(): void { + const nextIndex = (this.selectedResultIndex + 1) % this.searchResults.length; + void this.selectResult(nextIndex); + } +} + +const app = new ModelsApp(); +(window as any).modelsApp = app; + diff --git a/src/synthesis/SynthesisEngine.ts b/src/synthesis/SynthesisEngine.ts index d4eeb5f..1fe4d4c 100644 --- a/src/synthesis/SynthesisEngine.ts +++ b/src/synthesis/SynthesisEngine.ts @@ -1,31 +1,52 @@ -import type { RoleAssignment, MotifConfig, SynthLayer, Role, NoteEvent, ChordEvent } from '../types'; +import type { RoleAssignment, MotifConfig, SynthLayer, Role, NoteEvent, ChordEvent, SynthModel } from '../types'; export class SynthesisEngine { private audioContext: AudioContext; private config: MotifConfig; private masterGain: GainNode; + private postGain: GainNode; + private model: SynthModel; 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; + private effectCleanup: (() => void) | null = null; - constructor(audioContext: AudioContext, config: MotifConfig) { + constructor(audioContext: AudioContext, config: MotifConfig, model: SynthModel = 'nes_gb') { this.audioContext = audioContext; this.config = config; this.masterGain = audioContext.createGain(); - this.masterGain.connect(audioContext.destination); + this.postGain = audioContext.createGain(); + this.postGain.connect(audioContext.destination); + + this.model = model; + this.maxVoices = this.getMaxVoicesForModel(model, config.maxOscillators); + + // Default overall level this.masterGain.gain.value = 0.3; + this.postGain.gain.value = 1.0; + + // Route + optional effects + this.effectCleanup = this.configureMasterChain(model); } setupLayers(assignments: RoleAssignment[]): void { // Clean up existing layers this.cleanupLayers(); + // Reset voice budgeting per setup (important when switching models) + this.activeVoiceCount = 0; + + // Apply model role filtering + const filteredAssignments = this.filterAssignmentsForModel(assignments); + // Find the earliest event time across all assignments let earliestTime = Infinity; - for (const assignment of assignments) { + for (const assignment of filteredAssignments) { if (assignment.events.length > 0) { earliestTime = Math.min(earliestTime, assignment.events[0].time); } @@ -37,7 +58,7 @@ export class SynthesisEngine { // If we found events, normalize times to start at 0 if (earliestTime !== Infinity && earliestTime > 0) { console.log('Normalizing event times, earliest was:', earliestTime); - for (const assignment of assignments) { + for (const assignment of filteredAssignments) { // Normalize note events for (const event of assignment.events) { event.time -= earliestTime; @@ -50,7 +71,7 @@ export class SynthesisEngine { } // Store role assignments and create layers - for (const assignment of assignments) { + for (const assignment of filteredAssignments) { const layer = this.createSynthLayer(assignment.role); this.layers.set(assignment.role, layer); this.roleAssignments.set(assignment.role, assignment); @@ -175,37 +196,62 @@ export class SynthesisEngine { } private configureLayerForRole(gain: GainNode, filter: BiquadFilterNode, role: Role): void { + // Default per-model filter shaping (keeps presets recognizable) + if (this.model === 'pre8bit') { + // Very bright, very simple + filter.type = 'lowpass'; + filter.frequency.value = 6000; + filter.Q.value = 0.8; + } else if (this.model === 'snes_ish') { + // Warmer, a little more body + filter.type = 'lowpass'; + filter.frequency.value = 2400; + filter.Q.value = 0.9; + } + switch (role) { case 'bass': - gain.gain.value = 0.4; - filter.type = 'lowpass'; - filter.frequency.value = 200; + gain.gain.value = this.model === 'pre8bit' ? 0.5 : 0.4; + if (this.model !== 'pre8bit') { + filter.type = 'lowpass'; + filter.frequency.value = this.model === 'snes_ish' ? 260 : 200; + } else { + filter.frequency.value = 350; + } break; case 'drone': - gain.gain.value = 0.2; - filter.type = 'bandpass'; - filter.frequency.value = 400; + gain.gain.value = this.model === 'pre8bit' ? 0.0 : 0.2; + if (this.model !== 'pre8bit') { + filter.type = 'bandpass'; + filter.frequency.value = this.model === 'snes_ish' ? 520 : 400; + } break; case 'ostinato': - gain.gain.value = 0.3; - filter.type = 'highpass'; - filter.frequency.value = 300; + gain.gain.value = this.model === 'pre8bit' ? 0.25 : 0.3; + if (this.model !== 'pre8bit') { + filter.type = 'highpass'; + filter.frequency.value = this.model === 'snes_ish' ? 220 : 300; + } break; case 'texture': - gain.gain.value = 0.1; - filter.type = 'bandpass'; - filter.frequency.value = 800; + gain.gain.value = this.model === 'pre8bit' ? 0.0 : 0.1; + if (this.model !== 'pre8bit') { + filter.type = 'bandpass'; + filter.frequency.value = this.model === 'snes_ish' ? 900 : 800; + } break; case 'accents': - gain.gain.value = 0.5; - filter.type = 'peaking'; - filter.frequency.value = 1000; + gain.gain.value = this.model === 'pre8bit' ? 0.35 : 0.5; + if (this.model !== 'pre8bit') { + filter.type = 'peaking'; + filter.frequency.value = this.model === 'snes_ish' ? 1200 : 1000; + } break; case 'melody': // Passthrough mode - balanced sound for all notes - gain.gain.value = 0.35; + gain.gain.value = this.model === 'pre8bit' ? 0.4 : 0.35; filter.type = 'lowpass'; - filter.frequency.value = 4000; // Brighter sound for full range + filter.frequency.value = this.model === 'snes_ish' ? 3200 : 4000; // Brighter for chip, warmer for SNES-ish break; } } @@ -217,6 +263,13 @@ export class SynthesisEngine { private scheduleNote(role: Role, pitch: number, duration: number, velocity: number, when: number): void { const layer = this.layers.get(role); if (!layer) return; + + // Model gating: pre8bit runs intentionally sparse + 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(); @@ -225,33 +278,50 @@ export class SynthesisEngine { const frequency = this.midiToFrequency(pitch); osc.frequency.value = frequency; - // Choose oscillator type based on role - switch (role) { - case 'bass': - osc.type = 'square'; - break; - case 'drone': - osc.type = 'sawtooth'; - break; - case 'ostinato': - osc.type = 'triangle'; - break; - case 'melody': - osc.type = 'triangle'; - break; - case 'texture': - case 'accents': - osc.type = 'sine'; - break; + // Choose oscillator type based on model + role + if (this.model === 'pre8bit') { + osc.type = role === 'bass' ? 'triangle' : 'square'; + } else if (this.model === 'snes_ish') { + // Warmer “sample-ish” feel: richer waveforms + filtering + echo on master + if (role === 'bass') osc.type = 'triangle'; + else if (role === 'drone') osc.type = 'sawtooth'; + else if (role === 'ostinato') osc.type = 'triangle'; + else if (role === 'melody') osc.type = 'sawtooth'; + else osc.type = 'sine'; + + // Subtle detune (feels less “pure chip”) + osc.detune.value = (Math.random() - 0.5) * 8; // ±4 cents + } else { + // nes_gb (current default) + switch (role) { + case 'bass': + osc.type = 'square'; + break; + case 'drone': + osc.type = 'sawtooth'; + break; + case 'ostinato': + osc.type = 'triangle'; + break; + case 'melody': + osc.type = 'triangle'; + break; + case 'texture': + case 'accents': + osc.type = 'sine'; + break; + } } osc.connect(envelope); envelope.connect(layer.filterNode); - // Envelope based on velocity and duration with minimum times to prevent clicks + // Envelope tuned per model (still with minimum times to prevent clicks) const gainValue = velocity * 0.5; // Scale velocity - const attackTime = Math.max(0.005, Math.min(0.05, duration * 0.1)); // Min 5ms attack - const releaseTime = Math.max(0.01, Math.min(0.1, duration * 0.3)); // Min 10ms release + const attackBase = this.model === 'pre8bit' ? 0.003 : this.model === 'snes_ish' ? 0.01 : 0.005; + const releaseBase = this.model === 'pre8bit' ? 0.008 : this.model === 'snes_ish' ? 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_ish' ? 0.22 : 0.12, duration * 0.3)); envelope.gain.setValueAtTime(0, when); envelope.gain.linearRampToValueAtTime(gainValue, when + attackTime); @@ -260,6 +330,8 @@ export class SynthesisEngine { osc.start(when); osc.stop(when + duration + releaseTime + 0.01); // Stop after envelope completes + + this.activeVoiceCount++; // Clean up after note ends setTimeout(() => { @@ -269,6 +341,7 @@ export class SynthesisEngine { } catch (e) { // Already disconnected } + this.activeVoiceCount = Math.max(0, this.activeVoiceCount - 1); }, (duration + releaseTime + 0.1) * 1000); } @@ -290,6 +363,12 @@ export class SynthesisEngine { if (!events.length && !chords.length) return; + // pre8bit is intentionally simple: no chord scheduling + if (this.model === 'pre8bit') { + this.scheduleSingleEvents(role, events, scheduleUntil); + return; + } + // For roles that support polyphony (drone, texture), prefer chords if ((role === 'drone' || role === 'texture') && chords.length > 0) { this.scheduleChordEvents(role, chords, scheduleUntil); @@ -392,9 +471,13 @@ export class SynthesisEngine { 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; - // Create oscillator for each pitch in the chord - for (const pitch of pitches) { + // Create oscillator for each pitch in the chord (unless constrained) + const chordPitches = this.model === 'snes_ish' ? 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(); @@ -402,35 +485,46 @@ export class SynthesisEngine { const frequency = this.midiToFrequency(pitch); osc.frequency.value = frequency; - // Choose oscillator type based on role - switch (role) { - case 'bass': - osc.type = 'square'; - break; - case 'drone': - osc.type = 'sawtooth'; - break; - case 'ostinato': - osc.type = 'triangle'; - break; - case 'texture': - osc.type = 'sine'; - break; - case 'melody': - osc.type = 'triangle'; - break; - case 'accents': - osc.type = 'sine'; - break; + // Choose oscillator type based on model + role (same as note path) + if (this.model === 'snes_ish') { + if (role === 'bass') osc.type = 'triangle'; + else if (role === 'drone') osc.type = 'sawtooth'; + else if (role === 'ostinato') osc.type = 'triangle'; + else if (role === 'melody') osc.type = 'sawtooth'; + else osc.type = 'sine'; + osc.detune.value = (Math.random() - 0.5) * 8; + } else { + switch (role) { + case 'bass': + osc.type = 'square'; + break; + case 'drone': + osc.type = 'sawtooth'; + break; + case 'ostinato': + osc.type = 'triangle'; + break; + case 'texture': + osc.type = 'sine'; + break; + case 'melody': + osc.type = 'triangle'; + break; + case 'accents': + osc.type = 'sine'; + break; + } } osc.connect(envelope); envelope.connect(layer.filterNode); // Envelope based on velocity and duration, scaled for chords with minimum times to prevent clicks - const gainValue = (velocity * 0.3) / Math.max(pitches.length * 0.5, 1); // Scale down for chords - const attackTime = Math.max(0.005, Math.min(0.05, duration * 0.1)); // Min 5ms attack - const releaseTime = Math.max(0.01, Math.min(0.1, duration * 0.3)); // Min 10ms release + const gainValue = (velocity * 0.3) / Math.max(chordPitches.length * 0.5, 1); // Scale down for chords + const attackBase = this.model === 'snes_ish' ? 0.01 : 0.005; + const releaseBase = this.model === 'snes_ish' ? 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_ish' ? 0.24 : 0.12, duration * 0.3)); envelope.gain.setValueAtTime(0, when); envelope.gain.linearRampToValueAtTime(gainValue, when + attackTime); @@ -440,6 +534,8 @@ export class SynthesisEngine { osc.start(when); osc.stop(when + duration + releaseTime + 0.01); // Stop after envelope completes + this.activeVoiceCount++; + // Clean up after note ends setTimeout(() => { try { @@ -448,6 +544,7 @@ export class SynthesisEngine { } catch (e) { // Already disconnected } + this.activeVoiceCount = Math.max(0, this.activeVoiceCount - 1); }, (duration + releaseTime + 0.1) * 1000); } } @@ -467,4 +564,76 @@ export class SynthesisEngine { } this.layers.clear(); } + + private getMaxVoicesForModel(model: SynthModel, defaultMax: number): number { + if (model === 'pre8bit') return Math.min(2, defaultMax); + if (model === 'snes_ish') return Math.max(12, defaultMax); + return defaultMax; + } + + private filterAssignmentsForModel(assignments: RoleAssignment[]): RoleAssignment[] { + if (this.model === 'pre8bit') { + // Keep it intentionally simple and sparse. + const keep: Role[] = ['melody', 'bass']; + const kept = assignments.filter(a => keep.includes(a.role)); + // If role mapper didn’t produce those roles, fall back to first available assignment. + if (kept.length > 0) return kept.slice(0, 2); + return assignments.slice(0, 1); + } + return assignments; + } + + private configureMasterChain(model: SynthModel): () => void { + // Disconnect any previous chain + try { this.masterGain.disconnect(); } catch {} + if (this.effectCleanup) { + try { this.effectCleanup(); } catch {} + } + + // Default: dry only + if (model !== 'snes_ish') { + this.masterGain.connect(this.postGain); + return () => { + try { this.masterGain.disconnect(); } catch {} + }; + } + + // SNES-ish: add a simple echo/reverb-like feedback delay with filtering. + const dry = this.audioContext.createGain(); + const wet = this.audioContext.createGain(); + const delay = this.audioContext.createDelay(1.0); + const feedback = this.audioContext.createGain(); + const fbFilter = this.audioContext.createBiquadFilter(); + + dry.gain.value = 0.85; + wet.gain.value = 0.28; + delay.delayTime.value = 0.165; // ~165ms echo + feedback.gain.value = 0.32; + fbFilter.type = 'lowpass'; + fbFilter.frequency.value = 1800; + fbFilter.Q.value = 0.7; + + // master -> dry -> post + this.masterGain.connect(dry); + dry.connect(this.postGain); + + // master -> delay -> wet -> post + this.masterGain.connect(delay); + delay.connect(wet); + wet.connect(this.postGain); + + // feedback loop: delay -> filter -> feedback -> delay + delay.connect(fbFilter); + fbFilter.connect(feedback); + feedback.connect(delay); + + return () => { + try { this.masterGain.disconnect(); } catch {} + try { dry.disconnect(); } catch {} + try { wet.disconnect(); } catch {} + try { delay.disconnect(); } catch {} + try { feedback.disconnect(); } catch {} + try { fbFilter.disconnect(); } catch {} + }; + } } \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index 42261df..09acb86 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -38,6 +38,9 @@ export interface StructuralFeatures { export type Role = 'bass' | 'drone' | 'ostinato' | 'texture' | 'accents' | 'melody'; +// Procedural synth model presets (test page uses these) +export type SynthModel = 'pre8bit' | 'nes_gb' | 'snes_ish'; + export interface RoleAssignment { role: Role; sourceTrack: number; 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'), }, }, },