Implement complete search results + dual player UI
Backend Enhancements: - Add ParsedMIDIInfo with track analysis and quality issues - New /api/midi/parse endpoint for MIDI metadata extraction - Enhanced MIDIService with parseMIDI method MIDI Preview Player: - Complete MIDIPlayer class with polyphonic playback - Basic oscillator mapping (bass=square, mid=triangle, high=sine) - Lookahead scheduling with proper cleanup - Automatic stopping when MIDI ends New UI Flow: 1. Search → Results table with metadata 2. Select MIDI → Shows parsed info (duration, tracks, tempo) 3. Dual transport: Preview Original vs Generate Motif 4. Try Next Result button for easy A/B testing Search Results Table: - Title, source, confidence bar, duration, track count - Quality issues display (short duration, few notes, etc.) - Auto-select best result, manual selection available - Visual confidence bars and issue warnings This makes the project feel "real" - you can now: - See exactly what MIDI was found - Verify it's the right song via preview - Compare original vs procedural synthesis - Easily try different results 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,22 @@ export class MotifEngine {
|
||||
this.roleMapper = new RoleMapper();
|
||||
}
|
||||
|
||||
async generateFromMIDI(events: NoteEvent[]): Promise<void> {
|
||||
// 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<void> {
|
||||
let events: NoteEvent[];
|
||||
|
||||
|
||||
+233
-32
@@ -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<void> {
|
||||
private async handleSearch(): Promise<void> {
|
||||
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<void> {
|
||||
private displayResults(): void {
|
||||
this.resultsBody.innerHTML = '';
|
||||
|
||||
this.searchResults.forEach((result, index) => {
|
||||
const row = document.createElement('tr');
|
||||
if (index === this.selectedResultIndex) {
|
||||
row.classList.add('selected');
|
||||
}
|
||||
|
||||
row.innerHTML = `
|
||||
<td>${result.title}</td>
|
||||
<td>${result.source}</td>
|
||||
<td>
|
||||
<div class="confidence-bar">
|
||||
<div class="confidence-fill" style="width: ${result.confidence * 100}%"></div>
|
||||
</div>
|
||||
</td>
|
||||
<td>${result.parsed ? Math.round(result.parsed.durationSec) + 's' : '?'}</td>
|
||||
<td>${result.parsed ? result.parsed.tracks.length : '?'}</td>
|
||||
<td class="issues">${result.parsed?.issues.join(', ') || ''}</td>
|
||||
<td><button onclick="window.app.selectResult(${index})">Select</button></td>
|
||||
`;
|
||||
|
||||
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<void> {
|
||||
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 = `
|
||||
<strong>Source:</strong> ${result.source} |
|
||||
<strong>Duration:</strong> ${Math.round(metadata.duration || 0)}s |
|
||||
<strong>Tracks:</strong> ${metadata.trackCount} |
|
||||
<strong>Notes:</strong> ${events.length} |
|
||||
<strong>Tempo:</strong> ${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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
// Make app globally available for onclick handlers
|
||||
const app = new MotifApp();
|
||||
(window as any).app = app;
|
||||
@@ -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<ParsedMIDIInfo | null> {
|
||||
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<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/health`);
|
||||
@@ -58,4 +92,5 @@ export class MIDIService {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user