diff --git a/server/src/utils/ScoreUtils.ts b/server/src/utils/ScoreUtils.ts index 980963f..588572f 100644 --- a/server/src/utils/ScoreUtils.ts +++ b/server/src/utils/ScoreUtils.ts @@ -2,27 +2,52 @@ export class ScoreUtils { static calculateConfidence(title: string, query: string, source: string): number { const titleLower = title.toLowerCase(); const queryLower = query.toLowerCase(); - + let score = 0; - + // Token matching - split and check individual words const titleTokens = this.tokenize(titleLower); const queryTokens = this.tokenize(queryLower); - - // Exact title match gets high score - if (titleLower.includes(queryLower)) { + + // Exact title match gets very high score + if (titleLower === queryLower) { + score += 1.0; + } else if (titleLower.includes(queryLower)) { score += 0.8; } - - // Token overlap scoring - const matchingTokens = queryTokens.filter(token => - titleTokens.some(titleToken => - titleToken.includes(token) || token.includes(titleToken) - ) - ); - - const tokenMatchRatio = matchingTokens.length / queryTokens.length; - score += tokenMatchRatio * 0.6; + + // Parse for artist + song patterns (e.g., "Artist - Song" or "Artist Song") + const artistSongPattern = this.parseArtistSongQuery(queryLower); + if (artistSongPattern) { + const { artist, song } = artistSongPattern; + + // Check if title contains both artist and song (high confidence) + 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 const penalties = [ @@ -106,4 +131,48 @@ export class ScoreUtils { const stopWords = ['the', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by']; 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; + } } \ No newline at end of file diff --git a/src/core/MotifEngine.ts b/src/core/MotifEngine.ts index cdcbc47..f953ff7 100644 --- a/src/core/MotifEngine.ts +++ b/src/core/MotifEngine.ts @@ -1,4 +1,4 @@ -import type { NoteEvent, StructuralFeatures, MotifConfig } from '../types'; +import type { NoteEvent, MotifConfig } from '../types'; import { MIDIProcessor } from '../midi/MIDIProcessor'; import { MIDIParser } from '../midi/MIDIParser'; import { MIDIService } from '../services/MIDIService'; @@ -12,7 +12,6 @@ export class MotifEngine { private midiService: MIDIService; private roleMapper: RoleMapper; private synthesisEngine: SynthesisEngine | null = null; - private currentFeatures: StructuralFeatures | null = null; constructor() { this.config = { @@ -27,20 +26,45 @@ export class MotifEngine { this.roleMapper = new RoleMapper(); } - async generateFromMIDI(events: NoteEvent[]): Promise { - // Process events directly (bypass search/fetch) - const features = this.midiProcessor.extractFeatures(events); - const roleAssignments = this.roleMapper.assignRoles(features, events); - - this.currentFeatures = features; - + async generateFromMIDI(events: NoteEvent[], transformMode: 'passthrough' | 'procedural' = 'passthrough'): Promise { // Initialize audio context and synthesis engine if (!this.audioContext) { this.audioContext = new AudioContext(); } - + this.synthesisEngine = new SynthesisEngine(this.audioContext, this.config); - this.synthesisEngine.setupLayers(roleAssignments); + + 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 { @@ -76,9 +100,7 @@ export class MotifEngine { // Process events into structure const features = this.midiProcessor.extractFeatures(events); const roleAssignments = this.roleMapper.assignRoles(features, events); - - this.currentFeatures = features; - + // Initialize audio context and synthesis engine if (!this.audioContext) { this.audioContext = new AudioContext(); @@ -89,7 +111,7 @@ export class MotifEngine { } async play(): Promise { - if (!this.audioContext || !this.synthesisEngine || !this.currentFeatures) { + if (!this.audioContext || !this.synthesisEngine) { throw new Error('No audio generated yet'); } diff --git a/src/main.ts b/src/main.ts index 7190cad..591914f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,17 +6,6 @@ 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; - play(): void | Promise; - stop(): void; - setVolume(volume: number): void; - getDuration(): number; - getProgress(): number; -} - class MotifApp { private motifEngine: MotifEngine; private midiService: MIDIService; @@ -26,8 +15,6 @@ class MotifApp { private toneJSPlayer: ToneJSMIDIPlayer; private soundfontPlayer: SoundfontMIDIPlayer; private customPlayer: EnhancedMIDIPlayer; - private currentPlayer: MIDIPlayer; - private currentPlayerType: MIDIPlayerType = 'tonejs'; private searchBtn!: HTMLButtonElement; private songInput!: HTMLInputElement; @@ -40,21 +27,34 @@ class MotifApp { private selectedTitle!: HTMLElement; private selectedMeta!: HTMLElement; - private previewBtn!: HTMLButtonElement; - private previewStopBtn!: HTMLButtonElement; + // Tone.js player controls + 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 motifStopBtn!: HTMLButtonElement; - private nextResultBtn!: HTMLButtonElement; - - private previewVolumeSlider!: HTMLInputElement; private motifVolumeSlider!: HTMLInputElement; - private engineSelect!: HTMLSelectElement; + + private nextResultBtn!: HTMLButtonElement; private searchResults: any[] = []; private selectedResultIndex = 0; private currentMIDI: { events: NoteEvent[], metadata: any } | null = null; constructor() { + // Create AudioContext lazily on first use for iOS compatibility this.audioContext = new AudioContext(); this.motifEngine = new MotifEngine(); this.midiService = new MIDIService(); @@ -63,7 +63,6 @@ class MotifApp { 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(); @@ -81,15 +80,27 @@ class MotifApp { this.selectedTitle = document.getElementById('selectedTitle')!; this.selectedMeta = document.getElementById('selectedMeta')!; - this.previewBtn = document.getElementById('previewBtn') as HTMLButtonElement; - this.previewStopBtn = document.getElementById('previewStopBtn') as HTMLButtonElement; + // Tone.js controls + 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.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; + + this.nextResultBtn = document.getElementById('nextResultBtn') as HTMLButtonElement; } private setupEventListeners(): void { @@ -101,27 +112,39 @@ class MotifApp { } }); - this.previewBtn.addEventListener('click', () => this.handlePreview()); - this.previewStopBtn.addEventListener('click', () => this.handlePreviewStop()); - this.motifBtn.addEventListener('click', () => this.handleMotif()); - this.motifStopBtn.addEventListener('click', () => this.handleMotifStop()); - this.nextResultBtn.addEventListener('click', () => this.handleNextResult()); - - // Volume control event listeners - this.previewVolumeSlider.addEventListener('input', (e) => { + // Tone.js player + this.tonejsPlayBtn.addEventListener('click', () => this.handleTonejsPlay()); + this.tonejsStopBtn.addEventListener('click', () => this.handleTonejsStop()); + this.tonejsVolumeSlider.addEventListener('input', (e) => { 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) => { 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); - }); + this.nextResultBtn.addEventListener('click', () => this.handleNextResult()); } private async handleSearch(): Promise { @@ -181,7 +204,6 @@ class MotifApp { ${result.parsed ? Math.round(result.parsed.durationSec) + 's' : '?'} ${result.parsed ? result.parsed.tracks.length : '?'} - ${result.parsed?.issues.join(', ') || ''} `; @@ -230,8 +252,12 @@ class MotifApp { this.currentMIDI = { events, metadata: { ...metadata, duration: actualDuration } }; - // Load into current player - await this.currentPlayer.load(events); + // Load into all players + await Promise.all([ + this.toneJSPlayer.load(events), + this.soundfontPlayer.load(events), + this.customPlayer.load(events) + ]); // Update UI this.selectedTitle.textContent = result.title; @@ -252,85 +278,93 @@ class MotifApp { } } - private async handlePreview(): Promise { + // Tone.js player handlers + private async handleTonejsPlay(): Promise { if (!this.currentMIDI) return; - try { - // 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 original MIDI (${this.currentPlayerType})...`); + await this.toneJSPlayer.play(); + this.tonejsPlayBtn.disabled = true; + this.tonejsStopBtn.disabled = false; + this.updateStatus('Playing Tone.js piano...'); } 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 { - this.currentPlayer.stop(); - this.previewBtn.disabled = false; - this.previewStopBtn.disabled = true; - this.updateStatus('Preview stopped.'); + private handleTonejsStop(): void { + console.log('Tone.js stop button clicked'); + this.toneJSPlayer.stop(); + this.tonejsPlayBtn.disabled = false; + this.tonejsStopBtn.disabled = true; + this.updateStatus('Tone.js stopped.'); } - private async handleEngineChange(engineType: MIDIPlayerType): Promise { - // 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 { + // Soundfont player handlers + private async handleSoundfontPlay(): Promise { 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 { + 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 { + console.log('Motif Generate & Play button clicked'); + if (!this.currentMIDI) { + console.error('No MIDI loaded'); + return; + } 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); + console.log('Calling generateFromMIDI with', this.currentMIDI.events.length, '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(); this.motifStopBtn.disabled = false; this.updateStatus('Playing Motif synthesis...'); + console.log('Motif playback started successfully'); } catch (error) { + console.error('Motif error:', error); this.updateStatus(`Motif error: ${error instanceof Error ? error.message : 'Unknown error'}`); this.motifBtn.disabled = false; } @@ -354,14 +388,20 @@ class MotifApp { } private enablePlayerControls(): void { - this.previewBtn.disabled = false; + this.tonejsPlayBtn.disabled = false; + this.soundfontPlayBtn.disabled = false; + this.customPlayBtn.disabled = false; this.motifBtn.disabled = false; this.nextResultBtn.disabled = this.searchResults.length <= 1; } private disablePlayerControls(): void { - this.previewBtn.disabled = true; - this.previewStopBtn.disabled = true; + this.tonejsPlayBtn.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.motifStopBtn.disabled = true; this.nextResultBtn.disabled = true; diff --git a/src/synthesis/EnhancedMIDIPlayer.ts b/src/synthesis/EnhancedMIDIPlayer.ts index def1b85..19b75db 100644 --- a/src/synthesis/EnhancedMIDIPlayer.ts +++ b/src/synthesis/EnhancedMIDIPlayer.ts @@ -31,7 +31,7 @@ export class EnhancedMIDIPlayer { this.masterFilter.Q.value = 1; 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.masterGain.connect(audioContext.destination); @@ -68,7 +68,11 @@ export class EnhancedMIDIPlayer { } 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; @@ -77,11 +81,12 @@ export class EnhancedMIDIPlayer { this.schedulerIntervalId = null; } - // Stop all scheduled notes + // Stop all scheduled notes immediately + const now = this.audioContext.currentTime; for (const scheduledNote of this.scheduledNotes) { try { for (const osc of scheduledNote.oscillators) { - osc.stop(); + osc.stop(now); osc.disconnect(); } scheduledNote.gainNode.disconnect(); @@ -92,14 +97,16 @@ export class EnhancedMIDIPlayer { this.scheduledNotes = []; // 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 setTimeout(() => { if (!this.isPlaying) { - this.masterGain.gain.value = 0.6; + this.masterGain.gain.value = 0.8; } - }, 150); + }, 100); console.log('Enhanced MIDI playback stopped'); } @@ -138,6 +145,11 @@ export class EnhancedMIDIPlayer { } 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 { const gainNode = this.audioContext.createGain(); const filter = this.audioContext.createBiquadFilter(); diff --git a/src/synthesis/SoundfontMIDIPlayer.ts b/src/synthesis/SoundfontMIDIPlayer.ts index d660a0e..6f09d9e 100644 --- a/src/synthesis/SoundfontMIDIPlayer.ts +++ b/src/synthesis/SoundfontMIDIPlayer.ts @@ -71,16 +71,21 @@ export class SoundfontMIDIPlayer { } 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; - + if (this.schedulerIntervalId) { clearInterval(this.schedulerIntervalId); this.schedulerIntervalId = null; } - // Stop all scheduled notes + // Stop all scheduled notes immediately + const now = this.audioContext.currentTime; for (const scheduledNote of this.scheduledNotes) { try { scheduledNote.noteOff(); @@ -90,15 +95,17 @@ export class SoundfontMIDIPlayer { } this.scheduledNotes = []; - // Fade out master gain - this.masterGain.gain.linearRampToValueAtTime(0, this.audioContext.currentTime + 0.1); - + // Fade out master gain quickly + 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 setTimeout(() => { if (!this.isPlaying) { this.masterGain.gain.value = 1.0; } - }, 150); + }, 100); console.log('Soundfont MIDI playback stopped'); } @@ -139,6 +146,11 @@ export class SoundfontMIDIPlayer { private scheduleNote(event: NoteEvent, when: number): void { if (!this.instrument) return; + // Skip drum channel (channel 9 in 0-indexed, or channel 10 in MIDI spec) + if (event.channel === 9) { + return; + } + try { // Convert MIDI note number to note name (for soundfont-player) const noteName = this.midiNoteToName(event.pitch); diff --git a/src/synthesis/SynthesisEngine.ts b/src/synthesis/SynthesisEngine.ts index c87decb..d2f5c0f 100644 --- a/src/synthesis/SynthesisEngine.ts +++ b/src/synthesis/SynthesisEngine.ts @@ -116,6 +116,12 @@ export class SynthesisEngine { filter.type = 'peaking'; filter.frequency.value = 1000; 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': osc.type = 'triangle'; break; + case 'melody': + osc.type = 'triangle'; + break; case 'texture': case 'accents': osc.type = 'sine'; @@ -154,18 +163,18 @@ export class SynthesisEngine { osc.connect(envelope); 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 attackTime = Math.min(0.05, duration * 0.1); - const releaseTime = Math.min(0.1, duration * 0.3); - + const attackTime = Math.max(0.005, Math.min(0.05, duration * 0.1)); // Min 5ms attack + const releaseTime = Math.max(0.01, Math.min(0.1, duration * 0.3)); // Min 10ms release + envelope.gain.setValueAtTime(0, when); envelope.gain.linearRampToValueAtTime(gainValue, when + attackTime); - envelope.gain.linearRampToValueAtTime(gainValue * 0.7, when + duration - releaseTime); - envelope.gain.exponentialRampToValueAtTime(0.001, when + duration); - + envelope.gain.setValueAtTime(gainValue, when + Math.max(attackTime, duration - releaseTime)); + envelope.gain.exponentialRampToValueAtTime(0.001, when + duration + releaseTime); + osc.start(when); - osc.stop(when + duration); + osc.stop(when + duration + releaseTime + 0.01); // Stop after envelope completes // Clean up after note ends setTimeout(() => { @@ -175,7 +184,7 @@ export class SynthesisEngine { } catch (e) { // Already disconnected } - }, (duration + 0.1) * 1000); + }, (duration + releaseTime + 0.1) * 1000); } private scheduleEvents(): void { @@ -333,19 +342,19 @@ export class SynthesisEngine { osc.connect(envelope); 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 attackTime = Math.min(0.05, duration * 0.1); - const releaseTime = Math.min(0.1, duration * 0.3); - + const attackTime = Math.max(0.005, Math.min(0.05, duration * 0.1)); // Min 5ms attack + const releaseTime = Math.max(0.01, Math.min(0.1, duration * 0.3)); // Min 10ms release + envelope.gain.setValueAtTime(0, when); envelope.gain.linearRampToValueAtTime(gainValue, when + attackTime); - envelope.gain.linearRampToValueAtTime(gainValue * 0.7, when + duration - releaseTime); - envelope.gain.exponentialRampToValueAtTime(0.001, when + duration); - + envelope.gain.setValueAtTime(gainValue, when + Math.max(attackTime, duration - releaseTime)); + envelope.gain.exponentialRampToValueAtTime(0.001, when + duration + releaseTime); + osc.start(when); - osc.stop(when + duration); - + osc.stop(when + duration + releaseTime + 0.01); // Stop after envelope completes + // Clean up after note ends setTimeout(() => { try { @@ -354,7 +363,7 @@ export class SynthesisEngine { } catch (e) { // Already disconnected } - }, (duration + 0.1) * 1000); + }, (duration + releaseTime + 0.1) * 1000); } } diff --git a/src/synthesis/ToneJSMIDIPlayer.ts b/src/synthesis/ToneJSMIDIPlayer.ts index e1c4a8c..e5bc373 100644 --- a/src/synthesis/ToneJSMIDIPlayer.ts +++ b/src/synthesis/ToneJSMIDIPlayer.ts @@ -19,12 +19,18 @@ export class ToneJSMIDIPlayer { async load(events: NoteEvent[]): Promise { 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) { console.log('Loading Tone.js piano sampler...'); + await this.createSampler(); + } - // Use Tone.js built-in piano samples - this.sampler = new Tone.Sampler({ + console.log(`Loaded ${this.events.length} MIDI events for Tone.js playback`); + } + + private async createSampler(): Promise { + // Create new sampler instance + this.sampler = new Tone.Sampler({ urls: { A0: "A0.mp3", C1: "C1.mp3", @@ -58,14 +64,12 @@ export class ToneJSMIDIPlayer { C8: "C8.mp3" }, release: 1, + volume: 6, // +6dB boost for better default volume 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 { @@ -73,8 +77,11 @@ export class ToneJSMIDIPlayer { if (this.events.length === 0) { throw new Error('No MIDI events loaded'); } + + // Recreate sampler if it was disposed if (!this.sampler) { - throw new Error('Sampler not loaded'); + console.log('Recreating sampler after stop...'); + await this.createSampler(); } // Start Tone.js audio context @@ -86,6 +93,10 @@ export class ToneJSMIDIPlayer { // Schedule all events (skip drum channel) let drumNotesSkipped = 0; + if (!this.sampler) { + throw new Error('Sampler not initialized'); + } + for (const event of this.events) { // Skip drum channel (channel 9 in 0-indexed, or channel 10 in MIDI spec) if (event.channel === 9) { @@ -95,7 +106,8 @@ export class ToneJSMIDIPlayer { const noteName = this.midiNoteToName(event.pitch); 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 const eventId = this.sampler.triggerAttackRelease( @@ -124,7 +136,11 @@ export class ToneJSMIDIPlayer { } 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; @@ -134,15 +150,33 @@ export class ToneJSMIDIPlayer { 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) { - 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 this.scheduledEvents = []; - console.log('Tone.js MIDI playback stopped'); + console.log('Tone.js MIDI playback stopped - sampler disposed'); } private midiNoteToName(midiNote: number): string { @@ -168,7 +202,10 @@ export class ToneJSMIDIPlayer { setVolume(volume: number): void { 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; } } } diff --git a/src/types/index.ts b/src/types/index.ts index 0c9594e..42261df 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -4,6 +4,7 @@ export interface NoteEvent { pitch: number; velocity: number; track: number; + channel?: number; // MIDI channel (0-15, where 9 is drums) } export interface ChordEvent {