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:
@@ -8,6 +8,7 @@ export { KGCommandHistory } from './KGCommandHistory';
|
||||
|
||||
// Track commands
|
||||
export { AddTrackCommand } from './track/AddTrackCommand';
|
||||
export { AddAudioTrackCommand } from './track/AddAudioTrackCommand';
|
||||
export { RemoveTrackCommand } from './track/RemoveTrackCommand';
|
||||
export { ReorderTracksCommand } from './track/ReorderTracksCommand';
|
||||
export { UpdateTrackCommand, type TrackUpdateProperties } from './track/UpdateTrackCommand';
|
||||
@@ -19,6 +20,7 @@ export { ResizeRegionCommand } from './region/ResizeRegionCommand';
|
||||
export { MoveRegionCommand } from './region/MoveRegionCommand';
|
||||
export { PasteRegionsCommand } from './region/PasteRegionsCommand';
|
||||
export { UpdateRegionCommand, type RegionUpdateProperties } from './region/UpdateRegionCommand';
|
||||
export { ImportAudioCommand } from './region/ImportAudioCommand';
|
||||
|
||||
// Note commands
|
||||
export { CreateNoteCommand } from './note/CreateNoteCommand';
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { KGAudioRegion } from '../../region/KGAudioRegion';
|
||||
|
||||
/**
|
||||
* Command to import an audio file into an audio track as a region.
|
||||
* Async work (file decode, OPFS storage, buffer loading) must be done
|
||||
* before execute() is called — this command is synchronous.
|
||||
*/
|
||||
export class ImportAudioCommand extends KGCommand {
|
||||
private trackId: number;
|
||||
private trackIndex: number;
|
||||
private audioFileId: string;
|
||||
private audioFileName: string;
|
||||
private audioDurationSeconds: number;
|
||||
private insertBeat: number;
|
||||
private durationInBeats: number;
|
||||
private previousMaxBars: number;
|
||||
private newMaxBars: number;
|
||||
private regionId: string;
|
||||
private createdRegion: KGAudioRegion | null = null;
|
||||
|
||||
constructor(
|
||||
trackId: number,
|
||||
trackIndex: number,
|
||||
audioFileId: string,
|
||||
audioFileName: string,
|
||||
audioDurationSeconds: number,
|
||||
insertBeat: number,
|
||||
durationInBeats: number,
|
||||
previousMaxBars: number,
|
||||
newMaxBars: number
|
||||
) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.trackIndex = trackIndex;
|
||||
this.audioFileId = audioFileId;
|
||||
this.audioFileName = audioFileName;
|
||||
this.audioDurationSeconds = audioDurationSeconds;
|
||||
this.insertBeat = insertBeat;
|
||||
this.durationInBeats = durationInBeats;
|
||||
this.previousMaxBars = previousMaxBars;
|
||||
this.newMaxBars = newMaxBars;
|
||||
this.regionId = `audio_region_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const core = KGCore.instance();
|
||||
const currentProject = core.getCurrentProject();
|
||||
|
||||
// Create the audio region
|
||||
this.createdRegion = new KGAudioRegion(
|
||||
this.regionId,
|
||||
this.trackId.toString(),
|
||||
this.trackIndex,
|
||||
this.audioFileName,
|
||||
this.insertBeat,
|
||||
this.durationInBeats,
|
||||
this.audioFileId,
|
||||
this.audioFileName,
|
||||
this.audioDurationSeconds
|
||||
);
|
||||
|
||||
// Add region to the track
|
||||
const track = currentProject.getTracks().find(t => t.getId() === this.trackId);
|
||||
if (!track) {
|
||||
throw new Error(`Track ${this.trackId} not found`);
|
||||
}
|
||||
track.addRegion(this.createdRegion);
|
||||
|
||||
// Expand maxBars if needed
|
||||
if (this.newMaxBars > this.previousMaxBars) {
|
||||
currentProject.setMaxBars(this.newMaxBars);
|
||||
}
|
||||
|
||||
console.log(`Imported audio "${this.audioFileName}" at beat ${this.insertBeat}, duration: ${this.durationInBeats} beats`);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
const core = KGCore.instance();
|
||||
const currentProject = core.getCurrentProject();
|
||||
|
||||
// Remove the region from the track
|
||||
const track = currentProject.getTracks().find(t => t.getId() === this.trackId);
|
||||
if (track) {
|
||||
track.removeRegion(this.regionId);
|
||||
}
|
||||
|
||||
// Revert maxBars if we expanded it
|
||||
if (this.newMaxBars > this.previousMaxBars) {
|
||||
currentProject.setMaxBars(this.previousMaxBars);
|
||||
}
|
||||
|
||||
console.log(`Undid audio import "${this.audioFileName}"`);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `Import audio "${this.audioFileName}"`;
|
||||
}
|
||||
|
||||
public getCreatedRegion(): KGAudioRegion | null {
|
||||
return this.createdRegion;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user