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

+ + + + + + + + + + + + + + +
TitleSourceConfidenceDurationTracksIssues
+
+ +
+
+

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