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:
Generated
+2
-2
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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.');
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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.');
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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': '导出',
|
||||||
|
|||||||
@@ -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': '匯出',
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user