initial public release.
This commit is contained in:
@@ -0,0 +1,362 @@
|
||||
import * as Tone from 'tone';
|
||||
import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
|
||||
import type { InstrumentType } from '../track/KGMidiTrack';
|
||||
import { KGToneSamplerFactory } from './KGToneSamplerFactory';
|
||||
|
||||
// InstrumentType is defined in KGMidiTrack and re-used here
|
||||
|
||||
/**
|
||||
* KGAudioBus - Represents a complete audio bus for a track
|
||||
* Replaces the separate trackSynths, trackInstruments, trackVolumes, trackMuted, trackSolo maps
|
||||
* Each instance manages a single track's audio processing chain
|
||||
*/
|
||||
export class KGAudioBus {
|
||||
// Core audio components
|
||||
private sampler: Tone.Sampler;
|
||||
private instrument: InstrumentType;
|
||||
|
||||
// Audio properties
|
||||
private volume: number;
|
||||
private muted: boolean;
|
||||
private solo: boolean;
|
||||
|
||||
// Audio processing chain (for future expansion)
|
||||
// private gain: Tone.Gain;
|
||||
// private filter: Tone.Filter;
|
||||
|
||||
/**
|
||||
* Private constructor - use KGAudioBus.create() instead
|
||||
*/
|
||||
private constructor(
|
||||
sampler: Tone.Sampler,
|
||||
instrument: InstrumentType,
|
||||
volume: number,
|
||||
muted: boolean,
|
||||
solo: boolean
|
||||
) {
|
||||
this.sampler = sampler;
|
||||
this.instrument = instrument;
|
||||
this.volume = volume;
|
||||
this.muted = muted;
|
||||
this.solo = solo;
|
||||
|
||||
// Set initial volume on the sampler
|
||||
this.updateSamplerVolume();
|
||||
|
||||
console.log(`KGAudioBus created for ${instrument} - volume: ${volume}, muted: ${muted}, solo: ${solo}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new KGAudioBus instance (async factory method)
|
||||
* This is the main way to create audio buses since we need to wait for sampler creation
|
||||
*/
|
||||
public static async create(
|
||||
instrument: InstrumentType = 'acoustic_grand_piano',
|
||||
volume: number = AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME,
|
||||
muted: boolean = false,
|
||||
solo: boolean = false
|
||||
): Promise<KGAudioBus> {
|
||||
try {
|
||||
console.log(`Creating KGAudioBus for ${instrument}...`);
|
||||
|
||||
// Create the sampler using the factory
|
||||
const samplerFactory = KGToneSamplerFactory.instance();
|
||||
const sampler = await samplerFactory.createSampler(String(instrument));
|
||||
|
||||
// Create the audio bus instance
|
||||
const audioBus = new KGAudioBus(sampler, instrument, volume, muted, solo);
|
||||
|
||||
console.log(`KGAudioBus created successfully for ${instrument}`);
|
||||
return audioBus;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Failed to create KGAudioBus for ${instrument}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== AUDIO PLAYBACK =====
|
||||
|
||||
/**
|
||||
* Trigger a note on this audio bus
|
||||
*/
|
||||
public triggerAttackRelease(
|
||||
note: string,
|
||||
duration: Tone.Unit.Time,
|
||||
time?: number,
|
||||
velocity?: number
|
||||
): void {
|
||||
if (!this.shouldPlay()) {
|
||||
return; // Don't play if muted or should be silent due to solo logic
|
||||
}
|
||||
|
||||
try {
|
||||
this.sampler.triggerAttackRelease(note, duration, time, velocity);
|
||||
} catch (error) {
|
||||
console.error(`Error triggering note ${note} on ${this.instrument}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger note attack (start playing) without automatic release
|
||||
* Used for sustained notes like piano key presses
|
||||
*/
|
||||
public triggerAttack(
|
||||
note: string,
|
||||
time?: number,
|
||||
velocity?: number
|
||||
): void {
|
||||
if (!this.shouldPlay()) {
|
||||
return; // Don't play if muted or should be silent due to solo logic
|
||||
}
|
||||
|
||||
try {
|
||||
this.sampler.triggerAttack(note, time, velocity);
|
||||
} catch (error) {
|
||||
console.error(`Error triggering attack for note ${note} on ${this.instrument}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release a specific note
|
||||
* Used for ending sustained notes like piano key releases
|
||||
*/
|
||||
public triggerRelease(
|
||||
note: string,
|
||||
time?: number
|
||||
): void {
|
||||
try {
|
||||
this.sampler.triggerRelease(note, time);
|
||||
} catch (error) {
|
||||
console.error(`Error releasing note ${note} on ${this.instrument}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release all currently playing notes
|
||||
*/
|
||||
public releaseAll(): void {
|
||||
try {
|
||||
this.sampler.releaseAll();
|
||||
} catch (error) {
|
||||
console.error(`Error releasing all notes on ${this.instrument}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== AUDIO PROPERTIES =====
|
||||
|
||||
/**
|
||||
* Set the volume for this audio bus
|
||||
*/
|
||||
public setVolume(volume: number): void {
|
||||
this.volume = volume;
|
||||
this.updateSamplerVolume();
|
||||
console.log(`Set ${this.instrument} volume to ${volume}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current volume
|
||||
*/
|
||||
public getVolume(): number {
|
||||
return this.volume;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the mute state for this audio bus
|
||||
*/
|
||||
public setMuted(muted: boolean): void {
|
||||
this.muted = muted;
|
||||
this.updateSamplerVolume();
|
||||
console.log(`Set ${this.instrument} muted to ${muted}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current mute state
|
||||
*/
|
||||
public getMuted(): boolean {
|
||||
return this.muted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the solo state for this audio bus
|
||||
*/
|
||||
public setSolo(solo: boolean): void {
|
||||
this.solo = solo;
|
||||
console.log(`Set ${this.instrument} solo to ${solo}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current solo state
|
||||
*/
|
||||
public getSolo(): boolean {
|
||||
return this.solo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current instrument type
|
||||
*/
|
||||
public getInstrument(): InstrumentType {
|
||||
return this.instrument;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the instrument for this audio bus
|
||||
*/
|
||||
public async setInstrument(newInstrument: InstrumentType): Promise<void> {
|
||||
try {
|
||||
console.log(`Changing instrument from ${this.instrument} to ${newInstrument}...`);
|
||||
|
||||
// Dispose of the current sampler
|
||||
this.sampler.dispose();
|
||||
|
||||
// Create new sampler with new instrument
|
||||
const samplerFactory = KGToneSamplerFactory.instance();
|
||||
this.sampler = await samplerFactory.createSampler(String(newInstrument));
|
||||
this.instrument = newInstrument;
|
||||
|
||||
// Restore volume settings
|
||||
// this.updateSamplerVolume();
|
||||
|
||||
console.log(`Instrument changed successfully to ${newInstrument}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to change instrument to ${newInstrument}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== AUDIO ROUTING =====
|
||||
|
||||
/**
|
||||
* Connect this audio bus to a destination (gain node, master output, etc.)
|
||||
*/
|
||||
public connect(destination: Tone.InputNode): void {
|
||||
try {
|
||||
this.sampler.connect(destination);
|
||||
console.log(`Connected ${this.instrument} to audio destination`);
|
||||
} catch (error) {
|
||||
console.error(`Error connecting ${this.instrument} to destination:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect this audio bus from all destinations
|
||||
*/
|
||||
public disconnect(): void {
|
||||
try {
|
||||
this.sampler.disconnect();
|
||||
console.log(`Disconnected ${this.instrument} from all destinations`);
|
||||
} catch (error) {
|
||||
console.error(`Error disconnecting ${this.instrument}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the main output
|
||||
*/
|
||||
public toDestination(): void {
|
||||
try {
|
||||
this.sampler.toDestination();
|
||||
console.log(`Connected ${this.instrument} to main output`);
|
||||
} catch (error) {
|
||||
console.error(`Error connecting ${this.instrument} to main output:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== RESOURCE MANAGEMENT =====
|
||||
|
||||
/**
|
||||
* Dispose of this audio bus and clean up resources
|
||||
*/
|
||||
public dispose(): void {
|
||||
try {
|
||||
this.sampler.dispose();
|
||||
console.log(`Disposed KGAudioBus for ${this.instrument}`);
|
||||
} catch (error) {
|
||||
console.error(`Error disposing KGAudioBus for ${this.instrument}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== PRIVATE UTILITY METHODS =====
|
||||
|
||||
/**
|
||||
* Update the sampler volume based on current volume and mute state
|
||||
*/
|
||||
private updateSamplerVolume(): void {
|
||||
try {
|
||||
const effectiveVolume = this.muted ? 0 : this.volume;
|
||||
const volumeDb = effectiveVolume > 0 ? 20 * Math.log10(effectiveVolume) : -Infinity;
|
||||
this.sampler.volume.value = volumeDb;
|
||||
} catch (error) {
|
||||
console.error(`Error updating volume for ${this.instrument}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply effective volume considering both mute and solo context
|
||||
* When any track is soloed, only soloed tracks should be audible
|
||||
*/
|
||||
public applyEffectiveVolume(hasSoloedTracks: boolean): void {
|
||||
try {
|
||||
let effectiveVolume = this.volume;
|
||||
if (this.muted) {
|
||||
effectiveVolume = 0;
|
||||
} else if (hasSoloedTracks && !this.solo) {
|
||||
effectiveVolume = 0;
|
||||
}
|
||||
const volumeDb = effectiveVolume > 0 ? 20 * Math.log10(effectiveVolume) : -Infinity;
|
||||
this.sampler.volume.value = volumeDb;
|
||||
} catch (error) {
|
||||
console.error(`Error applying effective volume for ${this.instrument}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this audio bus should play (handles mute state)
|
||||
* Note: Solo logic should be handled at the audio interface level
|
||||
*/
|
||||
private shouldPlay(): boolean {
|
||||
return !this.muted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this audio bus should play considering solo logic
|
||||
* Called by audio interface with knowledge of other tracks' solo states
|
||||
*/
|
||||
public shouldPlayWithSolo(hasSoloedTracks: boolean): boolean {
|
||||
if (this.muted) {
|
||||
return false; // Muted tracks never play
|
||||
}
|
||||
|
||||
if (hasSoloedTracks) {
|
||||
return this.solo; // Only soloed tracks play when any track is soloed
|
||||
}
|
||||
|
||||
return true; // All non-muted tracks play when no tracks are soloed
|
||||
}
|
||||
|
||||
// ===== GETTERS FOR DEBUGGING =====
|
||||
|
||||
/**
|
||||
* Get the underlying Tone.Sampler (for debugging/advanced use)
|
||||
*/
|
||||
public getSampler(): Tone.Sampler {
|
||||
return this.sampler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a summary of this audio bus state
|
||||
*/
|
||||
public getState(): {
|
||||
instrument: InstrumentType;
|
||||
volume: number;
|
||||
muted: boolean;
|
||||
solo: boolean;
|
||||
} {
|
||||
return {
|
||||
instrument: this.instrument,
|
||||
volume: this.volume,
|
||||
muted: this.muted,
|
||||
solo: this.solo
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,675 @@
|
||||
import type { KGProject } from '../KGProject';
|
||||
import type { KGMidiNote } from '../midi/KGMidiNote';
|
||||
import { TIME_CONSTANTS, AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
|
||||
import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
|
||||
import { pitchToNoteNameString } from '../../util/midiUtil';
|
||||
import * as Tone from 'tone';
|
||||
import { KGAudioBus } from './KGAudioBus';
|
||||
import type { InstrumentType } from '../track/KGMidiTrack';
|
||||
import { KGCore } from '../KGCore';
|
||||
|
||||
/**
|
||||
* KGAudioInterface - Audio engine interface for the DAW
|
||||
* Implements the singleton pattern for global audio management
|
||||
* Abstracts audio engine implementation (Tone.js) for potential future replacement
|
||||
*/
|
||||
export class KGAudioInterface {
|
||||
// Private static instance for singleton pattern
|
||||
private static _instance: KGAudioInterface | null = null;
|
||||
|
||||
// Audio engine state
|
||||
private isInitialized: boolean = false;
|
||||
private isAudioContextStarted: boolean = false;
|
||||
|
||||
// Track management - now using KGAudioBus
|
||||
private trackAudioBuses: Map<string, KGAudioBus> = new Map();
|
||||
|
||||
// Playback state
|
||||
private isPlaying: boolean = false;
|
||||
private masterVolume: number = AUDIO_INTERFACE_CONSTANTS.DEFAULT_MASTER_VOLUME;
|
||||
private scheduledEvents: Set<number> = new Set(); // Tone event IDs
|
||||
|
||||
// Master volume control
|
||||
private masterGain: Tone.Gain | null = null;
|
||||
|
||||
// Private constructor to prevent direct instantiation
|
||||
private constructor() {
|
||||
console.log("KGAudioInterface initialized");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the singleton instance of KGAudioInterface
|
||||
* Creates the instance if it doesn't exist yet
|
||||
*/
|
||||
public static instance(): KGAudioInterface {
|
||||
if (!KGAudioInterface._instance) {
|
||||
KGAudioInterface._instance = new KGAudioInterface();
|
||||
}
|
||||
return KGAudioInterface._instance;
|
||||
}
|
||||
|
||||
// ===== INITIALIZATION =====
|
||||
|
||||
/**
|
||||
* Initialize the audio engine (Tone.js)
|
||||
*/
|
||||
public async initialize(): Promise<void> {
|
||||
if (this.isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Set up master gain for volume control
|
||||
this.masterGain = new Tone.Gain(this.masterVolume).toDestination();
|
||||
|
||||
// Configure transport settings
|
||||
Tone.Transport.bpm.value = TIME_CONSTANTS.DEFAULT_BPM; // Default BPM
|
||||
Tone.Transport.timeSignature = [TIME_CONSTANTS.DEFAULT_TIME_SIGNATURE.numerator, TIME_CONSTANTS.DEFAULT_TIME_SIGNATURE.denominator]; // Default time signature
|
||||
|
||||
this.isInitialized = true;
|
||||
console.log("Audio engine initialized successfully");
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize audio engine:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the audio context (required for Web Audio)
|
||||
*/
|
||||
public async startAudioContext(): Promise<void> {
|
||||
if (this.isAudioContextStarted) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await Tone.start();
|
||||
this.isAudioContextStarted = true;
|
||||
console.log("Audio context started successfully");
|
||||
} catch (error) {
|
||||
console.error("Failed to start audio context:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up audio resources
|
||||
*/
|
||||
public async dispose(): Promise<void> {
|
||||
try {
|
||||
// Stop playback
|
||||
this.stopPlayback();
|
||||
|
||||
// Clear all scheduled events
|
||||
this.clearScheduledEvents();
|
||||
|
||||
// Dispose of all audio buses
|
||||
this.trackAudioBuses.forEach(audioBus => {
|
||||
audioBus.dispose();
|
||||
});
|
||||
this.trackAudioBuses.clear();
|
||||
|
||||
// Dispose master gain
|
||||
if (this.masterGain) {
|
||||
this.masterGain.dispose();
|
||||
this.masterGain = null;
|
||||
}
|
||||
|
||||
this.isInitialized = false;
|
||||
this.isAudioContextStarted = false;
|
||||
|
||||
console.log("Audio resources disposed successfully");
|
||||
} catch (error) {
|
||||
console.error("Error disposing audio resources:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== TRACK MANAGEMENT =====
|
||||
|
||||
/**
|
||||
* Create a synth/sampler for a track (backward compatibility wrapper)
|
||||
*/
|
||||
public async createTrackSynth(trackId: string, instrumentType: InstrumentType): Promise<void> {
|
||||
await this.createTrackAudioBus(trackId, instrumentType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a track's synth (backward compatibility wrapper)
|
||||
*/
|
||||
public async removeTrackSynth(trackId: string): Promise<void> {
|
||||
await this.removeTrackAudioBus(trackId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an audio bus for a track (replaces createTrackSynth)
|
||||
*/
|
||||
public async createTrackAudioBus(trackId: string, instrumentType: InstrumentType): Promise<void> {
|
||||
// Remove existing audio bus if it exists
|
||||
await this.removeTrackAudioBus(trackId);
|
||||
|
||||
try {
|
||||
console.log(`Creating audio bus for track ${trackId} with instrument ${instrumentType}`);
|
||||
|
||||
// Create new audio bus
|
||||
// Initialize with track's stored volume if available
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
const track = project.getTracks().find(t => t.getId().toString() === trackId);
|
||||
const initialVolume = track ? track.getVolume() : AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME;
|
||||
const audioBus = await KGAudioBus.create(instrumentType, initialVolume);
|
||||
|
||||
// Connect to master gain if available, otherwise to destination
|
||||
if (this.masterGain) {
|
||||
audioBus.connect(this.masterGain);
|
||||
} else {
|
||||
audioBus.toDestination();
|
||||
}
|
||||
|
||||
// Store the audio bus
|
||||
this.trackAudioBuses.set(trackId, audioBus);
|
||||
|
||||
console.log(`Created audio bus for track ${trackId} with ${instrumentType}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to create audio bus for track ${trackId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a track's audio bus (replaces removeTrackSynth)
|
||||
*/
|
||||
public async removeTrackAudioBus(trackId: string): Promise<void> {
|
||||
try {
|
||||
const audioBus = this.trackAudioBuses.get(trackId);
|
||||
if (audioBus) {
|
||||
// Dispose of the audio bus
|
||||
audioBus.dispose();
|
||||
|
||||
// Remove from map
|
||||
this.trackAudioBuses.delete(trackId);
|
||||
|
||||
console.log(`Removed audio bus for track ${trackId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error removing audio bus for track ${trackId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Change instrument type for a track (replaces setTrackInstrument)
|
||||
*/
|
||||
public async setTrackInstrument(trackId: string, instrumentType: InstrumentType): Promise<void> {
|
||||
try {
|
||||
const audioBus = this.trackAudioBuses.get(trackId);
|
||||
if (audioBus) {
|
||||
await audioBus.setInstrument(instrumentType);
|
||||
|
||||
// reconnect to master gain
|
||||
if (this.masterGain) {
|
||||
audioBus.connect(this.masterGain);
|
||||
} else {
|
||||
audioBus.toDestination();
|
||||
}
|
||||
|
||||
console.log(`Changed track ${trackId} instrument to ${instrumentType}`);
|
||||
} else {
|
||||
// Create new audio bus if it doesn't exist
|
||||
await this.createTrackAudioBus(trackId, instrumentType);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to change instrument for track ${trackId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== PLAYBACK CONTROL =====
|
||||
|
||||
/**
|
||||
* Prepare playback by scheduling all MIDI events
|
||||
*/
|
||||
public preparePlayback(project: KGProject, startPosition: number): void {
|
||||
// Clear any existing scheduled events
|
||||
this.clearScheduledEvents();
|
||||
|
||||
try {
|
||||
// Set project BPM and time signature FIRST (this affects timing calculations)
|
||||
Tone.Transport.bpm.value = project.getBpm();
|
||||
const timeSignature = project.getTimeSignature();
|
||||
Tone.Transport.timeSignature = [timeSignature.numerator, timeSignature.denominator];
|
||||
|
||||
console.log(`Setting Tone.js BPM to ${project.getBpm()}, actual value: ${Tone.Transport.bpm.value}`);
|
||||
|
||||
// Set transport position (convert beats to Tone.js format)
|
||||
this.setTransportPosition(startPosition);
|
||||
|
||||
// Schedule all MIDI events
|
||||
project.getTracks().forEach(track => {
|
||||
const trackId = track.getId().toString();
|
||||
const audioBus = this.trackAudioBuses.get(trackId);
|
||||
|
||||
if (audioBus && track.getType() === 'MIDI') {
|
||||
track.getRegions().forEach(region => {
|
||||
if (region.constructor.name === 'KGMidiRegion') {
|
||||
const midiRegion = region as unknown as { getNotes: () => KGMidiNote[] };
|
||||
|
||||
// Get notes from region (assuming it has a getNotes method)
|
||||
if (midiRegion.getNotes) {
|
||||
midiRegion.getNotes().forEach((note: KGMidiNote) => {
|
||||
// Calculate absolute note timing in beats (note position + region start position)
|
||||
const regionStartBeat = region.getStartFromBeat();
|
||||
const noteStartBeat = note.getStartBeat() + regionStartBeat;
|
||||
const noteDurationBeats = note.getEndBeat() - note.getStartBeat();
|
||||
|
||||
// Only schedule notes that start at or after the playback start position
|
||||
if (noteStartBeat < startPosition) {
|
||||
return; // Skip notes that would have already finished before playback starts
|
||||
}
|
||||
|
||||
// Convert beats to Tone.js time format for scheduling
|
||||
const noteStartTime = this.beatsToToneTime(noteStartBeat);
|
||||
const noteDuration = this.beatsToToneTime(noteDurationBeats);
|
||||
|
||||
// Convert MIDI note number to note name
|
||||
const noteName = pitchToNoteNameString(note.getPitch());
|
||||
const velocity = note.getVelocity() / 127; // Normalize to 0-1
|
||||
|
||||
console.log(
|
||||
`Scheduling note ${noteName} at beat ${Number(noteStartBeat.toFixed ? noteStartBeat.toFixed(3) : noteStartBeat.toLocaleString(undefined, {maximumFractionDigits: 3}))}, Tone time: ${Number(Number(noteStartTime).toFixed(3))}, duration: ${Number(Number(noteDuration).toFixed(3))}`
|
||||
);
|
||||
|
||||
// Schedule the note
|
||||
const eventId = Tone.Transport.schedule((time) => {
|
||||
// Check if track should play considering solo logic
|
||||
const hasSoloedTracks = this.hasSoloedTracks();
|
||||
if (audioBus.shouldPlayWithSolo(hasSoloedTracks)) {
|
||||
audioBus.triggerAttackRelease(noteName, noteDuration, time, velocity);
|
||||
}
|
||||
}, noteStartTime);
|
||||
|
||||
this.scheduledEvents.add(eventId);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`Prepared playback from position ${startPosition} with ${this.scheduledEvents.size} events`);
|
||||
} catch (error) {
|
||||
console.error('Error preparing playback:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start playback
|
||||
*/
|
||||
public startPlayback(): void {
|
||||
try {
|
||||
if (!this.isInitialized) {
|
||||
throw new Error('Audio interface not initialized');
|
||||
}
|
||||
|
||||
if (!this.isAudioContextStarted) {
|
||||
throw new Error('Audio context not started');
|
||||
}
|
||||
|
||||
Tone.Transport.start();
|
||||
this.isPlaying = true;
|
||||
|
||||
console.log('Audio playback started');
|
||||
} catch (error) {
|
||||
console.error('Error starting playback:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop playback
|
||||
*/
|
||||
public stopPlayback(): void {
|
||||
try {
|
||||
Tone.Transport.stop();
|
||||
|
||||
// Release all currently playing notes
|
||||
this.trackAudioBuses.forEach(audioBus => {
|
||||
audioBus.releaseAll();
|
||||
});
|
||||
|
||||
this.isPlaying = false;
|
||||
|
||||
console.log('Audio playback stopped');
|
||||
} catch (error) {
|
||||
console.error('Error stopping playback:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a single MIDI note
|
||||
*/
|
||||
public triggerNote(trackId: string, note: KGMidiNote, time?: number): void {
|
||||
try {
|
||||
const audioBus = this.trackAudioBuses.get(trackId);
|
||||
if (!audioBus) {
|
||||
console.warn(`No audio bus found for track ${trackId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const noteName = pitchToNoteNameString(note.getPitch());
|
||||
const velocity = note.getVelocity() / 127; // Normalize to 0-1
|
||||
|
||||
// Convert note duration from beats to Tone.js time format
|
||||
const durationInBeats = note.getEndBeat() - note.getStartBeat();
|
||||
const duration = this.beatsToToneTime(durationInBeats);
|
||||
const triggerTime = time ?? Tone.now();
|
||||
|
||||
// Check if track should play considering solo logic
|
||||
const hasSoloedTracks = this.hasSoloedTracks();
|
||||
if (audioBus.shouldPlayWithSolo(hasSoloedTracks)) {
|
||||
audioBus.triggerAttackRelease(noteName, duration, triggerTime, velocity);
|
||||
console.log(`Triggered note ${noteName} for track ${trackId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error triggering note for track ${trackId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger note attack (start playing) without automatic release
|
||||
* Used for piano key press
|
||||
*/
|
||||
public triggerNoteAttack(trackId: string, pitch: number, velocity: number = 127, time?: number): void {
|
||||
try {
|
||||
const audioBus = this.trackAudioBuses.get(trackId);
|
||||
if (!audioBus) {
|
||||
console.warn(`No audio bus found for track ${trackId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const noteName = pitchToNoteNameString(pitch);
|
||||
const normalizedVelocity = velocity / 127; // Normalize to 0-1
|
||||
const triggerTime = time ?? Tone.now();
|
||||
|
||||
// Check if track should play considering solo logic
|
||||
const hasSoloedTracks = this.hasSoloedTracks();
|
||||
if (audioBus.shouldPlayWithSolo(hasSoloedTracks)) {
|
||||
audioBus.triggerAttack(noteName, triggerTime, normalizedVelocity);
|
||||
console.log(`Triggered attack for note ${noteName} (pitch ${pitch}) on track ${trackId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error triggering note attack for track ${trackId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release a specific note
|
||||
* Used for piano key release
|
||||
*/
|
||||
public releaseNote(trackId: string, pitch: number, time?: number): void {
|
||||
try {
|
||||
const audioBus = this.trackAudioBuses.get(trackId);
|
||||
if (!audioBus) {
|
||||
console.warn(`No audio bus found for track ${trackId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const noteName = pitchToNoteNameString(pitch);
|
||||
const releaseTime = time ?? Tone.now();
|
||||
|
||||
audioBus.triggerRelease(noteName, releaseTime);
|
||||
console.log(`Released note ${noteName} (pitch ${pitch}) on track ${trackId}`);
|
||||
} catch (error) {
|
||||
console.error(`Error releasing note for track ${trackId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all scheduled events
|
||||
*/
|
||||
public clearScheduledEvents(): void {
|
||||
try {
|
||||
// Cancel all scheduled events
|
||||
this.scheduledEvents.forEach(eventId => {
|
||||
Tone.Transport.clear(eventId);
|
||||
});
|
||||
|
||||
// Clear the set
|
||||
this.scheduledEvents.clear();
|
||||
|
||||
console.log('Cleared all scheduled events');
|
||||
} catch (error) {
|
||||
console.error('Error clearing scheduled events:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== TRANSPORT CONTROL =====
|
||||
|
||||
/**
|
||||
* Set transport position
|
||||
*/
|
||||
public setTransportPosition(position: number): void {
|
||||
try {
|
||||
// Convert beats to Tone.js time format
|
||||
const toneTime = this.beatsToToneTime(position);
|
||||
Tone.Transport.position = toneTime;
|
||||
console.log(`Set transport position to ${position} beats (${toneTime})`);
|
||||
} catch (error) {
|
||||
console.error('Error setting transport position:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current transport position
|
||||
*/
|
||||
public getTransportPosition(): number {
|
||||
try {
|
||||
const position = Tone.Transport.position;
|
||||
return this.toneTimeToBeats(position);
|
||||
} catch (error) {
|
||||
console.error('Error getting transport position:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set transport BPM
|
||||
*/
|
||||
public setBpm(bpm: number): void {
|
||||
try {
|
||||
Tone.Transport.bpm.value = bpm;
|
||||
console.log(`Set BPM to ${bpm}`);
|
||||
} catch (error) {
|
||||
console.error('Error setting BPM:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== TRACK PROPERTIES =====
|
||||
|
||||
/**
|
||||
* Set track volume
|
||||
*/
|
||||
public setTrackVolume(trackId: string, volume: number): void {
|
||||
try {
|
||||
const audioBus = this.trackAudioBuses.get(trackId);
|
||||
if (audioBus) {
|
||||
audioBus.setVolume(volume);
|
||||
console.log(`Set track ${trackId} volume to ${volume}`);
|
||||
} else {
|
||||
console.warn(`No audio bus found for track ${trackId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error setting track ${trackId} volume:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set track mute state
|
||||
*/
|
||||
public setTrackMute(trackId: string, muted: boolean): void {
|
||||
try {
|
||||
const audioBus = this.trackAudioBuses.get(trackId);
|
||||
if (audioBus) {
|
||||
audioBus.setMuted(muted);
|
||||
console.log(`Set track ${trackId} mute to ${muted}`);
|
||||
// Recompute effective volumes across all buses (solo logic)
|
||||
this.updateAllEffectiveVolumes();
|
||||
} else {
|
||||
console.warn(`No audio bus found for track ${trackId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error setting track ${trackId} mute:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set track solo state
|
||||
*/
|
||||
public setTrackSolo(trackId: string, solo: boolean): void {
|
||||
try {
|
||||
const audioBus = this.trackAudioBuses.get(trackId);
|
||||
if (audioBus) {
|
||||
audioBus.setSolo(solo);
|
||||
console.log(`Set track ${trackId} solo to ${solo}`);
|
||||
// Recompute effective volumes across all buses (solo logic)
|
||||
this.updateAllEffectiveVolumes();
|
||||
} else {
|
||||
console.warn(`No audio bus found for track ${trackId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error setting track ${trackId} solo:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set master volume
|
||||
*/
|
||||
public setMasterVolume(volume: number): void {
|
||||
try {
|
||||
if (this.masterGain) {
|
||||
this.masterGain.gain.value = volume;
|
||||
}
|
||||
|
||||
this.masterVolume = volume;
|
||||
console.log(`Set master volume to ${volume}`);
|
||||
} catch (error) {
|
||||
console.error('Error setting master volume:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== GETTERS =====
|
||||
|
||||
public getIsInitialized(): boolean {
|
||||
return this.isInitialized;
|
||||
}
|
||||
|
||||
public getIsAudioContextStarted(): boolean {
|
||||
return this.isAudioContextStarted;
|
||||
}
|
||||
|
||||
public getIsPlaying(): boolean {
|
||||
return this.isPlaying;
|
||||
}
|
||||
|
||||
public getTrackInstrument(trackId: string): InstrumentType | undefined {
|
||||
const audioBus = this.trackAudioBuses.get(trackId);
|
||||
return audioBus?.getInstrument();
|
||||
}
|
||||
|
||||
public getTrackVolume(trackId: string): number {
|
||||
const audioBus = this.trackAudioBuses.get(trackId);
|
||||
return audioBus?.getVolume() ?? AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME;
|
||||
}
|
||||
|
||||
public getTrackMuted(trackId: string): boolean {
|
||||
const audioBus = this.trackAudioBuses.get(trackId);
|
||||
return audioBus?.getMuted() ?? false;
|
||||
}
|
||||
|
||||
public getTrackSolo(trackId: string): boolean {
|
||||
const audioBus = this.trackAudioBuses.get(trackId);
|
||||
return audioBus?.getSolo() ?? false;
|
||||
}
|
||||
|
||||
public getMasterVolume(): number {
|
||||
return this.masterVolume;
|
||||
}
|
||||
|
||||
public getAvailableInstruments(): InstrumentType[] {
|
||||
return Object.keys(FLUIDR3_INSTRUMENT_MAP) as InstrumentType[];
|
||||
}
|
||||
|
||||
// ===== PRIVATE UTILITY METHODS =====
|
||||
|
||||
/**
|
||||
* Check if any tracks are currently soloed
|
||||
*/
|
||||
private hasSoloedTracks(): boolean {
|
||||
return Array.from(this.trackAudioBuses.values()).some(audioBus => audioBus.getSolo());
|
||||
}
|
||||
|
||||
/**
|
||||
* Update effective volume for all tracks according to mute/solo state
|
||||
*/
|
||||
private updateAllEffectiveVolumes(): void {
|
||||
try {
|
||||
const hasSoloedTracks = this.hasSoloedTracks();
|
||||
this.trackAudioBuses.forEach(bus => bus.applyEffectiveVolume(hasSoloedTracks));
|
||||
} catch (error) {
|
||||
console.error('Error updating effective volumes:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== TIME CONVERSION UTILITIES =====
|
||||
|
||||
/**
|
||||
* Convert beats to Tone.js time format using raw seconds
|
||||
* This approach handles triplets and all subdivisions correctly
|
||||
*/
|
||||
private beatsToToneTime(beats: number): Tone.Unit.Time {
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
const bpm = project.getBpm();
|
||||
|
||||
// Calculate seconds per beat - BPM is always quarter note beats per minute
|
||||
// Time signature denominator doesn't affect BPM, only subdivision
|
||||
const secondsPerBeat = 60 / bpm;
|
||||
|
||||
// Convert beats directly to seconds
|
||||
const totalSeconds = beats * secondsPerBeat;
|
||||
|
||||
return totalSeconds as Tone.Unit.Time;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Tone.js time format to beats
|
||||
*/
|
||||
private toneTimeToBeats(toneTime: Tone.Unit.Time): number {
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
const bpm = project.getBpm();
|
||||
|
||||
// Calculate seconds per beat - BPM is always quarter note beats per minute
|
||||
// Time signature denominator doesn't affect BPM, only subdivision
|
||||
const secondsPerBeat = 60 / bpm;
|
||||
|
||||
// Tone.Time() can handle both numbers and strings
|
||||
const seconds = Tone.Time(toneTime).toSeconds();
|
||||
const beats = seconds / secondsPerBeat;
|
||||
|
||||
return beats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current BPM from Tone.js transport
|
||||
*/
|
||||
public getCurrentBpm(): number {
|
||||
return Tone.Transport.bpm.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug method to check BPM setting
|
||||
*/
|
||||
public debugBpm(): void {
|
||||
console.log('=== BPM Debug Info ===');
|
||||
console.log('Tone.Transport.bpm.value:', Tone.Transport.bpm.value);
|
||||
console.log('Tone.Transport.state:', Tone.Transport.state);
|
||||
console.log('Audio context sample rate:', Tone.getContext().sampleRate);
|
||||
console.log('Audio context state:', Tone.getContext().state);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { SAMPLER_CONSTANTS } from '../../constants/coreConstants';
|
||||
import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
|
||||
import * as Tone from 'tone';
|
||||
|
||||
/**
|
||||
* KGToneBuffersPool - Singleton class for managing ToneAudioBuffers
|
||||
* Handles loading and caching of soundfont audio buffers for instruments
|
||||
*/
|
||||
export class KGToneBuffersPool {
|
||||
// Private static instance for singleton pattern
|
||||
private static _instance: KGToneBuffersPool | null = null;
|
||||
|
||||
// Map to store ToneAudioBuffers by instrument name
|
||||
private bufferMap: Map<string, Tone.ToneAudioBuffers> = new Map();
|
||||
|
||||
// Map to store loading promises to prevent duplicate loading and handle race conditions
|
||||
private loadingPromises: Map<string, Promise<Tone.ToneAudioBuffers>> = new Map();
|
||||
|
||||
// Simple event listeners for load start/end without coupling to UI layer
|
||||
private loadingListeners: Array<(_evt: { type: 'start' | 'end'; instrument: string }) => void> = [];
|
||||
|
||||
// Private constructor to prevent direct instantiation
|
||||
private constructor() {
|
||||
console.log("KGToneBuffersPool initialized");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the singleton instance of KGToneBuffersPool
|
||||
* Creates the instance if it doesn't exist yet
|
||||
*/
|
||||
public static instance(): KGToneBuffersPool {
|
||||
if (!KGToneBuffersPool._instance) {
|
||||
KGToneBuffersPool._instance = new KGToneBuffersPool();
|
||||
}
|
||||
return KGToneBuffersPool._instance;
|
||||
}
|
||||
|
||||
/** Get the number of instruments currently loading */
|
||||
public getActiveLoadCount(): number {
|
||||
return this.loadingPromises.size;
|
||||
}
|
||||
|
||||
/** Register a listener for buffer loading events */
|
||||
public addLoadingListener(listener: (_evt: { type: 'start' | 'end'; instrument: string }) => void): void {
|
||||
this.loadingListeners.push(listener);
|
||||
}
|
||||
|
||||
/** Unregister a previously added listener */
|
||||
public removeLoadingListener(listener: (_evt: { type: 'start' | 'end'; instrument: string }) => void): void {
|
||||
this.loadingListeners = this.loadingListeners.filter(l => l !== listener);
|
||||
}
|
||||
|
||||
private emitLoadingEvent(_evt: { type: 'start' | 'end'; instrument: string }): void {
|
||||
try {
|
||||
this.loadingListeners.forEach(l => {
|
||||
try { l(_evt); } catch { /* swallow listener errors */ }
|
||||
});
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ToneAudioBuffers for a specific instrument name
|
||||
* If not cached, creates and loads the buffers
|
||||
* Handles race conditions by ensuring only one loading operation per instrument
|
||||
*/
|
||||
public async getToneAudioBuffers(name: string): Promise<Tone.ToneAudioBuffers> {
|
||||
// Check if already fully loaded and cached
|
||||
const cachedBuffers = this.bufferMap.get(name);
|
||||
if (cachedBuffers && cachedBuffers.loaded) {
|
||||
console.log(`KGToneBuffersPool: Returning cached buffers for ${name}`);
|
||||
return cachedBuffers;
|
||||
}
|
||||
|
||||
// Check if currently loading - if so, wait for that promise
|
||||
if (this.loadingPromises.has(name)) {
|
||||
console.log(`KGToneBuffersPool: Waiting for existing loading operation for ${name}`);
|
||||
return await this.loadingPromises.get(name)!;
|
||||
}
|
||||
|
||||
// Start new loading operation
|
||||
console.log(`KGToneBuffersPool: Starting new loading operation for ${name}`);
|
||||
const loadingPromise = this.createToneAudioBuffers(name);
|
||||
this.loadingPromises.set(name, loadingPromise);
|
||||
// Emit start AFTER registering the promise to avoid duplicate start events in races
|
||||
this.emitLoadingEvent({ type: 'start', instrument: name });
|
||||
console.log(`[KGToneBuffersPool] start: Active load count: ${this.getActiveLoadCount()}`);
|
||||
|
||||
try {
|
||||
const buffers = await loadingPromise;
|
||||
|
||||
// Cache the fully loaded buffers
|
||||
this.bufferMap.set(name, buffers);
|
||||
console.log(`KGToneBuffersPool: Cached loaded buffers for ${name}`);
|
||||
|
||||
// Remove from loading promises since it's complete
|
||||
this.loadingPromises.delete(name);
|
||||
this.emitLoadingEvent({ type: 'end', instrument: name });
|
||||
console.log(`[KGToneBuffersPool] end: Active load count: ${this.getActiveLoadCount()}`);
|
||||
|
||||
return buffers;
|
||||
} catch (error) {
|
||||
// Remove failed loading promise so it can be retried
|
||||
this.loadingPromises.delete(name);
|
||||
console.error(`KGToneBuffersPool: Failed to load buffers for ${name}:`, error);
|
||||
// Emit end to allow UI to close spinner even on failure
|
||||
this.emitLoadingEvent({ type: 'end', instrument: name });
|
||||
console.log(`[KGToneBuffersPool] end: Active load count: ${this.getActiveLoadCount()}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create ToneAudioBuffers for an instrument
|
||||
*/
|
||||
private async createToneAudioBuffers(name: string): Promise<Tone.ToneAudioBuffers> {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
// Get instrument configuration from constants
|
||||
const fluidConfig = SAMPLER_CONSTANTS.TONE_SAMPLERS.FLUID;
|
||||
const instrumentName = name;
|
||||
|
||||
if (!instrumentName) {
|
||||
throw new Error(`Unknown instrument: ${name}`);
|
||||
}
|
||||
|
||||
// Generate URL mapping for all keys from A0 to Bb7
|
||||
const urls = this.generateKeyUrls(fluidConfig.url, instrumentName);
|
||||
|
||||
console.log(`Loading ToneAudioBuffers for ${name} (${instrumentName})...`);
|
||||
|
||||
// Create ToneAudioBuffers with onload callback
|
||||
const buffers = new Tone.ToneAudioBuffers(
|
||||
urls,
|
||||
() => {
|
||||
console.log(`ToneAudioBuffers loaded successfully for ${name}`);
|
||||
resolve(buffers);
|
||||
}
|
||||
);
|
||||
|
||||
// Don't cache until loading is complete - this will be handled in getToneAudioBuffers
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error creating ToneAudioBuffers for ${name}:`, error);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate URL mapping for all keys from A0 to Bb7
|
||||
* Uses Db notation instead of C# as specified
|
||||
*/
|
||||
private generateKeyUrls(baseUrl: string, instrumentName: string): { [key: string]: string } {
|
||||
const urls: { [key: string]: string } = {};
|
||||
|
||||
// get the range of the instrument.
|
||||
// TODO: make the sound library name configurable.
|
||||
const range = FLUIDR3_INSTRUMENT_MAP[instrumentName]?.pitchRange || [21, 108];
|
||||
|
||||
// Note names in order (using flats instead of sharps where applicable)
|
||||
const noteNames = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B'];
|
||||
|
||||
// Generate keys from A0 to C8 (MIDI notes 21 to 108)
|
||||
for (let midiNote = range[0]; midiNote <= range[1]; midiNote++) {
|
||||
const octave = Math.floor((midiNote - 12) / 12);
|
||||
const noteIndex = (midiNote - 12) % 12;
|
||||
const noteName = noteNames[noteIndex];
|
||||
const keyName = `${noteName}${octave}`;
|
||||
|
||||
// Generate URL for this key
|
||||
urls[keyName] = `${baseUrl}${instrumentName}-mp3/${keyName}.mp3`;
|
||||
}
|
||||
|
||||
console.log(`Generated ${Object.keys(urls).length} key URLs for ${instrumentName} from A0 to Bb7`);
|
||||
|
||||
return urls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached buffers and dispose of resources
|
||||
*/
|
||||
public dispose(): void {
|
||||
try {
|
||||
// Dispose of all ToneAudioBuffers
|
||||
this.bufferMap.forEach((buffers, name) => {
|
||||
try {
|
||||
buffers.dispose();
|
||||
console.log(`Disposed ToneAudioBuffers for ${name}`);
|
||||
} catch (error) {
|
||||
console.error(`Error disposing ToneAudioBuffers for ${name}:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
// Clear both maps
|
||||
this.bufferMap.clear();
|
||||
this.loadingPromises.clear();
|
||||
|
||||
console.log("KGToneBuffersPool disposed successfully");
|
||||
} catch (error) {
|
||||
console.error("Error disposing KGToneBuffersPool:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload buffers for specific instruments (optional performance optimization)
|
||||
*/
|
||||
public async preloadInstruments(instrumentNames: string[]): Promise<void> {
|
||||
const loadPromises = instrumentNames.map(name =>
|
||||
this.getToneAudioBuffers(name).catch(error => {
|
||||
console.warn(`Failed to preload ${name}:`, error);
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.allSettled(loadPromises);
|
||||
console.log(`Preloading completed for ${instrumentNames.length} instruments`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import * as Tone from 'tone';
|
||||
import { KGToneBuffersPool } from './KGToneBuffersPool';
|
||||
import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
|
||||
|
||||
/**
|
||||
* KGToneSamplerFactory - Singleton class for creating Tone.Sampler instances
|
||||
* Uses ToneAudioBuffers from KGToneBuffersPool to create samplers with real instrument sounds
|
||||
*/
|
||||
export class KGToneSamplerFactory {
|
||||
// Private static instance for singleton pattern
|
||||
private static _instance: KGToneSamplerFactory | null = null;
|
||||
|
||||
// Private constructor to prevent direct instantiation
|
||||
private constructor() {
|
||||
console.log("KGToneSamplerFactory initialized");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the singleton instance of KGToneSamplerFactory
|
||||
* Creates the instance if it doesn't exist yet
|
||||
*/
|
||||
public static instance(): KGToneSamplerFactory {
|
||||
if (!KGToneSamplerFactory._instance) {
|
||||
KGToneSamplerFactory._instance = new KGToneSamplerFactory();
|
||||
}
|
||||
return KGToneSamplerFactory._instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Tone.Sampler for the specified instrument
|
||||
* Uses ToneAudioBuffers from the pool for realistic instrument sounds
|
||||
*/
|
||||
public async createSampler(instrumentName: string): Promise<Tone.Sampler> {
|
||||
try {
|
||||
console.log(`Creating sampler for instrument: ${instrumentName}`);
|
||||
|
||||
// Get ToneAudioBuffers from the pool
|
||||
const buffersPool = KGToneBuffersPool.instance();
|
||||
const audioBuffers = await buffersPool.getToneAudioBuffers(instrumentName);
|
||||
|
||||
// Create sampler and wait for it to load
|
||||
return new Promise<Tone.Sampler>((resolve, reject) => {
|
||||
// Set a timeout to prevent hanging indefinitely
|
||||
const timeout = setTimeout(() => {
|
||||
console.error(`Timeout: Sampler failed to load for ${instrumentName} after 30 seconds`);
|
||||
reject(new Error(`Sampler loading timeout for ${instrumentName}`));
|
||||
}, 30000); // 30 second timeout
|
||||
|
||||
try {
|
||||
const sampler = new Tone.Sampler({
|
||||
urls: this.convertBuffersToUrls(audioBuffers, FLUIDR3_INSTRUMENT_MAP[instrumentName]?.pitchRange || [21, 108]),
|
||||
onload: () => {
|
||||
clearTimeout(timeout);
|
||||
console.log(`Sampler loaded successfully for ${instrumentName}`);
|
||||
resolve(sampler);
|
||||
},
|
||||
onerror: (error) => {
|
||||
clearTimeout(timeout);
|
||||
console.error(`Sampler failed to load for ${instrumentName}:`, error);
|
||||
reject(new Error(`Sampler loading error for ${instrumentName}: ${error}`));
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`Sampler created for ${instrumentName}, waiting for load...`);
|
||||
} catch (error) {
|
||||
clearTimeout(timeout);
|
||||
console.error(`Failed to create sampler for ${instrumentName}:`, error);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Failed to create sampler for ${instrumentName}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert ToneAudioBuffers to the URL format expected by Tone.Sampler
|
||||
* This creates a mapping from note names to the actual audio buffers
|
||||
*/
|
||||
private convertBuffersToUrls(audioBuffers: Tone.ToneAudioBuffers, range: number[] = [21, 118]): { [key: string]: Tone.ToneAudioBuffer } {
|
||||
const urls: { [key: string]: Tone.ToneAudioBuffer } = {};
|
||||
|
||||
// Note names in order (using flats instead of sharps where applicable)
|
||||
const noteNames = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B'];
|
||||
|
||||
// Generate keys from A0 to Bb7 (MIDI notes 21 to 118)
|
||||
for (let midiNote = range[0]; midiNote <= range[1]; midiNote++) {
|
||||
const octave = Math.floor((midiNote - 12) / 12);
|
||||
const noteIndex = (midiNote - 12) % 12;
|
||||
const noteName = noteNames[noteIndex];
|
||||
const keyName = `${noteName}${octave}`;
|
||||
|
||||
// Get the buffer for this key if it exists
|
||||
if (audioBuffers.has(keyName)) {
|
||||
urls[keyName] = audioBuffers.get(keyName)!;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Converted ${Object.keys(urls).length} audio buffers to sampler format`);
|
||||
return urls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create multiple samplers for different instruments
|
||||
* Useful for preloading multiple instruments at once
|
||||
*/
|
||||
public async createMultipleSamplers(instrumentNames: string[]): Promise<Map<string, Tone.Sampler>> {
|
||||
const samplers = new Map<string, Tone.Sampler>();
|
||||
|
||||
try {
|
||||
const createPromises = instrumentNames.map(async (instrumentName) => {
|
||||
try {
|
||||
const sampler = await this.createSampler(instrumentName);
|
||||
samplers.set(instrumentName, sampler);
|
||||
} catch (error) {
|
||||
console.error(`Failed to create sampler for ${instrumentName}:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.allSettled(createPromises);
|
||||
console.log(`Created ${samplers.size} samplers out of ${instrumentNames.length} requested`);
|
||||
|
||||
return samplers;
|
||||
} catch (error) {
|
||||
console.error('Error creating multiple samplers:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a sampler can be created for the given instrument
|
||||
* (i.e., if the buffers are available in the pool)
|
||||
*/
|
||||
public async canCreateSampler(instrumentName: string): Promise<boolean> {
|
||||
try {
|
||||
const buffersPool = KGToneBuffersPool.instance();
|
||||
const audioBuffers = await buffersPool.getToneAudioBuffers(instrumentName);
|
||||
return audioBuffers.loaded;
|
||||
} catch (error) {
|
||||
console.warn(`Cannot create sampler for ${instrumentName}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user