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 @@
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;