Add test models page and synth presets.

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