Merge pull request #52 from KGAudioLab/feat/2026-06-01-enhance-ai-agent

Feat/2026 06 01 enhance ai agent
This commit is contained in:
Xiaohan-Tian
2026-06-07 13:16:49 -07:00
committed by GitHub
20 changed files with 563 additions and 68 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "K.G.Studio", "name": "K.G.Studio",
"version": "0.19.0-build.20260531", "version": "0.20.2-build.20260606",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "K.G.Studio", "name": "K.G.Studio",
"version": "0.19.0-build.20260531", "version": "0.20.2-build.20260606",
"dependencies": { "dependencies": {
"@breezystack/lamejs": "^1.2.7", "@breezystack/lamejs": "^1.2.7",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
+63
View File
@@ -87,6 +87,26 @@ describe('AddNotesTool', () => {
expect(createdRegion.getNotes().map(note => note.getStartBeat())).toEqual([0, 2]); expect(createdRegion.getNotes().map(note => note.getStartBeat())).toEqual([0, 2]);
}); });
it('accepts a numeric track_id and creates notes on the matching track', async () => {
const track = new KGMidiTrack('Lead', 1);
const project = new KGProject('numeric-track-id-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
project.setTracks([track]);
mockCore(project);
const tool = new AddNotesTool();
const result = await tool.execute({
track_id: track.getId(),
notes: [{ pitch: 'C4', start: 8, length: 2 }],
});
expect(result.success).toBe(true);
expect(track.getRegions()).toHaveLength(1);
const createdRegion = track.getRegions()[0] as KGMidiRegion;
expect(createdRegion.getName()).toBe('Lead Region');
expect(createdRegion.getNotes()).toHaveLength(1);
expect(createdRegion.getNotes()[0].getStartBeat()).toBe(0);
});
it('targets a track by track_name when track_id is omitted', async () => { it('targets a track by track_name when track_id is omitted', async () => {
const targetTrack = new KGMidiTrack('Lead', 1); const targetTrack = new KGMidiTrack('Lead', 1);
const otherTrack = new KGMidiTrack('Bass', 2); const otherTrack = new KGMidiTrack('Bass', 2);
@@ -126,6 +146,26 @@ describe('AddNotesTool', () => {
expect((bassTrack.getRegions()[0] as KGMidiRegion).getName()).toBe('Bass Region'); expect((bassTrack.getRegions()[0] as KGMidiRegion).getName()).toBe('Bass Region');
}); });
it('uses a numeric track_id when both track_id and track_name are provided', async () => {
const leadTrack = new KGMidiTrack('Lead', 1);
const bassTrack = new KGMidiTrack('Bass', 2);
const project = new KGProject('numeric-track-id-precedence-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
project.setTracks([leadTrack, bassTrack]);
mockCore(project);
const tool = new AddNotesTool();
const result = await tool.execute({
track_id: bassTrack.getId(),
track_name: 'Lead',
notes: [{ pitch: 'C4', start: 8, length: 2 }],
});
expect(result.success).toBe(true);
expect(leadTrack.getRegions()).toHaveLength(0);
expect(bassTrack.getRegions()).toHaveLength(1);
expect((bassTrack.getRegions()[0] as KGMidiRegion).getName()).toBe('Bass Region');
});
it('uses the first matching track when duplicate track names exist', async () => { it('uses the first matching track when duplicate track names exist', async () => {
const firstLead = new KGMidiTrack('Lead', 1); const firstLead = new KGMidiTrack('Lead', 1);
const secondLead = new KGMidiTrack('Lead', 2); const secondLead = new KGMidiTrack('Lead', 2);
@@ -169,6 +209,29 @@ describe('AddNotesTool', () => {
expect(regionB.getNotes().find(note => note.getId() !== 'note-existing')?.getStartBeat()).toBe(0); expect(regionB.getNotes().find(note => note.getId() !== 'note-existing')?.getStartBeat()).toBe(0);
}); });
it('builds summaries when track_id is provided as a number', () => {
const track = new KGMidiTrack('Lead', 1);
const project = new KGProject('numeric-track-id-summary-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
project.setTracks([track]);
mockCore(project);
const tool = new AddNotesTool();
const args = {
track_id: track.getId(),
notes: [
{ pitch: 'C4', start: 8, length: 2 },
{ pitch: 'E4', start: 10, length: 2 },
],
};
expect(tool.buildToolResultDisplayContent(args, { success: true, result: 'raw result' })).toBe(
'Successfully created 2 notes in new region **Lead Region** on track **Lead**, spanning bars 3 to 3.',
);
expect(tool.buildConfirmationContent(args)).toBe(
'Allow creating 2 notes on track **Lead** in a new region, spanning bars 3 to 3?',
);
});
it('returns distinct raw, history, and UI guidance when no MIDI target is available', async () => { it('returns distinct raw, history, and UI guidance when no MIDI target is available', async () => {
const project = new KGProject('no-target-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major'); const project = new KGProject('no-target-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
mockCore(project); mockCore(project);
+8 -5
View File
@@ -16,6 +16,7 @@ import { ResizeRegionCommand } from '../../core/commands/region/ResizeRegionComm
import { KGCore } from '../../core/KGCore'; import { KGCore } from '../../core/KGCore';
import { KGMidiRegion } from '../../core/region/KGMidiRegion'; import { KGMidiRegion } from '../../core/region/KGMidiRegion';
import { KGMidiTrack } from '../../core/track/KGMidiTrack'; import { KGMidiTrack } from '../../core/track/KGMidiTrack';
import { normalizeOptionalTrackIdParam } from './trackIdNormalization';
interface RequestedNote { interface RequestedNote {
pitch: string; pitch: string;
@@ -226,9 +227,10 @@ export class AddNotesTool extends BaseTool {
async execute(params: Record<string, unknown>): Promise<ToolResult> { async execute(params: Record<string, unknown>): Promise<ToolResult> {
try { try {
this.validateParameters(params); const normalizedParams = normalizeOptionalTrackIdParam(params);
this.validateParameters(normalizedParams);
const notes = params.notes as RequestedNote[]; const notes = normalizedParams.notes as RequestedNote[];
if (notes.length === 0) { if (notes.length === 0) {
return this.createErrorResult('No notes were provided.'); return this.createErrorResult('No notes were provided.');
} }
@@ -255,8 +257,8 @@ export class AddNotesTool extends BaseTool {
} }
} }
const trackId = params.track_id as string | undefined; const trackId = normalizedParams.track_id as string | undefined;
const trackName = params.track_name as string | undefined; const trackName = normalizedParams.track_name as string | undefined;
if (trackId || trackName) { if (trackId || trackName) {
const explicitTrack = resolveMidiTrackByIdOrName(trackId, trackName); const explicitTrack = resolveMidiTrackByIdOrName(trackId, trackName);
if (!explicitTrack) { if (!explicitTrack) {
@@ -291,7 +293,8 @@ export class AddNotesTool extends BaseTool {
} }
private buildSummaryData(args: Record<string, unknown>): AddNotesSummaryData | null { private buildSummaryData(args: Record<string, unknown>): AddNotesSummaryData | null {
const typedArgs = args as { const normalizedArgs = normalizeOptionalTrackIdParam(args);
const typedArgs = normalizedArgs as {
notes?: Array<{ start: number; length: number }>; notes?: Array<{ start: number; length: number }>;
track_id?: string; track_id?: string;
track_name?: string; track_name?: string;
+35
View File
@@ -52,6 +52,24 @@ describe('DeleteTrackTool', () => {
); );
}); });
it('deletes a MIDI track by numeric track_id', async () => {
const leadTrack = new KGMidiTrack('Lead', 1, 'trumpet');
const bassTrack = new KGMidiTrack('Bass', 2, 'acoustic_bass');
const project = new KGProject('delete-by-numeric-id-project');
project.setTracks([leadTrack, bassTrack]);
mockCore(project);
const tool = new DeleteTrackTool();
const result = await tool.execute({ track_id: 1 });
expect(result).toEqual({
success: true,
result: 'Track deleted:\ntrack_id: 1\ntrack_name: Lead',
});
expect(project.getTracks().map(track => track.getName())).toEqual(['Bass']);
expect(tool.buildConfirmationContent({ track_id: 1 })).toBe('Allow deleting track ID **1**?');
});
it('deletes a MIDI track by track_name', async () => { it('deletes a MIDI track by track_name', async () => {
const leadTrack = new KGMidiTrack('Lead', 1, 'trumpet'); const leadTrack = new KGMidiTrack('Lead', 1, 'trumpet');
const bassTrack = new KGMidiTrack('Bass', 2, 'acoustic_bass'); const bassTrack = new KGMidiTrack('Bass', 2, 'acoustic_bass');
@@ -86,6 +104,23 @@ describe('DeleteTrackTool', () => {
expect(project.getTracks().map(track => track.getName())).toEqual(['Lead']); expect(project.getTracks().map(track => track.getName())).toEqual(['Lead']);
}); });
it('uses numeric track_id when both track_id and track_name are provided', async () => {
const leadTrack = new KGMidiTrack('Lead', 1, 'trumpet');
const bassTrack = new KGMidiTrack('Bass', 2, 'acoustic_bass');
const project = new KGProject('delete-numeric-track-id-precedence-project');
project.setTracks([leadTrack, bassTrack]);
mockCore(project);
const tool = new DeleteTrackTool();
const result = await tool.execute({
track_id: 2,
track_name: 'Lead',
});
expect(result.success).toBe(true);
expect(project.getTracks().map(track => track.getName())).toEqual(['Lead']);
});
it('rejects duplicate track names when track_id is omitted', async () => { it('rejects duplicate track names when track_id is omitted', async () => {
const firstLead = new KGMidiTrack('Lead', 1, 'trumpet'); const firstLead = new KGMidiTrack('Lead', 1, 'trumpet');
const secondLead = new KGMidiTrack('Lead', 2, 'flute'); const secondLead = new KGMidiTrack('Lead', 2, 'flute');
+11 -7
View File
@@ -3,6 +3,7 @@ import { KGMidiTrack } from '../../core/track/KGMidiTrack';
import { BaseTool } from './BaseTool'; import { BaseTool } from './BaseTool';
import type { ToolParameter, ToolResult } from './BaseTool'; import type { ToolParameter, ToolResult } from './BaseTool';
import { resolveMidiTrackByExactName, resolveMidiTrackByIdOrName } from './toolTargeting'; import { resolveMidiTrackByExactName, resolveMidiTrackByIdOrName } from './toolTargeting';
import { normalizeOptionalTrackIdParam } from './trackIdNormalization';
export class DeleteTrackTool extends BaseTool { export class DeleteTrackTool extends BaseTool {
readonly name = 'delete_track'; readonly name = 'delete_track';
@@ -35,12 +36,14 @@ export class DeleteTrackTool extends BaseTool {
return undefined; return undefined;
} }
if (typeof args.track_id === 'string') { const normalizedArgs = normalizeOptionalTrackIdParam(args);
return `Allow deleting track ID **${args.track_id}**?`;
if (typeof normalizedArgs.track_id === 'string') {
return `Allow deleting track ID **${normalizedArgs.track_id}**?`;
} }
if (typeof args.track_name === 'string') { if (typeof normalizedArgs.track_name === 'string') {
return `Allow deleting track **${args.track_name}**?`; return `Allow deleting track **${normalizedArgs.track_name}**?`;
} }
return undefined; return undefined;
@@ -66,10 +69,11 @@ export class DeleteTrackTool extends BaseTool {
async execute(params: Record<string, unknown>): Promise<ToolResult> { async execute(params: Record<string, unknown>): Promise<ToolResult> {
try { try {
this.validateParameters(params); const normalizedParams = normalizeOptionalTrackIdParam(params);
this.validateParameters(normalizedParams);
const trackId = params.track_id as string | undefined; const trackId = normalizedParams.track_id as string | undefined;
const trackName = params.track_name as string | undefined; const trackName = normalizedParams.track_name as string | undefined;
if (!trackId && !trackName) { if (!trackId && !trackName) {
return this.createErrorResult('Either track_id or track_name must be provided.'); return this.createErrorResult('Either track_id or track_name must be provided.');
+41
View File
@@ -68,6 +68,29 @@ describe('ReadMusicTool', () => {
expect(result.result).toContain('z4 | z4 | // No regions found'); expect(result.result).toContain('z4 | z4 | // No regions found');
}); });
it('reads a specific track when track_id is provided as a number', async () => {
const project = new KGProject('read-music-numeric-track-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
const leadTrack = buildTrack('Lead', 1, 0, 8);
const bassTrack = buildTrack('Bass', 2, 0, 8);
project.setTracks([leadTrack, bassTrack]);
vi.spyOn(KGCore, 'instance').mockReturnValue({
getCurrentProject: () => project,
} as unknown as KGCore);
const tool = new ReadMusicTool();
const result = await tool.execute({ track_id: 2, start: 0, length: 8 });
expect(result.success).toBe(true);
expect(result.result).toContain('track_id: 2');
expect(result.result).toContain('track_name: Bass');
expect(result.result).not.toContain('track_id: 1');
expect(tool.buildToolResultDisplayContent(
{ track_id: 2, start: 0, length: 8 },
{ success: true, result: 'raw result' },
)).toBe('Read track Bass from bars 1 to 2.');
});
it('returns a professional empty-project message when all MIDI tracks are empty', async () => { it('returns a professional empty-project message when all MIDI tracks are empty', async () => {
const project = new KGProject('read-music-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major'); const project = new KGProject('read-music-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
const emptyTrack = new KGMidiTrack('Lead', 1); const emptyTrack = new KGMidiTrack('Lead', 1);
@@ -87,6 +110,24 @@ describe('ReadMusicTool', () => {
expect(result.result).toBe('No musical content is present in the project yet.'); expect(result.result).toBe('No musical content is present in the project yet.');
}); });
it('preserves the all-tracks behavior when track_id is "all"', async () => {
const project = new KGProject('read-music-all-track-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
const leadTrack = buildTrack('Lead', 1, 0, 8);
const bassTrack = buildTrack('Bass', 2, 0, 8);
project.setTracks([leadTrack, bassTrack]);
vi.spyOn(KGCore, 'instance').mockReturnValue({
getCurrentProject: () => project,
} as unknown as KGCore);
const tool = new ReadMusicTool();
const result = await tool.execute({ track_id: 'all', start: 0, length: 8 });
expect(result.success).toBe(true);
expect(result.result).toContain('track_id: 1');
expect(result.result).toContain('track_id: 2');
});
it('returns a professional empty-range message when the selected range has no MIDI notes', async () => { it('returns a professional empty-range message when the selected range has no MIDI notes', async () => {
const project = new KGProject('read-music-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major'); const project = new KGProject('read-music-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
const leadTrack = buildTrack('Lead', 1, 0, 8); const leadTrack = buildTrack('Lead', 1, 0, 8);
+11 -8
View File
@@ -6,6 +6,7 @@ import { KGMidiRegion } from '../../core/region/KGMidiRegion';
import { convertRegionToABCNotation } from '../../util/abcNotationUtil'; import { convertRegionToABCNotation } from '../../util/abcNotationUtil';
import { KEY_SIGNATURE_MAP } from '../../constants/coreConstants'; import { KEY_SIGNATURE_MAP } from '../../constants/coreConstants';
import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants'; import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
import { normalizeOptionalTrackIdParam } from './trackIdNormalization';
/** /**
* Tool for reading music content from the project * Tool for reading music content from the project
@@ -68,12 +69,13 @@ export class ReadMusicTool extends BaseTool {
async execute(params: Record<string, unknown>): Promise<ToolResult> { async execute(params: Record<string, unknown>): Promise<ToolResult> {
try { try {
const normalizedParams = normalizeOptionalTrackIdParam(params);
// Validate parameters // Validate parameters
this.validateParameters(params); this.validateParameters(normalizedParams);
const trackId = params.track_id as string | undefined; const trackId = normalizedParams.track_id as string | undefined;
const startBeat = (params.start as number) || 0; const startBeat = (normalizedParams.start as number) || 0;
const length = params.length as number | undefined; const length = normalizedParams.length as number | undefined;
const project = this.getCurrentProject(); const project = this.getCurrentProject();
const tracks = project.getTracks(); const tracks = project.getTracks();
@@ -140,6 +142,7 @@ export class ReadMusicTool extends BaseTool {
startBar: number; startBar: number;
endBar: number; endBar: number;
} | null { } | null {
const normalizedArgs = normalizeOptionalTrackIdParam(args);
const project = this.getCurrentProject(); const project = this.getCurrentProject();
const tracks = project.getTracks(); const tracks = project.getTracks();
if (tracks.length === 0) { if (tracks.length === 0) {
@@ -147,8 +150,8 @@ export class ReadMusicTool extends BaseTool {
} }
const beatsPerBar = project.getTimeSignature().numerator; const beatsPerBar = project.getTimeSignature().numerator;
const startBeat = (args.start as number) || 0; const startBeat = (normalizedArgs.start as number) || 0;
const length = args.length as number | undefined; const length = normalizedArgs.length as number | undefined;
if (startBeat < 0 || (length !== undefined && length <= 0)) { if (startBeat < 0 || (length !== undefined && length <= 0)) {
return null; return null;
} }
@@ -157,9 +160,9 @@ export class ReadMusicTool extends BaseTool {
const rawEndBeat = length !== undefined ? startBeat + length : undefined; const rawEndBeat = length !== undefined ? startBeat + length : undefined;
const roundedEndBeat = rawEndBeat !== undefined const roundedEndBeat = rawEndBeat !== undefined
? Math.ceil(rawEndBeat / beatsPerBar) * beatsPerBar ? Math.ceil(rawEndBeat / beatsPerBar) * beatsPerBar
: this.getTrackReadEndBeat(args, tracks, roundedStartBeat); : this.getTrackReadEndBeat(normalizedArgs, tracks, roundedStartBeat);
const trackNames = this.resolveSummaryTrackNames(args, tracks); const trackNames = this.resolveSummaryTrackNames(normalizedArgs, tracks);
if (trackNames.length === 0 || roundedEndBeat === undefined) { if (trackNames.length === 0 || roundedEndBeat === undefined) {
return null; return null;
} }
+72
View File
@@ -82,6 +82,29 @@ describe('RemoveNotesTool', () => {
expect(regionB.getNotes()).toHaveLength(0); expect(regionB.getNotes()).toHaveLength(0);
}); });
it('removes notes across all MIDI regions on a track when track_id is numeric', async () => {
const track = new KGMidiTrack('Lead', 1);
const regionA = new KGMidiRegion('region-a', track.getId().toString(), track.getTrackIndex(), 'A', 0, 8);
const regionB = new KGMidiRegion('region-b', track.getId().toString(), track.getTrackIndex(), 'B', 8, 8);
regionA.setNotes([new KGMidiNote('note-1', 2, 3, 60, 100)]);
regionB.setNotes([new KGMidiNote('note-2', 2, 3, 64, 100)]);
track.setRegions([regionA, regionB]);
const project = new KGProject('numeric-track-remove-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
project.setTracks([track]);
mockCore(project);
const tool = new RemoveNotesTool();
const result = await tool.execute({
track_id: track.getId(),
start: 0,
end: 12,
});
expect(result.success).toBe(true);
expect(regionA.getNotes()).toHaveLength(0);
expect(regionB.getNotes()).toHaveLength(0);
});
it('removes notes across a track resolved by track_name when track_id is omitted', async () => { it('removes notes across a track resolved by track_name when track_id is omitted', async () => {
const leadTrack = new KGMidiTrack('Lead', 1); const leadTrack = new KGMidiTrack('Lead', 1);
const bassTrack = new KGMidiTrack('Bass', 2); const bassTrack = new KGMidiTrack('Bass', 2);
@@ -133,6 +156,32 @@ describe('RemoveNotesTool', () => {
expect(bassRegion.getNotes()).toHaveLength(1); expect(bassRegion.getNotes()).toHaveLength(1);
}); });
it('uses numeric track_id when both track_id and track_name are provided', async () => {
const leadTrack = new KGMidiTrack('Lead', 1);
const bassTrack = new KGMidiTrack('Bass', 2);
const leadRegion = new KGMidiRegion('lead-region', leadTrack.getId().toString(), leadTrack.getTrackIndex(), 'Lead Region', 0, 8);
const bassRegion = new KGMidiRegion('bass-region', bassTrack.getId().toString(), bassTrack.getTrackIndex(), 'Bass Region', 0, 8);
leadRegion.setNotes([new KGMidiNote('lead-note', 1, 2, 60, 100)]);
bassRegion.setNotes([new KGMidiNote('bass-note', 1, 2, 48, 100)]);
leadTrack.setRegions([leadRegion]);
bassTrack.setRegions([bassRegion]);
const project = new KGProject('numeric-remove-track-id-precedence-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
project.setTracks([leadTrack, bassTrack]);
mockCore(project);
const tool = new RemoveNotesTool();
const result = await tool.execute({
track_id: leadTrack.getId(),
track_name: 'Bass',
start: 0,
end: 4,
});
expect(result.success).toBe(true);
expect(leadRegion.getNotes()).toHaveLength(0);
expect(bassRegion.getNotes()).toHaveLength(1);
});
it('uses the first matching track when duplicate track names exist', async () => { it('uses the first matching track when duplicate track names exist', async () => {
const firstLead = new KGMidiTrack('Lead', 1); const firstLead = new KGMidiTrack('Lead', 1);
const secondLead = new KGMidiTrack('Lead', 2); const secondLead = new KGMidiTrack('Lead', 2);
@@ -158,6 +207,29 @@ describe('RemoveNotesTool', () => {
expect(secondRegion.getNotes()).toHaveLength(1); expect(secondRegion.getNotes()).toHaveLength(1);
}); });
it('builds confirmation and result summaries when track_id is numeric', () => {
const track = new KGMidiTrack('Lead', 1);
const region = new KGMidiRegion('region-1', track.getId().toString(), track.getTrackIndex(), 'Verse Melody', 0, 32);
region.setNotes([
new KGMidiNote('note-1', 16, 20, 60, 100),
new KGMidiNote('note-2', 20, 28, 64, 100),
]);
track.setRegions([region]);
const project = new KGProject('numeric-remove-summary-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
project.setTracks([track]);
mockCore(project);
const tool = new RemoveNotesTool();
const args = { track_id: track.getId(), start: 16, end: 24 };
expect(tool.buildConfirmationContent(args)).toBe(
'Allow removing 2 notes from beats 16-24, on track **Lead**, spanning bars 5 to 7?',
);
expect(tool.buildToolResultDisplayContent(args, { success: true, result: 'raw result' })).toBe(
'Successfully removed 2 notes from beats 16-24, on track **Lead**, spanning bars 5 to 7.',
);
});
it('returns distinct raw, history, and UI guidance when no MIDI target is available', async () => { it('returns distinct raw, history, and UI guidance when no MIDI target is available', async () => {
const project = new KGProject('no-target-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major'); const project = new KGProject('no-target-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
mockCore(project); mockCore(project);
+9 -6
View File
@@ -12,6 +12,7 @@ import { DeleteNotesCommand } from '../../core/commands/note/DeleteNotesCommand'
import { KGMidiNote } from '../../core/midi/KGMidiNote'; import { KGMidiNote } from '../../core/midi/KGMidiNote';
import { KGMidiRegion } from '../../core/region/KGMidiRegion'; import { KGMidiRegion } from '../../core/region/KGMidiRegion';
import { KGMidiTrack } from '../../core/track/KGMidiTrack'; import { KGMidiTrack } from '../../core/track/KGMidiTrack';
import { normalizeOptionalTrackIdParam } from './trackIdNormalization';
interface RemoveTargetRegionContext { interface RemoveTargetRegionContext {
region: KGMidiRegion; region: KGMidiRegion;
@@ -106,12 +107,13 @@ export class RemoveNotesTool extends BaseTool {
async execute(params: Record<string, unknown>): Promise<ToolResult> { async execute(params: Record<string, unknown>): Promise<ToolResult> {
try { try {
this.validateParameters(params); const normalizedParams = normalizeOptionalTrackIdParam(params);
this.validateParameters(normalizedParams);
const startBeat = params.start as number; const startBeat = normalizedParams.start as number;
const endBeat = params.end as number; const endBeat = normalizedParams.end as number;
const trackId = params.track_id as string | undefined; const trackId = normalizedParams.track_id as string | undefined;
const trackName = params.track_name as string | undefined; const trackName = normalizedParams.track_name as string | undefined;
if (startBeat < 0) { if (startBeat < 0) {
return this.createErrorResult(`Invalid start ${startBeat}. Must be >= 0.`); return this.createErrorResult(`Invalid start ${startBeat}. Must be >= 0.`);
@@ -159,7 +161,8 @@ export class RemoveNotesTool extends BaseTool {
} }
private buildSummaryData(args: Record<string, unknown>): RemoveNotesSummaryData | null { private buildSummaryData(args: Record<string, unknown>): RemoveNotesSummaryData | null {
const typedArgs = args as { const normalizedArgs = normalizeOptionalTrackIdParam(args);
const typedArgs = normalizedArgs as {
start?: number; start?: number;
end?: number; end?: number;
track_id?: string; track_id?: string;
+42
View File
@@ -56,6 +56,29 @@ describe('UpdateTrackTool', () => {
); );
}); });
it('renames a track by numeric track_id', async () => {
const track = new KGMidiTrack('Lead', 1, 'trumpet');
const project = new KGProject('rename-track-numeric-project');
project.setTracks([track]);
mockCore(project);
const tool = new UpdateTrackTool();
const result = await tool.execute({
track_id: 1,
new_track_name: 'Lead 2',
});
expect(result).toEqual({
success: true,
result: 'Track updated:\ntrack_id: 1\ntrack_name: Lead 2\ninstrument: Trumpet',
});
expect(track.getName()).toBe('Lead 2');
expect(tool.buildConfirmationContent({
track_id: 1,
new_track_name: 'Lead 2',
})).toBe('Allow updating track ID **1** to rename to **Lead 2**?');
});
it('updates a track instrument by track_name', async () => { it('updates a track instrument by track_name', async () => {
const track = new KGMidiTrack('Lead', 1, 'trumpet'); const track = new KGMidiTrack('Lead', 1, 'trumpet');
const project = new KGProject('instrument-track-project'); const project = new KGProject('instrument-track-project');
@@ -115,6 +138,25 @@ describe('UpdateTrackTool', () => {
expect(bassTrack.getName()).toBe('Bass 2'); expect(bassTrack.getName()).toBe('Bass 2');
}); });
it('uses numeric track_id when both track_id and track_name are provided', async () => {
const leadTrack = new KGMidiTrack('Lead', 1, 'trumpet');
const bassTrack = new KGMidiTrack('Bass', 2, 'acoustic_bass');
const project = new KGProject('numeric-track-id-precedence-project');
project.setTracks([leadTrack, bassTrack]);
mockCore(project);
const tool = new UpdateTrackTool();
const result = await tool.execute({
track_id: 2,
track_name: 'Lead',
new_track_name: 'Bass 2',
});
expect(result.success).toBe(true);
expect(leadTrack.getName()).toBe('Lead');
expect(bassTrack.getName()).toBe('Bass 2');
});
it('rejects duplicate track names when track_id is omitted', async () => { it('rejects duplicate track names when track_id is omitted', async () => {
const firstLead = new KGMidiTrack('Lead', 1, 'trumpet'); const firstLead = new KGMidiTrack('Lead', 1, 'trumpet');
const secondLead = new KGMidiTrack('Lead', 2, 'flute'); const secondLead = new KGMidiTrack('Lead', 2, 'flute');
+12 -9
View File
@@ -9,6 +9,7 @@ import {
resolveMidiTrackByExactName, resolveMidiTrackByExactName,
resolveMidiTrackByIdOrName, resolveMidiTrackByIdOrName,
} from './toolTargeting'; } from './toolTargeting';
import { normalizeOptionalTrackIdParam } from './trackIdNormalization';
export class UpdateTrackTool extends BaseTool { export class UpdateTrackTool extends BaseTool {
readonly name = 'update_track'; readonly name = 'update_track';
@@ -51,12 +52,13 @@ export class UpdateTrackTool extends BaseTool {
return undefined; return undefined;
} }
const normalizedArgs = normalizeOptionalTrackIdParam(args);
const normalizedInstrumentName = this.normalizeOptionalString(args.instrument); const normalizedInstrumentName = this.normalizeOptionalString(args.instrument);
const normalizedNewTrackName = this.normalizeOptionalString(args.new_track_name); const normalizedNewTrackName = this.normalizeOptionalString(args.new_track_name);
const targetLabel = typeof args.track_id === 'string' const targetLabel = typeof normalizedArgs.track_id === 'string'
? `track ID **${args.track_id}**` ? `track ID **${normalizedArgs.track_id}**`
: typeof args.track_name === 'string' : typeof normalizedArgs.track_name === 'string'
? `track **${args.track_name}**` ? `track **${normalizedArgs.track_name}**`
: null; : null;
if (!targetLabel) { if (!targetLabel) {
return undefined; return undefined;
@@ -98,12 +100,13 @@ export class UpdateTrackTool extends BaseTool {
async execute(params: Record<string, unknown>): Promise<ToolResult> { async execute(params: Record<string, unknown>): Promise<ToolResult> {
try { try {
this.validateParameters(params); const normalizedParams = normalizeOptionalTrackIdParam(params);
this.validateParameters(normalizedParams);
const trackId = params.track_id as string | undefined; const trackId = normalizedParams.track_id as string | undefined;
const trackName = params.track_name as string | undefined; const trackName = normalizedParams.track_name as string | undefined;
const instrumentName = this.normalizeOptionalString(params.instrument); const instrumentName = this.normalizeOptionalString(normalizedParams.instrument);
const newTrackName = this.normalizeOptionalString(params.new_track_name); const newTrackName = this.normalizeOptionalString(normalizedParams.new_track_name);
if (!trackId && !trackName) { if (!trackId && !trackName) {
return this.createErrorResult('Either track_id or track_name must be provided.'); return this.createErrorResult('Either track_id or track_name must be provided.');
+16
View File
@@ -0,0 +1,16 @@
export function normalizeOptionalTrackIdParam(params: Record<string, unknown>): Record<string, unknown> {
const rawTrackId = params.track_id;
if (rawTrackId === undefined || rawTrackId === null || typeof rawTrackId === 'string') {
return params;
}
if (typeof rawTrackId === 'number' && Number.isFinite(rawTrackId) && Number.isInteger(rawTrackId)) {
return {
...params,
track_id: String(rawTrackId),
};
}
return params;
}
+9 -4
View File
@@ -4,7 +4,7 @@ import { KGMidiTrack } from '../../core/track/KGMidiTrack';
import { KGAudioTrack } from '../../core/track/KGAudioTrack'; import { KGAudioTrack } from '../../core/track/KGAudioTrack';
import { useProjectStore } from '../../stores/projectStore'; import { useProjectStore } from '../../stores/projectStore';
import { TbPiano } from 'react-icons/tb'; import { TbPiano } from 'react-icons/tb';
import { TbSettings } from 'react-icons/tb'; import { TbDots } from 'react-icons/tb';
import { FaFileAudio } from 'react-icons/fa'; import { FaFileAudio } from 'react-icons/fa';
import KGDropdown from '../common/KGDropdown'; import KGDropdown from '../common/KGDropdown';
import FileImportModal from '../common/FileImportModal'; import FileImportModal from '../common/FileImportModal';
@@ -535,15 +535,20 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
)} )}
</div> </div>
<div style={{ position: 'relative' }} ref={settingsDropdownRef}> <div style={{ position: 'relative' }} ref={settingsDropdownRef}>
<button className="settings" onClick={handleSettingsButtonClick}> <button
<TbSettings /> className="settings"
onClick={handleSettingsButtonClick}
title={t('track.controls.moreActions')}
aria-label={t('track.controls.moreActions')}
>
<TbDots />
</button> </button>
<div style={{ position: 'absolute', top: '100%', left: 0, zIndex: 10000 }}> <div style={{ position: 'absolute', top: '100%', left: 0, zIndex: 10000 }}>
<KGDropdown <KGDropdown
options={[{ label: t('track.controls.settings.deleteTrack'), value: 'Delete Track' }]} options={[{ label: t('track.controls.settings.deleteTrack'), value: 'Delete Track' }]}
value={''} value={''}
onChange={handleSettingsAction} onChange={handleSettingsAction}
label={t('track.controls.settingsDropdown')} label={t('track.controls.moreActions')}
hideButton={true} hideButton={true}
isOpen={showSettingsDropdown} isOpen={showSettingsDropdown}
onToggle={setShowSettingsDropdown} onToggle={setShowSettingsDropdown}
@@ -339,6 +339,20 @@ export class KGAudioInterface {
} }
} }
/**
* Check whether an audio track currently has a player bus.
*/
public hasTrackAudioPlayerBus(trackId: string): boolean {
return this.trackAudioPlayerBuses.has(trackId);
}
/**
* Check whether an audio buffer is loaded on a specific track's player bus.
*/
public hasAudioBufferForTrack(trackId: string, audioFileId: string): boolean {
return this.trackAudioPlayerBuses.get(trackId)?.hasBuffer(audioFileId) ?? false;
}
/** /**
* Load an audio buffer into a track's player bus * Load an audio buffer into a track's player bus
*/ */
+1 -1
View File
@@ -537,7 +537,7 @@ export const enUsMessages: TranslationMessages = {
'track.controls.automationDropdown': 'Automation', 'track.controls.automationDropdown': 'Automation',
'track.controls.automation.volume': 'Volume', 'track.controls.automation.volume': 'Volume',
'track.controls.automation.pan': 'Pan', 'track.controls.automation.pan': 'Pan',
'track.controls.settingsDropdown': 'Settings', 'track.controls.moreActions': 'More actions',
'track.controls.settings.deleteTrack': 'Delete Track', 'track.controls.settings.deleteTrack': 'Delete Track',
'toolbar.keySignatureChooser': 'Choose key signature, current {value}', 'toolbar.keySignatureChooser': 'Choose key signature, current {value}',
'toolbar.export.label': 'Export', 'toolbar.export.label': 'Export',
+1 -1
View File
@@ -421,7 +421,7 @@ export const frFrMessages: TranslationMessages = {
'track.controls.automationDropdown': 'Automation', 'track.controls.automationDropdown': 'Automation',
'track.controls.automation.volume': 'Volume', 'track.controls.automation.volume': 'Volume',
'track.controls.automation.pan': 'Panoramique', 'track.controls.automation.pan': 'Panoramique',
'track.controls.settingsDropdown': 'Réglages', 'track.controls.moreActions': 'Autres actions',
'track.controls.settings.deleteTrack': 'Supprimer la piste', 'track.controls.settings.deleteTrack': 'Supprimer la piste',
'toolbar.keySignatureChooser': 'Choisir l\'armure, actuelle : {value}', 'toolbar.keySignatureChooser': 'Choisir l\'armure, actuelle : {value}',
'toolbar.export.label': 'Exporter', 'toolbar.export.label': 'Exporter',
+1 -1
View File
@@ -535,7 +535,7 @@ export const zhCnMessages: TranslationMessages = {
'track.controls.automationDropdown': '自动化', 'track.controls.automationDropdown': '自动化',
'track.controls.automation.volume': '音量', 'track.controls.automation.volume': '音量',
'track.controls.automation.pan': '声像', 'track.controls.automation.pan': '声像',
'track.controls.settingsDropdown': '设置', 'track.controls.moreActions': '更多操作',
'track.controls.settings.deleteTrack': '删除轨道', 'track.controls.settings.deleteTrack': '删除轨道',
'toolbar.keySignatureChooser': '选择调号,当前为 {value}', 'toolbar.keySignatureChooser': '选择调号,当前为 {value}',
'toolbar.export.label': '导出', 'toolbar.export.label': '导出',
+1 -1
View File
@@ -535,7 +535,7 @@ export const zhHkMessages: TranslationMessages = {
'track.controls.automationDropdown': '自動化', 'track.controls.automationDropdown': '自動化',
'track.controls.automation.volume': '音量', 'track.controls.automation.volume': '音量',
'track.controls.automation.pan': '聲像', 'track.controls.automation.pan': '聲像',
'track.controls.settingsDropdown': '設定', 'track.controls.moreActions': '更多操作',
'track.controls.settings.deleteTrack': '刪除音軌', 'track.controls.settings.deleteTrack': '刪除音軌',
'toolbar.keySignatureChooser': '選擇調號,當前為 {value}', 'toolbar.keySignatureChooser': '選擇調號,當前為 {value}',
'toolbar.export.label': '匯出', 'toolbar.export.label': '匯出',
+140
View File
@@ -2,6 +2,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { act } from '@testing-library/react'; import { act } from '@testing-library/react';
import { KGTrack } from '../core/track/KGTrack'; import { KGTrack } from '../core/track/KGTrack';
import { KGMidiTrack } from '../core/track/KGMidiTrack'; import { KGMidiTrack } from '../core/track/KGMidiTrack';
import { KGAudioTrack } from '../core/track/KGAudioTrack';
import { KGAudioRegion } from '../core/region/KGAudioRegion';
import { createDefaultGlobalTracks } from '../core/global-track'; import { createDefaultGlobalTracks } from '../core/global-track';
const pianoRollStateMocks = vi.hoisted(() => ({ const pianoRollStateMocks = vi.hoisted(() => ({
@@ -9,6 +11,25 @@ const pianoRollStateMocks = vi.hoisted(() => ({
setPianoRollZoom: vi.fn(), setPianoRollZoom: vi.fn(),
})); }));
const audioStorageMocks = vi.hoisted(() => ({
loadAudioFile: vi.fn(),
}));
const toneMocks = vi.hoisted(() => {
const decodeAudioData = vi.fn();
const toneBufferSet = vi.fn();
class MockToneAudioBuffer {
public set = toneBufferSet;
}
return {
decodeAudioData,
toneBufferSet,
ToneAudioBuffer: MockToneAudioBuffer,
};
});
let mockTracks: KGTrack[] = [new KGMidiTrack('Track 1', 0, 'acoustic_grand_piano')]; let mockTracks: KGTrack[] = [new KGMidiTrack('Track 1', 0, 'acoustic_grand_piano')];
let mockIsMetronomeEnabled = false; let mockIsMetronomeEnabled = false;
let mockShowGlobalTracks = false; let mockShowGlobalTracks = false;
@@ -44,6 +65,9 @@ const mockAudioInterface = {
removeTrackSynth: vi.fn(), removeTrackSynth: vi.fn(),
removeTrackAudioPlayerBus: vi.fn(), removeTrackAudioPlayerBus: vi.fn(),
createTrackAudioPlayerBus: vi.fn().mockResolvedValue(undefined), createTrackAudioPlayerBus: vi.fn().mockResolvedValue(undefined),
hasTrackAudioPlayerBus: vi.fn().mockReturnValue(true),
hasAudioBufferForTrack: vi.fn().mockReturnValue(false),
getAudioBuffer: vi.fn(),
loadAudioBufferForTrack: vi.fn(), loadAudioBufferForTrack: vi.fn(),
createTrackSynth: vi.fn(), createTrackSynth: vi.fn(),
setTrackVolume: vi.fn(), setTrackVolume: vi.fn(),
@@ -96,6 +120,21 @@ vi.mock('../core/audio-interface/KGAudioInterface', () => ({
}, },
})); }));
vi.mock('../core/io/KGAudioFileStorage', () => ({
KGAudioFileStorage: {
loadAudioFile: audioStorageMocks.loadAudioFile,
},
}));
vi.mock('tone', () => ({
getContext: () => ({
rawContext: {
decodeAudioData: toneMocks.decodeAudioData,
},
}),
ToneAudioBuffer: toneMocks.ToneAudioBuffer,
}));
vi.mock('../core/config/ConfigManager', () => ({ vi.mock('../core/config/ConfigManager', () => ({
ConfigManager: { ConfigManager: {
instance: () => ({ instance: () => ({
@@ -139,7 +178,18 @@ describe('projectStore piano roll state', () => {
mockAudioInterface.cancelAudioRecording.mockResolvedValue(undefined); mockAudioInterface.cancelAudioRecording.mockResolvedValue(undefined);
mockAudioInterface.getTransportPosition.mockReset(); mockAudioInterface.getTransportPosition.mockReset();
mockAudioInterface.getTransportPosition.mockReturnValue(8); mockAudioInterface.getTransportPosition.mockReturnValue(8);
mockAudioInterface.hasTrackAudioPlayerBus.mockReset();
mockAudioInterface.hasTrackAudioPlayerBus.mockReturnValue(true);
mockAudioInterface.hasAudioBufferForTrack.mockReset();
mockAudioInterface.hasAudioBufferForTrack.mockReturnValue(false);
mockAudioInterface.getAudioBuffer.mockReset();
mockAudioInterface.createTrackAudioPlayerBus.mockReset();
mockAudioInterface.createTrackAudioPlayerBus.mockResolvedValue(undefined);
mockAudioInterface.loadAudioBufferForTrack.mockReset();
mockAudioInterface.setMetronomeEnabled.mockReset(); mockAudioInterface.setMetronomeEnabled.mockReset();
audioStorageMocks.loadAudioFile.mockReset();
toneMocks.decodeAudioData.mockReset();
toneMocks.toneBufferSet.mockReset();
mockIsMetronomeEnabled = false; mockIsMetronomeEnabled = false;
mockShowGlobalTracks = false; mockShowGlobalTracks = false;
mockProject.setIsMetronomeEnabled.mockClear(); mockProject.setIsMetronomeEnabled.mockClear();
@@ -376,6 +426,96 @@ describe('projectStore piano roll state', () => {
expect(useProjectStore.getState().trackAutomationRedrawVersion).toBe(initialVersion + 2); expect(useProjectStore.getState().trackAutomationRedrawVersion).toBe(initialVersion + 2);
}); });
it('rehydrates missing audio buffers during refreshProjectState', async () => {
const audioTrack = new KGAudioTrack('Audio 1', 1);
audioTrack.setTrackIndex(0);
audioTrack.setRegions([
new KGAudioRegion('audio-region-1', '1', 0, 'clip.wav', 0, 4, 'audio-file-1.wav', 'clip.wav', 2.5),
]);
mockTracks = [audioTrack];
const decodedBuffer = { duration: 2.5 } as AudioBuffer;
audioStorageMocks.loadAudioFile.mockResolvedValue(new ArrayBuffer(16));
toneMocks.decodeAudioData.mockResolvedValue(decodedBuffer);
const { useProjectStore } = await import('./projectStore');
await act(async () => {
useProjectStore.getState().refreshProjectState();
await Promise.resolve();
await Promise.resolve();
});
expect(audioStorageMocks.loadAudioFile).toHaveBeenCalledWith('Test Project', 'audio-file-1.wav');
expect(toneMocks.decodeAudioData).toHaveBeenCalled();
expect(mockAudioInterface.loadAudioBufferForTrack).toHaveBeenCalledWith(
'1',
'audio-file-1.wav',
expect.any(toneMocks.ToneAudioBuffer),
);
});
it('reloads a restored audio track buffer on undo', async () => {
const restoredTrack = new KGAudioTrack('Audio 1', 1);
restoredTrack.setTrackIndex(0);
const restoredRegion = new KGAudioRegion(
'audio-region-1',
'1',
0,
'clip.wav',
0,
4,
'audio-file-1.wav',
'clip.wav',
2.5,
);
restoredTrack.setRegions([restoredRegion]);
mockTracks = [];
mockCore.undo.mockImplementationOnce(() => {
mockTracks = [restoredTrack];
return true;
});
const decodedBuffer = { duration: 2.5 } as AudioBuffer;
audioStorageMocks.loadAudioFile.mockResolvedValue(new ArrayBuffer(16));
toneMocks.decodeAudioData.mockResolvedValue(decodedBuffer);
const { useProjectStore } = await import('./projectStore');
const initialWaveformVersion = useProjectStore.getState().audioWaveformRedrawVersion;
await act(async () => {
useProjectStore.getState().undo();
await Promise.resolve();
await Promise.resolve();
});
const state = useProjectStore.getState();
expect(state.tracks).toHaveLength(1);
const restoredTrackState = state.tracks[0] as KGAudioTrack;
expect((restoredTrackState.getRegions()[0] as KGAudioRegion).getAudioFileId()).toBe('audio-file-1.wav');
expect(mockAudioInterface.loadAudioBufferForTrack).toHaveBeenCalledWith(
'1',
'audio-file-1.wav',
expect.any(toneMocks.ToneAudioBuffer),
);
expect(state.audioWaveformRedrawVersion).toBeGreaterThan(initialWaveformVersion);
});
it('does not attempt audio buffer hydration for MIDI-only undo', async () => {
mockTracks = [new KGMidiTrack('Track 1', 1, 'acoustic_grand_piano')];
const { useProjectStore } = await import('./projectStore');
await act(async () => {
useProjectStore.getState().undo();
await Promise.resolve();
});
expect(audioStorageMocks.loadAudioFile).not.toHaveBeenCalled();
expect(mockAudioInterface.loadAudioBufferForTrack).not.toHaveBeenCalled();
});
it('restores Chat after closing Settings when Chat was active on entry', async () => { it('restores Chat after closing Settings when Chat was active on entry', async () => {
const { useProjectStore } = await import('./projectStore'); const { useProjectStore } = await import('./projectStore');
+74 -23
View File
@@ -317,6 +317,66 @@ function getAudioRecordingExtension(mimeType: string): string {
return 'webm'; return 'webm';
} }
const pendingAudioBufferHydrations = new Set<string>();
async function decodeStoredAudioFile(arrayBuffer: ArrayBuffer): Promise<Tone.ToneAudioBuffer> {
const audioContext = Tone.getContext().rawContext as AudioContext;
const decoded = await audioContext.decodeAudioData(arrayBuffer);
const toneBuffer = new Tone.ToneAudioBuffer();
toneBuffer.set(decoded);
return toneBuffer;
}
async function hydrateAudioTrackBuffers(project: KGProject): Promise<boolean> {
const audioInterface = KGAudioInterface.instance();
const projectName = project.getName();
let hydratedAnyBuffer = false;
for (const track of project.getTracks()) {
if (track.getCurrentType() !== 'KGAudioTrack') {
continue;
}
const audioTrack = track as KGAudioTrack;
const trackId = audioTrack.getId().toString();
if (!audioInterface.hasTrackAudioPlayerBus(trackId)) {
await audioInterface.createTrackAudioPlayerBus(trackId, audioTrack.getVolume());
}
for (const region of audioTrack.getRegions()) {
if (region.getCurrentType() !== 'KGAudioRegion') {
continue;
}
const audioRegion = region as KGAudioRegion;
const audioFileId = audioRegion.getAudioFileId();
if (!audioFileId || audioInterface.hasAudioBufferForTrack(trackId, audioFileId)) {
continue;
}
const hydrationKey = `${projectName}:${trackId}:${audioFileId}`;
if (pendingAudioBufferHydrations.has(hydrationKey)) {
continue;
}
pendingAudioBufferHydrations.add(hydrationKey);
try {
const arrayBuffer = await KGAudioFileStorage.loadAudioFile(projectName, audioFileId);
const toneBuffer = await decodeStoredAudioFile(arrayBuffer);
audioInterface.loadAudioBufferForTrack(trackId, audioFileId, toneBuffer);
hydratedAnyBuffer = true;
} catch (err) {
console.error(`Failed to load audio file ${audioFileId}:`, err);
} finally {
pendingAudioBufferHydrations.delete(hydrationKey);
}
}
}
return hydratedAnyBuffer;
}
// Create the store // Create the store
export const useProjectStore = create<ProjectState>((set, get) => { export const useProjectStore = create<ProjectState>((set, get) => {
const currentProject = KGCore.instance().getCurrentProject(); const currentProject = KGCore.instance().getCurrentProject();
@@ -887,34 +947,12 @@ export const useProjectStore = create<ProjectState>((set, get) => {
}); });
// Create synths/buses for all tracks (with their stored volumes) // Create synths/buses for all tracks (with their stored volumes)
const projectName = projectToLoad.getName();
for (const track of tracks) { for (const track of tracks) {
const trackId = track.getId().toString(); const trackId = track.getId().toString();
if (track.getCurrentType() === 'KGAudioTrack') { if (track.getCurrentType() === 'KGAudioTrack') {
// Audio track: create player bus and load audio buffers // Audio track: create player bus; buffers are hydrated in a shared pass below
await audioInterface.createTrackAudioPlayerBus(trackId, track.getVolume()); await audioInterface.createTrackAudioPlayerBus(trackId, track.getVolume());
// Load audio buffers for all regions in this audio track
const audioTrack = track as KGAudioTrack;
for (const region of audioTrack.getRegions()) {
if (region.getCurrentType() === 'KGAudioRegion') {
const audioRegion = region as KGAudioRegion;
const audioFileId = audioRegion.getAudioFileId();
if (audioFileId) {
try {
const arrayBuffer = await KGAudioFileStorage.loadAudioFile(projectName, audioFileId);
const audioContext = Tone.getContext().rawContext as AudioContext;
const decoded = await audioContext.decodeAudioData(arrayBuffer);
const toneBuffer = new Tone.ToneAudioBuffer();
toneBuffer.set(decoded);
audioInterface.loadAudioBufferForTrack(trackId, audioFileId, toneBuffer);
} catch (err) {
console.error(`Failed to load audio file ${audioFileId}:`, err);
}
}
}
}
} else { } else {
// MIDI track: create sampler-based audio bus // MIDI track: create sampler-based audio bus
let instrument: InstrumentType = 'acoustic_grand_piano'; let instrument: InstrumentType = 'acoustic_grand_piano';
@@ -926,6 +964,8 @@ export const useProjectStore = create<ProjectState>((set, get) => {
} }
} }
await hydrateAudioTrackBuffers(projectToLoad);
// Reapply restored mute/solo state after all buses exist so solo logic can be // Reapply restored mute/solo state after all buses exist so solo logic can be
// computed against the full track set. // computed against the full track set.
for (const track of tracks) { for (const track of tracks) {
@@ -1926,6 +1966,17 @@ export const useProjectStore = create<ProjectState>((set, get) => {
const actions = get(); const actions = get();
actions.syncUndoRedoState(); actions.syncUndoRedoState();
actions.syncSelectionFromCore(); actions.syncSelectionFromCore();
void hydrateAudioTrackBuffers(project).then((hydratedAnyBuffer) => {
if (!hydratedAnyBuffer) {
return;
}
set(state => ({
tracks: [...project.getTracks()] as KGTrack[],
audioWaveformRedrawVersion: state.audioWaveformRedrawVersion + 1,
}));
});
}, },
// Initialize store with configuration values // Initialize store with configuration values