Improve MIDI search and add browser playback engines.

This commit is contained in:
b1rdmania
2025-12-18 14:25:38 +00:00
parent 4fdc6984bc
commit 2c9d06d8d7
30 changed files with 2080 additions and 93 deletions
+7 -1
View File
@@ -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);
+2 -2
View File
@@ -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>();
+42
View File
@@ -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');
});
+189
View File
@@ -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
View File
@@ -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) {
+2 -1
View File
@@ -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)
});
});
});
-1
View File
@@ -92,5 +92,4 @@ export class MIDIService {
return false;
}
}
}
}
+259
View File
@@ -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));
}
}
+215
View File
@@ -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));
}
}
+196
View File
@@ -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));
}
}
+7 -7
View File
@@ -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);
+174
View File
@@ -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)));
}
}
}