Improve MIDI search and add browser playback engines.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import type { NoteEvent, StructuralFeatures, MotifConfig, SynthLayer } from '../types';
|
||||
import type { NoteEvent, StructuralFeatures, MotifConfig } from '../types';
|
||||
import { MIDIProcessor } from '../midi/MIDIProcessor';
|
||||
import { MIDIParser } from '../midi/MIDIParser';
|
||||
import { MIDIService } from '../services/MIDIService';
|
||||
@@ -106,6 +106,12 @@ export class MotifEngine {
|
||||
}
|
||||
}
|
||||
|
||||
setVolume(volume: number): void {
|
||||
if (this.synthesisEngine) {
|
||||
this.synthesisEngine.setVolume(volume);
|
||||
}
|
||||
}
|
||||
|
||||
private generateSyntheticMIDI(songName: string): NoteEvent[] {
|
||||
// Generate procedural MIDI based on song name hash
|
||||
const hash = this.simpleHash(songName);
|
||||
|
||||
@@ -76,7 +76,7 @@ export class RoleMapper {
|
||||
};
|
||||
}
|
||||
|
||||
private calculateRoleScores(features: TrackFeatures, globalFeatures: StructuralFeatures): Map<Role, number> {
|
||||
private calculateRoleScores(features: TrackFeatures, _globalFeatures: StructuralFeatures): Map<Role, number> {
|
||||
const scores = new Map<Role, number>();
|
||||
|
||||
// Bass scoring
|
||||
@@ -130,7 +130,7 @@ export class RoleMapper {
|
||||
return scores;
|
||||
}
|
||||
|
||||
private allocateRoles(roleScores: Map<number, Map<Role, number>>, trackEvents: Map<number, NoteEvent[]>): Map<number, Role> {
|
||||
private allocateRoles(roleScores: Map<number, Map<Role, number>>, _trackEvents: Map<number, NoteEvent[]>): Map<number, Role> {
|
||||
const assignments = new Map<number, Role>();
|
||||
const assignedRoles = new Set<Role>();
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
console.log('Main script loading...');
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('DOM loaded');
|
||||
|
||||
const searchBtn = document.getElementById('searchBtn') as HTMLButtonElement;
|
||||
const songInput = document.getElementById('songInput') as HTMLInputElement;
|
||||
const status = document.getElementById('status')!;
|
||||
|
||||
if (!searchBtn || !songInput || !status) {
|
||||
console.error('UI elements not found!');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('UI elements found');
|
||||
|
||||
searchBtn.addEventListener('click', async function() {
|
||||
console.log('Search button clicked!');
|
||||
|
||||
const songName = songInput.value.trim();
|
||||
if (!songName) return;
|
||||
|
||||
status.textContent = 'Searching...';
|
||||
searchBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(`http://localhost:3001/api/midi/search?q=${encodeURIComponent(songName)}`);
|
||||
const data = await response.json();
|
||||
|
||||
console.log('Search results:', data);
|
||||
status.textContent = `Found ${data.count} results: ${data.results.map((r: any) => r.title).join(', ')}`;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
status.textContent = `Search error: ${error}`;
|
||||
} finally {
|
||||
searchBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Event listeners attached');
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
console.log('Full Motif app loading...');
|
||||
|
||||
interface SearchResult {
|
||||
id: string;
|
||||
title: string;
|
||||
source: string;
|
||||
pageUrl: string;
|
||||
midiUrl: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
class MotifApp {
|
||||
private searchResults: SearchResult[] = [];
|
||||
private selectedIndex = 0;
|
||||
|
||||
constructor() {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
this.initializeUI();
|
||||
});
|
||||
}
|
||||
|
||||
private initializeUI(): void {
|
||||
console.log('Initializing UI...');
|
||||
|
||||
const searchBtn = document.getElementById('searchBtn') as HTMLButtonElement;
|
||||
const songInput = document.getElementById('songInput') as HTMLInputElement;
|
||||
|
||||
if (!searchBtn || !songInput) {
|
||||
console.error('UI elements not found!');
|
||||
return;
|
||||
}
|
||||
|
||||
searchBtn.addEventListener('click', () => this.handleSearch());
|
||||
songInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') this.handleSearch();
|
||||
});
|
||||
|
||||
// Make selectResult globally available for onclick handlers
|
||||
(window as any).app = this;
|
||||
|
||||
console.log('UI initialized successfully');
|
||||
}
|
||||
|
||||
private async handleSearch(): Promise<void> {
|
||||
const songInput = document.getElementById('songInput') as HTMLInputElement;
|
||||
const searchBtn = document.getElementById('searchBtn') as HTMLButtonElement;
|
||||
const status = document.getElementById('status')!;
|
||||
|
||||
const songName = songInput.value.trim();
|
||||
if (!songName) return;
|
||||
|
||||
status.textContent = 'Searching for MIDI files...';
|
||||
searchBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(`http://localhost:3001/api/midi/search?q=${encodeURIComponent(songName)}`);
|
||||
const data = await response.json();
|
||||
|
||||
console.log('Search results:', data);
|
||||
|
||||
if (data.results.length === 0) {
|
||||
status.textContent = 'No MIDI files found. Try a different search.';
|
||||
return;
|
||||
}
|
||||
|
||||
this.searchResults = data.results;
|
||||
this.displayResults();
|
||||
status.textContent = `Found ${data.results.length} MIDI files. Select one to play.`;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
status.textContent = `Search error: ${error}`;
|
||||
} finally {
|
||||
searchBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
private displayResults(): void {
|
||||
const resultsSection = document.getElementById('resultsSection')!;
|
||||
const resultsBody = document.getElementById('resultsBody')!;
|
||||
|
||||
resultsBody.innerHTML = '';
|
||||
|
||||
this.searchResults.forEach((result, index) => {
|
||||
const row = document.createElement('tr');
|
||||
if (index === this.selectedIndex) {
|
||||
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>?</td>
|
||||
<td>?</td>
|
||||
<td></td>
|
||||
<td><button onclick="window.app.selectResult(${index})">Select</button></td>
|
||||
`;
|
||||
|
||||
resultsBody.appendChild(row);
|
||||
});
|
||||
|
||||
resultsSection.classList.add('visible');
|
||||
|
||||
// Auto-select first result
|
||||
if (this.searchResults.length > 0) {
|
||||
this.selectResult(0);
|
||||
}
|
||||
}
|
||||
|
||||
public async selectResult(index: number): Promise<void> {
|
||||
if (index < 0 || index >= this.searchResults.length) return;
|
||||
|
||||
this.selectedIndex = index;
|
||||
const result = this.searchResults[index];
|
||||
|
||||
// Update selection highlighting
|
||||
const rows = document.querySelectorAll('#resultsBody tr');
|
||||
rows.forEach((row, i) => {
|
||||
row.classList.toggle('selected', i === index);
|
||||
});
|
||||
|
||||
const status = document.getElementById('status')!;
|
||||
const playerSection = document.getElementById('playerSection')!;
|
||||
const selectedTitle = document.getElementById('selectedTitle')!;
|
||||
const selectedMeta = document.getElementById('selectedMeta')!;
|
||||
|
||||
status.textContent = 'Loading MIDI file...';
|
||||
|
||||
try {
|
||||
// Fetch MIDI data
|
||||
const response = await fetch(`http://localhost:3001/api/midi/fetch?u=${encodeURIComponent(result.midiUrl)}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch MIDI: ${response.status}`);
|
||||
}
|
||||
|
||||
const midiBuffer = await response.arrayBuffer();
|
||||
|
||||
// Update UI
|
||||
selectedTitle.textContent = result.title;
|
||||
selectedMeta.innerHTML = `
|
||||
<strong>Source:</strong> ${result.source} |
|
||||
<strong>Size:</strong> ${(midiBuffer.byteLength / 1024).toFixed(1)}KB |
|
||||
<strong>Confidence:</strong> ${Math.round(result.confidence * 100)}%
|
||||
`;
|
||||
|
||||
playerSection.classList.add('visible');
|
||||
|
||||
// Enable preview button
|
||||
const previewBtn = document.getElementById('previewBtn') as HTMLButtonElement;
|
||||
const motifBtn = document.getElementById('motifBtn') as HTMLButtonElement;
|
||||
previewBtn.disabled = false;
|
||||
motifBtn.disabled = false;
|
||||
|
||||
// Store MIDI data for playback
|
||||
(this as any).currentMIDI = { buffer: midiBuffer, result };
|
||||
|
||||
status.textContent = 'MIDI loaded. You can now preview or generate synthesis.';
|
||||
|
||||
} catch (error) {
|
||||
console.error('Load error:', error);
|
||||
status.textContent = `Load error: ${error}`;
|
||||
}
|
||||
}
|
||||
|
||||
public async handlePreview(): Promise<void> {
|
||||
console.log('Preview clicked - would play original MIDI here');
|
||||
const status = document.getElementById('status')!;
|
||||
status.textContent = 'Preview playback not yet implemented - but MIDI is loaded!';
|
||||
}
|
||||
|
||||
public async handleMotif(): Promise<void> {
|
||||
console.log('Motif clicked - would generate synthesis here');
|
||||
const status = document.getElementById('status')!;
|
||||
status.textContent = 'Motif synthesis not yet implemented - but MIDI is parsed!';
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize app
|
||||
new MotifApp();
|
||||
|
||||
// Expose handlers for buttons
|
||||
(window as any).handlePreview = () => (window as any).app.handlePreview();
|
||||
(window as any).handleMotif = () => (window as any).app.handleMotif();
|
||||
+130
-32
@@ -1,31 +1,54 @@
|
||||
import { MotifEngine } from './core/MotifEngine';
|
||||
import { MIDIService } from './services/MIDIService';
|
||||
import { MIDIParser } from './midi/MIDIParser';
|
||||
import { MIDIPlayer } from './synthesis/MIDIPlayer';
|
||||
import { EnhancedMIDIPlayer } from './synthesis/EnhancedMIDIPlayer';
|
||||
import { SoundfontMIDIPlayer } from './synthesis/SoundfontMIDIPlayer';
|
||||
import { ToneJSMIDIPlayer } from './synthesis/ToneJSMIDIPlayer';
|
||||
import type { NoteEvent } from './types';
|
||||
|
||||
type MIDIPlayerType = 'tonejs' | 'soundfont' | 'custom';
|
||||
|
||||
interface MIDIPlayer {
|
||||
load(events: NoteEvent[]): void | Promise<void>;
|
||||
play(): void | Promise<void>;
|
||||
stop(): void;
|
||||
setVolume(volume: number): void;
|
||||
getDuration(): number;
|
||||
getProgress(): number;
|
||||
}
|
||||
|
||||
class MotifApp {
|
||||
private motifEngine: MotifEngine;
|
||||
private midiService: MIDIService;
|
||||
private midiPlayer: MIDIPlayer;
|
||||
private audioContext: AudioContext;
|
||||
|
||||
// Multiple player instances
|
||||
private toneJSPlayer: ToneJSMIDIPlayer;
|
||||
private soundfontPlayer: SoundfontMIDIPlayer;
|
||||
private customPlayer: EnhancedMIDIPlayer;
|
||||
private currentPlayer: MIDIPlayer;
|
||||
private currentPlayerType: MIDIPlayerType = 'tonejs';
|
||||
|
||||
private searchBtn: HTMLButtonElement;
|
||||
private songInput: HTMLInputElement;
|
||||
private status: HTMLElement;
|
||||
private searchBtn!: HTMLButtonElement;
|
||||
private songInput!: HTMLInputElement;
|
||||
private status!: HTMLElement;
|
||||
|
||||
private resultsSection: HTMLElement;
|
||||
private resultsBody: HTMLElement;
|
||||
private playerSection: HTMLElement;
|
||||
private resultsSection!: HTMLElement;
|
||||
private resultsBody!: HTMLElement;
|
||||
private playerSection!: HTMLElement;
|
||||
|
||||
private selectedTitle: HTMLElement;
|
||||
private selectedMeta: HTMLElement;
|
||||
private selectedTitle!: HTMLElement;
|
||||
private selectedMeta!: HTMLElement;
|
||||
|
||||
private previewBtn: HTMLButtonElement;
|
||||
private previewStopBtn: HTMLButtonElement;
|
||||
private motifBtn: HTMLButtonElement;
|
||||
private motifStopBtn: HTMLButtonElement;
|
||||
private nextResultBtn: HTMLButtonElement;
|
||||
private previewBtn!: HTMLButtonElement;
|
||||
private previewStopBtn!: HTMLButtonElement;
|
||||
private motifBtn!: HTMLButtonElement;
|
||||
private motifStopBtn!: HTMLButtonElement;
|
||||
private nextResultBtn!: HTMLButtonElement;
|
||||
|
||||
private previewVolumeSlider!: HTMLInputElement;
|
||||
private motifVolumeSlider!: HTMLInputElement;
|
||||
private engineSelect!: HTMLSelectElement;
|
||||
|
||||
private searchResults: any[] = [];
|
||||
private selectedResultIndex = 0;
|
||||
@@ -35,8 +58,13 @@ class MotifApp {
|
||||
this.audioContext = new AudioContext();
|
||||
this.motifEngine = new MotifEngine();
|
||||
this.midiService = new MIDIService();
|
||||
this.midiPlayer = new MIDIPlayer(this.audioContext);
|
||||
|
||||
|
||||
// Initialize all players
|
||||
this.toneJSPlayer = new ToneJSMIDIPlayer();
|
||||
this.soundfontPlayer = new SoundfontMIDIPlayer(this.audioContext);
|
||||
this.customPlayer = new EnhancedMIDIPlayer(this.audioContext);
|
||||
this.currentPlayer = this.toneJSPlayer; // Default to Tone.js
|
||||
|
||||
this.initializeUI();
|
||||
this.setupEventListeners();
|
||||
}
|
||||
@@ -58,6 +86,10 @@ class MotifApp {
|
||||
this.motifBtn = document.getElementById('motifBtn') as HTMLButtonElement;
|
||||
this.motifStopBtn = document.getElementById('motifStopBtn') as HTMLButtonElement;
|
||||
this.nextResultBtn = document.getElementById('nextResultBtn') as HTMLButtonElement;
|
||||
|
||||
this.previewVolumeSlider = document.getElementById('previewVolume') as HTMLInputElement;
|
||||
this.motifVolumeSlider = document.getElementById('motifVolume') as HTMLInputElement;
|
||||
this.engineSelect = document.getElementById('engineSelect') as HTMLSelectElement;
|
||||
}
|
||||
|
||||
private setupEventListeners(): void {
|
||||
@@ -74,6 +106,22 @@ class MotifApp {
|
||||
this.motifBtn.addEventListener('click', () => this.handleMotif());
|
||||
this.motifStopBtn.addEventListener('click', () => this.handleMotifStop());
|
||||
this.nextResultBtn.addEventListener('click', () => this.handleNextResult());
|
||||
|
||||
// Volume control event listeners
|
||||
this.previewVolumeSlider.addEventListener('input', (e) => {
|
||||
const volume = parseFloat((e.target as HTMLInputElement).value);
|
||||
this.currentPlayer.setVolume(volume);
|
||||
});
|
||||
|
||||
this.motifVolumeSlider.addEventListener('input', (e) => {
|
||||
const volume = parseFloat((e.target as HTMLInputElement).value);
|
||||
this.motifEngine.setVolume(volume);
|
||||
});
|
||||
|
||||
// Engine selector event listener
|
||||
this.engineSelect.addEventListener('change', (e) => {
|
||||
this.handleEngineChange((e.target as HTMLSelectElement).value as MIDIPlayerType);
|
||||
});
|
||||
}
|
||||
|
||||
private async handleSearch(): Promise<void> {
|
||||
@@ -148,7 +196,7 @@ class MotifApp {
|
||||
}
|
||||
}
|
||||
|
||||
async selectResult(index: number): Promise<void> {
|
||||
public async selectResult(index: number): Promise<void> {
|
||||
if (index < 0 || index >= this.searchResults.length) return;
|
||||
|
||||
this.selectedResultIndex = index;
|
||||
@@ -173,17 +221,24 @@ class MotifApp {
|
||||
const events = MIDIParser.parseMIDI(midiBuffer);
|
||||
const metadata = result.parsed || MIDIParser.getMIDIInfo(midiBuffer);
|
||||
|
||||
this.currentMIDI = { events, metadata };
|
||||
|
||||
// Load into players
|
||||
this.midiPlayer.load(events);
|
||||
// Calculate duration from events if metadata duration is 0 or missing
|
||||
let actualDuration = metadata.duration || metadata.durationSec || 0;
|
||||
if (actualDuration === 0 && events.length > 0) {
|
||||
// Calculate duration from the last event
|
||||
actualDuration = Math.max(...events.map(e => e.time + e.duration));
|
||||
}
|
||||
|
||||
this.currentMIDI = { events, metadata: { ...metadata, duration: actualDuration } };
|
||||
|
||||
// Load into current player
|
||||
await this.currentPlayer.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>Source:</strong> ${result.source} |
|
||||
<strong>Duration:</strong> ${Math.round(actualDuration)}s |
|
||||
<strong>Tracks:</strong> ${metadata.trackCount} |
|
||||
<strong>Notes:</strong> ${events.length} |
|
||||
<strong>Tempo:</strong> ${metadata.tempo}bpm
|
||||
`;
|
||||
@@ -201,35 +256,78 @@ class MotifApp {
|
||||
if (!this.currentMIDI) return;
|
||||
|
||||
try {
|
||||
await this.midiPlayer.play();
|
||||
// Stop Motif if it's playing
|
||||
this.motifEngine.stop();
|
||||
this.motifBtn.disabled = false;
|
||||
this.motifStopBtn.disabled = true;
|
||||
|
||||
await this.currentPlayer.play();
|
||||
this.previewBtn.disabled = true;
|
||||
this.previewStopBtn.disabled = false;
|
||||
this.updateStatus('Playing MIDI preview...');
|
||||
this.updateStatus(`Playing original MIDI (${this.currentPlayerType})...`);
|
||||
} catch (error) {
|
||||
this.updateStatus(`Preview error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
private handlePreviewStop(): void {
|
||||
this.midiPlayer.stop();
|
||||
this.currentPlayer.stop();
|
||||
this.previewBtn.disabled = false;
|
||||
this.previewStopBtn.disabled = true;
|
||||
this.updateStatus('Preview stopped.');
|
||||
}
|
||||
|
||||
private async handleEngineChange(engineType: MIDIPlayerType): Promise<void> {
|
||||
// Stop current player
|
||||
this.currentPlayer.stop();
|
||||
|
||||
// Switch to new player
|
||||
this.currentPlayerType = engineType;
|
||||
switch (engineType) {
|
||||
case 'tonejs':
|
||||
this.currentPlayer = this.toneJSPlayer;
|
||||
break;
|
||||
case 'soundfont':
|
||||
this.currentPlayer = this.soundfontPlayer;
|
||||
break;
|
||||
case 'custom':
|
||||
this.currentPlayer = this.customPlayer;
|
||||
break;
|
||||
}
|
||||
|
||||
// Reload MIDI into new player if we have one loaded
|
||||
if (this.currentMIDI) {
|
||||
try {
|
||||
this.updateStatus(`Switching to ${engineType} engine...`);
|
||||
await this.currentPlayer.load(this.currentMIDI.events);
|
||||
|
||||
// Apply current volume setting
|
||||
const volume = parseFloat(this.previewVolumeSlider.value);
|
||||
this.currentPlayer.setVolume(volume);
|
||||
|
||||
this.updateStatus(`Switched to ${engineType} engine. Ready to play.`);
|
||||
} catch (error) {
|
||||
this.updateStatus(`Engine switch error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async handleMotif(): Promise<void> {
|
||||
if (!this.currentMIDI) return;
|
||||
|
||||
const result = this.searchResults[this.selectedResultIndex];
|
||||
|
||||
try {
|
||||
// Stop preview if it's playing
|
||||
this.currentPlayer.stop();
|
||||
this.previewBtn.disabled = false;
|
||||
this.previewStopBtn.disabled = true;
|
||||
|
||||
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) {
|
||||
|
||||
@@ -14,7 +14,8 @@ export class MIDIParser {
|
||||
duration: note.duration,
|
||||
pitch: note.midi,
|
||||
velocity: note.velocity,
|
||||
track: trackIndex
|
||||
track: trackIndex,
|
||||
channel: track.channel // Capture MIDI channel (9 = drums)
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -92,5 +92,4 @@ export class MIDIService {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import type { NoteEvent } from '../types';
|
||||
|
||||
interface ScheduledNote {
|
||||
oscillators: OscillatorNode[];
|
||||
gainNode: GainNode;
|
||||
time: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced MIDI player using rich multi-oscillator synthesis
|
||||
* Similar sound quality to the SynthesisEngine but for direct MIDI playback
|
||||
*/
|
||||
export class EnhancedMIDIPlayer {
|
||||
private audioContext: AudioContext;
|
||||
private isPlaying = false;
|
||||
private schedulerIntervalId: number | null = null;
|
||||
private startTime = 0;
|
||||
private events: NoteEvent[] = [];
|
||||
private currentEventIndex = 0;
|
||||
private scheduledNotes: ScheduledNote[] = [];
|
||||
private masterGain: GainNode;
|
||||
private masterFilter: BiquadFilterNode;
|
||||
|
||||
constructor(audioContext: AudioContext) {
|
||||
this.audioContext = audioContext;
|
||||
|
||||
// Create master filter for warmth
|
||||
this.masterFilter = audioContext.createBiquadFilter();
|
||||
this.masterFilter.type = 'lowpass';
|
||||
this.masterFilter.frequency.value = 3000;
|
||||
this.masterFilter.Q.value = 1;
|
||||
|
||||
this.masterGain = audioContext.createGain();
|
||||
this.masterGain.gain.value = 0.6; // Good default volume
|
||||
|
||||
this.masterFilter.connect(this.masterGain);
|
||||
this.masterGain.connect(audioContext.destination);
|
||||
}
|
||||
|
||||
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 enhanced playback`);
|
||||
}
|
||||
|
||||
async play(): Promise<void> {
|
||||
if (this.isPlaying) return;
|
||||
if (this.events.length === 0) {
|
||||
throw new Error('No MIDI events loaded');
|
||||
}
|
||||
|
||||
// Ensure AudioContext is resumed (browser autoplay policy)
|
||||
if (this.audioContext.state === 'suspended') {
|
||||
await this.audioContext.resume();
|
||||
}
|
||||
|
||||
this.isPlaying = true;
|
||||
this.startTime = this.audioContext.currentTime;
|
||||
this.currentEventIndex = 0;
|
||||
this.scheduledNotes = [];
|
||||
|
||||
// Schedule events with lookahead
|
||||
this.schedulerIntervalId = window.setInterval(() => {
|
||||
this.scheduleEvents();
|
||||
}, 25); // 25ms lookahead scheduling
|
||||
|
||||
console.log('Enhanced MIDI playback started');
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (!this.isPlaying) return;
|
||||
|
||||
this.isPlaying = false;
|
||||
|
||||
if (this.schedulerIntervalId) {
|
||||
clearInterval(this.schedulerIntervalId);
|
||||
this.schedulerIntervalId = null;
|
||||
}
|
||||
|
||||
// Stop all scheduled notes
|
||||
for (const scheduledNote of this.scheduledNotes) {
|
||||
try {
|
||||
for (const osc of scheduledNote.oscillators) {
|
||||
osc.stop();
|
||||
osc.disconnect();
|
||||
}
|
||||
scheduledNote.gainNode.disconnect();
|
||||
} catch (e) {
|
||||
// Note might already be stopped
|
||||
}
|
||||
}
|
||||
this.scheduledNotes = [];
|
||||
|
||||
// Fade out master gain
|
||||
this.masterGain.gain.linearRampToValueAtTime(0, this.audioContext.currentTime + 0.1);
|
||||
|
||||
// Reset volume after fade
|
||||
setTimeout(() => {
|
||||
if (!this.isPlaying) {
|
||||
this.masterGain.gain.value = 0.6;
|
||||
}
|
||||
}, 150);
|
||||
|
||||
console.log('Enhanced MIDI playback 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 - 0.01) { // Small tolerance for timing
|
||||
this.scheduleNote(event, eventTime);
|
||||
}
|
||||
|
||||
this.currentEventIndex++;
|
||||
}
|
||||
|
||||
// Stop when all events are done
|
||||
if (this.currentEventIndex >= this.events.length) {
|
||||
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 {
|
||||
try {
|
||||
const gainNode = this.audioContext.createGain();
|
||||
const filter = this.audioContext.createBiquadFilter();
|
||||
|
||||
// Create multiple detuned oscillators for richness (similar to unison)
|
||||
const oscillators: OscillatorNode[] = [];
|
||||
const numVoices = 3; // 3 oscillators per note for thickness
|
||||
const detune = 10; // Slight detuning for richness
|
||||
|
||||
for (let i = 0; i < numVoices; i++) {
|
||||
const oscillator = this.audioContext.createOscillator();
|
||||
const frequency = this.midiToFrequency(event.pitch);
|
||||
oscillator.frequency.value = frequency;
|
||||
|
||||
// Detune each voice slightly
|
||||
if (i === 0) oscillator.detune.value = -detune;
|
||||
else if (i === 1) oscillator.detune.value = 0;
|
||||
else oscillator.detune.value = detune;
|
||||
|
||||
// Mix of oscillator types for rich harmonic content
|
||||
// Use different waveforms based on pitch range for more interesting timbre
|
||||
if (event.pitch < 48) {
|
||||
// Low notes: square for bass presence
|
||||
oscillator.type = 'square';
|
||||
} else if (event.pitch < 72) {
|
||||
// Mid notes: sawtooth for brightness
|
||||
oscillator.type = 'sawtooth';
|
||||
} else {
|
||||
// High notes: triangle for smoothness
|
||||
oscillator.type = 'triangle';
|
||||
}
|
||||
|
||||
oscillator.connect(filter);
|
||||
oscillators.push(oscillator);
|
||||
}
|
||||
|
||||
// Dynamic filter based on velocity and pitch
|
||||
filter.type = 'lowpass';
|
||||
const velocity = event.velocity / 127;
|
||||
// Higher velocity = brighter sound
|
||||
filter.frequency.value = 800 + (velocity * 2000);
|
||||
filter.Q.value = 2;
|
||||
|
||||
// Connect to gain
|
||||
filter.connect(gainNode);
|
||||
gainNode.connect(this.masterFilter);
|
||||
|
||||
// Rich ADSR envelope
|
||||
const gainValue = (velocity * 0.25) / numVoices; // Scale down for multiple voices
|
||||
const attackTime = Math.min(0.02, event.duration * 0.1);
|
||||
const decayTime = Math.min(0.05, event.duration * 0.2);
|
||||
const sustainLevel = gainValue * 0.7;
|
||||
const releaseTime = Math.min(0.1, event.duration * 0.3);
|
||||
|
||||
// ADSR envelope
|
||||
gainNode.gain.setValueAtTime(0, when);
|
||||
gainNode.gain.linearRampToValueAtTime(gainValue, when + attackTime); // Attack
|
||||
gainNode.gain.linearRampToValueAtTime(sustainLevel, when + attackTime + decayTime); // Decay
|
||||
gainNode.gain.linearRampToValueAtTime(sustainLevel, when + event.duration - releaseTime); // Sustain
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.001, when + event.duration); // Release
|
||||
|
||||
// Start all oscillators
|
||||
for (const osc of oscillators) {
|
||||
osc.start(when);
|
||||
osc.stop(when + event.duration);
|
||||
}
|
||||
|
||||
// Track scheduled note
|
||||
const scheduledNote: ScheduledNote = {
|
||||
oscillators,
|
||||
gainNode,
|
||||
time: when
|
||||
};
|
||||
this.scheduledNotes.push(scheduledNote);
|
||||
|
||||
// Clean up old scheduled notes
|
||||
const cutoffTime = when - 5.0; // Keep 5 seconds of history
|
||||
this.scheduledNotes = this.scheduledNotes.filter(n => n.time > cutoffTime);
|
||||
|
||||
// Clean up after note ends
|
||||
setTimeout(() => {
|
||||
try {
|
||||
for (const osc of oscillators) {
|
||||
osc.disconnect();
|
||||
}
|
||||
filter.disconnect();
|
||||
gainNode.disconnect();
|
||||
} catch (e) {
|
||||
// Already disconnected
|
||||
}
|
||||
}, (event.duration + 0.1) * 1000);
|
||||
|
||||
} catch (error) {
|
||||
console.warn('Failed to schedule note:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private midiToFrequency(midiNote: number): number {
|
||||
return 440 * Math.pow(2, (midiNote - 69) / 12);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
setVolume(volume: number): void {
|
||||
this.masterGain.gain.value = Math.max(0, Math.min(1, volume));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import type { NoteEvent } from '../types';
|
||||
|
||||
interface ScheduledNote {
|
||||
oscillator: OscillatorNode;
|
||||
gainNode: GainNode;
|
||||
time: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple MIDI player using Web Audio oscillators
|
||||
* Plays MIDI events directly without soundfont loading
|
||||
*/
|
||||
export class SimpleMIDIPlayer {
|
||||
private audioContext: AudioContext;
|
||||
private isPlaying = false;
|
||||
private schedulerIntervalId: number | null = null;
|
||||
private startTime = 0;
|
||||
private events: NoteEvent[] = [];
|
||||
private currentEventIndex = 0;
|
||||
private scheduledNotes: ScheduledNote[] = [];
|
||||
private masterGain: GainNode;
|
||||
|
||||
constructor(audioContext: AudioContext) {
|
||||
this.audioContext = audioContext;
|
||||
this.masterGain = audioContext.createGain();
|
||||
this.masterGain.connect(audioContext.destination);
|
||||
this.masterGain.gain.value = 0.3; // Moderate volume
|
||||
}
|
||||
|
||||
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 simple playback`);
|
||||
}
|
||||
|
||||
async play(): Promise<void> {
|
||||
if (this.isPlaying) return;
|
||||
if (this.events.length === 0) {
|
||||
throw new Error('No MIDI events loaded');
|
||||
}
|
||||
|
||||
// Ensure AudioContext is resumed (browser autoplay policy)
|
||||
if (this.audioContext.state === 'suspended') {
|
||||
await this.audioContext.resume();
|
||||
}
|
||||
|
||||
this.isPlaying = true;
|
||||
this.startTime = this.audioContext.currentTime;
|
||||
this.currentEventIndex = 0;
|
||||
this.scheduledNotes = [];
|
||||
|
||||
// Schedule events with lookahead
|
||||
this.schedulerIntervalId = window.setInterval(() => {
|
||||
this.scheduleEvents();
|
||||
}, 25); // 25ms lookahead scheduling
|
||||
|
||||
console.log('Simple MIDI playback started');
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (!this.isPlaying) return;
|
||||
|
||||
this.isPlaying = false;
|
||||
|
||||
if (this.schedulerIntervalId) {
|
||||
clearInterval(this.schedulerIntervalId);
|
||||
this.schedulerIntervalId = null;
|
||||
}
|
||||
|
||||
// Stop all scheduled notes
|
||||
for (const scheduledNote of this.scheduledNotes) {
|
||||
try {
|
||||
scheduledNote.oscillator.stop();
|
||||
scheduledNote.oscillator.disconnect();
|
||||
scheduledNote.gainNode.disconnect();
|
||||
} catch (e) {
|
||||
// Note might already be stopped
|
||||
}
|
||||
}
|
||||
this.scheduledNotes = [];
|
||||
|
||||
// Fade out master gain
|
||||
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('Simple MIDI playback 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 - 0.01) { // Small tolerance for timing
|
||||
this.scheduleNote(event, eventTime);
|
||||
}
|
||||
|
||||
this.currentEventIndex++;
|
||||
}
|
||||
|
||||
// Stop when all events are done
|
||||
if (this.currentEventIndex >= this.events.length) {
|
||||
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 {
|
||||
try {
|
||||
const oscillator = this.audioContext.createOscillator();
|
||||
const gainNode = this.audioContext.createGain();
|
||||
const filter = this.audioContext.createBiquadFilter();
|
||||
|
||||
// Convert MIDI note to frequency
|
||||
const frequency = this.midiToFrequency(event.pitch);
|
||||
oscillator.frequency.value = frequency;
|
||||
|
||||
// Use triangle wave for a pleasant sound
|
||||
oscillator.type = 'triangle';
|
||||
|
||||
// Add a gentle lowpass filter for warmth
|
||||
filter.type = 'lowpass';
|
||||
filter.frequency.value = 2000;
|
||||
filter.Q.value = 1;
|
||||
|
||||
// Connect audio graph
|
||||
oscillator.connect(filter);
|
||||
filter.connect(gainNode);
|
||||
gainNode.connect(this.masterGain);
|
||||
|
||||
// Envelope based on velocity and duration
|
||||
const velocity = event.velocity / 127; // Normalize to 0-1
|
||||
const gainValue = velocity * 0.5; // Scale for pleasant volume
|
||||
const attackTime = Math.min(0.01, event.duration * 0.1);
|
||||
const releaseTime = Math.min(0.05, event.duration * 0.3);
|
||||
|
||||
// ADSR envelope
|
||||
gainNode.gain.setValueAtTime(0, when);
|
||||
gainNode.gain.linearRampToValueAtTime(gainValue, when + attackTime);
|
||||
gainNode.gain.linearRampToValueAtTime(gainValue * 0.7, when + event.duration - releaseTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.001, when + event.duration);
|
||||
|
||||
oscillator.start(when);
|
||||
oscillator.stop(when + event.duration);
|
||||
|
||||
// Track scheduled note
|
||||
const scheduledNote: ScheduledNote = {
|
||||
oscillator,
|
||||
gainNode,
|
||||
time: when
|
||||
};
|
||||
this.scheduledNotes.push(scheduledNote);
|
||||
|
||||
// Clean up old scheduled notes
|
||||
const cutoffTime = when - 5.0; // Keep 5 seconds of history
|
||||
this.scheduledNotes = this.scheduledNotes.filter(n => n.time > cutoffTime);
|
||||
|
||||
// Clean up after note ends
|
||||
setTimeout(() => {
|
||||
try {
|
||||
oscillator.disconnect();
|
||||
filter.disconnect();
|
||||
gainNode.disconnect();
|
||||
} catch (e) {
|
||||
// Already disconnected
|
||||
}
|
||||
}, (event.duration + 0.1) * 1000);
|
||||
|
||||
} catch (error) {
|
||||
console.warn('Failed to schedule note:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private midiToFrequency(midiNote: number): number {
|
||||
return 440 * Math.pow(2, (midiNote - 69) / 12);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
setVolume(volume: number): void {
|
||||
this.masterGain.gain.value = Math.max(0, Math.min(1, volume));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import Soundfont from 'soundfont-player';
|
||||
import type { NoteEvent } from '../types';
|
||||
|
||||
interface ScheduledNote {
|
||||
noteOff: () => void;
|
||||
time: number;
|
||||
}
|
||||
|
||||
export class SoundfontMIDIPlayer {
|
||||
private audioContext: AudioContext;
|
||||
private instrument: any = null;
|
||||
private isPlaying = false;
|
||||
private schedulerIntervalId: number | null = null;
|
||||
private startTime = 0;
|
||||
private events: NoteEvent[] = [];
|
||||
private currentEventIndex = 0;
|
||||
private scheduledNotes: ScheduledNote[] = [];
|
||||
private masterGain: GainNode;
|
||||
|
||||
constructor(audioContext: AudioContext) {
|
||||
this.audioContext = audioContext;
|
||||
this.masterGain = audioContext.createGain();
|
||||
this.masterGain.connect(audioContext.destination);
|
||||
this.masterGain.gain.value = 1.0; // Full volume
|
||||
}
|
||||
|
||||
async load(events: NoteEvent[]): Promise<void> {
|
||||
this.events = [...events].sort((a, b) => a.time - b.time);
|
||||
this.currentEventIndex = 0;
|
||||
|
||||
// Load the acoustic grand piano instrument (most versatile for MIDI playback)
|
||||
if (!this.instrument) {
|
||||
console.log('Loading acoustic grand piano soundfont...');
|
||||
try {
|
||||
this.instrument = await Soundfont.instrument(this.audioContext, 'acoustic_grand_piano');
|
||||
console.log('Soundfont loaded successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to load soundfont:', error);
|
||||
throw new Error('Could not load piano soundfont - check internet connection');
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Loaded ${this.events.length} MIDI events for soundfont playback`);
|
||||
}
|
||||
|
||||
async play(): Promise<void> {
|
||||
if (this.isPlaying) return;
|
||||
if (this.events.length === 0) {
|
||||
throw new Error('No MIDI events loaded');
|
||||
}
|
||||
if (!this.instrument) {
|
||||
throw new Error('Soundfont not loaded');
|
||||
}
|
||||
|
||||
// Ensure AudioContext is resumed (browser autoplay policy)
|
||||
if (this.audioContext.state === 'suspended') {
|
||||
await this.audioContext.resume();
|
||||
}
|
||||
|
||||
this.isPlaying = true;
|
||||
this.startTime = this.audioContext.currentTime;
|
||||
this.currentEventIndex = 0;
|
||||
this.scheduledNotes = [];
|
||||
|
||||
// Schedule events with lookahead
|
||||
this.schedulerIntervalId = window.setInterval(() => {
|
||||
this.scheduleEvents();
|
||||
}, 25); // 25ms lookahead scheduling
|
||||
|
||||
console.log('Soundfont MIDI playback started');
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (!this.isPlaying) return;
|
||||
|
||||
this.isPlaying = false;
|
||||
|
||||
if (this.schedulerIntervalId) {
|
||||
clearInterval(this.schedulerIntervalId);
|
||||
this.schedulerIntervalId = null;
|
||||
}
|
||||
|
||||
// Stop all scheduled notes
|
||||
for (const scheduledNote of this.scheduledNotes) {
|
||||
try {
|
||||
scheduledNote.noteOff();
|
||||
} catch (e) {
|
||||
// Note might already be stopped
|
||||
}
|
||||
}
|
||||
this.scheduledNotes = [];
|
||||
|
||||
// Fade out master gain
|
||||
this.masterGain.gain.linearRampToValueAtTime(0, this.audioContext.currentTime + 0.1);
|
||||
|
||||
// Reset volume after fade
|
||||
setTimeout(() => {
|
||||
if (!this.isPlaying) {
|
||||
this.masterGain.gain.value = 1.0;
|
||||
}
|
||||
}, 150);
|
||||
|
||||
console.log('Soundfont MIDI playback 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 - 0.01) { // Small tolerance for timing
|
||||
this.scheduleNote(event, eventTime);
|
||||
}
|
||||
|
||||
this.currentEventIndex++;
|
||||
}
|
||||
|
||||
// Stop when all events are done
|
||||
if (this.currentEventIndex >= this.events.length) {
|
||||
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 {
|
||||
if (!this.instrument) return;
|
||||
|
||||
try {
|
||||
// Convert MIDI note number to note name (for soundfont-player)
|
||||
const noteName = this.midiNoteToName(event.pitch);
|
||||
const duration = event.duration;
|
||||
const velocity = event.velocity / 127; // Normalize to 0-1
|
||||
|
||||
// Play note with soundfont
|
||||
const noteOff = this.instrument.play(noteName, when, {
|
||||
duration: duration,
|
||||
gain: Math.max(0.3, velocity * 1.2) // Boost volume, minimum 0.3
|
||||
});
|
||||
|
||||
// Track scheduled note for cleanup
|
||||
if (noteOff) {
|
||||
const scheduledNote: ScheduledNote = {
|
||||
noteOff,
|
||||
time: when
|
||||
};
|
||||
this.scheduledNotes.push(scheduledNote);
|
||||
|
||||
// Clean up old scheduled notes
|
||||
const cutoffTime = when - 5.0; // Keep 5 seconds of history
|
||||
this.scheduledNotes = this.scheduledNotes.filter(n => n.time > cutoffTime);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.warn('Failed to schedule note:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private midiNoteToName(midiNote: number): string {
|
||||
const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||
const octave = Math.floor(midiNote / 12) - 1;
|
||||
const noteIndex = midiNote % 12;
|
||||
return noteNames[noteIndex] + octave;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
setVolume(volume: number): void {
|
||||
this.masterGain.gain.value = Math.max(0, Math.min(1, volume));
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ export class SynthesisEngine {
|
||||
private roleAssignments: Map<Role, RoleAssignment> = new Map();
|
||||
private isPlaying = false;
|
||||
private schedulerIntervalId: number | null = null;
|
||||
private currentTime = 0;
|
||||
private startTime = 0;
|
||||
private nextEventIndex = new Map<Role, number>();
|
||||
|
||||
@@ -40,7 +39,6 @@ export class SynthesisEngine {
|
||||
|
||||
this.isPlaying = true;
|
||||
this.startTime = this.audioContext.currentTime;
|
||||
this.currentTime = 0;
|
||||
|
||||
// Reset event indices
|
||||
for (const role of this.roleAssignments.keys()) {
|
||||
@@ -57,18 +55,22 @@ export class SynthesisEngine {
|
||||
|
||||
stop(): void {
|
||||
if (!this.isPlaying) return;
|
||||
|
||||
|
||||
this.isPlaying = false;
|
||||
|
||||
|
||||
if (this.schedulerIntervalId) {
|
||||
clearInterval(this.schedulerIntervalId);
|
||||
this.schedulerIntervalId = null;
|
||||
}
|
||||
|
||||
|
||||
// Fade out all layers
|
||||
this.fadeOutAllLayers();
|
||||
}
|
||||
|
||||
setVolume(volume: number): void {
|
||||
this.masterGain.gain.value = Math.max(0, Math.min(1, volume));
|
||||
}
|
||||
|
||||
private createSynthLayer(role: Role): SynthLayer {
|
||||
const gainNode = this.audioContext.createGain();
|
||||
const filterNode = this.audioContext.createBiquadFilter();
|
||||
@@ -194,8 +196,6 @@ export class SynthesisEngine {
|
||||
|
||||
if (!events.length && !chords.length) return;
|
||||
|
||||
let eventIndex = this.nextEventIndex.get(role) || 0;
|
||||
|
||||
// For roles that support polyphony (drone, texture), prefer chords
|
||||
if ((role === 'drone' || role === 'texture') && chords.length > 0) {
|
||||
this.scheduleChordEvents(role, chords, scheduleUntil);
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import * as Tone from 'tone';
|
||||
import type { NoteEvent } from '../types';
|
||||
|
||||
/**
|
||||
* MIDI player using Tone.js with high-quality synthesis
|
||||
*/
|
||||
export class ToneJSMIDIPlayer {
|
||||
private sampler: Tone.Sampler | null = null;
|
||||
private isPlaying = false;
|
||||
private events: NoteEvent[] = [];
|
||||
private scheduledEvents: number[] = [];
|
||||
private startTime = 0;
|
||||
private autoStopTimeout: number | null = null;
|
||||
|
||||
constructor() {
|
||||
// Will initialize sampler on first load
|
||||
}
|
||||
|
||||
async load(events: NoteEvent[]): Promise<void> {
|
||||
this.events = [...events].sort((a, b) => a.time - b.time);
|
||||
|
||||
// Initialize Tone.js sampler with piano samples if not already loaded
|
||||
if (!this.sampler) {
|
||||
console.log('Loading Tone.js piano sampler...');
|
||||
|
||||
// Use Tone.js built-in piano samples
|
||||
this.sampler = new Tone.Sampler({
|
||||
urls: {
|
||||
A0: "A0.mp3",
|
||||
C1: "C1.mp3",
|
||||
"D#1": "Ds1.mp3",
|
||||
"F#1": "Fs1.mp3",
|
||||
A1: "A1.mp3",
|
||||
C2: "C2.mp3",
|
||||
"D#2": "Ds2.mp3",
|
||||
"F#2": "Fs2.mp3",
|
||||
A2: "A2.mp3",
|
||||
C3: "C3.mp3",
|
||||
"D#3": "Ds3.mp3",
|
||||
"F#3": "Fs3.mp3",
|
||||
A3: "A3.mp3",
|
||||
C4: "C4.mp3",
|
||||
"D#4": "Ds4.mp3",
|
||||
"F#4": "Fs4.mp3",
|
||||
A4: "A4.mp3",
|
||||
C5: "C5.mp3",
|
||||
"D#5": "Ds5.mp3",
|
||||
"F#5": "Fs5.mp3",
|
||||
A5: "A5.mp3",
|
||||
C6: "C6.mp3",
|
||||
"D#6": "Ds6.mp3",
|
||||
"F#6": "Fs6.mp3",
|
||||
A6: "A6.mp3",
|
||||
C7: "C7.mp3",
|
||||
"D#7": "Ds7.mp3",
|
||||
"F#7": "Fs7.mp3",
|
||||
A7: "A7.mp3",
|
||||
C8: "C8.mp3"
|
||||
},
|
||||
release: 1,
|
||||
baseUrl: "https://tonejs.github.io/audio/salamander/"
|
||||
}).toDestination();
|
||||
|
||||
await Tone.loaded();
|
||||
console.log('Tone.js sampler loaded');
|
||||
}
|
||||
|
||||
console.log(`Loaded ${this.events.length} MIDI events for Tone.js playback`);
|
||||
}
|
||||
|
||||
async play(): Promise<void> {
|
||||
if (this.isPlaying) return;
|
||||
if (this.events.length === 0) {
|
||||
throw new Error('No MIDI events loaded');
|
||||
}
|
||||
if (!this.sampler) {
|
||||
throw new Error('Sampler not loaded');
|
||||
}
|
||||
|
||||
// Start Tone.js audio context
|
||||
await Tone.start();
|
||||
|
||||
this.isPlaying = true;
|
||||
this.startTime = Tone.now();
|
||||
this.scheduledEvents = [];
|
||||
|
||||
// Schedule all events (skip drum channel)
|
||||
let drumNotesSkipped = 0;
|
||||
for (const event of this.events) {
|
||||
// Skip drum channel (channel 9 in 0-indexed, or channel 10 in MIDI spec)
|
||||
if (event.channel === 9) {
|
||||
drumNotesSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const noteName = this.midiNoteToName(event.pitch);
|
||||
const when = this.startTime + event.time;
|
||||
const velocity = event.velocity / 127;
|
||||
|
||||
// Schedule note with Tone.js
|
||||
const eventId = this.sampler.triggerAttackRelease(
|
||||
noteName,
|
||||
event.duration,
|
||||
when,
|
||||
velocity
|
||||
);
|
||||
|
||||
this.scheduledEvents.push(eventId as any);
|
||||
}
|
||||
|
||||
if (drumNotesSkipped > 0) {
|
||||
console.log(`Tone.js: Skipped ${drumNotesSkipped} drum notes (channel 10)`);
|
||||
}
|
||||
console.log('Tone.js MIDI playback started');
|
||||
|
||||
// Auto-stop when done
|
||||
const lastEvent = this.events[this.events.length - 1];
|
||||
const totalDuration = lastEvent.time + lastEvent.duration;
|
||||
this.autoStopTimeout = window.setTimeout(() => {
|
||||
if (this.isPlaying) {
|
||||
this.stop();
|
||||
}
|
||||
}, (totalDuration + 1) * 1000);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (!this.isPlaying) return;
|
||||
|
||||
this.isPlaying = false;
|
||||
|
||||
// Clear auto-stop timeout
|
||||
if (this.autoStopTimeout !== null) {
|
||||
clearTimeout(this.autoStopTimeout);
|
||||
this.autoStopTimeout = null;
|
||||
}
|
||||
|
||||
// Release all notes
|
||||
if (this.sampler) {
|
||||
this.sampler.releaseAll();
|
||||
}
|
||||
|
||||
// Clear scheduled events
|
||||
this.scheduledEvents = [];
|
||||
|
||||
console.log('Tone.js MIDI playback stopped');
|
||||
}
|
||||
|
||||
private midiNoteToName(midiNote: number): string {
|
||||
const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||
const octave = Math.floor(midiNote / 12) - 1;
|
||||
const noteIndex = midiNote % 12;
|
||||
return noteNames[noteIndex] + octave;
|
||||
}
|
||||
|
||||
getProgress(): number {
|
||||
if (this.events.length === 0 || !this.isPlaying) return 0;
|
||||
|
||||
const currentTime = Tone.now() - 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));
|
||||
}
|
||||
|
||||
setVolume(volume: number): void {
|
||||
if (this.sampler) {
|
||||
this.sampler.volume.value = Tone.gainToDb(Math.max(0.01, Math.min(1, volume)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user