initial public release.

This commit is contained in:
Xiaohan-Tian
2025-08-11 18:37:21 -07:00
commit de51967b49
186 changed files with 32322 additions and 0 deletions
@@ -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);
}
}