feat: add audio track support with WAV/MP3 playback and looping

Introduce KGAudioTrack and KGAudioRegion as new track/region types
alongside existing MIDI tracks. Users can import WAV, MP3, OGG, FLAC,
and AAC files into audio tracks with full playback, looping, and
solo/mute/volume support.

New core models:
- KGAudioTrack (extends KGTrack with TrackType.Wave)
- KGAudioRegion (extends KGRegion with audio file reference)
- KGAudioPlayerBus (Tone.Gain + ToneBufferSource-based playback)
- KGAudioFileStorage (OPFS media/ directory for binary audio files)

New commands:
- AddAudioTrackCommand (create audio track with player bus)
- ImportAudioCommand (import audio file, create region at playhead,
auto-expand maxBars)

Playback features:
- Audio regions scheduled via Tone.Transport alongside MIDI events
- Loop support with duration capping at loop boundaries
- Resume safety offset to prevent ToneBufferSource timing misses
- Cross-track buffer copy when moving audio regions between tracks
- Proper buffer cleanup on track deletion and project switch

UI changes:
- Separate "+ MIDI" and "+ Audio" buttons in top-left spacer
- Audio track shows upload button instead of instrument selector
- FileImportModal reused for audio file drag-and-drop upload
- Real waveform rendering on audio region canvas
- Green color scheme for audio regions (vs blue for MIDI)
- Pencil icon and resize handles hidden for audio regions
- Cross-type region moves blocked (MIDI ↔ Audio)
- Piano roll blocked for audio regions
- Audio region deletion support

Infrastructure:
- Project structure version bumped to 4 (no-op migration)
- class-transformer discriminators updated for new subtypes
- RemoveTrackCommand updated to clean up audio player buses
This commit is contained in:
Xiaohan-Tian
2026-04-09 21:54:54 -07:00
parent de2979178e
commit fc84edfc59
23 changed files with 1406 additions and 100 deletions
@@ -0,0 +1,83 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { KGAudioTrack } from '../../track/KGAudioTrack';
import { KGAudioInterface } from '../../audio-interface/KGAudioInterface';
import { generateNewTrackName } from '../../../util/miscUtil';
/**
* Command to add a new audio track to the project.
* Handles both the core model update and audio player bus setup.
*/
export class AddAudioTrackCommand extends KGCommand {
private trackId: number;
private trackName: string;
private trackIndex: number;
private createdTrack: KGAudioTrack | null = null;
constructor(trackId?: number, trackName?: string) {
super();
if (trackId === undefined) {
const currentProject = KGCore.instance().getCurrentProject();
const tracks = currentProject.getTracks();
this.trackId = tracks.length > 0
? Math.max(...tracks.map(track => track.getId())) + 1
: 1;
} else {
this.trackId = trackId;
}
this.trackName = trackName || generateNewTrackName();
this.trackIndex = 0;
}
execute(): void {
const core = KGCore.instance();
const currentProject = core.getCurrentProject();
const tracks = currentProject.getTracks();
this.trackIndex = tracks.length;
this.createdTrack = new KGAudioTrack(this.trackName, this.trackId);
this.createdTrack.setTrackIndex(this.trackIndex);
const updatedTracks = [...tracks, this.createdTrack];
currentProject.setTracks(updatedTracks);
// Create audio player bus for the new track
const audioInterface = KGAudioInterface.instance();
audioInterface.createTrackAudioPlayerBus(this.trackId.toString());
console.log(`Added audio track ${this.trackId}`);
}
undo(): void {
if (!this.createdTrack) {
throw new Error('Cannot undo: no track was created');
}
const core = KGCore.instance();
const currentProject = core.getCurrentProject();
const tracks = currentProject.getTracks();
const updatedTracks = tracks.filter(track => track.getId() !== this.trackId);
currentProject.setTracks(updatedTracks);
const audioInterface = KGAudioInterface.instance();
audioInterface.removeTrackAudioPlayerBus(this.trackId.toString());
console.log(`Removed audio track ${this.trackId}`);
}
getDescription(): string {
return `Add audio track "${this.trackName}"`;
}
public getTrackId(): number {
return this.trackId;
}
public getCreatedTrack(): KGAudioTrack | null {
return this.createdTrack;
}
}
+10 -5
View File
@@ -39,9 +39,10 @@ export class RemoveTrackCommand extends KGCommand {
this.originalInstrument = (trackToRemove as KGMidiTrack).getInstrument();
}
// Remove audio synth for the track
// Remove audio bus for the track (handles both MIDI synth and audio player bus)
const audioInterface = KGAudioInterface.instance();
audioInterface.removeTrackSynth(this.trackId.toString());
audioInterface.removeTrackAudioPlayerBus(this.trackId.toString());
// Remove the track from the core model
const updatedTracks = tracks.filter(track => track.getId() !== this.trackId);
@@ -85,11 +86,15 @@ export class RemoveTrackCommand extends KGCommand {
// Update the core model
currentProject.setTracks(updatedTracks);
// Recreate audio synth for the track
// Recreate audio bus for the track
const audioInterface = KGAudioInterface.instance();
audioInterface.createTrackSynth(this.trackId.toString(), this.originalInstrument);
console.log(`Restored track ${this.trackId} with ${this.originalInstrument} instrument`);
if (this.removedTrack.getCurrentType() === 'KGAudioTrack') {
audioInterface.createTrackAudioPlayerBus(this.trackId.toString());
} else {
audioInterface.createTrackSynth(this.trackId.toString(), this.originalInstrument);
}
console.log(`Restored track ${this.trackId}`);
}
getDescription(): string {