Refine playback stability and scoring.
This commit is contained in:
@@ -2,27 +2,52 @@ export class ScoreUtils {
|
|||||||
static calculateConfidence(title: string, query: string, source: string): number {
|
static calculateConfidence(title: string, query: string, source: string): number {
|
||||||
const titleLower = title.toLowerCase();
|
const titleLower = title.toLowerCase();
|
||||||
const queryLower = query.toLowerCase();
|
const queryLower = query.toLowerCase();
|
||||||
|
|
||||||
let score = 0;
|
let score = 0;
|
||||||
|
|
||||||
// Token matching - split and check individual words
|
// Token matching - split and check individual words
|
||||||
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;
|
||||||
)
|
|
||||||
);
|
// Check if title contains both artist and song (high confidence)
|
||||||
|
const hasArtist = titleLower.includes(artist);
|
||||||
const tokenMatchRatio = matchingTokens.length / queryTokens.length;
|
const hasSong = titleLower.includes(song);
|
||||||
score += tokenMatchRatio * 0.6;
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+37
-15
@@ -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> {
|
||||||
@@ -76,9 +100,7 @@ export class MotifEngine {
|
|||||||
// Process events into structure
|
// Process events into structure
|
||||||
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
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -71,16 +71,21 @@ 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;
|
||||||
|
|
||||||
if (this.schedulerIntervalId) {
|
if (this.schedulerIntervalId) {
|
||||||
clearInterval(this.schedulerIntervalId);
|
clearInterval(this.schedulerIntervalId);
|
||||||
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);
|
||||||
|
|||||||
@@ -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,19 +342,19 @@ 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(() => {
|
||||||
try {
|
try {
|
||||||
@@ -354,7 +363,7 @@ export class SynthesisEngine {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Already disconnected
|
// Already disconnected
|
||||||
}
|
}
|
||||||
}, (duration + 0.1) * 1000);
|
}, (duration + releaseTime + 0.1) * 1000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user