Files
KGStudio/src/core/audio-interface/KGAudioBus.ts
T

724 lines
22 KiB
TypeScript

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<number, LiveMidiSource[]> = 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<KGAudioBus> {
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<void> {
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();
}
}
}