Refine playback stability and scoring.

This commit is contained in:
b1rdmania
2025-12-18 15:14:37 +00:00
parent 5a6921c483
commit 593bbd7032
8 changed files with 383 additions and 181 deletions
+79 -10
View File
@@ -9,20 +9,45 @@ export class ScoreUtils {
const titleTokens = this.tokenize(titleLower); const titleTokens = this.tokenize(titleLower);
const queryTokens = this.tokenize(queryLower); const queryTokens = this.tokenize(queryLower);
// Exact title match gets high score // Exact title match gets very high score
if (titleLower.includes(queryLower)) { if (titleLower === queryLower) {
score += 1.0;
} else if (titleLower.includes(queryLower)) {
score += 0.8; score += 0.8;
} }
// Token overlap scoring // Parse for artist + song patterns (e.g., "Artist - Song" or "Artist Song")
const matchingTokens = queryTokens.filter(token => const artistSongPattern = this.parseArtistSongQuery(queryLower);
titleTokens.some(titleToken => if (artistSongPattern) {
titleToken.includes(token) || token.includes(titleToken) const { artist, song } = artistSongPattern;
)
);
const tokenMatchRatio = matchingTokens.length / queryTokens.length; // Check if title contains both artist and song (high confidence)
score += tokenMatchRatio * 0.6; const hasArtist = titleLower.includes(artist);
const hasSong = titleLower.includes(song);
if (hasArtist && hasSong) {
score += 0.9; // Very high confidence for artist + song match
} else if (hasArtist) {
score += 0.4; // Partial credit for artist match
} else if (hasSong) {
score += 0.5; // Partial credit for song match
}
} else {
// Standard token overlap scoring (fallback)
const matchingTokens = queryTokens.filter(token =>
titleTokens.some(titleToken =>
titleToken.includes(token) || token.includes(titleToken)
)
);
const tokenMatchRatio = matchingTokens.length / queryTokens.length;
score += tokenMatchRatio * 0.6;
// Bonus for having all query words present (in any order)
if (tokenMatchRatio === 1.0) {
score += 0.2;
}
}
// Penalty for low-quality indicators // Penalty for low-quality indicators
const penalties = [ const penalties = [
@@ -106,4 +131,48 @@ export class ScoreUtils {
const stopWords = ['the', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by']; const stopWords = ['the', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by'];
return stopWords.includes(word); return stopWords.includes(word);
} }
/**
* Parse query for artist + song patterns
* Handles patterns like: "Artist Song", "Artist - Song", "Artist: Song", etc.
* Returns null if no clear pattern detected
*/
private static parseArtistSongQuery(query: string): { artist: string; song: string } | null {
// Pattern 1: "Artist - Song" or "Artist : Song"
const dashPattern = /^(.+?)\s*[-:]\s*(.+)$/;
const dashMatch = query.match(dashPattern);
if (dashMatch) {
return {
artist: dashMatch[1].trim(),
song: dashMatch[2].trim()
};
}
// Pattern 2: Multi-word query where first few words might be artist
// Common patterns: "[FirstName LastName] [SongWords...]"
const words = query.split(/\s+/);
if (words.length >= 3) {
// Try 2-word artist name (e.g., "David Barry Live on Mars")
const twoWordArtist = words.slice(0, 2).join(' ');
const remainingSong = words.slice(2).join(' ');
// Heuristic: if first words are capitalized names and rest is longer, likely artist + song
if (remainingSong.length > twoWordArtist.length) {
return {
artist: twoWordArtist,
song: remainingSong
};
}
// Try 1-word artist name (e.g., "Madonna Like a Prayer")
if (words.length >= 3) {
return {
artist: words[0],
song: words.slice(1).join(' ')
};
}
}
return null;
}
} }
+35 -13
View File
@@ -1,4 +1,4 @@
import type { NoteEvent, StructuralFeatures, MotifConfig } from '../types'; import type { NoteEvent, MotifConfig } from '../types';
import { MIDIProcessor } from '../midi/MIDIProcessor'; import { MIDIProcessor } from '../midi/MIDIProcessor';
import { MIDIParser } from '../midi/MIDIParser'; import { MIDIParser } from '../midi/MIDIParser';
import { MIDIService } from '../services/MIDIService'; import { MIDIService } from '../services/MIDIService';
@@ -12,7 +12,6 @@ export class MotifEngine {
private midiService: MIDIService; private midiService: MIDIService;
private roleMapper: RoleMapper; private roleMapper: RoleMapper;
private synthesisEngine: SynthesisEngine | null = null; private synthesisEngine: SynthesisEngine | null = null;
private currentFeatures: StructuralFeatures | null = null;
constructor() { constructor() {
this.config = { this.config = {
@@ -27,20 +26,45 @@ export class MotifEngine {
this.roleMapper = new RoleMapper(); this.roleMapper = new RoleMapper();
} }
async generateFromMIDI(events: NoteEvent[]): Promise<void> { async generateFromMIDI(events: NoteEvent[], transformMode: 'passthrough' | 'procedural' = 'passthrough'): Promise<void> {
// Process events directly (bypass search/fetch)
const features = this.midiProcessor.extractFeatures(events);
const roleAssignments = this.roleMapper.assignRoles(features, events);
this.currentFeatures = features;
// Initialize audio context and synthesis engine // Initialize audio context and synthesis engine
if (!this.audioContext) { if (!this.audioContext) {
this.audioContext = new AudioContext(); this.audioContext = new AudioContext();
} }
this.synthesisEngine = new SynthesisEngine(this.audioContext, this.config); this.synthesisEngine = new SynthesisEngine(this.audioContext, this.config);
this.synthesisEngine.setupLayers(roleAssignments);
if (transformMode === 'passthrough') {
// Direct playback mode - play MIDI as-is without transformations
// Create a single "melody" role assignment with all original events
const passthroughAssignment = [{
role: 'melody' as const,
sourceTrack: 0,
events: events,
chords: [], // No chord processing
confidence: 1.0,
features: {
medianPitch: 60,
pitchRange: 48,
noteDensity: 1.0,
polyphonyRatio: 0.5,
averageDuration: 0.5,
repetitionScore: 0.5,
isMonophonic: false,
hasPhraseContinuity: true,
register: 'mid' as const
}
}];
this.synthesisEngine.setupLayers(passthroughAssignment);
console.log('Motif: Passthrough mode - playing original MIDI patterns');
} else {
// Procedural mode - transform the MIDI with role mapping
const features = this.midiProcessor.extractFeatures(events);
const roleAssignments = this.roleMapper.assignRoles(features, events);
this.synthesisEngine.setupLayers(roleAssignments);
console.log('Motif: Procedural mode - transforming MIDI with role mapping');
}
} }
async generateFromSong(songName: string): Promise<void> { async generateFromSong(songName: string): Promise<void> {
@@ -77,8 +101,6 @@ export class MotifEngine {
const features = this.midiProcessor.extractFeatures(events); const features = this.midiProcessor.extractFeatures(events);
const roleAssignments = this.roleMapper.assignRoles(features, events); const roleAssignments = this.roleMapper.assignRoles(features, events);
this.currentFeatures = features;
// Initialize audio context and synthesis engine // Initialize audio context and synthesis engine
if (!this.audioContext) { if (!this.audioContext) {
this.audioContext = new AudioContext(); this.audioContext = new AudioContext();
@@ -89,7 +111,7 @@ export class MotifEngine {
} }
async play(): Promise<void> { async play(): Promise<void> {
if (!this.audioContext || !this.synthesisEngine || !this.currentFeatures) { if (!this.audioContext || !this.synthesisEngine) {
throw new Error('No audio generated yet'); throw new Error('No audio generated yet');
} }
+145 -105
View File
@@ -6,17 +6,6 @@ import { SoundfontMIDIPlayer } from './synthesis/SoundfontMIDIPlayer';
import { ToneJSMIDIPlayer } from './synthesis/ToneJSMIDIPlayer'; import { ToneJSMIDIPlayer } from './synthesis/ToneJSMIDIPlayer';
import type { NoteEvent } from './types'; 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 { class MotifApp {
private motifEngine: MotifEngine; private motifEngine: MotifEngine;
private midiService: MIDIService; private midiService: MIDIService;
@@ -26,8 +15,6 @@ class MotifApp {
private toneJSPlayer: ToneJSMIDIPlayer; private toneJSPlayer: ToneJSMIDIPlayer;
private soundfontPlayer: SoundfontMIDIPlayer; private soundfontPlayer: SoundfontMIDIPlayer;
private customPlayer: EnhancedMIDIPlayer; private customPlayer: EnhancedMIDIPlayer;
private currentPlayer: MIDIPlayer;
private currentPlayerType: MIDIPlayerType = 'tonejs';
private searchBtn!: HTMLButtonElement; private searchBtn!: HTMLButtonElement;
private songInput!: HTMLInputElement; private songInput!: HTMLInputElement;
@@ -40,21 +27,34 @@ class MotifApp {
private selectedTitle!: HTMLElement; private selectedTitle!: HTMLElement;
private selectedMeta!: HTMLElement; private selectedMeta!: HTMLElement;
private previewBtn!: HTMLButtonElement; // Tone.js player controls
private previewStopBtn!: HTMLButtonElement; private tonejsPlayBtn!: HTMLButtonElement;
private tonejsStopBtn!: HTMLButtonElement;
private tonejsVolumeSlider!: HTMLInputElement;
// Soundfont player controls
private soundfontPlayBtn!: HTMLButtonElement;
private soundfontStopBtn!: HTMLButtonElement;
private soundfontVolumeSlider!: HTMLInputElement;
// Custom player controls
private customPlayBtn!: HTMLButtonElement;
private customStopBtn!: HTMLButtonElement;
private customVolumeSlider!: HTMLInputElement;
// Motif controls
private motifBtn!: HTMLButtonElement; private motifBtn!: HTMLButtonElement;
private motifStopBtn!: HTMLButtonElement; private motifStopBtn!: HTMLButtonElement;
private nextResultBtn!: HTMLButtonElement;
private previewVolumeSlider!: HTMLInputElement;
private motifVolumeSlider!: HTMLInputElement; private motifVolumeSlider!: HTMLInputElement;
private engineSelect!: HTMLSelectElement;
private nextResultBtn!: HTMLButtonElement;
private searchResults: any[] = []; private searchResults: any[] = [];
private selectedResultIndex = 0; private selectedResultIndex = 0;
private currentMIDI: { events: NoteEvent[], metadata: any } | null = null; private currentMIDI: { events: NoteEvent[], metadata: any } | null = null;
constructor() { constructor() {
// Create AudioContext lazily on first use for iOS compatibility
this.audioContext = new AudioContext(); this.audioContext = new AudioContext();
this.motifEngine = new MotifEngine(); this.motifEngine = new MotifEngine();
this.midiService = new MIDIService(); this.midiService = new MIDIService();
@@ -63,7 +63,6 @@ class MotifApp {
this.toneJSPlayer = new ToneJSMIDIPlayer(); this.toneJSPlayer = new ToneJSMIDIPlayer();
this.soundfontPlayer = new SoundfontMIDIPlayer(this.audioContext); this.soundfontPlayer = new SoundfontMIDIPlayer(this.audioContext);
this.customPlayer = new EnhancedMIDIPlayer(this.audioContext); this.customPlayer = new EnhancedMIDIPlayer(this.audioContext);
this.currentPlayer = this.toneJSPlayer; // Default to Tone.js
this.initializeUI(); this.initializeUI();
this.setupEventListeners(); this.setupEventListeners();
@@ -81,15 +80,27 @@ class MotifApp {
this.selectedTitle = document.getElementById('selectedTitle')!; this.selectedTitle = document.getElementById('selectedTitle')!;
this.selectedMeta = document.getElementById('selectedMeta')!; this.selectedMeta = document.getElementById('selectedMeta')!;
this.previewBtn = document.getElementById('previewBtn') as HTMLButtonElement; // Tone.js controls
this.previewStopBtn = document.getElementById('previewStopBtn') as HTMLButtonElement; this.tonejsPlayBtn = document.getElementById('tonejsPlayBtn') as HTMLButtonElement;
this.tonejsStopBtn = document.getElementById('tonejsStopBtn') as HTMLButtonElement;
this.tonejsVolumeSlider = document.getElementById('tonejsVolume') as HTMLInputElement;
// Soundfont controls
this.soundfontPlayBtn = document.getElementById('soundfontPlayBtn') as HTMLButtonElement;
this.soundfontStopBtn = document.getElementById('soundfontStopBtn') as HTMLButtonElement;
this.soundfontVolumeSlider = document.getElementById('soundfontVolume') as HTMLInputElement;
// Custom controls
this.customPlayBtn = document.getElementById('customPlayBtn') as HTMLButtonElement;
this.customStopBtn = document.getElementById('customStopBtn') as HTMLButtonElement;
this.customVolumeSlider = document.getElementById('customVolume') as HTMLInputElement;
// Motif controls
this.motifBtn = document.getElementById('motifBtn') as HTMLButtonElement; this.motifBtn = document.getElementById('motifBtn') as HTMLButtonElement;
this.motifStopBtn = document.getElementById('motifStopBtn') 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.motifVolumeSlider = document.getElementById('motifVolume') as HTMLInputElement;
this.engineSelect = document.getElementById('engineSelect') as HTMLSelectElement;
this.nextResultBtn = document.getElementById('nextResultBtn') as HTMLButtonElement;
} }
private setupEventListeners(): void { private setupEventListeners(): void {
@@ -101,27 +112,39 @@ class MotifApp {
} }
}); });
this.previewBtn.addEventListener('click', () => this.handlePreview()); // Tone.js player
this.previewStopBtn.addEventListener('click', () => this.handlePreviewStop()); this.tonejsPlayBtn.addEventListener('click', () => this.handleTonejsPlay());
this.motifBtn.addEventListener('click', () => this.handleMotif()); this.tonejsStopBtn.addEventListener('click', () => this.handleTonejsStop());
this.motifStopBtn.addEventListener('click', () => this.handleMotifStop()); this.tonejsVolumeSlider.addEventListener('input', (e) => {
this.nextResultBtn.addEventListener('click', () => this.handleNextResult());
// Volume control event listeners
this.previewVolumeSlider.addEventListener('input', (e) => {
const volume = parseFloat((e.target as HTMLInputElement).value); const volume = parseFloat((e.target as HTMLInputElement).value);
this.currentPlayer.setVolume(volume); this.toneJSPlayer.setVolume(volume);
}); });
// Soundfont player
this.soundfontPlayBtn.addEventListener('click', () => this.handleSoundfontPlay());
this.soundfontStopBtn.addEventListener('click', () => this.handleSoundfontStop());
this.soundfontVolumeSlider.addEventListener('input', (e) => {
const volume = parseFloat((e.target as HTMLInputElement).value);
this.soundfontPlayer.setVolume(volume);
});
// Custom player
this.customPlayBtn.addEventListener('click', () => this.handleCustomPlay());
this.customStopBtn.addEventListener('click', () => this.handleCustomStop());
this.customVolumeSlider.addEventListener('input', (e) => {
const volume = parseFloat((e.target as HTMLInputElement).value);
this.customPlayer.setVolume(volume);
});
// Motif
this.motifBtn.addEventListener('click', () => this.handleMotif());
this.motifStopBtn.addEventListener('click', () => this.handleMotifStop());
this.motifVolumeSlider.addEventListener('input', (e) => { this.motifVolumeSlider.addEventListener('input', (e) => {
const volume = parseFloat((e.target as HTMLInputElement).value); const volume = parseFloat((e.target as HTMLInputElement).value);
this.motifEngine.setVolume(volume); this.motifEngine.setVolume(volume);
}); });
// Engine selector event listener this.nextResultBtn.addEventListener('click', () => this.handleNextResult());
this.engineSelect.addEventListener('change', (e) => {
this.handleEngineChange((e.target as HTMLSelectElement).value as MIDIPlayerType);
});
} }
private async handleSearch(): Promise<void> { private async handleSearch(): Promise<void> {
@@ -181,7 +204,6 @@ class MotifApp {
</td> </td>
<td>${result.parsed ? Math.round(result.parsed.durationSec) + 's' : '?'}</td> <td>${result.parsed ? Math.round(result.parsed.durationSec) + 's' : '?'}</td>
<td>${result.parsed ? result.parsed.tracks.length : '?'}</td> <td>${result.parsed ? result.parsed.tracks.length : '?'}</td>
<td class="issues">${result.parsed?.issues.join(', ') || ''}</td>
<td><button onclick="window.app.selectResult(${index})">Select</button></td> <td><button onclick="window.app.selectResult(${index})">Select</button></td>
`; `;
@@ -230,8 +252,12 @@ class MotifApp {
this.currentMIDI = { events, metadata: { ...metadata, duration: actualDuration } }; this.currentMIDI = { events, metadata: { ...metadata, duration: actualDuration } };
// Load into current player // Load into all players
await this.currentPlayer.load(events); await Promise.all([
this.toneJSPlayer.load(events),
this.soundfontPlayer.load(events),
this.customPlayer.load(events)
]);
// Update UI // Update UI
this.selectedTitle.textContent = result.title; this.selectedTitle.textContent = result.title;
@@ -252,85 +278,93 @@ class MotifApp {
} }
} }
private async handlePreview(): Promise<void> { // Tone.js player handlers
private async handleTonejsPlay(): Promise<void> {
if (!this.currentMIDI) return; if (!this.currentMIDI) return;
try { try {
// Stop Motif if it's playing await this.toneJSPlayer.play();
this.motifEngine.stop(); this.tonejsPlayBtn.disabled = true;
this.motifBtn.disabled = false; this.tonejsStopBtn.disabled = false;
this.motifStopBtn.disabled = true; this.updateStatus('Playing Tone.js piano...');
await this.currentPlayer.play();
this.previewBtn.disabled = true;
this.previewStopBtn.disabled = false;
this.updateStatus(`Playing original MIDI (${this.currentPlayerType})...`);
} catch (error) { } catch (error) {
this.updateStatus(`Preview error: ${error instanceof Error ? error.message : 'Unknown error'}`); this.updateStatus(`Tone.js error: ${error instanceof Error ? error.message : 'Unknown error'}`);
} }
} }
private handlePreviewStop(): void { private handleTonejsStop(): void {
this.currentPlayer.stop(); console.log('Tone.js stop button clicked');
this.previewBtn.disabled = false; this.toneJSPlayer.stop();
this.previewStopBtn.disabled = true; this.tonejsPlayBtn.disabled = false;
this.updateStatus('Preview stopped.'); this.tonejsStopBtn.disabled = true;
this.updateStatus('Tone.js stopped.');
} }
private async handleEngineChange(engineType: MIDIPlayerType): Promise<void> { // Soundfont player handlers
// Stop current player private async handleSoundfontPlay(): Promise<void> {
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; if (!this.currentMIDI) return;
try {
await this.soundfontPlayer.play();
this.soundfontPlayBtn.disabled = true;
this.soundfontStopBtn.disabled = false;
this.updateStatus('Playing Soundfont piano...');
} catch (error) {
this.updateStatus(`Soundfont error: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
private handleSoundfontStop(): void {
console.log('Soundfont stop button clicked');
this.soundfontPlayer.stop();
this.soundfontPlayBtn.disabled = false;
this.soundfontStopBtn.disabled = true;
this.updateStatus('Soundfont stopped.');
}
// Custom player handlers
private async handleCustomPlay(): Promise<void> {
if (!this.currentMIDI) return;
try {
await this.customPlayer.play();
this.customPlayBtn.disabled = true;
this.customStopBtn.disabled = false;
this.updateStatus('Playing custom synthesis...');
} catch (error) {
this.updateStatus(`Custom player error: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
private handleCustomStop(): void {
console.log('Custom stop button clicked');
this.customPlayer.stop();
this.customPlayBtn.disabled = false;
this.customStopBtn.disabled = true;
this.updateStatus('Custom synthesis stopped.');
}
// Motif handlers
private async handleMotif(): Promise<void> {
console.log('Motif Generate & Play button clicked');
if (!this.currentMIDI) {
console.error('No MIDI loaded');
return;
}
try { try {
// Stop preview if it's playing
this.currentPlayer.stop();
this.previewBtn.disabled = false;
this.previewStopBtn.disabled = true;
this.updateStatus('Generating Motif synthesis...'); this.updateStatus('Generating Motif synthesis...');
this.motifBtn.disabled = true; this.motifBtn.disabled = true;
// Use the current MIDI data directly console.log('Calling generateFromMIDI with', this.currentMIDI.events.length, 'events');
await this.motifEngine.generateFromMIDI(this.currentMIDI.events); // Use the current MIDI data directly in passthrough mode
await this.motifEngine.generateFromMIDI(this.currentMIDI.events, 'passthrough');
console.log('Calling motifEngine.play()');
await this.motifEngine.play(); await this.motifEngine.play();
this.motifStopBtn.disabled = false; this.motifStopBtn.disabled = false;
this.updateStatus('Playing Motif synthesis...'); this.updateStatus('Playing Motif synthesis...');
console.log('Motif playback started successfully');
} catch (error) { } catch (error) {
console.error('Motif error:', error);
this.updateStatus(`Motif error: ${error instanceof Error ? error.message : 'Unknown error'}`); this.updateStatus(`Motif error: ${error instanceof Error ? error.message : 'Unknown error'}`);
this.motifBtn.disabled = false; this.motifBtn.disabled = false;
} }
@@ -354,14 +388,20 @@ class MotifApp {
} }
private enablePlayerControls(): void { private enablePlayerControls(): void {
this.previewBtn.disabled = false; this.tonejsPlayBtn.disabled = false;
this.soundfontPlayBtn.disabled = false;
this.customPlayBtn.disabled = false;
this.motifBtn.disabled = false; this.motifBtn.disabled = false;
this.nextResultBtn.disabled = this.searchResults.length <= 1; this.nextResultBtn.disabled = this.searchResults.length <= 1;
} }
private disablePlayerControls(): void { private disablePlayerControls(): void {
this.previewBtn.disabled = true; this.tonejsPlayBtn.disabled = true;
this.previewStopBtn.disabled = true; this.tonejsStopBtn.disabled = true;
this.soundfontPlayBtn.disabled = true;
this.soundfontStopBtn.disabled = true;
this.customPlayBtn.disabled = true;
this.customStopBtn.disabled = true;
this.motifBtn.disabled = true; this.motifBtn.disabled = true;
this.motifStopBtn.disabled = true; this.motifStopBtn.disabled = true;
this.nextResultBtn.disabled = true; this.nextResultBtn.disabled = true;
+19 -7
View File
@@ -31,7 +31,7 @@ export class EnhancedMIDIPlayer {
this.masterFilter.Q.value = 1; this.masterFilter.Q.value = 1;
this.masterGain = audioContext.createGain(); this.masterGain = audioContext.createGain();
this.masterGain.gain.value = 0.6; // Good default volume this.masterGain.gain.value = 0.8; // Good default volume
this.masterFilter.connect(this.masterGain); this.masterFilter.connect(this.masterGain);
this.masterGain.connect(audioContext.destination); this.masterGain.connect(audioContext.destination);
@@ -68,7 +68,11 @@ export class EnhancedMIDIPlayer {
} }
stop(): void { stop(): void {
if (!this.isPlaying) return; console.log('EnhancedMIDIPlayer.stop() called, isPlaying:', this.isPlaying);
if (!this.isPlaying) {
console.log('Already stopped, returning');
return;
}
this.isPlaying = false; this.isPlaying = false;
@@ -77,11 +81,12 @@ export class EnhancedMIDIPlayer {
this.schedulerIntervalId = null; this.schedulerIntervalId = null;
} }
// Stop all scheduled notes // Stop all scheduled notes immediately
const now = this.audioContext.currentTime;
for (const scheduledNote of this.scheduledNotes) { for (const scheduledNote of this.scheduledNotes) {
try { try {
for (const osc of scheduledNote.oscillators) { for (const osc of scheduledNote.oscillators) {
osc.stop(); osc.stop(now);
osc.disconnect(); osc.disconnect();
} }
scheduledNote.gainNode.disconnect(); scheduledNote.gainNode.disconnect();
@@ -92,14 +97,16 @@ export class EnhancedMIDIPlayer {
this.scheduledNotes = []; this.scheduledNotes = [];
// Fade out master gain // Fade out master gain
this.masterGain.gain.linearRampToValueAtTime(0, this.audioContext.currentTime + 0.1); this.masterGain.gain.cancelScheduledValues(now);
this.masterGain.gain.setValueAtTime(this.masterGain.gain.value, now);
this.masterGain.gain.linearRampToValueAtTime(0, now + 0.05);
// Reset volume after fade // Reset volume after fade
setTimeout(() => { setTimeout(() => {
if (!this.isPlaying) { if (!this.isPlaying) {
this.masterGain.gain.value = 0.6; this.masterGain.gain.value = 0.8;
} }
}, 150); }, 100);
console.log('Enhanced MIDI playback stopped'); console.log('Enhanced MIDI playback stopped');
} }
@@ -138,6 +145,11 @@ export class EnhancedMIDIPlayer {
} }
private scheduleNote(event: NoteEvent, when: number): void { private scheduleNote(event: NoteEvent, when: number): void {
// Skip drum channel (channel 9 in 0-indexed, or channel 10 in MIDI spec)
if (event.channel === 9) {
return;
}
try { try {
const gainNode = this.audioContext.createGain(); const gainNode = this.audioContext.createGain();
const filter = this.audioContext.createBiquadFilter(); const filter = this.audioContext.createBiquadFilter();
+17 -5
View File
@@ -71,7 +71,11 @@ export class SoundfontMIDIPlayer {
} }
stop(): void { stop(): void {
if (!this.isPlaying) return; console.log('SoundfontMIDIPlayer.stop() called, isPlaying:', this.isPlaying);
if (!this.isPlaying) {
console.log('Already stopped, returning');
return;
}
this.isPlaying = false; this.isPlaying = false;
@@ -80,7 +84,8 @@ export class SoundfontMIDIPlayer {
this.schedulerIntervalId = null; this.schedulerIntervalId = null;
} }
// Stop all scheduled notes // Stop all scheduled notes immediately
const now = this.audioContext.currentTime;
for (const scheduledNote of this.scheduledNotes) { for (const scheduledNote of this.scheduledNotes) {
try { try {
scheduledNote.noteOff(); scheduledNote.noteOff();
@@ -90,15 +95,17 @@ export class SoundfontMIDIPlayer {
} }
this.scheduledNotes = []; this.scheduledNotes = [];
// Fade out master gain // Fade out master gain quickly
this.masterGain.gain.linearRampToValueAtTime(0, this.audioContext.currentTime + 0.1); this.masterGain.gain.cancelScheduledValues(now);
this.masterGain.gain.setValueAtTime(this.masterGain.gain.value, now);
this.masterGain.gain.linearRampToValueAtTime(0, now + 0.05);
// Reset volume after fade // Reset volume after fade
setTimeout(() => { setTimeout(() => {
if (!this.isPlaying) { if (!this.isPlaying) {
this.masterGain.gain.value = 1.0; this.masterGain.gain.value = 1.0;
} }
}, 150); }, 100);
console.log('Soundfont MIDI playback stopped'); console.log('Soundfont MIDI playback stopped');
} }
@@ -139,6 +146,11 @@ export class SoundfontMIDIPlayer {
private scheduleNote(event: NoteEvent, when: number): void { private scheduleNote(event: NoteEvent, when: number): void {
if (!this.instrument) return; if (!this.instrument) return;
// Skip drum channel (channel 9 in 0-indexed, or channel 10 in MIDI spec)
if (event.channel === 9) {
return;
}
try { try {
// Convert MIDI note number to note name (for soundfont-player) // Convert MIDI note number to note name (for soundfont-player)
const noteName = this.midiNoteToName(event.pitch); const noteName = this.midiNoteToName(event.pitch);
+23 -14
View File
@@ -116,6 +116,12 @@ export class SynthesisEngine {
filter.type = 'peaking'; filter.type = 'peaking';
filter.frequency.value = 1000; filter.frequency.value = 1000;
break; break;
case 'melody':
// Passthrough mode - balanced sound for all notes
gain.gain.value = 0.35;
filter.type = 'lowpass';
filter.frequency.value = 4000; // Brighter sound for full range
break;
} }
} }
@@ -145,6 +151,9 @@ export class SynthesisEngine {
case 'ostinato': case 'ostinato':
osc.type = 'triangle'; osc.type = 'triangle';
break; break;
case 'melody':
osc.type = 'triangle';
break;
case 'texture': case 'texture':
case 'accents': case 'accents':
osc.type = 'sine'; osc.type = 'sine';
@@ -154,18 +163,18 @@ export class SynthesisEngine {
osc.connect(envelope); osc.connect(envelope);
envelope.connect(layer.filterNode); envelope.connect(layer.filterNode);
// Envelope based on velocity and duration // Envelope based on velocity and duration with minimum times to prevent clicks
const gainValue = velocity * 0.5; // Scale velocity const gainValue = velocity * 0.5; // Scale velocity
const attackTime = Math.min(0.05, duration * 0.1); const attackTime = Math.max(0.005, Math.min(0.05, duration * 0.1)); // Min 5ms attack
const releaseTime = Math.min(0.1, duration * 0.3); const releaseTime = Math.max(0.01, Math.min(0.1, duration * 0.3)); // Min 10ms release
envelope.gain.setValueAtTime(0, when); envelope.gain.setValueAtTime(0, when);
envelope.gain.linearRampToValueAtTime(gainValue, when + attackTime); envelope.gain.linearRampToValueAtTime(gainValue, when + attackTime);
envelope.gain.linearRampToValueAtTime(gainValue * 0.7, when + duration - releaseTime); envelope.gain.setValueAtTime(gainValue, when + Math.max(attackTime, duration - releaseTime));
envelope.gain.exponentialRampToValueAtTime(0.001, when + duration); envelope.gain.exponentialRampToValueAtTime(0.001, when + duration + releaseTime);
osc.start(when); osc.start(when);
osc.stop(when + duration); osc.stop(when + duration + releaseTime + 0.01); // Stop after envelope completes
// Clean up after note ends // Clean up after note ends
setTimeout(() => { setTimeout(() => {
@@ -175,7 +184,7 @@ export class SynthesisEngine {
} catch (e) { } catch (e) {
// Already disconnected // Already disconnected
} }
}, (duration + 0.1) * 1000); }, (duration + releaseTime + 0.1) * 1000);
} }
private scheduleEvents(): void { private scheduleEvents(): void {
@@ -333,18 +342,18 @@ export class SynthesisEngine {
osc.connect(envelope); osc.connect(envelope);
envelope.connect(layer.filterNode); envelope.connect(layer.filterNode);
// Envelope based on velocity and duration, scaled for chords // Envelope based on velocity and duration, scaled for chords with minimum times to prevent clicks
const gainValue = (velocity * 0.3) / Math.max(pitches.length * 0.5, 1); // Scale down for chords const gainValue = (velocity * 0.3) / Math.max(pitches.length * 0.5, 1); // Scale down for chords
const attackTime = Math.min(0.05, duration * 0.1); const attackTime = Math.max(0.005, Math.min(0.05, duration * 0.1)); // Min 5ms attack
const releaseTime = Math.min(0.1, duration * 0.3); const releaseTime = Math.max(0.01, Math.min(0.1, duration * 0.3)); // Min 10ms release
envelope.gain.setValueAtTime(0, when); envelope.gain.setValueAtTime(0, when);
envelope.gain.linearRampToValueAtTime(gainValue, when + attackTime); envelope.gain.linearRampToValueAtTime(gainValue, when + attackTime);
envelope.gain.linearRampToValueAtTime(gainValue * 0.7, when + duration - releaseTime); envelope.gain.setValueAtTime(gainValue, when + Math.max(attackTime, duration - releaseTime));
envelope.gain.exponentialRampToValueAtTime(0.001, when + duration); envelope.gain.exponentialRampToValueAtTime(0.001, when + duration + releaseTime);
osc.start(when); osc.start(when);
osc.stop(when + duration); osc.stop(when + duration + releaseTime + 0.01); // Stop after envelope completes
// Clean up after note ends // Clean up after note ends
setTimeout(() => { setTimeout(() => {
@@ -354,7 +363,7 @@ export class SynthesisEngine {
} catch (e) { } catch (e) {
// Already disconnected // Already disconnected
} }
}, (duration + 0.1) * 1000); }, (duration + releaseTime + 0.1) * 1000);
} }
} }
+50 -13
View File
@@ -19,12 +19,18 @@ export class ToneJSMIDIPlayer {
async load(events: NoteEvent[]): Promise<void> { async load(events: NoteEvent[]): Promise<void> {
this.events = [...events].sort((a, b) => a.time - b.time); this.events = [...events].sort((a, b) => a.time - b.time);
// Initialize Tone.js sampler with piano samples if not already loaded // Always ensure we have a fresh sampler (may have been disposed on stop)
if (!this.sampler) { if (!this.sampler) {
console.log('Loading Tone.js piano sampler...'); console.log('Loading Tone.js piano sampler...');
await this.createSampler();
}
// Use Tone.js built-in piano samples console.log(`Loaded ${this.events.length} MIDI events for Tone.js playback`);
this.sampler = new Tone.Sampler({ }
private async createSampler(): Promise<void> {
// Create new sampler instance
this.sampler = new Tone.Sampler({
urls: { urls: {
A0: "A0.mp3", A0: "A0.mp3",
C1: "C1.mp3", C1: "C1.mp3",
@@ -58,14 +64,12 @@ export class ToneJSMIDIPlayer {
C8: "C8.mp3" C8: "C8.mp3"
}, },
release: 1, release: 1,
volume: 6, // +6dB boost for better default volume
baseUrl: "https://tonejs.github.io/audio/salamander/" baseUrl: "https://tonejs.github.io/audio/salamander/"
}).toDestination(); }).toDestination();
await Tone.loaded(); await Tone.loaded();
console.log('Tone.js sampler loaded'); console.log('Tone.js sampler loaded');
}
console.log(`Loaded ${this.events.length} MIDI events for Tone.js playback`);
} }
async play(): Promise<void> { async play(): Promise<void> {
@@ -73,8 +77,11 @@ export class ToneJSMIDIPlayer {
if (this.events.length === 0) { if (this.events.length === 0) {
throw new Error('No MIDI events loaded'); throw new Error('No MIDI events loaded');
} }
// Recreate sampler if it was disposed
if (!this.sampler) { if (!this.sampler) {
throw new Error('Sampler not loaded'); console.log('Recreating sampler after stop...');
await this.createSampler();
} }
// Start Tone.js audio context // Start Tone.js audio context
@@ -86,6 +93,10 @@ export class ToneJSMIDIPlayer {
// Schedule all events (skip drum channel) // Schedule all events (skip drum channel)
let drumNotesSkipped = 0; let drumNotesSkipped = 0;
if (!this.sampler) {
throw new Error('Sampler not initialized');
}
for (const event of this.events) { for (const event of this.events) {
// Skip drum channel (channel 9 in 0-indexed, or channel 10 in MIDI spec) // Skip drum channel (channel 9 in 0-indexed, or channel 10 in MIDI spec)
if (event.channel === 9) { if (event.channel === 9) {
@@ -95,7 +106,8 @@ export class ToneJSMIDIPlayer {
const noteName = this.midiNoteToName(event.pitch); const noteName = this.midiNoteToName(event.pitch);
const when = this.startTime + event.time; const when = this.startTime + event.time;
const velocity = event.velocity / 127; // Boost velocity for better volume (normalize and scale up)
const velocity = Math.min(1.5, (event.velocity / 127) * 1.8);
// Schedule note with Tone.js // Schedule note with Tone.js
const eventId = this.sampler.triggerAttackRelease( const eventId = this.sampler.triggerAttackRelease(
@@ -124,7 +136,11 @@ export class ToneJSMIDIPlayer {
} }
stop(): void { stop(): void {
if (!this.isPlaying) return; console.log('ToneJSMIDIPlayer.stop() called, isPlaying:', this.isPlaying);
if (!this.isPlaying) {
console.log('Already stopped, returning');
return;
}
this.isPlaying = false; this.isPlaying = false;
@@ -134,15 +150,33 @@ export class ToneJSMIDIPlayer {
this.autoStopTimeout = null; this.autoStopTimeout = null;
} }
// Release all notes // CRITICAL: Dispose and recreate the sampler to cancel all scheduled notes
// Tone.js doesn't provide a way to cancel scheduled triggerAttackRelease calls
// So we need to destroy and recreate the instrument
if (this.sampler) { if (this.sampler) {
this.sampler.releaseAll(); try {
this.sampler.releaseAll();
this.sampler.disconnect();
this.sampler.dispose();
console.log('Tone.js: Disposed sampler to cancel scheduled events');
this.sampler = null;
} catch (e) {
console.error('Error disposing sampler:', e);
}
}
// Stop and clear the transport
try {
Tone.Transport.stop();
Tone.Transport.cancel();
} catch (e) {
// Transport might not be running
} }
// Clear scheduled events // Clear scheduled events
this.scheduledEvents = []; this.scheduledEvents = [];
console.log('Tone.js MIDI playback stopped'); console.log('Tone.js MIDI playback stopped - sampler disposed');
} }
private midiNoteToName(midiNote: number): string { private midiNoteToName(midiNote: number): string {
@@ -168,7 +202,10 @@ export class ToneJSMIDIPlayer {
setVolume(volume: number): void { setVolume(volume: number): void {
if (this.sampler) { if (this.sampler) {
this.sampler.volume.value = Tone.gainToDb(Math.max(0.01, Math.min(1, volume))); // Convert 0-1 volume to dB with extra headroom
// At volume=0.8, this gives us ~4dB (instead of -2dB)
const dbValue = Tone.gainToDb(Math.max(0.01, Math.min(2, volume * 1.5)));
this.sampler.volume.value = dbValue;
} }
} }
} }
+1
View File
@@ -4,6 +4,7 @@ export interface NoteEvent {
pitch: number; pitch: number;
velocity: number; velocity: number;
track: number; track: number;
channel?: number; // MIDI channel (0-15, where 9 is drums)
} }
export interface ChordEvent { export interface ChordEvent {