diff --git a/index.html b/index.html index 96cde02..163d714 100644 --- a/index.html +++ b/index.html @@ -84,12 +84,6 @@ .topbar h1 { margin: 0; } - .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); @@ -825,10 +819,7 @@

🎵 MOTIF

-
- Models - -
+

Procedural Music Synthesis from MIDI Structure

diff --git a/src/core/MotifEngine.ts b/src/core/MotifEngine.ts index 63366f1..357f1e2 100644 --- a/src/core/MotifEngine.ts +++ b/src/core/MotifEngine.ts @@ -1,4 +1,4 @@ -import type { NoteEvent, MotifConfig, SynthModel } from '../types'; +import type { NoteEvent, MotifConfig } from '../types'; import { MIDIProcessor } from '../midi/MIDIProcessor'; import { MIDIParser } from '../midi/MIDIParser'; import { MIDIService } from '../services/MIDIService'; @@ -27,17 +27,13 @@ export class MotifEngine { this.roleMapper = new RoleMapper(); } - async generateFromMIDI( - events: NoteEvent[], - transformMode: 'passthrough' | 'procedural' = 'passthrough', - model: SynthModel = 'nes_gb' - ): Promise { + async generateFromMIDI(events: NoteEvent[], transformMode: 'passthrough' | 'procedural' = 'passthrough'): Promise { // Initialize audio context using shared unlock (iOS compatibility) if (!this.audioContext) { this.audioContext = await unlockAudio(); } - this.synthesisEngine = new SynthesisEngine(this.audioContext, this.config, model); + this.synthesisEngine = new SynthesisEngine(this.audioContext, this.config); if (transformMode === 'passthrough') { // Direct playback mode - play MIDI as-is without transformations @@ -111,7 +107,7 @@ export class MotifEngine { this.audioContext = await unlockAudio(); } - this.synthesisEngine = new SynthesisEngine(this.audioContext, this.config, 'nes_gb'); + this.synthesisEngine = new SynthesisEngine(this.audioContext, this.config); this.synthesisEngine.setupLayers(roleAssignments); } diff --git a/src/models.ts b/src/models.ts index a624680..c6ba628 100644 --- a/src/models.ts +++ b/src/models.ts @@ -1,13 +1,19 @@ -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'; +import { MIDIProcessor } from './midi/MIDIProcessor'; +import { RoleMapper } from './core/RoleMapper'; +import { TestModelSynthesisEngine } from './synthesis/TestModelSynthesisEngine'; +import type { NoteEvent } from './types'; + +type SynthModel = 'pre8bit' | 'nes_gb' | 'snes_ish'; class ModelsApp { - private motifEngine: MotifEngine; private midiService: MIDIService; + private midiProcessor: MIDIProcessor; + private roleMapper: RoleMapper; + private testEngine: TestModelSynthesisEngine | null = null; private soundfontPlayer: SoundfontMIDIPlayer | null = null; @@ -44,8 +50,9 @@ class ModelsApp { private currentMIDI: { events: NoteEvent[]; metadata: any } | null = null; constructor() { - this.motifEngine = new MotifEngine(); this.midiService = new MIDIService(); + this.midiProcessor = new MIDIProcessor(); + this.roleMapper = new RoleMapper(); this.initializeUI(); this.setupEventListeners(); this.syncModelHint(); @@ -106,7 +113,7 @@ class ModelsApp { this.motifStopBtn.addEventListener('click', () => this.handleMotifStop()); this.motifVolumeSlider.addEventListener('input', (e) => { const volume = parseFloat((e.target as HTMLInputElement).value); - this.motifEngine.setVolume(volume); + this.testEngine?.setVolume(volume); }); this.modelSelect.addEventListener('change', () => this.syncModelHint()); @@ -350,8 +357,17 @@ class ModelsApp { this.updateStatus(`Generating Motif (${model})...`); this.motifBtn.disabled = true; - await this.motifEngine.generateFromMIDI(this.currentMIDI.events, 'procedural', model); - await this.motifEngine.play(); + // Ensure the previous model engine is stopped/cleared so each run is isolated. + this.handleMotifStop(); + + const audioContext = getAudioContext(); + const features = this.midiProcessor.extractFeatures(this.currentMIDI.events); + const assignments = this.roleMapper.assignRoles(features, this.currentMIDI.events); + + this.testEngine = new TestModelSynthesisEngine(audioContext, model); + this.testEngine.setupLayers(assignments); + this.testEngine.setVolume(parseFloat(this.motifVolumeSlider.value)); + this.testEngine.start(); this.motifStopBtn.disabled = false; this.updateStatus(`Playing Motif (${model})...`); @@ -362,7 +378,8 @@ class ModelsApp { } private handleMotifStop(): void { - this.motifEngine.stop(); + this.testEngine?.stop(); + this.testEngine = null; this.motifBtn.disabled = false; this.motifStopBtn.disabled = true; } diff --git a/src/synthesis/SynthesisEngine.ts b/src/synthesis/SynthesisEngine.ts index 1fe4d4c..2c27fbe 100644 --- a/src/synthesis/SynthesisEngine.ts +++ b/src/synthesis/SynthesisEngine.ts @@ -1,52 +1,31 @@ -import type { RoleAssignment, MotifConfig, SynthLayer, Role, NoteEvent, ChordEvent, SynthModel } from '../types'; +import type { RoleAssignment, MotifConfig, SynthLayer, Role, NoteEvent, ChordEvent } 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, model: SynthModel = 'nes_gb') { + constructor(audioContext: AudioContext, config: MotifConfig) { this.audioContext = audioContext; this.config = config; this.masterGain = audioContext.createGain(); - this.postGain = audioContext.createGain(); - this.postGain.connect(audioContext.destination); - - this.model = model; - this.maxVoices = this.getMaxVoicesForModel(model, config.maxOscillators); - - // Default overall level + this.masterGain.connect(audioContext.destination); 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 filteredAssignments) { + for (const assignment of assignments) { if (assignment.events.length > 0) { earliestTime = Math.min(earliestTime, assignment.events[0].time); } @@ -58,7 +37,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 filteredAssignments) { + for (const assignment of assignments) { // Normalize note events for (const event of assignment.events) { event.time -= earliestTime; @@ -71,7 +50,7 @@ export class SynthesisEngine { } // Store role assignments and create layers - for (const assignment of filteredAssignments) { + for (const assignment of assignments) { const layer = this.createSynthLayer(assignment.role); this.layers.set(assignment.role, layer); this.roleAssignments.set(assignment.role, assignment); @@ -196,62 +175,37 @@ 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 = 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; - } + gain.gain.value = 0.4; + filter.type = 'lowpass'; + filter.frequency.value = 200; break; case 'drone': - 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; - } + gain.gain.value = 0.2; + filter.type = 'bandpass'; + filter.frequency.value = 400; break; case 'ostinato': - 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; - } + gain.gain.value = 0.3; + filter.type = 'highpass'; + filter.frequency.value = 300; break; case 'texture': - 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; - } + gain.gain.value = 0.1; + filter.type = 'bandpass'; + filter.frequency.value = 800; break; case 'accents': - 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; - } + gain.gain.value = 0.5; + filter.type = 'peaking'; + filter.frequency.value = 1000; break; case 'melody': // Passthrough mode - balanced sound for all notes - gain.gain.value = this.model === 'pre8bit' ? 0.4 : 0.35; + gain.gain.value = 0.35; filter.type = 'lowpass'; - filter.frequency.value = this.model === 'snes_ish' ? 3200 : 4000; // Brighter for chip, warmer for SNES-ish + filter.frequency.value = 4000; // Brighter sound for full range break; } } @@ -263,13 +217,6 @@ 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(); @@ -278,50 +225,33 @@ export class SynthesisEngine { const frequency = this.midiToFrequency(pitch); osc.frequency.value = frequency; - // 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; - } + // 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; } osc.connect(envelope); envelope.connect(layer.filterNode); - // Envelope tuned per model (still with minimum times to prevent clicks) + // Envelope based on velocity and duration with minimum times to prevent clicks const gainValue = velocity * 0.5; // Scale velocity - 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)); + 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 envelope.gain.setValueAtTime(0, when); envelope.gain.linearRampToValueAtTime(gainValue, when + attackTime); @@ -330,8 +260,6 @@ 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(() => { @@ -341,7 +269,6 @@ export class SynthesisEngine { } catch (e) { // Already disconnected } - this.activeVoiceCount = Math.max(0, this.activeVoiceCount - 1); }, (duration + releaseTime + 0.1) * 1000); } @@ -362,12 +289,6 @@ export class SynthesisEngine { const chords = assignment.chords; 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) { @@ -471,13 +392,9 @@ 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 (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; + // Create oscillator for each pitch in the chord + for (const pitch of pitches) { const osc = this.audioContext.createOscillator(); const envelope = this.audioContext.createGain(); @@ -485,46 +402,35 @@ export class SynthesisEngine { const frequency = this.midiToFrequency(pitch); osc.frequency.value = frequency; - // 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; - } + // 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; } 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(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)); + 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 envelope.gain.setValueAtTime(0, when); envelope.gain.linearRampToValueAtTime(gainValue, when + attackTime); @@ -534,8 +440,6 @@ 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 { @@ -544,7 +448,6 @@ export class SynthesisEngine { } catch (e) { // Already disconnected } - this.activeVoiceCount = Math.max(0, this.activeVoiceCount - 1); }, (duration + releaseTime + 0.1) * 1000); } } @@ -564,76 +467,4 @@ 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/synthesis/TestModelSynthesisEngine.ts b/src/synthesis/TestModelSynthesisEngine.ts new file mode 100644 index 0000000..a7fea7c --- /dev/null +++ b/src/synthesis/TestModelSynthesisEngine.ts @@ -0,0 +1,431 @@ +import type { RoleAssignment, Role, SynthLayer, NoteEvent, ChordEvent } from '../types'; + +export type SynthModel = 'pre8bit' | 'nes_gb' | 'snes_ish'; + +/** + * TestModelSynthesisEngine + * ----------------------- + * Used ONLY by /models to compare different synthesis “models”. + * This is intentionally isolated from the main `SynthesisEngine` so the main app stays stable. + */ +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; + + // scheduling config (kept simple for /models) + private lookaheadTime = 0.12; + private scheduleInterval = 25; + private fadeTime = 0.05; + + 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; + + this.maxVoices = this.model === 'pre8bit' ? 2 : this.model === 'snes_ish' ? 14 : 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_ish') return; + + // SNES-ish echo: feedback delay with a lowpass in the loop. + 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.26; + 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(); + + 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 { + // Base curve per model + if (this.model === 'pre8bit') { + filter.type = 'lowpass'; + filter.frequency.value = 6500; + filter.Q.value = 0.8; + } else if (this.model === 'snes_ish') { + filter.type = 'lowpass'; + filter.frequency.value = 2600; + filter.Q.value = 0.9; + } + + switch (role) { + case 'bass': + gain.gain.value = this.model === 'pre8bit' ? 0.55 : 0.4; + if (this.model !== 'pre8bit') { + filter.type = 'lowpass'; + filter.frequency.value = this.model === 'snes_ish' ? 260 : 200; + } else { + filter.frequency.value = 420; + } + break; + case 'drone': + 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 = 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 = 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 = 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': + gain.gain.value = this.model === 'pre8bit' ? 0.42 : 0.35; + filter.type = 'lowpass'; + filter.frequency.value = this.model === 'snes_ish' ? 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 all roles looped, reset startTime. + 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); + + // Model-specific voice + if (this.model === 'pre8bit') { + osc.type = role === 'bass' ? 'triangle' : 'square'; + } else if (this.model === 'snes_ish') { + 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 { + // nes_gb + 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); + envelope.connect(layer.filterNode); + + const gainValue = velocity * 0.5; + 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); + 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(); + } 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_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(); + osc.frequency.value = this.midiToFrequency(pitch); + + if (this.model === 'snes_ish') { + 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); + envelope.connect(layer.filterNode); + + const gainValue = (velocity * 0.3) / Math.max(chordPitches.length * 0.5, 1); + 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); + 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(); + } catch {} + this.activeVoiceCount = Math.max(0, this.activeVoiceCount - 1); + }, (duration + releaseTime + 0.1) * 1000); + } + } + + 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/src/types/index.ts b/src/types/index.ts index 09acb86..42261df 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -38,9 +38,6 @@ 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;