Refine playback stability and scoring.
This commit is contained in:
@@ -9,12 +9,31 @@ export class ScoreUtils {
|
||||
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
|
||||
// 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)
|
||||
@@ -24,6 +43,12 @@ export class ScoreUtils {
|
||||
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 = [
|
||||
{ pattern: /karaoke|kar|midkar/, penalty: 0.3 },
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+34
-12
@@ -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<void> {
|
||||
// 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<void> {
|
||||
// Initialize audio context and synthesis engine
|
||||
if (!this.audioContext) {
|
||||
this.audioContext = new AudioContext();
|
||||
}
|
||||
|
||||
this.synthesisEngine = new SynthesisEngine(this.audioContext, this.config);
|
||||
|
||||
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> {
|
||||
@@ -77,8 +101,6 @@ export class MotifEngine {
|
||||
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<void> {
|
||||
if (!this.audioContext || !this.synthesisEngine || !this.currentFeatures) {
|
||||
if (!this.audioContext || !this.synthesisEngine) {
|
||||
throw new Error('No audio generated yet');
|
||||
}
|
||||
|
||||
|
||||
+140
-100
@@ -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<void>;
|
||||
play(): void | Promise<void>;
|
||||
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<void> {
|
||||
@@ -181,7 +204,6 @@ class MotifApp {
|
||||
</td>
|
||||
<td>${result.parsed ? Math.round(result.parsed.durationSec) + 's' : '?'}</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>
|
||||
`;
|
||||
|
||||
@@ -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<void> {
|
||||
// Tone.js player handlers
|
||||
private async handleTonejsPlay(): Promise<void> {
|
||||
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<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) {
|
||||
// Soundfont player handlers
|
||||
private async handleSoundfontPlay(): Promise<void> {
|
||||
if (!this.currentMIDI) return;
|
||||
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.`);
|
||||
await this.soundfontPlayer.play();
|
||||
this.soundfontPlayBtn.disabled = true;
|
||||
this.soundfontStopBtn.disabled = false;
|
||||
this.updateStatus('Playing Soundfont piano...');
|
||||
} catch (error) {
|
||||
this.updateStatus(`Engine switch error: ${error instanceof Error ? error.message : 'Unknown 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> {
|
||||
if (!this.currentMIDI) return;
|
||||
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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -71,7 +71,11 @@ 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;
|
||||
|
||||
@@ -80,7 +84,8 @@ export class SoundfontMIDIPlayer {
|
||||
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);
|
||||
|
||||
@@ -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,18 +342,18 @@ 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(() => {
|
||||
@@ -354,7 +363,7 @@ export class SynthesisEngine {
|
||||
} catch (e) {
|
||||
// Already disconnected
|
||||
}
|
||||
}, (duration + 0.1) * 1000);
|
||||
}, (duration + releaseTime + 0.1) * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,11 +19,17 @@ export class ToneJSMIDIPlayer {
|
||||
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
|
||||
// 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
|
||||
console.log(`Loaded ${this.events.length} MIDI events for Tone.js playback`);
|
||||
}
|
||||
|
||||
private async createSampler(): Promise<void> {
|
||||
// Create new sampler instance
|
||||
this.sampler = new Tone.Sampler({
|
||||
urls: {
|
||||
A0: "A0.mp3",
|
||||
@@ -58,6 +64,7 @@ export class ToneJSMIDIPlayer {
|
||||
C8: "C8.mp3"
|
||||
},
|
||||
release: 1,
|
||||
volume: 6, // +6dB boost for better default volume
|
||||
baseUrl: "https://tonejs.github.io/audio/salamander/"
|
||||
}).toDestination();
|
||||
|
||||
@@ -65,16 +72,16 @@ export class ToneJSMIDIPlayer {
|
||||
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');
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user