From a60225d1d460d528df5f8e52cb0bc65b86b972bc Mon Sep 17 00:00:00 2001 From: b1rdmania <102524336+b1rdmania@users.noreply.github.com> Date: Sat, 20 Dec 2025 12:28:02 +0000 Subject: [PATCH] Revert "Isolate /models synth variants and revert main." This reverts commit 633197b81ac6ac52441f7d34a43ce84ebd3487cc. --- index.html | 11 +- src/core/MotifEngine.ts | 12 +- src/models.ts | 33 +- src/synthesis/SynthesisEngine.ts | 307 +++++++++++---- src/synthesis/TestModelSynthesisEngine.ts | 431 ---------------------- src/types/index.ts | 3 + 6 files changed, 267 insertions(+), 530 deletions(-) delete mode 100644 src/synthesis/TestModelSynthesisEngine.ts diff --git a/index.html b/index.html index 163d714..96cde02 100644 --- a/index.html +++ b/index.html @@ -84,6 +84,12 @@ .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); @@ -819,7 +825,10 @@

🎵 MOTIF

- +
+ Models + +

Procedural Music Synthesis from MIDI Structure

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 index c6ba628..a624680 100644 --- a/src/models.ts +++ b/src/models.ts @@ -1,19 +1,13 @@ +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 { 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'; +import type { NoteEvent, SynthModel } from './types'; class ModelsApp { + private motifEngine: MotifEngine; private midiService: MIDIService; - private midiProcessor: MIDIProcessor; - private roleMapper: RoleMapper; - private testEngine: TestModelSynthesisEngine | null = null; private soundfontPlayer: SoundfontMIDIPlayer | null = null; @@ -50,9 +44,8 @@ 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(); @@ -113,7 +106,7 @@ class ModelsApp { this.motifStopBtn.addEventListener('click', () => this.handleMotifStop()); this.motifVolumeSlider.addEventListener('input', (e) => { const volume = parseFloat((e.target as HTMLInputElement).value); - this.testEngine?.setVolume(volume); + this.motifEngine.setVolume(volume); }); this.modelSelect.addEventListener('change', () => this.syncModelHint()); @@ -357,17 +350,8 @@ class ModelsApp { this.updateStatus(`Generating Motif (${model})...`); this.motifBtn.disabled = true; - // 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(); + await this.motifEngine.generateFromMIDI(this.currentMIDI.events, 'procedural', model); + await this.motifEngine.play(); this.motifStopBtn.disabled = false; this.updateStatus(`Playing Motif (${model})...`); @@ -378,8 +362,7 @@ class ModelsApp { } private handleMotifStop(): void { - this.testEngine?.stop(); - this.testEngine = null; + this.motifEngine.stop(); this.motifBtn.disabled = false; this.motifStopBtn.disabled = true; } diff --git a/src/synthesis/SynthesisEngine.ts b/src/synthesis/SynthesisEngine.ts index 2c27fbe..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); } @@ -289,6 +362,12 @@ 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) { @@ -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/synthesis/TestModelSynthesisEngine.ts b/src/synthesis/TestModelSynthesisEngine.ts deleted file mode 100644 index a7fea7c..0000000 --- a/src/synthesis/TestModelSynthesisEngine.ts +++ /dev/null @@ -1,431 +0,0 @@ -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 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;