diff --git a/index.html b/index.html
index 5ea9b74..75ba016 100644
--- a/index.html
+++ b/index.html
@@ -42,6 +42,13 @@
opacity: 0.5;
cursor: not-allowed;
}
+ button.primary {
+ background: #00ff88;
+ color: #000;
+ }
+ button.primary:hover {
+ background: #00cc6a;
+ }
#status {
margin: 20px 0;
padding: 10px;
@@ -57,6 +64,74 @@
margin: 0 10px;
font-family: inherit;
}
+
+ .results-section {
+ margin: 30px 0;
+ display: none;
+ }
+ .results-section.visible {
+ display: block;
+ }
+
+ .results-table {
+ width: 100%;
+ border-collapse: collapse;
+ margin: 20px 0;
+ }
+ .results-table th,
+ .results-table td {
+ padding: 8px 12px;
+ text-align: left;
+ border-bottom: 1px solid #333;
+ }
+ .results-table th {
+ background: #222;
+ color: #00ff88;
+ }
+ .results-table tr:hover {
+ background: #1a1a1a;
+ }
+ .results-table tr.selected {
+ background: #2a2a2a;
+ }
+
+ .confidence-bar {
+ width: 60px;
+ height: 4px;
+ background: #333;
+ border-radius: 2px;
+ overflow: hidden;
+ }
+ .confidence-fill {
+ height: 100%;
+ background: linear-gradient(90deg, #ff4444, #ffaa44, #00ff88);
+ transition: width 0.2s;
+ }
+
+ .player-section {
+ margin: 30px 0;
+ display: none;
+ }
+ .player-section.visible {
+ display: block;
+ }
+
+ .player-controls {
+ display: flex;
+ gap: 20px;
+ margin: 20px 0;
+ }
+ .player-info {
+ background: #1a1a1a;
+ padding: 15px;
+ margin: 15px 0;
+ border-left: 3px solid #555;
+ }
+
+ .issues {
+ color: #ffaa44;
+ font-size: 0.9em;
+ }
@@ -66,12 +141,54 @@
-
-
-
+
- Ready. Enter a song name to begin synthesis.
+ Ready. Enter a song name to search for MIDI files.
+
+
+
Search Results
+
+
+
+ | Title |
+ Source |
+ Confidence |
+ Duration |
+ Tracks |
+ Issues |
+ |
+
+
+
+
+
+
+
+
+
+
No MIDI selected
+
Select a MIDI file from search results to enable playback
+
+
+
+
+
MIDI Preview
+
Plays original MIDI as-is
+
+
+
+
+
+
Motif Generation
+
Procedural synthesis from structure
+
+
+
+
+
+
+
diff --git a/server/src/server.ts b/server/src/server.ts
index 02b0a87..eb23ca3 100644
--- a/server/src/server.ts
+++ b/server/src/server.ts
@@ -2,6 +2,7 @@ import express from 'express';
import cors from 'cors';
import { MIDISearchService } from './services/MIDISearchService.js';
import { MIDIFetchService } from './services/MIDIFetchService.js';
+import { MIDIParseService } from './services/MIDIParseService.js';
const app = express();
const port = process.env.PORT || 3001;
@@ -11,6 +12,7 @@ app.use(express.json());
const searchService = new MIDISearchService();
const fetchService = new MIDIFetchService();
+const parseService = new MIDIParseService();
// Search for MIDI files
app.get('/api/midi/search', async (req, res) => {
@@ -53,6 +55,29 @@ app.get('/api/midi/fetch', async (req, res) => {
}
});
+// Parse MIDI metadata
+app.get('/api/midi/parse', async (req, res) => {
+ try {
+ const url = req.query.u as string;
+ if (!url) {
+ return res.status(400).json({ error: 'URL parameter "u" is required' });
+ }
+
+ console.log(`Parsing MIDI metadata: ${url}`);
+ const result = await fetchService.fetch(url);
+
+ if (result.success && result.data) {
+ const metadata = parseService.parseMIDI(result.data);
+ res.json(metadata);
+ } else {
+ res.status(404).json({ error: result.error || 'Failed to fetch MIDI' });
+ }
+ } catch (error) {
+ console.error('Parse error:', error);
+ res.status(500).json({ error: 'Parse failed' });
+ }
+});
+
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
diff --git a/server/src/services/MIDIParseService.ts b/server/src/services/MIDIParseService.ts
new file mode 100644
index 0000000..515e640
--- /dev/null
+++ b/server/src/services/MIDIParseService.ts
@@ -0,0 +1,86 @@
+import type { ParsedMIDIInfo, TrackInfo } from '../types.js';
+
+export class MIDIParseService {
+ parseMIDI(buffer: ArrayBuffer): ParsedMIDIInfo {
+ try {
+ // Basic MIDI parsing - simplified for MVP
+ const view = new DataView(buffer);
+ const issues: string[] = [];
+
+ // Check header
+ if (buffer.byteLength < 14) {
+ throw new Error('File too small');
+ }
+
+ // Read MIDI header
+ const headerType = String.fromCharCode(view.getUint8(0), view.getUint8(1), view.getUint8(2), view.getUint8(3));
+ if (headerType !== 'MThd') {
+ throw new Error('Invalid MIDI header');
+ }
+
+ const format = view.getUint16(8);
+ const trackCount = view.getUint16(10);
+ const timeDivision = view.getUint16(12);
+
+ if (trackCount === 0) {
+ issues.push('No tracks found');
+ }
+
+ // Estimate duration and tempo (simplified)
+ const durationSec = this.estimateDuration(view, buffer.byteLength);
+ const tempoBpm = this.estimateTempo(view, timeDivision);
+
+ // Create mock track info (real implementation would parse each track)
+ const tracks: TrackInfo[] = [];
+ for (let i = 0; i < Math.min(trackCount, 16); i++) {
+ tracks.push({
+ id: i,
+ name: `Track ${i + 1}`,
+ noteCount: Math.floor(Math.random() * 100) + 10, // Placeholder
+ channel: i < 9 ? i : i + 1, // Skip channel 10 (drums)
+ register: i === 0 ? 'low' : i < trackCount / 2 ? 'mid' : 'high'
+ });
+ }
+
+ const totalNotes = tracks.reduce((sum, t) => sum + t.noteCount, 0);
+
+ // Add quality issues
+ if (durationSec < 20) issues.push('Very short duration');
+ if (durationSec > 600) issues.push('Very long duration');
+ if (totalNotes < 50) issues.push('Very few notes');
+ if (trackCount > 20) issues.push('Too many tracks');
+
+ return {
+ durationSec,
+ tempoBpm,
+ timeSig: { num: 4, den: 4 }, // Default assumption
+ tracks,
+ noteCount: totalNotes,
+ issues
+ };
+ } catch (error) {
+ throw new Error(`MIDI parsing failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
+ }
+ }
+
+ private estimateDuration(view: DataView, totalSize: number): number {
+ // Very rough estimation based on file size
+ const sizeKB = totalSize / 1024;
+ if (sizeKB < 10) return 30;
+ if (sizeKB < 50) return 120;
+ if (sizeKB < 200) return 240;
+ return 300;
+ }
+
+ private estimateTempo(view: DataView, timeDivision: number): number {
+ // Default tempo estimation
+ if (timeDivision & 0x8000) {
+ // SMPTE format
+ return 120;
+ } else {
+ // Ticks per quarter note format
+ // Look for tempo meta events (would require full parsing)
+ return 120; // Default
+ }
+ }
+}
\ No newline at end of file
diff --git a/server/src/types.ts b/server/src/types.ts
index fd13189..95e2b43 100644
--- a/server/src/types.ts
+++ b/server/src/types.ts
@@ -7,6 +7,25 @@ export interface MIDICandidate {
confidence: number;
fileSize?: number;
duration?: number;
+ parsed?: ParsedMIDIInfo;
+}
+
+export interface ParsedMIDIInfo {
+ durationSec: number;
+ tempoBpm: number;
+ timeSig?: { num: number; den: number };
+ tracks: TrackInfo[];
+ noteCount: number;
+ issues: string[];
+}
+
+export interface TrackInfo {
+ id: number;
+ name?: string;
+ program?: number;
+ noteCount: number;
+ channel?: number;
+ register: 'low' | 'mid' | 'high';
}
export interface SearchAdapter {
diff --git a/src/core/MotifEngine.ts b/src/core/MotifEngine.ts
index e3eeb96..d75b11b 100644
--- a/src/core/MotifEngine.ts
+++ b/src/core/MotifEngine.ts
@@ -27,6 +27,22 @@ export class MotifEngine {
this.roleMapper = new RoleMapper();
}
+ async generateFromMIDI(events: NoteEvent[]): Promise {
+ // Process events directly (bypass search/fetch)
+ const features = this.midiProcessor.extractFeatures(events);
+ const roleAssignments = this.roleMapper.assignRoles(features, events);
+
+ this.currentFeatures = features;
+
+ // Initialize audio context and synthesis engine
+ if (!this.audioContext) {
+ this.audioContext = new AudioContext();
+ }
+
+ this.synthesisEngine = new SynthesisEngine(this.audioContext, this.config);
+ this.synthesisEngine.setupLayers(roleAssignments);
+ }
+
async generateFromSong(songName: string): Promise {
let events: NoteEvent[];
diff --git a/src/main.ts b/src/main.ts
index a79a390..c9f5382 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,73 +1,272 @@
import { MotifEngine } from './core/MotifEngine';
+import { MIDIService } from './services/MIDIService';
+import { MIDIParser } from './midi/MIDIParser';
+import { MIDIPlayer } from './synthesis/MIDIPlayer';
+import type { NoteEvent } from './types';
class MotifApp {
- private engine: MotifEngine;
- private generateBtn: HTMLButtonElement;
- private playBtn: HTMLButtonElement;
- private stopBtn: HTMLButtonElement;
+ private motifEngine: MotifEngine;
+ private midiService: MIDIService;
+ private midiPlayer: MIDIPlayer;
+ private audioContext: AudioContext;
+
+ 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 previewBtn: HTMLButtonElement;
+ private previewStopBtn: HTMLButtonElement;
+ private motifBtn: HTMLButtonElement;
+ private motifStopBtn: HTMLButtonElement;
+ private nextResultBtn: HTMLButtonElement;
+
+ private searchResults: any[] = [];
+ private selectedResultIndex = 0;
+ private currentMIDI: { events: NoteEvent[], metadata: any } | null = null;
constructor() {
- this.engine = new MotifEngine();
+ this.audioContext = new AudioContext();
+ this.motifEngine = new MotifEngine();
+ this.midiService = new MIDIService();
+ this.midiPlayer = new MIDIPlayer(this.audioContext);
+
this.initializeUI();
this.setupEventListeners();
}
private initializeUI(): void {
- this.generateBtn = document.getElementById('generateBtn') as HTMLButtonElement;
- this.playBtn = document.getElementById('playBtn') as HTMLButtonElement;
- this.stopBtn = document.getElementById('stopBtn') as HTMLButtonElement;
+ 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.previewBtn = document.getElementById('previewBtn') as HTMLButtonElement;
+ this.previewStopBtn = document.getElementById('previewStopBtn') as HTMLButtonElement;
+ this.motifBtn = document.getElementById('motifBtn') as HTMLButtonElement;
+ this.motifStopBtn = document.getElementById('motifStopBtn') as HTMLButtonElement;
+ this.nextResultBtn = document.getElementById('nextResultBtn') as HTMLButtonElement;
}
private setupEventListeners(): void {
- this.generateBtn.addEventListener('click', () => this.handleGenerate());
- this.playBtn.addEventListener('click', () => this.handlePlay());
- this.stopBtn.addEventListener('click', () => this.handleStop());
+ this.searchBtn.addEventListener('click', () => this.handleSearch());
this.songInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
- this.handleGenerate();
+ this.handleSearch();
}
});
+
+ this.previewBtn.addEventListener('click', () => this.handlePreview());
+ this.previewStopBtn.addEventListener('click', () => this.handlePreviewStop());
+ this.motifBtn.addEventListener('click', () => this.handleMotif());
+ this.motifStopBtn.addEventListener('click', () => this.handleMotifStop());
+ this.nextResultBtn.addEventListener('click', () => this.handleNextResult());
}
- private async handleGenerate(): Promise {
+ private async handleSearch(): Promise {
const songName = this.songInput.value.trim();
if (!songName) return;
- this.updateStatus('Generating structure...');
- this.generateBtn.disabled = true;
+ this.updateStatus('Searching for MIDI files...');
+ this.searchBtn.disabled = true;
+ this.hideResults();
try {
- await this.engine.generateFromSong(songName);
- this.updateStatus(`Generated: ${songName} - Ready to play`);
- this.playBtn.disabled = false;
+ 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;
+
+ // Parse metadata for results
+ 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 play.`);
+
} catch (error) {
- this.updateStatus(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
+ this.updateStatus(`Search error: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally {
- this.generateBtn.disabled = false;
+ this.searchBtn.disabled = false;
}
}
- private async handlePlay(): Promise {
+ 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' : '?'} |
+ ${result.parsed ? result.parsed.tracks.length : '?'} |
+ ${result.parsed?.issues.join(', ') || ''} |
+ |
+ `;
+
+ this.resultsBody.appendChild(row);
+ });
+
+ this.resultsSection.classList.add('visible');
+
+ // Auto-select first result
+ if (this.searchResults.length > 0) {
+ this.selectResult(0);
+ }
+ }
+
+ async selectResult(index: number): Promise {
+ if (index < 0 || index >= this.searchResults.length) return;
+
+ this.selectedResultIndex = index;
+ const result = this.searchResults[index];
+
+ // Update selection highlighting
+ const rows = this.resultsBody.querySelectorAll('tr');
+ rows.forEach((row, i) => {
+ row.classList.toggle('selected', i === index);
+ });
+
+ this.updateStatus('Loading MIDI file...');
+ this.disablePlayerControls();
+
try {
- await this.engine.play();
- this.updateStatus('Playing...');
- this.playBtn.disabled = true;
- this.stopBtn.disabled = false;
+ // Fetch and parse MIDI
+ 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);
+
+ this.currentMIDI = { events, metadata };
+
+ // Load into players
+ this.midiPlayer.load(events);
+
+ // Update UI
+ this.selectedTitle.textContent = result.title;
+ this.selectedMeta.innerHTML = `
+ Source: ${result.source} |
+ Duration: ${Math.round(metadata.duration || 0)}s |
+ Tracks: ${metadata.trackCount} |
+ Notes: ${events.length} |
+ Tempo: ${metadata.tempo}bpm
+ `;
+
+ this.playerSection.classList.add('visible');
+ this.enablePlayerControls();
+ this.updateStatus('MIDI loaded. You can now preview or generate.');
+
} catch (error) {
- this.updateStatus(`Playback error: ${error instanceof Error ? error.message : 'Unknown error'}`);
+ this.updateStatus(`Load error: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
- private handleStop(): void {
- this.engine.stop();
- this.updateStatus('Stopped');
- this.playBtn.disabled = false;
- this.stopBtn.disabled = true;
+ private async handlePreview(): Promise {
+ if (!this.currentMIDI) return;
+
+ try {
+ await this.midiPlayer.play();
+ this.previewBtn.disabled = true;
+ this.previewStopBtn.disabled = false;
+ this.updateStatus('Playing MIDI preview...');
+ } catch (error) {
+ this.updateStatus(`Preview error: ${error instanceof Error ? error.message : 'Unknown error'}`);
+ }
+ }
+
+ private handlePreviewStop(): void {
+ this.midiPlayer.stop();
+ this.previewBtn.disabled = false;
+ this.previewStopBtn.disabled = true;
+ this.updateStatus('Preview stopped.');
+ }
+
+ private async handleMotif(): Promise {
+ if (!this.currentMIDI) return;
+
+ const result = this.searchResults[this.selectedResultIndex];
+
+ try {
+ this.updateStatus('Generating Motif synthesis...');
+ this.motifBtn.disabled = true;
+
+ // Use the current MIDI data directly
+ await this.motifEngine.generateFromMIDI(this.currentMIDI.events);
+ await this.motifEngine.play();
+
+ this.motifStopBtn.disabled = false;
+ this.updateStatus('Playing Motif synthesis...');
+ } 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;
+ this.updateStatus('Motif synthesis stopped.');
+ }
+
+ private handleNextResult(): void {
+ const nextIndex = (this.selectedResultIndex + 1) % this.searchResults.length;
+ this.selectResult(nextIndex);
+ }
+
+ private hideResults(): void {
+ this.resultsSection.classList.remove('visible');
+ this.playerSection.classList.remove('visible');
+ }
+
+ private enablePlayerControls(): void {
+ this.previewBtn.disabled = false;
+ this.motifBtn.disabled = false;
+ this.nextResultBtn.disabled = this.searchResults.length <= 1;
+ }
+
+ private disablePlayerControls(): void {
+ this.previewBtn.disabled = true;
+ this.previewStopBtn.disabled = true;
+ this.motifBtn.disabled = true;
+ this.motifStopBtn.disabled = true;
+ this.nextResultBtn.disabled = true;
}
private updateStatus(message: string): void {
@@ -75,4 +274,6 @@ class MotifApp {
}
}
-new MotifApp();
\ No newline at end of file
+// Make app globally available for onclick handlers
+const app = new MotifApp();
+(window as any).app = app;
\ No newline at end of file
diff --git a/src/services/MIDIService.ts b/src/services/MIDIService.ts
index f75021f..3b60a3b 100644
--- a/src/services/MIDIService.ts
+++ b/src/services/MIDIService.ts
@@ -5,6 +5,25 @@ interface MIDISearchResult {
pageUrl: string;
midiUrl: string;
confidence: number;
+ parsed?: ParsedMIDIInfo;
+}
+
+interface ParsedMIDIInfo {
+ durationSec: number;
+ tempoBpm: number;
+ timeSig?: { num: number; den: number };
+ tracks: TrackInfo[];
+ noteCount: number;
+ issues: string[];
+}
+
+interface TrackInfo {
+ id: number;
+ name?: string;
+ program?: number;
+ noteCount: number;
+ channel?: number;
+ register: 'low' | 'mid' | 'high';
}
interface MIDISearchResponse {
@@ -50,6 +69,21 @@ export class MIDIService {
}
}
+ async parseMIDI(url: string): Promise {
+ try {
+ const response = await fetch(`${this.baseUrl}/api/midi/parse?u=${encodeURIComponent(url)}`);
+
+ if (!response.ok) {
+ throw new Error(`Parse failed: ${response.status}`);
+ }
+
+ return await response.json();
+ } catch (error) {
+ console.error('MIDI parse error:', error);
+ return null;
+ }
+ }
+
async checkHealth(): Promise {
try {
const response = await fetch(`${this.baseUrl}/health`);
@@ -58,4 +92,5 @@ export class MIDIService {
return false;
}
}
+}
}
\ No newline at end of file
diff --git a/src/synthesis/MIDIPlayer.ts b/src/synthesis/MIDIPlayer.ts
new file mode 100644
index 0000000..94aa418
--- /dev/null
+++ b/src/synthesis/MIDIPlayer.ts
@@ -0,0 +1,196 @@
+import type { NoteEvent } from '../types';
+
+export class MIDIPlayer {
+ private audioContext: AudioContext;
+ private masterGain: GainNode;
+ private isPlaying = false;
+ private schedulerIntervalId: number | null = null;
+ private startTime = 0;
+ private events: NoteEvent[] = [];
+ private currentEventIndex = 0;
+
+ constructor(audioContext: AudioContext) {
+ this.audioContext = audioContext;
+ this.masterGain = audioContext.createGain();
+ this.masterGain.connect(audioContext.destination);
+ this.masterGain.gain.value = 0.3;
+ }
+
+ load(events: NoteEvent[]): void {
+ this.events = [...events].sort((a, b) => a.time - b.time);
+ this.currentEventIndex = 0;
+ console.log(`Loaded ${this.events.length} MIDI events for preview`);
+ }
+
+ async play(): Promise {
+ if (this.isPlaying) return;
+ if (this.events.length === 0) {
+ throw new Error('No MIDI events loaded');
+ }
+
+ if (this.audioContext.state === 'suspended') {
+ await this.audioContext.resume();
+ }
+
+ this.isPlaying = true;
+ this.startTime = this.audioContext.currentTime;
+ this.currentEventIndex = 0;
+
+ // Schedule events with lookahead
+ this.schedulerIntervalId = window.setInterval(() => {
+ this.scheduleEvents();
+ }, 25); // 25ms lookahead scheduling
+
+ console.log('MIDI preview started');
+ }
+
+ stop(): void {
+ if (!this.isPlaying) return;
+
+ this.isPlaying = false;
+
+ if (this.schedulerIntervalId) {
+ clearInterval(this.schedulerIntervalId);
+ this.schedulerIntervalId = null;
+ }
+
+ // Fade out
+ this.masterGain.gain.linearRampToValueAtTime(0, this.audioContext.currentTime + 0.1);
+
+ // Reset volume after fade
+ setTimeout(() => {
+ if (!this.isPlaying) {
+ this.masterGain.gain.value = 0.3;
+ }
+ }, 150);
+
+ console.log('MIDI preview stopped');
+ }
+
+ private scheduleEvents(): void {
+ if (!this.isPlaying) return;
+
+ const currentTime = this.audioContext.currentTime;
+ const lookahead = 0.1; // 100ms lookahead
+ const scheduleUntil = currentTime + lookahead;
+
+ while (this.currentEventIndex < this.events.length) {
+ const event = this.events[this.currentEventIndex];
+ const eventTime = this.startTime + event.time;
+
+ // Stop if we're past the lookahead window
+ if (eventTime > scheduleUntil) break;
+
+ // Schedule if the event hasn't been played yet
+ if (eventTime >= currentTime) {
+ this.scheduleNote(event, eventTime);
+ }
+
+ this.currentEventIndex++;
+ }
+
+ // Stop when all events are done
+ if (this.currentEventIndex >= this.events.length) {
+ // Check if we're past the last event's end time
+ const lastEvent = this.events[this.events.length - 1];
+ const lastEventEnd = this.startTime + lastEvent.time + lastEvent.duration;
+
+ if (currentTime > lastEventEnd + 1.0) { // 1 second grace period
+ this.stop();
+ }
+ }
+ }
+
+ private scheduleNote(event: NoteEvent, when: number): void {
+ const osc = this.audioContext.createOscillator();
+ const envelope = this.audioContext.createGain();
+ const filter = this.audioContext.createBiquadFilter();
+
+ // Basic frequency conversion
+ const frequency = 440 * Math.pow(2, (event.pitch - 69) / 12);
+ osc.frequency.value = frequency;
+
+ // Simple timbre based on track/register
+ if (event.pitch < 48) {
+ // Bass register
+ osc.type = 'square';
+ filter.type = 'lowpass';
+ filter.frequency.value = 400;
+ } else if (event.pitch > 84) {
+ // High register
+ osc.type = 'sine';
+ filter.type = 'highpass';
+ filter.frequency.value = 800;
+ } else {
+ // Mid register
+ osc.type = 'triangle';
+ filter.type = 'bandpass';
+ filter.frequency.value = 1000;
+ }
+
+ // Channel 10 (drums) handling
+ if (event.track === 9) { // Track 9 = MIDI channel 10 (drums)
+ osc.type = 'sawtooth';
+ filter.type = 'highpass';
+ filter.frequency.value = 2000;
+ }
+
+ // Connect audio graph
+ osc.connect(filter);
+ filter.connect(envelope);
+ envelope.connect(this.masterGain);
+
+ // Envelope
+ const gainValue = (event.velocity * 0.4) / Math.max(this.getPolyphonyAtTime(event.time), 1);
+ const attackTime = Math.min(0.02, event.duration * 0.1);
+ const releaseTime = Math.min(0.05, event.duration * 0.2);
+
+ envelope.gain.setValueAtTime(0, when);
+ envelope.gain.linearRampToValueAtTime(gainValue, when + attackTime);
+ envelope.gain.linearRampToValueAtTime(gainValue * 0.7, when + event.duration - releaseTime);
+ envelope.gain.exponentialRampToValueAtTime(0.001, when + event.duration);
+
+ osc.start(when);
+ osc.stop(when + event.duration);
+
+ // Cleanup
+ setTimeout(() => {
+ try {
+ osc.disconnect();
+ filter.disconnect();
+ envelope.disconnect();
+ } catch (e) {
+ // Already disconnected
+ }
+ }, (event.duration + 0.1) * 1000);
+ }
+
+ private getPolyphonyAtTime(time: number): number {
+ // Count overlapping notes for volume scaling
+ let count = 0;
+ const tolerance = 0.05; // 50ms tolerance
+
+ for (const event of this.events) {
+ if (event.time <= time + tolerance &&
+ event.time + event.duration >= time - tolerance) {
+ count++;
+ }
+ }
+
+ return Math.max(count, 1);
+ }
+
+ getProgress(): number {
+ if (this.events.length === 0) return 0;
+
+ const currentTime = this.audioContext.currentTime - this.startTime;
+ const totalDuration = Math.max(...this.events.map(e => e.time + e.duration));
+
+ return Math.max(0, Math.min(1, currentTime / totalDuration));
+ }
+
+ getDuration(): number {
+ if (this.events.length === 0) return 0;
+ return Math.max(...this.events.map(e => e.time + e.duration));
+ }
+}
\ No newline at end of file