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

🎵 MOTIF

-
- Models - -
+

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

🎵 MOTIF

-
- Home -
-
-

Test models: Pre‑8bit → NES/GB → SNES‑ish

- -
- - -
- -
Ready. Enter a song name to search for MIDI files.
- -
-
-

Search Results

-

Pick a MIDI, then compare models.

-
-
- - - - - - - - - -
TitleSourceDuration
-
- -
-
-
-

No MIDI selected

-

Select a MIDI file from search results to enable playback

-
-
- -
-
-

Preview

-

Soundfont MIDI preview (baseline).

-
-
-
- - -
- - -
- -
-
- -
-
-

Motif generation

-

Same MIDI → different synth models.

-
-
- -
- - - 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'), }, }, },