From b3426f5ccdfe0c198f1d029c6062d8a75f5d9e00 Mon Sep 17 00:00:00 2001
From: b1rdmania <102524336+b1rdmania@users.noreply.github.com>
Date: Sat, 20 Dec 2025 12:28:02 +0000
Subject: [PATCH] Revert "Add test models page and synth presets."
This reverts commit b7a79e93c99ba305fcbbc028a99df526a91bae87.
---
index.html | 12 +-
models.html | 258 ---------------------
src/core/MotifEngine.ts | 12 +-
src/models.ts | 378 -------------------------------
src/synthesis/SynthesisEngine.ts | 307 ++++++-------------------
src/types/index.ts | 3 -
vercel.json | 4 -
vite.config.ts | 1 -
8 files changed, 75 insertions(+), 900 deletions(-)
delete mode 100644 models.html
delete mode 100644 src/models.ts
diff --git a/index.html b/index.html
index 96cde02..99ae1e3 100644
--- a/index.html
+++ b/index.html
@@ -84,13 +84,8 @@
.topbar h1 {
margin: 0;
}
- .topbar-actions {
- margin-left: auto;
- display: inline-flex;
- align-items: center;
- gap: 8px;
- }
.faq-link {
+ margin-left: auto;
background: transparent;
border: 1px solid rgba(255,255,255,0.14);
color: rgba(255,255,255,0.85);
@@ -825,10 +820,7 @@
Procedural Music Synthesis from MIDI Structure
diff --git a/models.html b/models.html
deleted file mode 100644
index 9b9c807..0000000
--- a/models.html
+++ /dev/null
@@ -1,258 +0,0 @@
-
-
-
-
-
-
MOTIF — Test models
-
-
-
-
-
-
Test models: Pre‑8bit → NES/GB → SNES‑ish
-
-
-
-
-
-
-
Ready. Enter a song name to search for MIDI files.
-
-
-
-
-
-
-
- | Title |
- Source |
- Duration |
-
-
-
-
-
-
-
-
-
-
No MIDI selected
-
Select a MIDI file from search results to enable playback
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Square/triangle/noise vibe, tight envelope.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- iOS Safari: tap once to enable audio, then press Generate & Play.
-
-
-
-
Audio: …
-
-
-
-
-
-
-
-
-
diff --git a/src/core/MotifEngine.ts b/src/core/MotifEngine.ts
index 63366f1..357f1e2 100644
--- a/src/core/MotifEngine.ts
+++ b/src/core/MotifEngine.ts
@@ -1,4 +1,4 @@
-import type { NoteEvent, MotifConfig, SynthModel } from '../types';
+import type { NoteEvent, MotifConfig } from '../types';
import { MIDIProcessor } from '../midi/MIDIProcessor';
import { MIDIParser } from '../midi/MIDIParser';
import { MIDIService } from '../services/MIDIService';
@@ -27,17 +27,13 @@ export class MotifEngine {
this.roleMapper = new RoleMapper();
}
- async generateFromMIDI(
- events: NoteEvent[],
- transformMode: 'passthrough' | 'procedural' = 'passthrough',
- model: SynthModel = 'nes_gb'
- ): Promise
{
+ async generateFromMIDI(events: NoteEvent[], transformMode: 'passthrough' | 'procedural' = 'passthrough'): Promise {
// Initialize audio context using shared unlock (iOS compatibility)
if (!this.audioContext) {
this.audioContext = await unlockAudio();
}
- this.synthesisEngine = new SynthesisEngine(this.audioContext, this.config, model);
+ this.synthesisEngine = new SynthesisEngine(this.audioContext, this.config);
if (transformMode === 'passthrough') {
// Direct playback mode - play MIDI as-is without transformations
@@ -111,7 +107,7 @@ export class MotifEngine {
this.audioContext = await unlockAudio();
}
- this.synthesisEngine = new SynthesisEngine(this.audioContext, this.config, 'nes_gb');
+ this.synthesisEngine = new SynthesisEngine(this.audioContext, this.config);
this.synthesisEngine.setupLayers(roleAssignments);
}
diff --git a/src/models.ts b/src/models.ts
deleted file mode 100644
index a624680..0000000
--- a/src/models.ts
+++ /dev/null
@@ -1,378 +0,0 @@
-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 {
- 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 {
- 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 {
- 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 = `
- ${result.title} |
- ${result.source} |
- ${result.parsed ? Math.round(result.parsed.durationSec) + 's' : '?'} |
- `;
-
- 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 {
- 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 = `
- Source: ${result.source} |
- Duration: ${Math.round(actualDuration)}s |
- Tracks: ${metadata.trackCount} |
- Notes: ${events.length} |
- Tempo: ${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 {
- 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 {
- 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;
-
diff --git a/src/synthesis/SynthesisEngine.ts b/src/synthesis/SynthesisEngine.ts
index 1fe4d4c..d4eeb5f 100644
--- a/src/synthesis/SynthesisEngine.ts
+++ b/src/synthesis/SynthesisEngine.ts
@@ -1,52 +1,31 @@
-import type { RoleAssignment, MotifConfig, SynthLayer, Role, NoteEvent, ChordEvent, SynthModel } from '../types';
+import type { RoleAssignment, MotifConfig, SynthLayer, Role, NoteEvent, ChordEvent } from '../types';
export class SynthesisEngine {
private audioContext: AudioContext;
private config: MotifConfig;
private masterGain: GainNode;
- private postGain: GainNode;
- private model: SynthModel;
private layers: Map = new Map();
private roleAssignments: Map = new Map();
private isPlaying = false;
private schedulerIntervalId: number | null = null;
private startTime = 0;
private nextEventIndex = new Map();
- private activeVoiceCount = 0;
- private maxVoices: number;
- private effectCleanup: (() => void) | null = null;
- constructor(audioContext: AudioContext, config: MotifConfig, model: SynthModel = 'nes_gb') {
+ constructor(audioContext: AudioContext, config: MotifConfig) {
this.audioContext = audioContext;
this.config = config;
this.masterGain = audioContext.createGain();
- this.postGain = audioContext.createGain();
- this.postGain.connect(audioContext.destination);
-
- this.model = model;
- this.maxVoices = this.getMaxVoicesForModel(model, config.maxOscillators);
-
- // Default overall level
+ this.masterGain.connect(audioContext.destination);
this.masterGain.gain.value = 0.3;
- this.postGain.gain.value = 1.0;
-
- // Route + optional effects
- this.effectCleanup = this.configureMasterChain(model);
}
setupLayers(assignments: RoleAssignment[]): void {
// Clean up existing layers
this.cleanupLayers();
- // Reset voice budgeting per setup (important when switching models)
- this.activeVoiceCount = 0;
-
- // Apply model role filtering
- const filteredAssignments = this.filterAssignmentsForModel(assignments);
-
// Find the earliest event time across all assignments
let earliestTime = Infinity;
- for (const assignment of filteredAssignments) {
+ for (const assignment of assignments) {
if (assignment.events.length > 0) {
earliestTime = Math.min(earliestTime, assignment.events[0].time);
}
@@ -58,7 +37,7 @@ export class SynthesisEngine {
// If we found events, normalize times to start at 0
if (earliestTime !== Infinity && earliestTime > 0) {
console.log('Normalizing event times, earliest was:', earliestTime);
- for (const assignment of filteredAssignments) {
+ for (const assignment of assignments) {
// Normalize note events
for (const event of assignment.events) {
event.time -= earliestTime;
@@ -71,7 +50,7 @@ export class SynthesisEngine {
}
// Store role assignments and create layers
- for (const assignment of filteredAssignments) {
+ for (const assignment of assignments) {
const layer = this.createSynthLayer(assignment.role);
this.layers.set(assignment.role, layer);
this.roleAssignments.set(assignment.role, assignment);
@@ -196,62 +175,37 @@ export class SynthesisEngine {
}
private configureLayerForRole(gain: GainNode, filter: BiquadFilterNode, role: Role): void {
- // Default per-model filter shaping (keeps presets recognizable)
- if (this.model === 'pre8bit') {
- // Very bright, very simple
- filter.type = 'lowpass';
- filter.frequency.value = 6000;
- filter.Q.value = 0.8;
- } else if (this.model === 'snes_ish') {
- // Warmer, a little more body
- filter.type = 'lowpass';
- filter.frequency.value = 2400;
- filter.Q.value = 0.9;
- }
-
switch (role) {
case 'bass':
- gain.gain.value = this.model === 'pre8bit' ? 0.5 : 0.4;
- if (this.model !== 'pre8bit') {
- filter.type = 'lowpass';
- filter.frequency.value = this.model === 'snes_ish' ? 260 : 200;
- } else {
- filter.frequency.value = 350;
- }
+ gain.gain.value = 0.4;
+ filter.type = 'lowpass';
+ filter.frequency.value = 200;
break;
case 'drone':
- gain.gain.value = this.model === 'pre8bit' ? 0.0 : 0.2;
- if (this.model !== 'pre8bit') {
- filter.type = 'bandpass';
- filter.frequency.value = this.model === 'snes_ish' ? 520 : 400;
- }
+ gain.gain.value = 0.2;
+ filter.type = 'bandpass';
+ filter.frequency.value = 400;
break;
case 'ostinato':
- gain.gain.value = this.model === 'pre8bit' ? 0.25 : 0.3;
- if (this.model !== 'pre8bit') {
- filter.type = 'highpass';
- filter.frequency.value = this.model === 'snes_ish' ? 220 : 300;
- }
+ gain.gain.value = 0.3;
+ filter.type = 'highpass';
+ filter.frequency.value = 300;
break;
case 'texture':
- gain.gain.value = this.model === 'pre8bit' ? 0.0 : 0.1;
- if (this.model !== 'pre8bit') {
- filter.type = 'bandpass';
- filter.frequency.value = this.model === 'snes_ish' ? 900 : 800;
- }
+ gain.gain.value = 0.1;
+ filter.type = 'bandpass';
+ filter.frequency.value = 800;
break;
case 'accents':
- gain.gain.value = this.model === 'pre8bit' ? 0.35 : 0.5;
- if (this.model !== 'pre8bit') {
- filter.type = 'peaking';
- filter.frequency.value = this.model === 'snes_ish' ? 1200 : 1000;
- }
+ gain.gain.value = 0.5;
+ filter.type = 'peaking';
+ filter.frequency.value = 1000;
break;
case 'melody':
// Passthrough mode - balanced sound for all notes
- gain.gain.value = this.model === 'pre8bit' ? 0.4 : 0.35;
+ gain.gain.value = 0.35;
filter.type = 'lowpass';
- filter.frequency.value = this.model === 'snes_ish' ? 3200 : 4000; // Brighter for chip, warmer for SNES-ish
+ filter.frequency.value = 4000; // Brighter sound for full range
break;
}
}
@@ -263,13 +217,6 @@ export class SynthesisEngine {
private scheduleNote(role: Role, pitch: number, duration: number, velocity: number, when: number): void {
const layer = this.layers.get(role);
if (!layer) return;
-
- // Model gating: pre8bit runs intentionally sparse
- if (this.model === 'pre8bit') {
- if (role !== 'bass' && role !== 'melody') return;
- }
-
- if (this.activeVoiceCount >= this.maxVoices) return;
const osc = this.audioContext.createOscillator();
const envelope = this.audioContext.createGain();
@@ -278,50 +225,33 @@ export class SynthesisEngine {
const frequency = this.midiToFrequency(pitch);
osc.frequency.value = frequency;
- // Choose oscillator type based on model + role
- if (this.model === 'pre8bit') {
- osc.type = role === 'bass' ? 'triangle' : 'square';
- } else if (this.model === 'snes_ish') {
- // Warmer “sample-ish” feel: richer waveforms + filtering + echo on master
- if (role === 'bass') osc.type = 'triangle';
- else if (role === 'drone') osc.type = 'sawtooth';
- else if (role === 'ostinato') osc.type = 'triangle';
- else if (role === 'melody') osc.type = 'sawtooth';
- else osc.type = 'sine';
-
- // Subtle detune (feels less “pure chip”)
- osc.detune.value = (Math.random() - 0.5) * 8; // ±4 cents
- } else {
- // nes_gb (current default)
- switch (role) {
- case 'bass':
- osc.type = 'square';
- break;
- case 'drone':
- osc.type = 'sawtooth';
- break;
- case 'ostinato':
- osc.type = 'triangle';
- break;
- case 'melody':
- osc.type = 'triangle';
- break;
- case 'texture':
- case 'accents':
- osc.type = 'sine';
- break;
- }
+ // Choose oscillator type based on role
+ switch (role) {
+ case 'bass':
+ osc.type = 'square';
+ break;
+ case 'drone':
+ osc.type = 'sawtooth';
+ break;
+ case 'ostinato':
+ osc.type = 'triangle';
+ break;
+ case 'melody':
+ osc.type = 'triangle';
+ break;
+ case 'texture':
+ case 'accents':
+ osc.type = 'sine';
+ break;
}
osc.connect(envelope);
envelope.connect(layer.filterNode);
- // Envelope tuned per model (still with minimum times to prevent clicks)
+ // Envelope based on velocity and duration with minimum times to prevent clicks
const gainValue = velocity * 0.5; // Scale velocity
- const attackBase = this.model === 'pre8bit' ? 0.003 : this.model === 'snes_ish' ? 0.01 : 0.005;
- const releaseBase = this.model === 'pre8bit' ? 0.008 : this.model === 'snes_ish' ? 0.14 : 0.01;
- const attackTime = Math.max(attackBase, Math.min(0.06, duration * 0.1));
- const releaseTime = Math.max(releaseBase, Math.min(this.model === 'snes_ish' ? 0.22 : 0.12, duration * 0.3));
+ const attackTime = Math.max(0.005, Math.min(0.05, duration * 0.1)); // Min 5ms attack
+ const releaseTime = Math.max(0.01, Math.min(0.1, duration * 0.3)); // Min 10ms release
envelope.gain.setValueAtTime(0, when);
envelope.gain.linearRampToValueAtTime(gainValue, when + attackTime);
@@ -330,8 +260,6 @@ export class SynthesisEngine {
osc.start(when);
osc.stop(when + duration + releaseTime + 0.01); // Stop after envelope completes
-
- this.activeVoiceCount++;
// Clean up after note ends
setTimeout(() => {
@@ -341,7 +269,6 @@ export class SynthesisEngine {
} catch (e) {
// Already disconnected
}
- this.activeVoiceCount = Math.max(0, this.activeVoiceCount - 1);
}, (duration + releaseTime + 0.1) * 1000);
}
@@ -363,12 +290,6 @@ 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);
@@ -471,13 +392,9 @@ export class SynthesisEngine {
private scheduleChord(role: Role, pitches: number[], duration: number, velocity: number, when: number): void {
const layer = this.layers.get(role);
if (!layer) return;
-
- if (this.activeVoiceCount >= this.maxVoices) return;
- // Create oscillator for each pitch in the chord (unless constrained)
- const chordPitches = this.model === 'snes_ish' ? pitches.slice(0, 4) : pitches.slice(0, 3);
- for (const pitch of chordPitches) {
- if (this.activeVoiceCount >= this.maxVoices) break;
+ // Create oscillator for each pitch in the chord
+ for (const pitch of pitches) {
const osc = this.audioContext.createOscillator();
const envelope = this.audioContext.createGain();
@@ -485,46 +402,35 @@ export class SynthesisEngine {
const frequency = this.midiToFrequency(pitch);
osc.frequency.value = frequency;
- // Choose oscillator type based on model + role (same as note path)
- if (this.model === 'snes_ish') {
- if (role === 'bass') osc.type = 'triangle';
- else if (role === 'drone') osc.type = 'sawtooth';
- else if (role === 'ostinato') osc.type = 'triangle';
- else if (role === 'melody') osc.type = 'sawtooth';
- else osc.type = 'sine';
- osc.detune.value = (Math.random() - 0.5) * 8;
- } else {
- switch (role) {
- case 'bass':
- osc.type = 'square';
- break;
- case 'drone':
- osc.type = 'sawtooth';
- break;
- case 'ostinato':
- osc.type = 'triangle';
- break;
- case 'texture':
- osc.type = 'sine';
- break;
- case 'melody':
- osc.type = 'triangle';
- break;
- case 'accents':
- osc.type = 'sine';
- break;
- }
+ // Choose oscillator type based on role
+ switch (role) {
+ case 'bass':
+ osc.type = 'square';
+ break;
+ case 'drone':
+ osc.type = 'sawtooth';
+ break;
+ case 'ostinato':
+ osc.type = 'triangle';
+ break;
+ case 'texture':
+ osc.type = 'sine';
+ break;
+ case 'melody':
+ osc.type = 'triangle';
+ break;
+ case 'accents':
+ osc.type = 'sine';
+ break;
}
osc.connect(envelope);
envelope.connect(layer.filterNode);
// Envelope based on velocity and duration, scaled for chords with minimum times to prevent clicks
- const gainValue = (velocity * 0.3) / Math.max(chordPitches.length * 0.5, 1); // Scale down for chords
- const attackBase = this.model === 'snes_ish' ? 0.01 : 0.005;
- const releaseBase = this.model === 'snes_ish' ? 0.16 : 0.01;
- const attackTime = Math.max(attackBase, Math.min(0.06, duration * 0.1));
- const releaseTime = Math.max(releaseBase, Math.min(this.model === 'snes_ish' ? 0.24 : 0.12, duration * 0.3));
+ const gainValue = (velocity * 0.3) / Math.max(pitches.length * 0.5, 1); // Scale down for chords
+ const attackTime = Math.max(0.005, Math.min(0.05, duration * 0.1)); // Min 5ms attack
+ const releaseTime = Math.max(0.01, Math.min(0.1, duration * 0.3)); // Min 10ms release
envelope.gain.setValueAtTime(0, when);
envelope.gain.linearRampToValueAtTime(gainValue, when + attackTime);
@@ -534,8 +440,6 @@ export class SynthesisEngine {
osc.start(when);
osc.stop(when + duration + releaseTime + 0.01); // Stop after envelope completes
- this.activeVoiceCount++;
-
// Clean up after note ends
setTimeout(() => {
try {
@@ -544,7 +448,6 @@ export class SynthesisEngine {
} catch (e) {
// Already disconnected
}
- this.activeVoiceCount = Math.max(0, this.activeVoiceCount - 1);
}, (duration + releaseTime + 0.1) * 1000);
}
}
@@ -564,76 +467,4 @@ export class SynthesisEngine {
}
this.layers.clear();
}
-
- private getMaxVoicesForModel(model: SynthModel, defaultMax: number): number {
- if (model === 'pre8bit') return Math.min(2, defaultMax);
- if (model === 'snes_ish') return Math.max(12, defaultMax);
- return defaultMax;
- }
-
- private filterAssignmentsForModel(assignments: RoleAssignment[]): RoleAssignment[] {
- if (this.model === 'pre8bit') {
- // Keep it intentionally simple and sparse.
- const keep: Role[] = ['melody', 'bass'];
- const kept = assignments.filter(a => keep.includes(a.role));
- // If role mapper didn’t produce those roles, fall back to first available assignment.
- if (kept.length > 0) return kept.slice(0, 2);
- return assignments.slice(0, 1);
- }
- return assignments;
- }
-
- private configureMasterChain(model: SynthModel): () => void {
- // Disconnect any previous chain
- try { this.masterGain.disconnect(); } catch {}
- if (this.effectCleanup) {
- try { this.effectCleanup(); } catch {}
- }
-
- // Default: dry only
- if (model !== 'snes_ish') {
- this.masterGain.connect(this.postGain);
- return () => {
- try { this.masterGain.disconnect(); } catch {}
- };
- }
-
- // SNES-ish: add a simple echo/reverb-like feedback delay with filtering.
- const dry = this.audioContext.createGain();
- const wet = this.audioContext.createGain();
- const delay = this.audioContext.createDelay(1.0);
- const feedback = this.audioContext.createGain();
- const fbFilter = this.audioContext.createBiquadFilter();
-
- dry.gain.value = 0.85;
- wet.gain.value = 0.28;
- delay.delayTime.value = 0.165; // ~165ms echo
- feedback.gain.value = 0.32;
- fbFilter.type = 'lowpass';
- fbFilter.frequency.value = 1800;
- fbFilter.Q.value = 0.7;
-
- // master -> dry -> post
- this.masterGain.connect(dry);
- dry.connect(this.postGain);
-
- // master -> delay -> wet -> post
- this.masterGain.connect(delay);
- delay.connect(wet);
- wet.connect(this.postGain);
-
- // feedback loop: delay -> filter -> feedback -> delay
- delay.connect(fbFilter);
- fbFilter.connect(feedback);
- feedback.connect(delay);
-
- return () => {
- try { this.masterGain.disconnect(); } catch {}
- try { dry.disconnect(); } catch {}
- try { wet.disconnect(); } catch {}
- try { delay.disconnect(); } catch {}
- try { feedback.disconnect(); } catch {}
- try { fbFilter.disconnect(); } catch {}
- };
- }
}
\ No newline at end of file
diff --git a/src/types/index.ts b/src/types/index.ts
index 09acb86..42261df 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -38,9 +38,6 @@ export interface StructuralFeatures {
export type Role = 'bass' | 'drone' | 'ostinato' | 'texture' | 'accents' | 'melody';
-// Procedural synth model presets (test page uses these)
-export type SynthModel = 'pre8bit' | 'nes_gb' | 'snes_ish';
-
export interface RoleAssignment {
role: Role;
sourceTrack: number;
diff --git a/vercel.json b/vercel.json
index c402c1f..cffc83d 100644
--- a/vercel.json
+++ b/vercel.json
@@ -26,10 +26,6 @@
"src": "/embed",
"dest": "/embed.html"
},
- {
- "src": "/models",
- "dest": "/models.html"
- },
{ "handle": "filesystem" },
{
"src": "/(.*)",
diff --git a/vite.config.ts b/vite.config.ts
index 2658b45..1b65634 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -12,7 +12,6 @@ export default defineConfig({
input: {
main: resolve(__dirname, 'index.html'),
embed: resolve(__dirname, 'embed.html'),
- models: resolve(__dirname, 'models.html'),
},
},
},