import * as Tone from 'tone'; import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants'; import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants'; import type { InstrumentType } from '../track/KGMidiTrack'; import { KGToneBuffersPool } from './KGToneBuffersPool'; import { KGToneSamplerFactory } from './KGToneSamplerFactory'; import { createStereoTrackPanner } from './createStereoTrackPanner'; // InstrumentType is defined in KGMidiTrack and re-used here interface LiveMidiSource { source: Tone.ToneBufferSource; gainNode: Tone.Gain; basePlaybackRate: number; isPressed: boolean; pendingSustainRelease: boolean; } /** * 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 { // Fixed at +/-2 semitones for now. Future work: make this user-configurable // or honor MIDI RPN 0,0 (Pitch Bend Sensitivity). public static readonly LIVE_MIDI_PITCH_BEND_RANGE_SEMITONES = 2; private static readonly LIVE_MIDI_NOTE_NAMES = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B']; // Core audio components private sampler: Tone.Sampler; private audioBuffers: Tone.ToneAudioBuffers; private instrument: InstrumentType; private panner: Tone.Panner; // Audio properties private volume: number; private automationVolume: number | null = null; private pan: number; private automationPan: number | null = null; private muted: boolean; private solo: boolean; private liveMidiPitchBend: number = 0; private liveExpressionNormalized: number = 1; private sustainPedalDown: boolean = false; private liveMidiSources: Map = new Map(); // 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, audioBuffers: Tone.ToneAudioBuffers, instrument: InstrumentType, panner: Tone.Panner, volume: number, pan: number, muted: boolean, solo: boolean ) { this.sampler = sampler; this.audioBuffers = audioBuffers; this.instrument = instrument; this.panner = panner; this.volume = volume; this.pan = pan; this.muted = muted; this.solo = solo; // Set initial volume on the sampler this.updateSamplerVolume(); this.updatePanValue(); 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, pan: number = 0, muted: boolean = false, solo: boolean = false ): Promise { try { console.log(`Creating KGAudioBus for ${instrument}...`); // Create the sampler using the factory const samplerFactory = KGToneSamplerFactory.instance(); const buffersPool = KGToneBuffersPool.instance(); const [sampler, audioBuffers] = await Promise.all([ samplerFactory.createSampler(String(instrument)), buffersPool.getToneAudioBuffers(String(instrument)), ]); // Create the audio bus instance const panner = createStereoTrackPanner(pan); sampler.connect(panner); const audioBus = new KGAudioBus(sampler, audioBuffers, instrument, panner, volume, pan, 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); } } /** * Trigger note attack for live MIDI keyboard monitoring. * This path tracks the underlying buffer sources so pitch bend can retune held notes. */ public triggerLiveMidiAttack( pitch: number, time?: number, velocity?: number ): void { this.triggerPitchBendAwareAttack(pitch, time, velocity); } public triggerPitchBendAwareAttack( pitch: number, time?: number, velocity?: number, duration?: number ): void { if (!this.shouldPlay()) { return; } try { const liveSource = this.createPitchBendAwareSource(pitch, time, velocity, duration); if (!liveSource) { return; } const activeSources = this.liveMidiSources.get(pitch) ?? []; activeSources.push(liveSource); this.liveMidiSources.set(pitch, activeSources); } catch (error) { console.error(`Error triggering live MIDI attack for pitch ${pitch} 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 a live MIDI note and forget any active bent sources tied to that pitch. */ public releaseLiveMidiNote(pitch: number, time?: number): void { try { const activeSources = this.liveMidiSources.get(pitch); if (!activeSources || activeSources.length === 0) { return; } const liveSource = [...activeSources].reverse().find((entry) => entry.isPressed) ?? activeSources[activeSources.length - 1]; if (!liveSource) { return; } liveSource.isPressed = false; if (this.sustainPedalDown) { liveSource.pendingSustainRelease = true; return; } this.stopLiveMidiSource(pitch, liveSource, time ?? Tone.now()); } catch (error) { console.error(`Error releasing live MIDI note ${pitch} on ${this.instrument}:`, error); } } public setLiveMidiPitchBend(normalizedBend: number): void { this.liveMidiPitchBend = Math.max(-1, Math.min(1, normalizedBend)); for (const activeSources of this.liveMidiSources.values()) { activeSources.forEach(({ source, basePlaybackRate }) => { this.setPlaybackRateValue(source, this.applyPitchBendToPlaybackRate(basePlaybackRate)); }); } } public scheduleLiveMidiPitchBend(normalizedBend: number, time: number): void { this.liveMidiPitchBend = Math.max(-1, Math.min(1, normalizedBend)); for (const activeSources of this.liveMidiSources.values()) { activeSources.forEach(({ source, basePlaybackRate }) => { this.setPlaybackRateValue(source, this.applyPitchBendToPlaybackRate(basePlaybackRate), time); }); } } public resetLiveMidiPitchBend(): void { this.setLiveMidiPitchBend(0); } public setLiveMidiExpression(normalizedValue: number): void { this.liveExpressionNormalized = Math.max(0, Math.min(1, normalizedValue)); for (const activeSources of this.liveMidiSources.values()) { activeSources.forEach(({ gainNode }) => { this.setGainValue(gainNode, this.liveExpressionNormalized); }); } } public scheduleLiveMidiExpression(normalizedValue: number, time: number): void { this.liveExpressionNormalized = Math.max(0, Math.min(1, normalizedValue)); for (const activeSources of this.liveMidiSources.values()) { activeSources.forEach(({ gainNode }) => { this.setGainValue(gainNode, this.liveExpressionNormalized, time); }); } } public setLiveMidiSustain(isDown: boolean, time?: number): void { this.sustainPedalDown = isDown; if (isDown) { return; } const releaseTime = time ?? Tone.now(); for (const [pitch, activeSources] of this.liveMidiSources.entries()) { [...activeSources] .filter((entry) => !entry.isPressed && entry.pendingSustainRelease) .forEach((entry) => this.stopLiveMidiSource(pitch, entry, releaseTime)); } } /** * Release all currently playing notes */ public releaseAll(): void { try { this.sampler.releaseAll(); this.liveMidiSources.forEach((activeSources) => { activeSources.forEach(({ source, gainNode }) => { try { source.stop(); } catch (error) { console.error(`Error stopping live MIDI source on ${this.instrument}:`, error); } gainNode.dispose(); }); }); this.liveMidiSources.clear(); this.resetLiveMidiPitchBend(); this.liveExpressionNormalized = 1; this.sustainPedalDown = false; } 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}`); } public setAutomationVolume(volume: number | null): void { this.automationVolume = volume; this.updateSamplerVolume(); } /** * Get the current volume */ public getVolume(): number { return this.volume; } public setPan(pan: number): void { this.pan = Math.max(-1, Math.min(1, pan)); this.updatePanValue(); } public setAutomationPan(pan: number | null): void { this.automationPan = pan === null ? null : Math.max(-1, Math.min(1, pan)); this.updatePanValue(); } public scheduleAutomationPan(pan: number, time: number): void { const clampedPan = Math.max(-1, Math.min(1, pan)); this.automationPan = clampedPan; if (typeof this.panner.pan.setValueAtTime === 'function') { this.panner.pan.setValueAtTime(clampedPan, time); return; } this.panner.pan.value = clampedPan; } public getPan(): number { return this.pan; } /** * 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 { try { console.log(`Changing instrument from ${this.instrument} to ${newInstrument}...`); this.releaseAll(); // Dispose of the current sampler this.sampler.dispose(); // Create new sampler with new instrument const samplerFactory = KGToneSamplerFactory.instance(); const buffersPool = KGToneBuffersPool.instance(); const [sampler, audioBuffers] = await Promise.all([ samplerFactory.createSampler(String(newInstrument)), buffersPool.getToneAudioBuffers(String(newInstrument)), ]); this.sampler = sampler; this.audioBuffers = audioBuffers; this.instrument = newInstrument; this.sampler.connect(this.panner); // Restore volume settings this.updateSamplerVolume(); this.updatePanValue(); 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.panner.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.panner.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.panner.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.releaseAll(); this.sampler.dispose(); this.panner.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.automationVolume ?? this.volume; const isSilent = this.muted || effectiveVolume <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB; this.sampler.volume.value = isSilent ? -Infinity : effectiveVolume; } catch (error) { console.error(`Error updating volume for ${this.instrument}:`, error); } } private updatePanValue(): void { try { const effectivePan = this.automationPan ?? this.pan; if (typeof this.panner.pan.setValueAtTime === 'function') { this.panner.pan.setValueAtTime(effectivePan, Tone.now()); } else { this.panner.pan.value = effectivePan; } } catch (error) { console.error(`Error updating pan 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 { const effectiveVolume = this.automationVolume ?? this.volume; const isSilent = this.muted || (hasSoloedTracks && !this.solo) || effectiveVolume <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB; this.sampler.volume.value = isSilent ? -Infinity : effectiveVolume; } 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; pan: number; muted: boolean; solo: boolean; } { return { instrument: this.instrument, volume: this.volume, pan: this.pan, muted: this.muted, solo: this.solo }; } private createPitchBendAwareSource( pitch: number, time?: number, velocity?: number, duration?: number ): LiveMidiSource | null { const closestPitch = KGAudioBus.findClosestBufferedPitch(this.instrument, this.audioBuffers, pitch); if (closestPitch === null) { console.warn(`No audio buffer found for live MIDI pitch ${pitch} on ${this.instrument}`); return null; } const bufferKey = KGAudioBus.midiPitchToBufferKey(closestPitch); const buffer = this.audioBuffers.get(bufferKey); if (!buffer) { console.warn(`Missing audio buffer ${bufferKey} for ${this.instrument}`); return null; } const basePlaybackRate = Math.pow(2, (pitch - closestPitch) / 12); const gainNode = new Tone.Gain(this.liveExpressionNormalized); const source = new Tone.ToneBufferSource({ url: buffer, fadeIn: this.sampler.attack, fadeOut: this.sampler.release, curve: this.sampler.curve, playbackRate: this.applyPitchBendToPlaybackRate(basePlaybackRate), }); source.connect(gainNode); gainNode.connect(this.sampler.output); source.onended = () => { const currentSources = this.liveMidiSources.get(pitch); if (!currentSources) { gainNode.dispose(); return; } const nextSources = currentSources.filter((entry) => entry.source !== source); if (nextSources.length === 0) { this.liveMidiSources.delete(pitch); } else { this.liveMidiSources.set(pitch, nextSources); } gainNode.dispose(); }; source.start(time, 0, duration ?? buffer.duration / basePlaybackRate, velocity ?? 1); return { source, gainNode, basePlaybackRate, isPressed: true, pendingSustainRelease: false, }; } public static applyNormalizedPitchBendToPlaybackRate(basePlaybackRate: number, normalizedBend: number): number { const bendSemitones = normalizedBend * KGAudioBus.LIVE_MIDI_PITCH_BEND_RANGE_SEMITONES; return basePlaybackRate * Math.pow(2, bendSemitones / 12); } private applyPitchBendToPlaybackRate(basePlaybackRate: number): number { return KGAudioBus.applyNormalizedPitchBendToPlaybackRate(basePlaybackRate, this.liveMidiPitchBend); } public static findClosestBufferedPitch( instrument: InstrumentType, audioBuffers: Tone.ToneAudioBuffers, targetPitch: number ): number | null { const [minPitch, maxPitch] = FLUIDR3_INSTRUMENT_MAP[instrument]?.pitchRange || [21, 108]; const boundedPitch = Math.max(minPitch, Math.min(maxPitch, targetPitch)); for (let offset = 0; offset <= 96; offset++) { const upwardPitch = boundedPitch + offset; if (upwardPitch <= maxPitch && audioBuffers.has(KGAudioBus.midiPitchToBufferKey(upwardPitch))) { return upwardPitch; } const downwardPitch = boundedPitch - offset; if (downwardPitch >= minPitch && audioBuffers.has(KGAudioBus.midiPitchToBufferKey(downwardPitch))) { return downwardPitch; } } return null; } public static midiPitchToBufferKey(pitch: number): string { const octave = Math.floor((pitch - 12) / 12); const noteIndex = (pitch - 12) % 12; return `${KGAudioBus.LIVE_MIDI_NOTE_NAMES[noteIndex]}${octave}`; } private setPlaybackRateValue(source: Tone.ToneBufferSource, value: number, time?: number): void { const playbackRate = source.playbackRate as unknown as { value: number; setValueAtTime?: (nextValue: number, nextTime: number) => void; }; if (time !== undefined && typeof playbackRate.setValueAtTime === 'function') { playbackRate.setValueAtTime(value, time); return; } playbackRate.value = value; } private setGainValue(gainNode: Tone.Gain, value: number, time?: number): void { if (time !== undefined && typeof gainNode.gain.setValueAtTime === 'function') { gainNode.gain.setValueAtTime(value, time); return; } gainNode.gain.value = value; } private stopLiveMidiSource(pitch: number, liveSource: LiveMidiSource, time: number): void { try { liveSource.pendingSustainRelease = false; liveSource.source.stop(time); } catch (error) { console.error(`Error stopping live MIDI source for pitch ${pitch} on ${this.instrument}:`, error); const remainingSources = this.liveMidiSources.get(pitch)?.filter((entry) => entry !== liveSource) ?? []; if (remainingSources.length === 0) { this.liveMidiSources.delete(pitch); } else { this.liveMidiSources.set(pitch, remainingSources); } liveSource.gainNode.dispose(); } } }