260 lines
8.0 KiB
TypeScript
260 lines
8.0 KiB
TypeScript
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));
|
|
}
|
|
}
|