Revert "Isolate /models synth variants and revert main."
This reverts commit 633197b81a.
This commit is contained in:
@@ -84,6 +84,12 @@
|
|||||||
.topbar h1 {
|
.topbar h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
.topbar-actions {
|
||||||
|
margin-left: auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
.faq-link {
|
.faq-link {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid rgba(255,255,255,0.14);
|
border: 1px solid rgba(255,255,255,0.14);
|
||||||
@@ -819,8 +825,11 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<h1>🎵 MOTIF</h1>
|
<h1>🎵 MOTIF</h1>
|
||||||
|
<div class="topbar-actions">
|
||||||
|
<a class="faq-link" href="/models" style="text-decoration:none;" aria-label="Open test models page">Models</a>
|
||||||
<button id="faqBtn" class="faq-link" type="button">FAQ</button>
|
<button id="faqBtn" class="faq-link" type="button">FAQ</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<p>Procedural Music Synthesis from MIDI Structure</p>
|
<p>Procedural Music Synthesis from MIDI Structure</p>
|
||||||
|
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { NoteEvent, MotifConfig } from '../types';
|
import type { NoteEvent, MotifConfig, SynthModel } from '../types';
|
||||||
import { MIDIProcessor } from '../midi/MIDIProcessor';
|
import { MIDIProcessor } from '../midi/MIDIProcessor';
|
||||||
import { MIDIParser } from '../midi/MIDIParser';
|
import { MIDIParser } from '../midi/MIDIParser';
|
||||||
import { MIDIService } from '../services/MIDIService';
|
import { MIDIService } from '../services/MIDIService';
|
||||||
@@ -27,13 +27,17 @@ export class MotifEngine {
|
|||||||
this.roleMapper = new RoleMapper();
|
this.roleMapper = new RoleMapper();
|
||||||
}
|
}
|
||||||
|
|
||||||
async generateFromMIDI(events: NoteEvent[], transformMode: 'passthrough' | 'procedural' = 'passthrough'): Promise<void> {
|
async generateFromMIDI(
|
||||||
|
events: NoteEvent[],
|
||||||
|
transformMode: 'passthrough' | 'procedural' = 'passthrough',
|
||||||
|
model: SynthModel = 'nes_gb'
|
||||||
|
): Promise<void> {
|
||||||
// Initialize audio context using shared unlock (iOS compatibility)
|
// Initialize audio context using shared unlock (iOS compatibility)
|
||||||
if (!this.audioContext) {
|
if (!this.audioContext) {
|
||||||
this.audioContext = await unlockAudio();
|
this.audioContext = await unlockAudio();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.synthesisEngine = new SynthesisEngine(this.audioContext, this.config);
|
this.synthesisEngine = new SynthesisEngine(this.audioContext, this.config, model);
|
||||||
|
|
||||||
if (transformMode === 'passthrough') {
|
if (transformMode === 'passthrough') {
|
||||||
// Direct playback mode - play MIDI as-is without transformations
|
// Direct playback mode - play MIDI as-is without transformations
|
||||||
@@ -107,7 +111,7 @@ export class MotifEngine {
|
|||||||
this.audioContext = await unlockAudio();
|
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);
|
this.synthesisEngine.setupLayers(roleAssignments);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+8
-25
@@ -1,19 +1,13 @@
|
|||||||
|
import { MotifEngine } from './core/MotifEngine';
|
||||||
import { MIDIService } from './services/MIDIService';
|
import { MIDIService } from './services/MIDIService';
|
||||||
import { MIDIParser } from './midi/MIDIParser';
|
import { MIDIParser } from './midi/MIDIParser';
|
||||||
import { SoundfontMIDIPlayer } from './synthesis/SoundfontMIDIPlayer';
|
import { SoundfontMIDIPlayer } from './synthesis/SoundfontMIDIPlayer';
|
||||||
import { getAudioContext, isAudioReady, unlockAudio } from './utils/audioUnlock';
|
import { getAudioContext, isAudioReady, unlockAudio } from './utils/audioUnlock';
|
||||||
import { MIDIProcessor } from './midi/MIDIProcessor';
|
import type { NoteEvent, SynthModel } from './types';
|
||||||
import { RoleMapper } from './core/RoleMapper';
|
|
||||||
import { TestModelSynthesisEngine } from './synthesis/TestModelSynthesisEngine';
|
|
||||||
import type { NoteEvent } from './types';
|
|
||||||
|
|
||||||
type SynthModel = 'pre8bit' | 'nes_gb' | 'snes_ish';
|
|
||||||
|
|
||||||
class ModelsApp {
|
class ModelsApp {
|
||||||
|
private motifEngine: MotifEngine;
|
||||||
private midiService: MIDIService;
|
private midiService: MIDIService;
|
||||||
private midiProcessor: MIDIProcessor;
|
|
||||||
private roleMapper: RoleMapper;
|
|
||||||
private testEngine: TestModelSynthesisEngine | null = null;
|
|
||||||
|
|
||||||
private soundfontPlayer: SoundfontMIDIPlayer | null = null;
|
private soundfontPlayer: SoundfontMIDIPlayer | null = null;
|
||||||
|
|
||||||
@@ -50,9 +44,8 @@ class ModelsApp {
|
|||||||
private currentMIDI: { events: NoteEvent[]; metadata: any } | null = null;
|
private currentMIDI: { events: NoteEvent[]; metadata: any } | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
this.motifEngine = new MotifEngine();
|
||||||
this.midiService = new MIDIService();
|
this.midiService = new MIDIService();
|
||||||
this.midiProcessor = new MIDIProcessor();
|
|
||||||
this.roleMapper = new RoleMapper();
|
|
||||||
this.initializeUI();
|
this.initializeUI();
|
||||||
this.setupEventListeners();
|
this.setupEventListeners();
|
||||||
this.syncModelHint();
|
this.syncModelHint();
|
||||||
@@ -113,7 +106,7 @@ class ModelsApp {
|
|||||||
this.motifStopBtn.addEventListener('click', () => this.handleMotifStop());
|
this.motifStopBtn.addEventListener('click', () => this.handleMotifStop());
|
||||||
this.motifVolumeSlider.addEventListener('input', (e) => {
|
this.motifVolumeSlider.addEventListener('input', (e) => {
|
||||||
const volume = parseFloat((e.target as HTMLInputElement).value);
|
const volume = parseFloat((e.target as HTMLInputElement).value);
|
||||||
this.testEngine?.setVolume(volume);
|
this.motifEngine.setVolume(volume);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.modelSelect.addEventListener('change', () => this.syncModelHint());
|
this.modelSelect.addEventListener('change', () => this.syncModelHint());
|
||||||
@@ -357,17 +350,8 @@ class ModelsApp {
|
|||||||
this.updateStatus(`Generating Motif (${model})...`);
|
this.updateStatus(`Generating Motif (${model})...`);
|
||||||
this.motifBtn.disabled = true;
|
this.motifBtn.disabled = true;
|
||||||
|
|
||||||
// Ensure the previous model engine is stopped/cleared so each run is isolated.
|
await this.motifEngine.generateFromMIDI(this.currentMIDI.events, 'procedural', model);
|
||||||
this.handleMotifStop();
|
await this.motifEngine.play();
|
||||||
|
|
||||||
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.motifStopBtn.disabled = false;
|
||||||
this.updateStatus(`Playing Motif (${model})...`);
|
this.updateStatus(`Playing Motif (${model})...`);
|
||||||
@@ -378,8 +362,7 @@ class ModelsApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private handleMotifStop(): void {
|
private handleMotifStop(): void {
|
||||||
this.testEngine?.stop();
|
this.motifEngine.stop();
|
||||||
this.testEngine = null;
|
|
||||||
this.motifBtn.disabled = false;
|
this.motifBtn.disabled = false;
|
||||||
this.motifStopBtn.disabled = true;
|
this.motifStopBtn.disabled = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
export class SynthesisEngine {
|
||||||
private audioContext: AudioContext;
|
private audioContext: AudioContext;
|
||||||
private config: MotifConfig;
|
private config: MotifConfig;
|
||||||
private masterGain: GainNode;
|
private masterGain: GainNode;
|
||||||
|
private postGain: GainNode;
|
||||||
|
private model: SynthModel;
|
||||||
private layers: Map<Role, SynthLayer> = new Map();
|
private layers: Map<Role, SynthLayer> = new Map();
|
||||||
private roleAssignments: Map<Role, RoleAssignment> = new Map();
|
private roleAssignments: Map<Role, RoleAssignment> = new Map();
|
||||||
private isPlaying = false;
|
private isPlaying = false;
|
||||||
private schedulerIntervalId: number | null = null;
|
private schedulerIntervalId: number | null = null;
|
||||||
private startTime = 0;
|
private startTime = 0;
|
||||||
private nextEventIndex = new Map<Role, number>();
|
private nextEventIndex = new Map<Role, number>();
|
||||||
|
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.audioContext = audioContext;
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.masterGain = audioContext.createGain();
|
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.masterGain.gain.value = 0.3;
|
||||||
|
this.postGain.gain.value = 1.0;
|
||||||
|
|
||||||
|
// Route + optional effects
|
||||||
|
this.effectCleanup = this.configureMasterChain(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
setupLayers(assignments: RoleAssignment[]): void {
|
setupLayers(assignments: RoleAssignment[]): void {
|
||||||
// Clean up existing layers
|
// Clean up existing layers
|
||||||
this.cleanupLayers();
|
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
|
// Find the earliest event time across all assignments
|
||||||
let earliestTime = Infinity;
|
let earliestTime = Infinity;
|
||||||
for (const assignment of assignments) {
|
for (const assignment of filteredAssignments) {
|
||||||
if (assignment.events.length > 0) {
|
if (assignment.events.length > 0) {
|
||||||
earliestTime = Math.min(earliestTime, assignment.events[0].time);
|
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 we found events, normalize times to start at 0
|
||||||
if (earliestTime !== Infinity && earliestTime > 0) {
|
if (earliestTime !== Infinity && earliestTime > 0) {
|
||||||
console.log('Normalizing event times, earliest was:', earliestTime);
|
console.log('Normalizing event times, earliest was:', earliestTime);
|
||||||
for (const assignment of assignments) {
|
for (const assignment of filteredAssignments) {
|
||||||
// Normalize note events
|
// Normalize note events
|
||||||
for (const event of assignment.events) {
|
for (const event of assignment.events) {
|
||||||
event.time -= earliestTime;
|
event.time -= earliestTime;
|
||||||
@@ -50,7 +71,7 @@ export class SynthesisEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Store role assignments and create layers
|
// Store role assignments and create layers
|
||||||
for (const assignment of assignments) {
|
for (const assignment of filteredAssignments) {
|
||||||
const layer = this.createSynthLayer(assignment.role);
|
const layer = this.createSynthLayer(assignment.role);
|
||||||
this.layers.set(assignment.role, layer);
|
this.layers.set(assignment.role, layer);
|
||||||
this.roleAssignments.set(assignment.role, assignment);
|
this.roleAssignments.set(assignment.role, assignment);
|
||||||
@@ -175,37 +196,62 @@ export class SynthesisEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private configureLayerForRole(gain: GainNode, filter: BiquadFilterNode, role: Role): void {
|
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) {
|
switch (role) {
|
||||||
case 'bass':
|
case 'bass':
|
||||||
gain.gain.value = 0.4;
|
gain.gain.value = this.model === 'pre8bit' ? 0.5 : 0.4;
|
||||||
|
if (this.model !== 'pre8bit') {
|
||||||
filter.type = 'lowpass';
|
filter.type = 'lowpass';
|
||||||
filter.frequency.value = 200;
|
filter.frequency.value = this.model === 'snes_ish' ? 260 : 200;
|
||||||
|
} else {
|
||||||
|
filter.frequency.value = 350;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'drone':
|
case 'drone':
|
||||||
gain.gain.value = 0.2;
|
gain.gain.value = this.model === 'pre8bit' ? 0.0 : 0.2;
|
||||||
|
if (this.model !== 'pre8bit') {
|
||||||
filter.type = 'bandpass';
|
filter.type = 'bandpass';
|
||||||
filter.frequency.value = 400;
|
filter.frequency.value = this.model === 'snes_ish' ? 520 : 400;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'ostinato':
|
case 'ostinato':
|
||||||
gain.gain.value = 0.3;
|
gain.gain.value = this.model === 'pre8bit' ? 0.25 : 0.3;
|
||||||
|
if (this.model !== 'pre8bit') {
|
||||||
filter.type = 'highpass';
|
filter.type = 'highpass';
|
||||||
filter.frequency.value = 300;
|
filter.frequency.value = this.model === 'snes_ish' ? 220 : 300;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'texture':
|
case 'texture':
|
||||||
gain.gain.value = 0.1;
|
gain.gain.value = this.model === 'pre8bit' ? 0.0 : 0.1;
|
||||||
|
if (this.model !== 'pre8bit') {
|
||||||
filter.type = 'bandpass';
|
filter.type = 'bandpass';
|
||||||
filter.frequency.value = 800;
|
filter.frequency.value = this.model === 'snes_ish' ? 900 : 800;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'accents':
|
case 'accents':
|
||||||
gain.gain.value = 0.5;
|
gain.gain.value = this.model === 'pre8bit' ? 0.35 : 0.5;
|
||||||
|
if (this.model !== 'pre8bit') {
|
||||||
filter.type = 'peaking';
|
filter.type = 'peaking';
|
||||||
filter.frequency.value = 1000;
|
filter.frequency.value = this.model === 'snes_ish' ? 1200 : 1000;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'melody':
|
case 'melody':
|
||||||
// Passthrough mode - balanced sound for all notes
|
// 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.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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -218,6 +264,13 @@ export class SynthesisEngine {
|
|||||||
const layer = this.layers.get(role);
|
const layer = this.layers.get(role);
|
||||||
if (!layer) return;
|
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 osc = this.audioContext.createOscillator();
|
||||||
const envelope = this.audioContext.createGain();
|
const envelope = this.audioContext.createGain();
|
||||||
|
|
||||||
@@ -225,7 +278,21 @@ export class SynthesisEngine {
|
|||||||
const frequency = this.midiToFrequency(pitch);
|
const frequency = this.midiToFrequency(pitch);
|
||||||
osc.frequency.value = frequency;
|
osc.frequency.value = frequency;
|
||||||
|
|
||||||
// Choose oscillator type based on role
|
// 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) {
|
switch (role) {
|
||||||
case 'bass':
|
case 'bass':
|
||||||
osc.type = 'square';
|
osc.type = 'square';
|
||||||
@@ -244,14 +311,17 @@ export class SynthesisEngine {
|
|||||||
osc.type = 'sine';
|
osc.type = 'sine';
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
osc.connect(envelope);
|
osc.connect(envelope);
|
||||||
envelope.connect(layer.filterNode);
|
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 gainValue = velocity * 0.5; // Scale velocity
|
||||||
const attackTime = Math.max(0.005, Math.min(0.05, duration * 0.1)); // Min 5ms attack
|
const attackBase = this.model === 'pre8bit' ? 0.003 : this.model === 'snes_ish' ? 0.01 : 0.005;
|
||||||
const releaseTime = Math.max(0.01, Math.min(0.1, duration * 0.3)); // Min 10ms release
|
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.setValueAtTime(0, when);
|
||||||
envelope.gain.linearRampToValueAtTime(gainValue, when + attackTime);
|
envelope.gain.linearRampToValueAtTime(gainValue, when + attackTime);
|
||||||
@@ -261,6 +331,8 @@ export class SynthesisEngine {
|
|||||||
osc.start(when);
|
osc.start(when);
|
||||||
osc.stop(when + duration + releaseTime + 0.01); // Stop after envelope completes
|
osc.stop(when + duration + releaseTime + 0.01); // Stop after envelope completes
|
||||||
|
|
||||||
|
this.activeVoiceCount++;
|
||||||
|
|
||||||
// Clean up after note ends
|
// Clean up after note ends
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
try {
|
try {
|
||||||
@@ -269,6 +341,7 @@ export class SynthesisEngine {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Already disconnected
|
// Already disconnected
|
||||||
}
|
}
|
||||||
|
this.activeVoiceCount = Math.max(0, this.activeVoiceCount - 1);
|
||||||
}, (duration + releaseTime + 0.1) * 1000);
|
}, (duration + releaseTime + 0.1) * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,6 +363,12 @@ export class SynthesisEngine {
|
|||||||
|
|
||||||
if (!events.length && !chords.length) return;
|
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
|
// For roles that support polyphony (drone, texture), prefer chords
|
||||||
if ((role === 'drone' || role === 'texture') && chords.length > 0) {
|
if ((role === 'drone' || role === 'texture') && chords.length > 0) {
|
||||||
this.scheduleChordEvents(role, chords, scheduleUntil);
|
this.scheduleChordEvents(role, chords, scheduleUntil);
|
||||||
@@ -393,8 +472,12 @@ export class SynthesisEngine {
|
|||||||
const layer = this.layers.get(role);
|
const layer = this.layers.get(role);
|
||||||
if (!layer) return;
|
if (!layer) return;
|
||||||
|
|
||||||
// Create oscillator for each pitch in the chord
|
if (this.activeVoiceCount >= this.maxVoices) return;
|
||||||
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 osc = this.audioContext.createOscillator();
|
||||||
const envelope = this.audioContext.createGain();
|
const envelope = this.audioContext.createGain();
|
||||||
|
|
||||||
@@ -402,7 +485,15 @@ export class SynthesisEngine {
|
|||||||
const frequency = this.midiToFrequency(pitch);
|
const frequency = this.midiToFrequency(pitch);
|
||||||
osc.frequency.value = frequency;
|
osc.frequency.value = frequency;
|
||||||
|
|
||||||
// Choose oscillator type based on role
|
// 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) {
|
switch (role) {
|
||||||
case 'bass':
|
case 'bass':
|
||||||
osc.type = 'square';
|
osc.type = 'square';
|
||||||
@@ -423,14 +514,17 @@ export class SynthesisEngine {
|
|||||||
osc.type = 'sine';
|
osc.type = 'sine';
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
osc.connect(envelope);
|
osc.connect(envelope);
|
||||||
envelope.connect(layer.filterNode);
|
envelope.connect(layer.filterNode);
|
||||||
|
|
||||||
// Envelope based on velocity and duration, scaled for chords with minimum times to prevent clicks
|
// 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 gainValue = (velocity * 0.3) / Math.max(chordPitches.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 attackBase = this.model === 'snes_ish' ? 0.01 : 0.005;
|
||||||
const releaseTime = Math.max(0.01, Math.min(0.1, duration * 0.3)); // Min 10ms release
|
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.setValueAtTime(0, when);
|
||||||
envelope.gain.linearRampToValueAtTime(gainValue, when + attackTime);
|
envelope.gain.linearRampToValueAtTime(gainValue, when + attackTime);
|
||||||
@@ -440,6 +534,8 @@ export class SynthesisEngine {
|
|||||||
osc.start(when);
|
osc.start(when);
|
||||||
osc.stop(when + duration + releaseTime + 0.01); // Stop after envelope completes
|
osc.stop(when + duration + releaseTime + 0.01); // Stop after envelope completes
|
||||||
|
|
||||||
|
this.activeVoiceCount++;
|
||||||
|
|
||||||
// Clean up after note ends
|
// Clean up after note ends
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
try {
|
try {
|
||||||
@@ -448,6 +544,7 @@ export class SynthesisEngine {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Already disconnected
|
// Already disconnected
|
||||||
}
|
}
|
||||||
|
this.activeVoiceCount = Math.max(0, this.activeVoiceCount - 1);
|
||||||
}, (duration + releaseTime + 0.1) * 1000);
|
}, (duration + releaseTime + 0.1) * 1000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -467,4 +564,76 @@ export class SynthesisEngine {
|
|||||||
}
|
}
|
||||||
this.layers.clear();
|
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 {}
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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<Role, SynthLayer> = new Map();
|
|
||||||
private roleAssignments: Map<Role, RoleAssignment> = new Map();
|
|
||||||
|
|
||||||
private isPlaying = false;
|
|
||||||
private schedulerIntervalId: number | null = null;
|
|
||||||
private startTime = 0;
|
|
||||||
private nextEventIndex = new Map<Role, number>();
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -38,6 +38,9 @@ export interface StructuralFeatures {
|
|||||||
|
|
||||||
export type Role = 'bass' | 'drone' | 'ostinato' | 'texture' | 'accents' | 'melody';
|
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 {
|
export interface RoleAssignment {
|
||||||
role: Role;
|
role: Role;
|
||||||
sourceTrack: number;
|
sourceTrack: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user