feat: added global key signature track, global chord progression track, global tempo(BPM) track, and global marker track related tools for AI Agent

This commit is contained in:
Xiaohan-Tian
2026-06-05 16:50:51 -07:00
parent 622dbbea83
commit c4f5d7b78e
29 changed files with 3277 additions and 8 deletions
+85
View File
@@ -0,0 +1,85 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ReadBpmTool } from './ReadBpmTool';
import { KGProject } from '../../core/KGProject';
import { KGCore } from '../../core/KGCore';
import { GlobalTrackType } from '../../core/global-track';
import { KGTempoRegion } from '../../core/region/KGTempoRegion';
import { findGlobalTrackByType } from '../../util/globalTrackUtil';
describe('ReadBpmTool', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('is only available in regular mode', () => {
const tool = new ReadBpmTool();
expect(tool.isAvailableInEfficientMode()).toBe(false);
});
it('reads ordered BPM regions from the global tempo track', async () => {
const project = new KGProject('read-bpm', 8, 0, 120, { numerator: 4, denominator: 4 });
const track = findGlobalTrackByType(project, GlobalTrackType.Tempo);
expect(track).not.toBeNull();
track!.setRegions([
new KGTempoRegion('region-2', track!.getId(), track!.getTrackIndex(), 140, 4, 4, 4),
new KGTempoRegion('region-1', track!.getId(), track!.getTrackIndex(), 120, 0, 4, 4),
]);
vi.spyOn(KGCore, 'instance').mockReturnValue({
getCurrentProject: () => project,
getSelectedItems: () => [],
} as unknown as KGCore);
const tool = new ReadBpmTool();
const result = await tool.execute({});
expect(result.success).toBe(true);
expect(result.result).toBe('[Beat: 0]: 120 BPM\n[Beat: 16]: 140 BPM');
});
it('preserves line breaks in UI and history display content', () => {
const tool = new ReadBpmTool();
const formatted = '[Beat: 0]: 120 BPM \n[Beat: 16]: 140 BPM';
const raw = '[Beat: 0]: 120 BPM\n[Beat: 16]: 140 BPM';
expect(tool.buildToolResultDisplayContent(null, {
success: true,
result: raw,
})).toBe(formatted);
expect(tool.buildToolHistoryContent(null, {
success: true,
result: raw,
})).toBe(raw);
});
it('falls back to the project-level BPM at beat 0 when no regions exist', async () => {
const project = new KGProject('fallback-bpm', 8, 0, 132, { numerator: 4, denominator: 4 });
vi.spyOn(KGCore, 'instance').mockReturnValue({
getCurrentProject: () => project,
getSelectedItems: () => [],
} as unknown as KGCore);
const tool = new ReadBpmTool();
const result = await tool.execute({});
expect(result.success).toBe(true);
expect(result.result).toBe('[Beat: 0]: 132 BPM');
});
it('returns a clean failure when the tempo track is missing', async () => {
const project = new KGProject('missing-tempo-track', 8, 0, 120, { numerator: 4, denominator: 4 });
project.setGlobalTracks([]);
vi.spyOn(KGCore, 'instance').mockReturnValue({
getCurrentProject: () => project,
getSelectedItems: () => [],
} as unknown as KGCore);
const tool = new ReadBpmTool();
const result = await tool.execute({});
expect(result.success).toBe(false);
expect(result.result).toContain('Tempo global track not found');
});
});
+53
View File
@@ -0,0 +1,53 @@
import { BaseTool } from './BaseTool';
import type { ToolParameter, ToolResult } from './BaseTool';
import { GlobalTrackType } from '../../core/global-track';
import { KGTempoRegion } from '../../core/region/KGTempoRegion';
import { findGlobalTrackByType, getSortedTempoRegions } from '../../util/globalTrackUtil';
export class ReadBpmTool extends BaseTool {
readonly name = 'read_bpm';
readonly description = 'Read the BPM changes from the global Tempo track. If no tempo regions exist, fall back to the project-level BPM and return it at beat 0.';
readonly parameters: Record<string, ToolParameter> = {};
override isAvailableInEfficientMode(): boolean {
return false;
}
override buildToolResultDisplayContent(_args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
return this.formatMultilineResult(toolResult.result);
}
override buildToolHistoryContent(_args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
return toolResult.result;
}
async execute(_params: Record<string, unknown>): Promise<ToolResult> {
try {
const project = this.getCurrentProject();
const track = findGlobalTrackByType(project, GlobalTrackType.Tempo);
if (!track) {
return this.createErrorResult('Tempo global track not found');
}
const beatsPerBar = project.getTimeSignature().numerator;
const regions = getSortedTempoRegions(track, beatsPerBar)
.filter((region): region is KGTempoRegion => region instanceof KGTempoRegion);
if (regions.length === 0) {
return this.createSuccessResult(`[Beat: 0]: ${project.getBpm()} BPM`);
}
const result = regions
.map(region => `[Beat: ${region.getStartFromBeat()}]: ${region.getBpm()} BPM`)
.join('\n');
return this.createSuccessResult(result);
} catch (error) {
return this.createErrorResult(`Failed to read BPM: ${error}`);
}
}
private formatMultilineResult(result: string): string {
return result.replace(/\n/g, ' \n');
}
}
@@ -0,0 +1,85 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ReadKeySignatureTool } from './ReadKeySignatureTool';
import { KGProject } from '../../core/KGProject';
import { KGCore } from '../../core/KGCore';
import { GlobalTrackType } from '../../core/global-track';
import { KGKeySignatureRegion } from '../../core/region/KGKeySignatureRegion';
import { findGlobalTrackByType } from '../../util/globalTrackUtil';
describe('ReadKeySignatureTool', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('is only available in regular mode', () => {
const tool = new ReadKeySignatureTool();
expect(tool.isAvailableInEfficientMode()).toBe(false);
});
it('reads ordered key-signature regions from the global signature track', async () => {
const project = new KGProject('read-signatures', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
const track = findGlobalTrackByType(project, GlobalTrackType.Signature);
expect(track).not.toBeNull();
track!.setRegions([
new KGKeySignatureRegion('region-2', track!.getId(), track!.getTrackIndex(), 'D major', 4, 4, 4),
new KGKeySignatureRegion('region-1', track!.getId(), track!.getTrackIndex(), 'G major', 0, 4, 4),
]);
vi.spyOn(KGCore, 'instance').mockReturnValue({
getCurrentProject: () => project,
getSelectedItems: () => [],
} as unknown as KGCore);
const tool = new ReadKeySignatureTool();
const result = await tool.execute({});
expect(result.success).toBe(true);
expect(result.result).toBe('[Beat: 0]: G major\n[Beat: 16]: D major');
});
it('preserves line breaks in UI and history display content', () => {
const tool = new ReadKeySignatureTool();
const formatted = '[Beat: 0]: G major \n[Beat: 16]: D major';
const raw = '[Beat: 0]: G major\n[Beat: 16]: D major';
expect(tool.buildToolResultDisplayContent(null, {
success: true,
result: raw,
})).toBe(formatted);
expect(tool.buildToolHistoryContent(null, {
success: true,
result: raw,
})).toBe(raw);
});
it('falls back to the project-level key signature at beat 0 when no regions exist', async () => {
const project = new KGProject('fallback-signatures', 8, 0, 120, { numerator: 4, denominator: 4 }, 'E minor');
vi.spyOn(KGCore, 'instance').mockReturnValue({
getCurrentProject: () => project,
getSelectedItems: () => [],
} as unknown as KGCore);
const tool = new ReadKeySignatureTool();
const result = await tool.execute({});
expect(result.success).toBe(true);
expect(result.result).toBe('[Beat: 0]: E minor');
});
it('returns a clean failure when the signature track is missing', async () => {
const project = new KGProject('missing-signature-track', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
project.setGlobalTracks([]);
vi.spyOn(KGCore, 'instance').mockReturnValue({
getCurrentProject: () => project,
getSelectedItems: () => [],
} as unknown as KGCore);
const tool = new ReadKeySignatureTool();
const result = await tool.execute({});
expect(result.success).toBe(false);
expect(result.result).toContain('Signature global track not found');
});
});
+53
View File
@@ -0,0 +1,53 @@
import { BaseTool } from './BaseTool';
import type { ToolParameter, ToolResult } from './BaseTool';
import { GlobalTrackType } from '../../core/global-track';
import { KGKeySignatureRegion } from '../../core/region/KGKeySignatureRegion';
import { findGlobalTrackByType, getSortedKeySignatureRegions } from '../../util/globalTrackUtil';
export class ReadKeySignatureTool extends BaseTool {
readonly name = 'read_key_signature';
readonly description = 'Read the key-signature changes from the global Signature track. If no key-signature regions exist, fall back to the project-level key signature and return it at beat 0.';
readonly parameters: Record<string, ToolParameter> = {};
override isAvailableInEfficientMode(): boolean {
return false;
}
override buildToolResultDisplayContent(_args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
return this.formatMultilineResult(toolResult.result);
}
override buildToolHistoryContent(_args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
return toolResult.result;
}
async execute(_params: Record<string, unknown>): Promise<ToolResult> {
try {
const project = this.getCurrentProject();
const track = findGlobalTrackByType(project, GlobalTrackType.Signature);
if (!track) {
return this.createErrorResult('Signature global track not found');
}
const beatsPerBar = project.getTimeSignature().numerator;
const regions = getSortedKeySignatureRegions(track, beatsPerBar)
.filter((region): region is KGKeySignatureRegion => region instanceof KGKeySignatureRegion);
if (regions.length === 0) {
return this.createSuccessResult(`[Beat: 0]: ${project.getKeySignature()}`);
}
const result = regions
.map(region => `[Beat: ${region.getStartFromBeat()}]: ${region.getKeySignature()}`)
.join('\n');
return this.createSuccessResult(result);
} catch (error) {
return this.createErrorResult(`Failed to read key signature: ${error}`);
}
}
private formatMultilineResult(result: string): string {
return result.replace(/\n/g, ' \n');
}
}
+69
View File
@@ -0,0 +1,69 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ReadMarkersTool } from './ReadMarkersTool';
import { KGProject } from '../../core/KGProject';
import { KGCore } from '../../core/KGCore';
import { GlobalTrackType } from '../../core/global-track';
import { KGMarkerRegion } from '../../core/region/KGMarkerRegion';
import { findGlobalTrackByType } from '../../util/globalTrackUtil';
describe('ReadMarkersTool', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('is only available in regular mode', () => {
const tool = new ReadMarkersTool();
expect(tool.isAvailableInEfficientMode()).toBe(false);
});
it('reads ordered marker regions from the global marker track', async () => {
const project = new KGProject('read-markers', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
const track = findGlobalTrackByType(project, GlobalTrackType.Marker);
expect(track).not.toBeNull();
track!.setRegions([
new KGMarkerRegion('region-2', track!.getId(), track!.getTrackIndex(), 'Verse', 8, 4),
new KGMarkerRegion('region-1', track!.getId(), track!.getTrackIndex(), 'Intro', 0, 8),
]);
vi.spyOn(KGCore, 'instance').mockReturnValue({
getCurrentProject: () => project,
getSelectedItems: () => [],
} as unknown as KGCore);
const tool = new ReadMarkersTool();
const result = await tool.execute({});
expect(result.success).toBe(true);
expect(result.result).toBe('[Beat: 0; Length: 8]: Intro\n[Beat: 8; Length: 4]: Verse');
});
it('preserves line breaks in UI and history display content', () => {
const tool = new ReadMarkersTool();
const formatted = '[Beat: 0; Length: 8]: Intro \n[Beat: 8; Length: 4]: Verse';
const raw = '[Beat: 0; Length: 8]: Intro\n[Beat: 8; Length: 4]: Verse';
expect(tool.buildToolResultDisplayContent(null, {
success: true,
result: raw,
})).toBe(formatted);
expect(tool.buildToolHistoryContent(null, {
success: true,
result: raw,
})).toBe(raw);
});
it('returns an absence message when no marker regions exist', async () => {
const project = new KGProject('empty-markers', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
vi.spyOn(KGCore, 'instance').mockReturnValue({
getCurrentProject: () => project,
getSelectedItems: () => [],
} as unknown as KGCore);
const tool = new ReadMarkersTool();
const result = await tool.execute({});
expect(result.success).toBe(true);
expect(result.result).toBe('No marker regions found on the global Marker track.');
});
});
+53
View File
@@ -0,0 +1,53 @@
import { BaseTool } from './BaseTool';
import type { ToolParameter, ToolResult } from './BaseTool';
import { GlobalTrackType } from '../../core/global-track';
import { KGMarkerRegion } from '../../core/region/KGMarkerRegion';
import { findGlobalTrackByType } from '../../util/globalTrackUtil';
export class ReadMarkersTool extends BaseTool {
readonly name = 'read_markers';
readonly description = 'Read marker annotations from the global Marker track. Marker regions are timeline annotations only and do not affect playback.';
readonly parameters: Record<string, ToolParameter> = {};
override isAvailableInEfficientMode(): boolean {
return false;
}
override buildToolResultDisplayContent(_args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
return this.formatMultilineResult(toolResult.result);
}
override buildToolHistoryContent(_args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
return toolResult.result;
}
async execute(_params: Record<string, unknown>): Promise<ToolResult> {
try {
const project = this.getCurrentProject();
const track = findGlobalTrackByType(project, GlobalTrackType.Marker);
if (!track) {
return this.createErrorResult('Marker global track not found');
}
const regions = track.getRegions()
.filter((region): region is KGMarkerRegion => region instanceof KGMarkerRegion)
.sort((left, right) => left.getStartFromBeat() - right.getStartFromBeat());
if (regions.length === 0) {
return this.createSuccessResult('No marker regions found on the global Marker track.');
}
const result = regions
.map(region => `[Beat: ${region.getStartFromBeat()}; Length: ${region.getLength()}]: ${region.getName()}`)
.join('\n');
return this.createSuccessResult(result);
} catch (error) {
return this.createErrorResult(`Failed to read markers: ${error}`);
}
}
private formatMultilineResult(result: string): string {
return result.replace(/\n/g, ' \n');
}
}
+139
View File
@@ -0,0 +1,139 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { RemoveBpmTool } from './RemoveBpmTool';
import { KGProject } from '../../core/KGProject';
import { KGCore } from '../../core/KGCore';
import { KGTempoRegion } from '../../core/region/KGTempoRegion';
import { findGlobalTrackByType } from '../../util/globalTrackUtil';
import { GlobalTrackType } from '../../core/global-track';
function mockCore(project: KGProject) {
vi.spyOn(KGCore, 'instance').mockReturnValue({
getCurrentProject: () => project,
getSelectedItems: () => [],
executeCommand: (command: { execute(): void }) => command.execute(),
removeSelectedItem: vi.fn(),
} as unknown as KGCore);
}
function getTempoTrack(project: KGProject) {
const track = findGlobalTrackByType(project, GlobalTrackType.Tempo);
expect(track).not.toBeNull();
return track!;
}
describe('RemoveBpmTool', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('is only available in regular mode', () => {
const tool = new RemoveBpmTool();
expect(tool.isReadOnlyTool()).toBe(false);
expect(tool.isAvailableInEfficientMode()).toBe(false);
});
it('removes a single exact start-beat match when start equals end', async () => {
const project = new KGProject('exact-remove-project', 8, 0, 120, { numerator: 4, denominator: 4 });
const track = getTempoTrack(project);
track.setRegions([
new KGTempoRegion('tempo-1', track.getId(), track.getTrackIndex(), 120, 0, 2, 4),
new KGTempoRegion('tempo-2', track.getId(), track.getTrackIndex(), 140, 2, 6, 4),
]);
mockCore(project);
const tool = new RemoveBpmTool();
const result = await tool.execute({ start: 8, end: 8 });
expect(result.success).toBe(true);
expect((track.getRegions() as KGTempoRegion[]).map(region => ({
bpm: region.getBpm(),
startBar: region.getStartBar(),
lengthBars: region.getLengthBars(),
}))).toEqual([
{ bpm: 120, startBar: 0, lengthBars: 8 },
]);
expect(tool.buildToolHistoryContent({}, result)).toBe(result.result);
expect(tool.buildToolResultDisplayContent({}, result)).toBe(result.result);
});
it('removes multiple BPM regions and preserves gapless collapse', async () => {
const project = new KGProject('range-remove-project', 8, 0, 120, { numerator: 4, denominator: 4 });
const track = getTempoTrack(project);
track.setRegions([
new KGTempoRegion('tempo-1', track.getId(), track.getTrackIndex(), 120, 0, 2, 4),
new KGTempoRegion('tempo-2', track.getId(), track.getTrackIndex(), 128, 2, 2, 4),
new KGTempoRegion('tempo-3', track.getId(), track.getTrackIndex(), 140, 4, 4, 4),
]);
mockCore(project);
const tool = new RemoveBpmTool();
const result = await tool.execute({ start: 8, end: 20 });
expect(result.success).toBe(true);
expect((track.getRegions() as KGTempoRegion[]).map(region => ({
bpm: region.getBpm(),
startBar: region.getStartBar(),
lengthBars: region.getLengthBars(),
}))).toEqual([
{ bpm: 120, startBar: 0, lengthBars: 8 },
]);
expect(result.result).toContain('"128 BPM" at beat 8');
expect(result.result).toContain('"140 BPM" at beat 16');
});
it('returns a successful message when no BPM regions match the range', async () => {
const project = new KGProject('none-remove-project', 8, 0, 120, { numerator: 4, denominator: 4 });
const track = getTempoTrack(project);
track.setRegions([
new KGTempoRegion('tempo-1', track.getId(), track.getTrackIndex(), 120, 0, 8, 4),
]);
mockCore(project);
const tool = new RemoveBpmTool();
const result = await tool.execute({ start: 12, end: 16 });
expect(result.success).toBe(true);
expect(result.result).toContain('No BPM regions found');
});
it('deletes the only remaining explicit BPM region', async () => {
const project = new KGProject('single-region-project', 8, 0, 120, { numerator: 4, denominator: 4 });
const track = getTempoTrack(project);
track.setRegions([
new KGTempoRegion('tempo-1', track.getId(), track.getTrackIndex(), 132, 0, 8, 4),
]);
mockCore(project);
const tool = new RemoveBpmTool();
const result = await tool.execute({ start: 0, end: 0 });
expect(result.success).toBe(true);
expect(track.getRegions()).toHaveLength(0);
expect(project.getBpm()).toBe(120);
});
it('builds a confirmation summary for the affected bar span', () => {
const project = new KGProject('confirmation-project', 8, 0, 120, { numerator: 4, denominator: 4 });
const track = getTempoTrack(project);
track.setRegions([
new KGTempoRegion('tempo-1', track.getId(), track.getTrackIndex(), 128, 2, 2, 4),
new KGTempoRegion('tempo-2', track.getId(), track.getTrackIndex(), 140, 4, 4, 4),
]);
mockCore(project);
const tool = new RemoveBpmTool();
expect(tool.buildConfirmationContent({ start: 8, end: 20 }))
.toBe('Allow removing 2 BPM regions from the global Tempo Track across bars 3 to 8?');
});
it('rejects invalid beat ranges', async () => {
const project = new KGProject('bad-range-project', 8, 0, 120, { numerator: 4, denominator: 4 });
mockCore(project);
const tool = new RemoveBpmTool();
const result = await tool.execute({ start: 8, end: 4 });
expect(result.success).toBe(false);
expect(result.result).toContain('must be greater than or equal to start');
});
});
+152
View File
@@ -0,0 +1,152 @@
import { BaseTool } from './BaseTool';
import type { ToolParameter, ToolResult } from './BaseTool';
import { DeleteMultipleTempoRegionsCommand } from '../../core/commands/global-region/DeleteTempoRegionCommand';
import { GlobalTrackType } from '../../core/global-track';
import { KGTempoRegion } from '../../core/region/KGTempoRegion';
import { findGlobalTrackByType, getSortedTempoRegions } from '../../util/globalTrackUtil';
interface BpmRemovalSummaryData {
regionCount: number;
startBeat: number;
endBeat: number;
firstBar: number;
lastBar: number;
}
export class RemoveBpmTool extends BaseTool {
readonly name = 'remove_bpm';
readonly description = 'Remove BPM regions from the global Tempo track by absolute start-beat range. This removes whole tempo regions whose start beat falls within the requested range.';
override isReadOnlyTool(): boolean {
return false;
}
override isAvailableInEfficientMode(): boolean {
return false;
}
readonly parameters: Record<string, ToolParameter> = {
start: {
type: 'number',
description: 'Start beat — the absolute beat position where the removal range begins. When start is less than end, regions starting exactly at this beat are removed.',
required: true,
},
end: {
type: 'number',
description: 'End beat — the absolute beat position where the removal range ends. When start is less than end, this value is exclusive. When start equals end, only the region starting exactly at that beat is removed.',
required: true,
},
};
override buildToolResultDisplayContent(_args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
return toolResult.result;
}
override buildToolHistoryContent(_args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
return toolResult.result;
}
override buildConfirmationContent(args: Record<string, unknown> | null): string | undefined {
if (!args) {
return undefined;
}
const summary = this.buildSummaryData(args);
if (!summary) {
return undefined;
}
return `Allow removing ${summary.regionCount} BPM ${summary.regionCount === 1 ? 'region' : 'regions'} from the global Tempo Track across ${summary.firstBar === summary.lastBar ? `bar ${summary.firstBar}` : `bars ${summary.firstBar} to ${summary.lastBar}`}?`;
}
async execute(params: Record<string, unknown>): Promise<ToolResult> {
try {
this.validateParameters(params);
const startBeat = params.start as number;
const endBeat = params.end as number;
this.validateRange(startBeat, endBeat);
const matchingRegions = this.findMatchingRegions(startBeat, endBeat);
if (matchingRegions.length === 0) {
return this.createSuccessResult(
`No BPM regions found with start beats in the requested range from beat ${startBeat} to ${endBeat}.`,
);
}
await this.executeCommand(new DeleteMultipleTempoRegionsCommand(matchingRegions.map(region => region.getId())));
const details = matchingRegions
.map(region => `"${region.getBpm()} BPM" at beat ${region.getStartFromBeat()}`)
.join(', ');
return this.createSuccessResult(
`Successfully removed ${matchingRegions.length} BPM ${matchingRegions.length === 1 ? 'region' : 'regions'} from the global Tempo track: ${details}.`,
);
} catch (error) {
return this.createErrorResult(`Failed to remove BPM: ${error}`);
}
}
private validateRange(startBeat: number, endBeat: number): void {
if (startBeat < 0) {
throw new Error(`Invalid start ${startBeat}. Must be >= 0.`);
}
if (endBeat < startBeat) {
throw new Error(`Invalid beat range: end (${endBeat}) must be greater than or equal to start (${startBeat}).`);
}
}
private findMatchingRegions(startBeat: number, endBeat: number): KGTempoRegion[] {
const project = this.getCurrentProject();
const tempoTrack = findGlobalTrackByType(project, GlobalTrackType.Tempo);
if (!tempoTrack) {
return [];
}
const beatsPerBar = project.getTimeSignature().numerator;
return getSortedTempoRegions(tempoTrack, beatsPerBar)
.filter(region => this.matchesRange(region.getStartFromBeat(), startBeat, endBeat));
}
private matchesRange(regionStartBeat: number, startBeat: number, endBeat: number): boolean {
if (startBeat === endBeat) {
return regionStartBeat === startBeat;
}
return regionStartBeat >= startBeat && regionStartBeat < endBeat;
}
private buildSummaryData(args: Record<string, unknown>): BpmRemovalSummaryData | null {
const typedArgs = args as { start?: number; end?: number };
if (typeof typedArgs.start !== 'number' || typeof typedArgs.end !== 'number') {
return null;
}
if (typedArgs.start < 0 || typedArgs.end < typedArgs.start) {
return null;
}
const matchingRegions = this.findMatchingRegions(typedArgs.start, typedArgs.end);
const beatsPerBar = this.getCurrentProject().getTimeSignature().numerator;
if (matchingRegions.length === 0) {
const bar = Math.floor(typedArgs.start / beatsPerBar) + 1;
return {
regionCount: 0,
startBeat: typedArgs.start,
endBeat: typedArgs.end,
firstBar: bar,
lastBar: bar,
};
}
const firstBeat = Math.min(...matchingRegions.map(region => region.getStartFromBeat()));
const lastBeat = Math.max(...matchingRegions.map(region => region.getStartFromBeat() + region.getLength()));
return {
regionCount: matchingRegions.length,
startBeat: typedArgs.start,
endBeat: typedArgs.end,
firstBar: Math.floor(firstBeat / beatsPerBar) + 1,
lastBar: Math.max(1, Math.ceil(lastBeat / beatsPerBar)),
};
}
}
@@ -0,0 +1,113 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { RemoveChordProgressionTool } from './RemoveChordProgressionTool';
import { KGProject } from '../../core/KGProject';
import { KGCore } from '../../core/KGCore';
import { KGChordRegion } from '../../core/region/KGChordRegion';
import { findGlobalTrackByType } from '../../util/globalTrackUtil';
import { GlobalTrackType } from '../../core/global-track';
function mockCore(project: KGProject) {
vi.spyOn(KGCore, 'instance').mockReturnValue({
getCurrentProject: () => project,
getSelectedItems: () => [],
executeCommand: (command: { execute(): void }) => command.execute(),
removeSelectedItem: vi.fn(),
} as unknown as KGCore);
}
function getChordTrack(project: KGProject) {
const track = findGlobalTrackByType(project, GlobalTrackType.Chord);
expect(track).not.toBeNull();
return track!;
}
describe('RemoveChordProgressionTool', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('is only available in regular mode', () => {
const tool = new RemoveChordProgressionTool();
expect(tool.isReadOnlyTool()).toBe(false);
expect(tool.isAvailableInEfficientMode()).toBe(false);
});
it('removes a single exact start-beat match when start equals end', async () => {
const project = new KGProject('exact-remove-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
const chordTrack = getChordTrack(project);
chordTrack.setRegions([
new KGChordRegion('chord-1', chordTrack.getId(), chordTrack.getTrackIndex(), 'C', 0, 4),
new KGChordRegion('chord-2', chordTrack.getId(), chordTrack.getTrackIndex(), 'G', 4, 4),
]);
mockCore(project);
const tool = new RemoveChordProgressionTool();
const result = await tool.execute({ start: 4, end: 4 });
expect(result.success).toBe(true);
expect((chordTrack.getRegions() as KGChordRegion[]).map(region => region.getSymbol())).toEqual(['C']);
expect(result.result).toContain('"G" at beat 4');
});
it('removes multiple chord regions in a start-inclusive, end-exclusive range', async () => {
const project = new KGProject('range-remove-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
const chordTrack = getChordTrack(project);
chordTrack.setRegions([
new KGChordRegion('chord-1', chordTrack.getId(), chordTrack.getTrackIndex(), 'C', 0, 4),
new KGChordRegion('chord-2', chordTrack.getId(), chordTrack.getTrackIndex(), 'Dm', 4, 4),
new KGChordRegion('chord-3', chordTrack.getId(), chordTrack.getTrackIndex(), 'G', 8, 4),
]);
mockCore(project);
const tool = new RemoveChordProgressionTool();
const result = await tool.execute({ start: 4, end: 8 });
expect(result.success).toBe(true);
expect((chordTrack.getRegions() as KGChordRegion[]).map(region => region.getSymbol())).toEqual(['C', 'G']);
expect(tool.buildToolHistoryContent({}, result)).toBe(result.result);
expect(tool.buildToolResultDisplayContent({ start: 4, end: 8 }, result))
.toBe('Removed 1 chord reference from the global Chord Track across bar 2.');
});
it('returns a successful message when no chord references match the range', async () => {
const project = new KGProject('none-remove-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
const chordTrack = getChordTrack(project);
chordTrack.setRegions([
new KGChordRegion('chord-1', chordTrack.getId(), chordTrack.getTrackIndex(), 'C', 0, 4),
]);
mockCore(project);
const tool = new RemoveChordProgressionTool();
const result = await tool.execute({ start: 12, end: 16 });
expect(result.success).toBe(true);
expect(result.result).toContain('No chord references found');
expect(tool.buildToolResultDisplayContent({ start: 12, end: 16 }, result))
.toBe('No chord references found for removal at beats 12-16.');
});
it('builds a confirmation summary for the affected bar span', () => {
const project = new KGProject('confirmation-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
const chordTrack = getChordTrack(project);
chordTrack.setRegions([
new KGChordRegion('chord-1', chordTrack.getId(), chordTrack.getTrackIndex(), 'Dm', 4, 4),
new KGChordRegion('chord-2', chordTrack.getId(), chordTrack.getTrackIndex(), 'G', 8, 4),
]);
mockCore(project);
const tool = new RemoveChordProgressionTool();
expect(tool.buildConfirmationContent({ start: 4, end: 12 }))
.toBe('Allow removing 2 chord references from the global Chord Track across bars 2 to 3?');
});
it('rejects invalid beat ranges', async () => {
const project = new KGProject('bad-range-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
mockCore(project);
const tool = new RemoveChordProgressionTool();
const result = await tool.execute({ start: 8, end: 4 });
expect(result.success).toBe(false);
expect(result.result).toContain('must be greater than or equal to start');
});
});
@@ -0,0 +1,199 @@
import { BaseTool } from './BaseTool';
import type { ToolParameter, ToolResult } from './BaseTool';
import { DeleteMultipleGlobalRegionsCommand } from '../../core/commands/global-region/DeleteGlobalRegionCommand';
import { GlobalTrackType } from '../../core/global-track';
import { KGChordRegion } from '../../core/region/KGChordRegion';
import { findGlobalTrackByType } from '../../util/globalTrackUtil';
interface ChordRemovalSummaryData {
chordCount: number;
startBeat: number;
endBeat: number;
firstBar: number;
lastBar: number;
}
export class RemoveChordProgressionTool extends BaseTool {
readonly name = 'remove_chord_progression';
readonly description = 'Remove chord-reference regions from the global Chord track by absolute start-beat range. This removes whole chord-reference regions whose start beat falls within the requested range.';
override isReadOnlyTool(): boolean {
return false;
}
override isAvailableInEfficientMode(): boolean {
return false;
}
readonly parameters: Record<string, ToolParameter> = {
start: {
type: 'number',
description: 'Start beat — the absolute beat position where the removal range begins. When start is less than end, regions starting exactly at this beat are removed.',
required: true,
},
end: {
type: 'number',
description: 'End beat — the absolute beat position where the removal range ends. When start is less than end, this value is exclusive. When start equals end, only regions starting exactly at that beat are removed.',
required: true,
},
};
override buildToolResultDisplayContent(args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
if (!args || !toolResult.success) {
return undefined;
}
if (toolResult.result.startsWith('No chord references found')) {
const summary = this.buildSummaryData(args);
return summary
? `No chord references found for removal at beats ${summary.startBeat}-${summary.endBeat}.`
: undefined;
}
const summary = this.buildResultSummaryData(args, toolResult.result);
if (!summary) {
return undefined;
}
return `Removed ${summary.chordCount} chord ${summary.chordCount === 1 ? 'reference' : 'references'} from the global Chord Track across ${summary.firstBar === summary.lastBar ? `bar ${summary.firstBar}` : `bars ${summary.firstBar} to ${summary.lastBar}`}.`;
}
override buildToolHistoryContent(_args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
return toolResult.result;
}
override buildConfirmationContent(args: Record<string, unknown> | null): string | undefined {
if (!args) {
return undefined;
}
const summary = this.buildSummaryData(args);
if (!summary) {
return undefined;
}
return `Allow removing ${summary.chordCount} chord ${summary.chordCount === 1 ? 'reference' : 'references'} from the global Chord Track across ${summary.firstBar === summary.lastBar ? `bar ${summary.firstBar}` : `bars ${summary.firstBar} to ${summary.lastBar}`}?`;
}
async execute(params: Record<string, unknown>): Promise<ToolResult> {
try {
this.validateParameters(params);
const startBeat = params.start as number;
const endBeat = params.end as number;
this.validateRange(startBeat, endBeat);
const matchingRegions = this.findMatchingRegions(startBeat, endBeat);
if (matchingRegions.length === 0) {
return this.createSuccessResult(
`No chord references found with start beats in the requested range from beat ${startBeat} to ${endBeat}.`,
);
}
await this.executeCommand(new DeleteMultipleGlobalRegionsCommand(matchingRegions.map(region => region.getId())));
const details = matchingRegions
.map(region => `"${region.getSymbol()}" at beat ${region.getStartFromBeat()}`)
.join(', ');
return this.createSuccessResult(
`Successfully removed ${matchingRegions.length} chord ${matchingRegions.length === 1 ? 'reference' : 'references'} from the global chord track: ${details}.`,
);
} catch (error) {
return this.createErrorResult(`Failed to remove chord progression: ${error}`);
}
}
private validateRange(startBeat: number, endBeat: number): void {
if (startBeat < 0) {
throw new Error(`Invalid start ${startBeat}. Must be >= 0.`);
}
if (endBeat < startBeat) {
throw new Error(`Invalid beat range: end (${endBeat}) must be greater than or equal to start (${startBeat}).`);
}
}
private findMatchingRegions(startBeat: number, endBeat: number): KGChordRegion[] {
const project = this.getCurrentProject();
const chordTrack = findGlobalTrackByType(project, GlobalTrackType.Chord);
if (!chordTrack) {
return [];
}
return chordTrack.getRegions()
.filter((region): region is KGChordRegion => region instanceof KGChordRegion)
.filter(region => this.matchesRange(region.getStartFromBeat(), startBeat, endBeat))
.sort((left, right) => left.getStartFromBeat() - right.getStartFromBeat());
}
private matchesRange(regionStartBeat: number, startBeat: number, endBeat: number): boolean {
if (startBeat === endBeat) {
return regionStartBeat === startBeat;
}
return regionStartBeat >= startBeat && regionStartBeat < endBeat;
}
private buildSummaryData(args: Record<string, unknown>): ChordRemovalSummaryData | null {
const typedArgs = args as { start?: number; end?: number };
if (typeof typedArgs.start !== 'number' || typeof typedArgs.end !== 'number') {
return null;
}
if (typedArgs.start < 0 || typedArgs.end < typedArgs.start) {
return null;
}
const matchingRegions = this.findMatchingRegions(typedArgs.start, typedArgs.end);
const beatsPerBar = this.getCurrentProject().getTimeSignature().numerator;
if (matchingRegions.length === 0) {
const bar = Math.floor(typedArgs.start / beatsPerBar) + 1;
return {
chordCount: 0,
startBeat: typedArgs.start,
endBeat: typedArgs.end,
firstBar: bar,
lastBar: bar,
};
}
const firstBeat = Math.min(...matchingRegions.map(region => region.getStartFromBeat()));
const lastBeat = Math.max(...matchingRegions.map(region => region.getStartFromBeat() + region.getLength()));
return {
chordCount: matchingRegions.length,
startBeat: typedArgs.start,
endBeat: typedArgs.end,
firstBar: Math.floor(firstBeat / beatsPerBar) + 1,
lastBar: Math.max(1, Math.ceil(lastBeat / beatsPerBar)),
};
}
private buildResultSummaryData(args: Record<string, unknown>, resultText: string): ChordRemovalSummaryData | null {
const typedArgs = args as { start?: number; end?: number };
if (typeof typedArgs.start !== 'number' || typeof typedArgs.end !== 'number') {
return null;
}
if (typedArgs.start < 0 || typedArgs.end < typedArgs.start) {
return null;
}
const countMatch = resultText.match(/Successfully removed (\d+) chord/);
if (!countMatch) {
return null;
}
const beatsPerBar = this.getCurrentProject().getTimeSignature().numerator;
const firstBar = Math.floor(typedArgs.start / beatsPerBar) + 1;
const lastBeatExclusive = typedArgs.start === typedArgs.end
? typedArgs.start + 1
: typedArgs.end;
const lastBar = Math.max(1, Math.ceil(lastBeatExclusive / beatsPerBar));
return {
chordCount: Number(countMatch[1]),
startBeat: typedArgs.start,
endBeat: typedArgs.end,
firstBar,
lastBar,
};
}
}
@@ -0,0 +1,138 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { RemoveKeySignatureTool } from './RemoveKeySignatureTool';
import { KGProject } from '../../core/KGProject';
import { KGCore } from '../../core/KGCore';
import { KGKeySignatureRegion } from '../../core/region/KGKeySignatureRegion';
import { findGlobalTrackByType } from '../../util/globalTrackUtil';
import { GlobalTrackType } from '../../core/global-track';
function mockCore(project: KGProject) {
vi.spyOn(KGCore, 'instance').mockReturnValue({
getCurrentProject: () => project,
getSelectedItems: () => [],
executeCommand: (command: { execute(): void }) => command.execute(),
removeSelectedItem: vi.fn(),
} as unknown as KGCore);
}
function getSignatureTrack(project: KGProject) {
const track = findGlobalTrackByType(project, GlobalTrackType.Signature);
expect(track).not.toBeNull();
return track!;
}
describe('RemoveKeySignatureTool', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('is only available in regular mode', () => {
const tool = new RemoveKeySignatureTool();
expect(tool.isReadOnlyTool()).toBe(false);
expect(tool.isAvailableInEfficientMode()).toBe(false);
});
it('removes a single exact start-beat match when start equals end', async () => {
const project = new KGProject('exact-remove-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
const track = getSignatureTrack(project);
track.setRegions([
new KGKeySignatureRegion('sig-1', track.getId(), track.getTrackIndex(), 'C major', 0, 2, 4),
new KGKeySignatureRegion('sig-2', track.getId(), track.getTrackIndex(), 'G major', 2, 6, 4),
]);
mockCore(project);
const tool = new RemoveKeySignatureTool();
const result = await tool.execute({ start: 8, end: 8 });
expect(result.success).toBe(true);
expect((track.getRegions() as KGKeySignatureRegion[]).map(region => ({
key: region.getKeySignature(),
startBar: region.getStartBar(),
lengthBars: region.getLengthBars(),
}))).toEqual([
{ key: 'C major', startBar: 0, lengthBars: 8 },
]);
expect(tool.buildToolHistoryContent({}, result)).toBe(result.result);
expect(tool.buildToolResultDisplayContent({}, result)).toBe(result.result);
});
it('removes multiple key-signature regions and preserves gapless collapse', async () => {
const project = new KGProject('range-remove-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
const track = getSignatureTrack(project);
track.setRegions([
new KGKeySignatureRegion('sig-1', track.getId(), track.getTrackIndex(), 'C major', 0, 2, 4),
new KGKeySignatureRegion('sig-2', track.getId(), track.getTrackIndex(), 'G major', 2, 2, 4),
new KGKeySignatureRegion('sig-3', track.getId(), track.getTrackIndex(), 'D major', 4, 4, 4),
]);
mockCore(project);
const tool = new RemoveKeySignatureTool();
const result = await tool.execute({ start: 8, end: 20 });
expect(result.success).toBe(true);
expect((track.getRegions() as KGKeySignatureRegion[]).map(region => ({
key: region.getKeySignature(),
startBar: region.getStartBar(),
lengthBars: region.getLengthBars(),
}))).toEqual([
{ key: 'C major', startBar: 0, lengthBars: 8 },
]);
expect(result.result).toContain('"G major" at beat 8');
expect(result.result).toContain('"D major" at beat 16');
});
it('returns a successful message when no key-signature regions match the range', async () => {
const project = new KGProject('none-remove-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
const track = getSignatureTrack(project);
track.setRegions([
new KGKeySignatureRegion('sig-1', track.getId(), track.getTrackIndex(), 'C major', 0, 8, 4),
]);
mockCore(project);
const tool = new RemoveKeySignatureTool();
const result = await tool.execute({ start: 12, end: 16 });
expect(result.success).toBe(true);
expect(result.result).toContain('No key-signature regions found');
});
it('deletes the only remaining key-signature region', async () => {
const project = new KGProject('single-region-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
const track = getSignatureTrack(project);
track.setRegions([
new KGKeySignatureRegion('sig-1', track.getId(), track.getTrackIndex(), 'E minor', 0, 8, 4),
]);
mockCore(project);
const tool = new RemoveKeySignatureTool();
const result = await tool.execute({ start: 0, end: 0 });
expect(result.success).toBe(true);
expect(track.getRegions()).toHaveLength(0);
});
it('builds a confirmation summary for the affected bar span', () => {
const project = new KGProject('confirmation-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
const track = getSignatureTrack(project);
track.setRegions([
new KGKeySignatureRegion('sig-1', track.getId(), track.getTrackIndex(), 'G major', 2, 2, 4),
new KGKeySignatureRegion('sig-2', track.getId(), track.getTrackIndex(), 'D major', 4, 4, 4),
]);
mockCore(project);
const tool = new RemoveKeySignatureTool();
expect(tool.buildConfirmationContent({ start: 8, end: 20 }))
.toBe('Allow removing 2 key signature regions from the global Signature Track across bars 3 to 8?');
});
it('rejects invalid beat ranges', async () => {
const project = new KGProject('bad-range-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
mockCore(project);
const tool = new RemoveKeySignatureTool();
const result = await tool.execute({ start: 8, end: 4 });
expect(result.success).toBe(false);
expect(result.result).toContain('must be greater than or equal to start');
});
});
+152
View File
@@ -0,0 +1,152 @@
import { BaseTool } from './BaseTool';
import type { ToolParameter, ToolResult } from './BaseTool';
import { DeleteMultipleKeySignatureRegionsCommand } from '../../core/commands/global-region/DeleteKeySignatureRegionCommand';
import { GlobalTrackType } from '../../core/global-track';
import { KGKeySignatureRegion } from '../../core/region/KGKeySignatureRegion';
import { findGlobalTrackByType, getSortedKeySignatureRegions } from '../../util/globalTrackUtil';
interface KeySignatureRemovalSummaryData {
regionCount: number;
startBeat: number;
endBeat: number;
firstBar: number;
lastBar: number;
}
export class RemoveKeySignatureTool extends BaseTool {
readonly name = 'remove_key_signature';
readonly description = 'Remove key-signature regions from the global Signature track by absolute start-beat range. This removes whole key-signature regions whose start beat falls within the requested range.';
override isReadOnlyTool(): boolean {
return false;
}
override isAvailableInEfficientMode(): boolean {
return false;
}
readonly parameters: Record<string, ToolParameter> = {
start: {
type: 'number',
description: 'Start beat — the absolute beat position where the removal range begins. When start is less than end, regions starting exactly at this beat are removed.',
required: true,
},
end: {
type: 'number',
description: 'End beat — the absolute beat position where the removal range ends. When start is less than end, this value is exclusive. When start equals end, only regions starting exactly at that beat are removed.',
required: true,
},
};
override buildToolResultDisplayContent(_args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
return toolResult.result;
}
override buildToolHistoryContent(_args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
return toolResult.result;
}
override buildConfirmationContent(args: Record<string, unknown> | null): string | undefined {
if (!args) {
return undefined;
}
const summary = this.buildSummaryData(args);
if (!summary) {
return undefined;
}
return `Allow removing ${summary.regionCount} key signature ${summary.regionCount === 1 ? 'region' : 'regions'} from the global Signature Track across ${summary.firstBar === summary.lastBar ? `bar ${summary.firstBar}` : `bars ${summary.firstBar} to ${summary.lastBar}`}?`;
}
async execute(params: Record<string, unknown>): Promise<ToolResult> {
try {
this.validateParameters(params);
const startBeat = params.start as number;
const endBeat = params.end as number;
this.validateRange(startBeat, endBeat);
const matchingRegions = this.findMatchingRegions(startBeat, endBeat);
if (matchingRegions.length === 0) {
return this.createSuccessResult(
`No key-signature regions found with start beats in the requested range from beat ${startBeat} to ${endBeat}.`,
);
}
await this.executeCommand(new DeleteMultipleKeySignatureRegionsCommand(matchingRegions.map(region => region.getId())));
const details = matchingRegions
.map(region => `"${region.getKeySignature()}" at beat ${region.getStartFromBeat()}`)
.join(', ');
return this.createSuccessResult(
`Successfully removed ${matchingRegions.length} key signature ${matchingRegions.length === 1 ? 'region' : 'regions'} from the global Signature track: ${details}.`,
);
} catch (error) {
return this.createErrorResult(`Failed to remove key signature: ${error}`);
}
}
private validateRange(startBeat: number, endBeat: number): void {
if (startBeat < 0) {
throw new Error(`Invalid start ${startBeat}. Must be >= 0.`);
}
if (endBeat < startBeat) {
throw new Error(`Invalid beat range: end (${endBeat}) must be greater than or equal to start (${startBeat}).`);
}
}
private findMatchingRegions(startBeat: number, endBeat: number): KGKeySignatureRegion[] {
const project = this.getCurrentProject();
const signatureTrack = findGlobalTrackByType(project, GlobalTrackType.Signature);
if (!signatureTrack) {
return [];
}
const beatsPerBar = project.getTimeSignature().numerator;
return getSortedKeySignatureRegions(signatureTrack, beatsPerBar)
.filter(region => this.matchesRange(region.getStartFromBeat(), startBeat, endBeat));
}
private matchesRange(regionStartBeat: number, startBeat: number, endBeat: number): boolean {
if (startBeat === endBeat) {
return regionStartBeat === startBeat;
}
return regionStartBeat >= startBeat && regionStartBeat < endBeat;
}
private buildSummaryData(args: Record<string, unknown>): KeySignatureRemovalSummaryData | null {
const typedArgs = args as { start?: number; end?: number };
if (typeof typedArgs.start !== 'number' || typeof typedArgs.end !== 'number') {
return null;
}
if (typedArgs.start < 0 || typedArgs.end < typedArgs.start) {
return null;
}
const matchingRegions = this.findMatchingRegions(typedArgs.start, typedArgs.end);
const beatsPerBar = this.getCurrentProject().getTimeSignature().numerator;
if (matchingRegions.length === 0) {
const bar = Math.floor(typedArgs.start / beatsPerBar) + 1;
return {
regionCount: 0,
startBeat: typedArgs.start,
endBeat: typedArgs.end,
firstBar: bar,
lastBar: bar,
};
}
const firstBeat = Math.min(...matchingRegions.map(region => region.getStartFromBeat()));
const lastBeat = Math.max(...matchingRegions.map(region => region.getStartFromBeat() + region.getLength()));
return {
regionCount: matchingRegions.length,
startBeat: typedArgs.start,
endBeat: typedArgs.end,
firstBar: Math.floor(firstBeat / beatsPerBar) + 1,
lastBar: Math.max(1, Math.ceil(lastBeat / beatsPerBar)),
};
}
}
+96
View File
@@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { RemoveMarkersTool } from './RemoveMarkersTool';
import { KGProject } from '../../core/KGProject';
import { KGCore } from '../../core/KGCore';
import { KGMarkerRegion } from '../../core/region/KGMarkerRegion';
import { findGlobalTrackByType } from '../../util/globalTrackUtil';
import { GlobalTrackType } from '../../core/global-track';
function mockCore(project: KGProject) {
vi.spyOn(KGCore, 'instance').mockReturnValue({
getCurrentProject: () => project,
getSelectedItems: () => [],
executeCommand: (command: { execute(): void }) => command.execute(),
removeSelectedItem: vi.fn(),
} as unknown as KGCore);
}
function getMarkerTrack(project: KGProject) {
const track = findGlobalTrackByType(project, GlobalTrackType.Marker);
expect(track).not.toBeNull();
return track!;
}
describe('RemoveMarkersTool', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('is only available in regular mode', () => {
const tool = new RemoveMarkersTool();
expect(tool.isReadOnlyTool()).toBe(false);
expect(tool.isAvailableInEfficientMode()).toBe(false);
});
it('removes a single exact start-beat match when start equals end', async () => {
const project = new KGProject('exact-remove-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
const markerTrack = getMarkerTrack(project);
markerTrack.setRegions([
new KGMarkerRegion('marker-1', markerTrack.getId(), markerTrack.getTrackIndex(), 'Intro', 0, 4),
new KGMarkerRegion('marker-2', markerTrack.getId(), markerTrack.getTrackIndex(), 'Verse', 4, 4),
]);
mockCore(project);
const tool = new RemoveMarkersTool();
const result = await tool.execute({ start: 4, end: 4 });
expect(result.success).toBe(true);
expect((markerTrack.getRegions() as KGMarkerRegion[]).map(region => region.getName())).toEqual(['Intro']);
expect(result.result).toContain('[Beat: 4; Length: 4]: Verse');
});
it('removes multiple marker regions in a start-inclusive, end-exclusive range', async () => {
const project = new KGProject('range-remove-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
const markerTrack = getMarkerTrack(project);
markerTrack.setRegions([
new KGMarkerRegion('marker-1', markerTrack.getId(), markerTrack.getTrackIndex(), 'Intro', 0, 4),
new KGMarkerRegion('marker-2', markerTrack.getId(), markerTrack.getTrackIndex(), 'Verse', 4, 4),
new KGMarkerRegion('marker-3', markerTrack.getId(), markerTrack.getTrackIndex(), 'Chorus', 8, 4),
]);
mockCore(project);
const tool = new RemoveMarkersTool();
const result = await tool.execute({ start: 4, end: 8 });
expect(result.success).toBe(true);
expect((markerTrack.getRegions() as KGMarkerRegion[]).map(region => region.getName())).toEqual(['Intro', 'Chorus']);
expect(tool.buildToolHistoryContent({}, result)).toBe(result.result);
expect(tool.buildToolResultDisplayContent({}, result)).toContain('Successfully removed 1 marker annotation');
});
it('returns a successful message when no markers match the range', async () => {
const project = new KGProject('none-remove-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
const markerTrack = getMarkerTrack(project);
markerTrack.setRegions([
new KGMarkerRegion('marker-1', markerTrack.getId(), markerTrack.getTrackIndex(), 'Intro', 0, 4),
]);
mockCore(project);
const tool = new RemoveMarkersTool();
const result = await tool.execute({ start: 12, end: 16 });
expect(result.success).toBe(true);
expect(result.result).toContain('No marker annotations found');
});
it('rejects invalid beat ranges', async () => {
const project = new KGProject('bad-range-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
mockCore(project);
const tool = new RemoveMarkersTool();
const result = await tool.execute({ start: 8, end: 4 });
expect(result.success).toBe(false);
expect(result.result).toContain('must be greater than or equal to start');
});
});
+115
View File
@@ -0,0 +1,115 @@
import { BaseTool } from './BaseTool';
import type { ToolParameter, ToolResult } from './BaseTool';
import { DeleteMultipleGlobalRegionsCommand } from '../../core/commands/global-region/DeleteGlobalRegionCommand';
import { GlobalTrackType } from '../../core/global-track';
import { KGMarkerRegion } from '../../core/region/KGMarkerRegion';
import { findGlobalTrackByType } from '../../util/globalTrackUtil';
export class RemoveMarkersTool extends BaseTool {
readonly name = 'remove_markers';
readonly description = 'Remove marker annotations from the global Marker track by region start beat. This deletes whole marker regions whose start beat is in the requested range. Markers are annotations only and do not affect playback.';
override isReadOnlyTool(): boolean {
return false;
}
override isAvailableInEfficientMode(): boolean {
return false;
}
readonly parameters: Record<string, ToolParameter> = {
start: {
type: 'number',
description: 'Start beat where the removal range begins. When start is less than end, markers starting exactly at this beat are removed.',
required: true,
},
end: {
type: 'number',
description: 'End beat where the removal range ends. When start is less than end, this value is exclusive. When start equals end, only the marker starting exactly at that beat is removed.',
required: true,
},
};
override buildToolResultDisplayContent(_args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
return this.formatMultilineResult(toolResult.result);
}
override buildToolHistoryContent(_args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
return toolResult.result;
}
override buildConfirmationContent(args: Record<string, unknown> | null): string | undefined {
if (!args) {
return undefined;
}
const typedArgs = args as { start?: number; end?: number };
if (typeof typedArgs.start !== 'number' || typeof typedArgs.end !== 'number') {
return undefined;
}
return `Allow removing marker annotations from the global Marker track in the start-beat range ${typedArgs.start} to ${typedArgs.end}?`;
}
async execute(params: Record<string, unknown>): Promise<ToolResult> {
try {
this.validateParameters(params);
const startBeat = params.start as number;
const endBeat = params.end as number;
this.validateRange(startBeat, endBeat);
const matchingRegions = this.findMatchingRegions(startBeat, endBeat);
if (matchingRegions.length === 0) {
return this.createSuccessResult(
`No marker annotations found with start beats in the requested range from beat ${startBeat} to ${endBeat}.`,
);
}
await this.executeCommand(new DeleteMultipleGlobalRegionsCommand(matchingRegions.map(region => region.getId())));
const details = matchingRegions
.map(region => `[Beat: ${region.getStartFromBeat()}; Length: ${region.getLength()}]: ${region.getName()}`)
.join('\n');
return this.createSuccessResult(
`Successfully removed ${matchingRegions.length} marker ${matchingRegions.length === 1 ? 'annotation' : 'annotations'} from the global Marker track. Markers are annotation-only and do not affect playback.\n${details}`,
);
} catch (error) {
return this.createErrorResult(`Failed to remove markers: ${error}`);
}
}
private validateRange(startBeat: number, endBeat: number): void {
if (startBeat < 0) {
throw new Error(`Invalid start ${startBeat}. Must be >= 0.`);
}
if (endBeat < startBeat) {
throw new Error(`Invalid beat range: end (${endBeat}) must be greater than or equal to start (${startBeat}).`);
}
}
private findMatchingRegions(startBeat: number, endBeat: number): KGMarkerRegion[] {
const project = this.getCurrentProject();
const markerTrack = findGlobalTrackByType(project, GlobalTrackType.Marker);
if (!markerTrack) {
return [];
}
return markerTrack.getRegions()
.filter((region): region is KGMarkerRegion => region instanceof KGMarkerRegion)
.filter(region => this.matchesRange(region.getStartFromBeat(), startBeat, endBeat))
.sort((left, right) => left.getStartFromBeat() - right.getStartFromBeat());
}
private matchesRange(regionStartBeat: number, startBeat: number, endBeat: number): boolean {
if (startBeat === endBeat) {
return regionStartBeat === startBeat;
}
return regionStartBeat >= startBeat && regionStartBeat < endBeat;
}
private formatMultilineResult(result: string): string {
return result.replace(/\n/g, ' \n');
}
}
+177
View File
@@ -0,0 +1,177 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { WriteBpmTool } from './WriteBpmTool';
import { KGProject } from '../../core/KGProject';
import { KGCore } from '../../core/KGCore';
import { KGTempoRegion } from '../../core/region/KGTempoRegion';
import { findGlobalTrackByType } from '../../util/globalTrackUtil';
import { GlobalTrackType } from '../../core/global-track';
function mockCore(project: KGProject) {
vi.spyOn(KGCore, 'instance').mockReturnValue({
getCurrentProject: () => project,
getSelectedItems: () => [],
executeCommand: (command: { execute(): void }) => command.execute(),
} as unknown as KGCore);
}
function getTempoTrack(project: KGProject) {
const track = findGlobalTrackByType(project, GlobalTrackType.Tempo);
expect(track).not.toBeNull();
return track!;
}
describe('WriteBpmTool', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('exposes the expected write-only availability and schema details', () => {
const project = new KGProject('tool-definition-project', 8, 0, 120, { numerator: 4, denominator: 4 });
mockCore(project);
const tool = new WriteBpmTool();
const definition = tool.getDefinition();
expect(tool.isReadOnlyTool()).toBe(false);
expect(tool.isAvailableInEfficientMode()).toBe(false);
expect(definition.function.name).toBe('write_bpm');
expect(definition.function.description).toContain('Tempo track');
expect(JSON.stringify(definition.function.parameters)).toContain('bpms');
});
it('builds a confirmation summary for a full-song rewrite', () => {
const project = new KGProject('confirmation-project', 8, 0, 120, { numerator: 4, denominator: 4 });
mockCore(project);
const tool = new WriteBpmTool();
expect(tool.buildConfirmationContent({
bpms: [
{ bpm: 100 },
{ bpm: 128, beat: 8 },
],
})).toBe('Allow rebuilding the global Tempo track with default tempo 100 BPM and 1 explicit tempo change from beat 8 to beat 8?');
});
it('writes only a global/default BPM', async () => {
const project = new KGProject('single-write-project', 8, 0, 120, { numerator: 4, denominator: 4 });
mockCore(project);
const tool = new WriteBpmTool();
const result = await tool.execute({
bpms: [{ bpm: 96 }],
});
const track = getTempoTrack(project);
expect(result.success).toBe(true);
expect(project.getBpm()).toBe(96);
expect(track.getRegions()).toHaveLength(0);
expect(result.result).toContain('Project default BPM: 96 BPM');
expect(tool.buildToolHistoryContent({}, result)).toBe(result.result);
expect(tool.buildToolResultDisplayContent({}, result)).toBe(result.result);
});
it('writes default BPM plus explicit entries and keeps the track gapless', async () => {
const project = new KGProject('explicit-write-project', 8, 0, 120, { numerator: 4, denominator: 4 });
const track = getTempoTrack(project);
track.setRegions([
new KGTempoRegion('existing-1', track.getId(), track.getTrackIndex(), 120, 0, 2, 4),
new KGTempoRegion('existing-2', track.getId(), track.getTrackIndex(), 140, 2, 6, 4),
]);
mockCore(project);
const tool = new WriteBpmTool();
const result = await tool.execute({
bpms: [
{ bpm: 100 },
{ bpm: 128, beat: 8 },
{ bpm: 144, beat: 16 },
],
});
expect(result.success).toBe(true);
expect(project.getBpm()).toBe(100);
expect((track.getRegions() as KGTempoRegion[]).map(region => ({
bpm: region.getBpm(),
startBar: region.getStartBar(),
lengthBars: region.getLengthBars(),
}))).toEqual([
{ bpm: 100, startBar: 0, lengthBars: 2 },
{ bpm: 128, startBar: 2, lengthBars: 2 },
{ bpm: 144, startBar: 4, lengthBars: 4 },
]);
expect(result.result).toContain('128 BPM from beat 8 (bar 3)');
});
it('rejects an empty BPM list', async () => {
const project = new KGProject('empty-project', 8, 0, 120, { numerator: 4, denominator: 4 });
mockCore(project);
const tool = new WriteBpmTool();
const result = await tool.execute({ bpms: [] });
expect(result.success).toBe(false);
expect(result.result).toContain('must contain at least one BPM entry');
});
it('rejects invalid BPM values', async () => {
const project = new KGProject('bad-bpm-project', 8, 0, 120, { numerator: 4, denominator: 4 });
mockCore(project);
const tool = new WriteBpmTool();
const zeroResult = await tool.execute({ bpms: [{ bpm: 0 }] });
const nanResult = await tool.execute({ bpms: [{ bpm: Number.NaN }] });
expect(zeroResult.success).toBe(false);
expect(zeroResult.result).toContain('invalid "bpm"');
expect(nanResult.success).toBe(false);
expect(nanResult.result).toContain('invalid "bpm"');
});
it('rejects invalid beats', async () => {
const project = new KGProject('bad-beat-project', 8, 0, 120, { numerator: 4, denominator: 4 });
mockCore(project);
const tool = new WriteBpmTool();
const badBeatResult = await tool.execute({ bpms: [{ bpm: 120, beat: -1 }] });
const outOfRangeResult = await tool.execute({ bpms: [{ bpm: 120, beat: 32 }] });
expect(badBeatResult.success).toBe(false);
expect(badBeatResult.result).toContain('invalid "beat"');
expect(outOfRangeResult.success).toBe(false);
expect(outOfRangeResult.result).toContain('within the song range');
});
it('rejects duplicate global/default entries', async () => {
const project = new KGProject('duplicate-default-project', 8, 0, 120, { numerator: 4, denominator: 4 });
mockCore(project);
const tool = new WriteBpmTool();
const result = await tool.execute({
bpms: [
{ bpm: 100 },
{ bpm: 120, beat: '' as const },
],
});
expect(result.success).toBe(false);
expect(result.result).toContain('Only one global/default BPM entry');
});
it('rejects entries that collapse into the same bar after normalization', async () => {
const project = new KGProject('same-bar-project', 8, 0, 120, { numerator: 4, denominator: 4 });
mockCore(project);
const tool = new WriteBpmTool();
const result = await tool.execute({
bpms: [
{ bpm: 100 },
{ bpm: 128, beat: 4 },
{ bpm: 132, beat: 7 },
],
});
expect(result.success).toBe(false);
expect(result.result).toContain('after bar alignment');
});
});
+186
View File
@@ -0,0 +1,186 @@
import { BaseTool } from './BaseTool';
import type { ToolParameter, ToolResult } from './BaseTool';
import {
WriteTempoTrackCommand,
type WriteTempoEntry,
} from '../../core/commands/global-region/WriteTempoTrackCommand';
interface RequestedBpmEntry {
bpm: number;
beat?: number | null | '';
}
interface NormalizedExplicitEntry extends WriteTempoEntry {
inputBeat: number;
normalizedBar: number;
}
interface NormalizedPayload {
baseBpm: number;
explicitEntries: NormalizedExplicitEntry[];
}
export class WriteBpmTool extends BaseTool {
readonly name = 'write_bpm';
readonly description = 'Write BPM changes to the global Tempo track. This tool updates the project default BPM and rebuilds the Tempo track as a gapless full-song tempo plan with bar-aligned boundaries.';
readonly parameters: Record<string, ToolParameter> = {
bpms: {
type: 'array',
description: 'The complete BPM plan to write. Provide one optional beat-less item for the project default BPM, plus any explicit beat-based tempo changes.',
required: true,
items: {
type: 'object',
description: 'One BPM entry. Omit "beat", set it to null, or set it to an empty string to provide the global/default BPM.',
properties: {
bpm: {
type: 'number',
description: 'Required BPM value. Must be a finite number greater than 0.',
required: true,
},
beat: {
type: 'number',
description: 'Optional absolute beat on the project timeline. When omitted, null, or empty, this entry becomes the project default BPM.',
required: false,
},
},
},
},
};
override isReadOnlyTool(): boolean {
return false;
}
override isAvailableInEfficientMode(): boolean {
return false;
}
override buildToolResultDisplayContent(_args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
return toolResult.result;
}
override buildToolHistoryContent(_args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
return toolResult.result;
}
override buildConfirmationContent(args: Record<string, unknown> | null): string | undefined {
if (!args) {
return undefined;
}
try {
const normalized = this.normalizePayload(args.bpms as RequestedBpmEntry[]);
const explicitEntries = normalized.explicitEntries;
if (explicitEntries.length === 0) {
return `Allow rebuilding the global Tempo track using project default tempo ${normalized.baseBpm} BPM with no explicit tempo regions?`;
}
const firstBeat = explicitEntries[0].inputBeat;
const lastBeat = explicitEntries[explicitEntries.length - 1].inputBeat;
return `Allow rebuilding the global Tempo track with default tempo ${normalized.baseBpm} BPM and ${explicitEntries.length} explicit tempo ${explicitEntries.length === 1 ? 'change' : 'changes'} from beat ${firstBeat} to beat ${lastBeat}?`;
} catch {
return undefined;
}
}
async execute(params: Record<string, unknown>): Promise<ToolResult> {
try {
if (!Array.isArray(params.bpms)) {
throw new Error('Parameter "bpms" must be an array.');
}
const normalized = this.normalizePayload(params.bpms as RequestedBpmEntry[]);
const command = new WriteTempoTrackCommand(
normalized.baseBpm,
normalized.explicitEntries.map(entry => ({
startBeat: entry.startBeat,
bpm: entry.bpm,
})),
);
await this.executeCommand(command);
const explicitDetails = normalized.explicitEntries.length === 0
? 'No explicit tempo regions were written; the Tempo track now falls back entirely to the project default BPM.'
: normalized.explicitEntries
.map(entry => `${entry.bpm} BPM from beat ${entry.startBeat} (bar ${entry.normalizedBar + 1})`)
.join(', ');
return this.createSuccessResult(
`Successfully rebuilt the global Tempo track. Project default BPM: ${normalized.baseBpm} BPM. ${explicitDetails}`,
);
} catch (error) {
return this.createErrorResult(`Failed to write BPM: ${error}`);
}
}
private normalizePayload(entries: RequestedBpmEntry[]): NormalizedPayload {
if (!Array.isArray(entries) || entries.length === 0) {
throw new Error('Parameter "bpms" must contain at least one BPM entry.');
}
const project = this.getCurrentProject();
const beatsPerBar = project.getTimeSignature().numerator;
const songEndBeat = project.getMaxBars() * beatsPerBar;
let baseBpm = project.getBpm();
let sawDefaultEntry = false;
const explicitEntries: NormalizedExplicitEntry[] = [];
entries.forEach((entry, index) => {
const bpm = this.validateBpm(entry.bpm, index);
const beat = entry.beat;
if (beat === undefined || beat === null || beat === '') {
if (sawDefaultEntry) {
throw new Error('Only one global/default BPM entry may omit the "beat" field.');
}
baseBpm = bpm;
sawDefaultEntry = true;
return;
}
if (!Number.isFinite(beat)) {
throw new Error(`BPM entry ${index + 1} has invalid "beat": ${String(beat)}. Expected a finite number >= 0.`);
}
if (beat < 0) {
throw new Error(`BPM entry ${index + 1} has invalid "beat": ${beat}. Expected a value >= 0.`);
}
if (beat >= songEndBeat) {
throw new Error(`BPM entry ${index + 1} has invalid "beat": ${beat}. It must be within the song range.`);
}
explicitEntries.push({
bpm,
startBeat: beat,
inputBeat: beat,
normalizedBar: Math.floor(beat / beatsPerBar),
});
});
explicitEntries.sort((left, right) => left.startBeat - right.startBeat);
for (let index = 1; index < explicitEntries.length; index += 1) {
const previous = explicitEntries[index - 1];
const current = explicitEntries[index];
if (current.normalizedBar <= previous.normalizedBar) {
throw new Error(
`BPM entry ${index + 1} overlaps with or collapses into entry ${index} after bar alignment. Entry ${index} normalizes to bar ${previous.normalizedBar + 1}, and entry ${index + 1} normalizes to bar ${current.normalizedBar + 1}.`,
);
}
}
return {
baseBpm,
explicitEntries,
};
}
private validateBpm(rawValue: number, index: number): number {
if (!Number.isFinite(rawValue) || rawValue <= 0) {
throw new Error(`BPM entry ${index + 1} has invalid "bpm": ${String(rawValue)}. Expected a finite number greater than 0.`);
}
return rawValue;
}
}
@@ -0,0 +1,175 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { WriteKeySignatureTool } from './WriteKeySignatureTool';
import { KGProject } from '../../core/KGProject';
import { KGCore } from '../../core/KGCore';
import { KGKeySignatureRegion } from '../../core/region/KGKeySignatureRegion';
import { findGlobalTrackByType } from '../../util/globalTrackUtil';
import { GlobalTrackType } from '../../core/global-track';
function mockCore(project: KGProject) {
vi.spyOn(KGCore, 'instance').mockReturnValue({
getCurrentProject: () => project,
getSelectedItems: () => [],
executeCommand: (command: { execute(): void }) => command.execute(),
} as unknown as KGCore);
}
function getSignatureTrack(project: KGProject) {
const track = findGlobalTrackByType(project, GlobalTrackType.Signature);
expect(track).not.toBeNull();
return track!;
}
describe('WriteKeySignatureTool', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('exposes the expected write-only availability and schema details', () => {
const project = new KGProject('tool-definition-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
mockCore(project);
const tool = new WriteKeySignatureTool();
const definition = tool.getDefinition();
expect(tool.isReadOnlyTool()).toBe(false);
expect(tool.isAvailableInEfficientMode()).toBe(false);
expect(definition.function.name).toBe('write_key_signature');
expect(definition.function.description).toContain('C major');
expect(JSON.stringify(definition.function.parameters)).toContain('F# minor');
expect(JSON.stringify(definition.function.parameters)).toContain('Bb major');
});
it('builds a confirmation summary for a full-song rewrite', () => {
const project = new KGProject('confirmation-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
mockCore(project);
const tool = new WriteKeySignatureTool();
expect(tool.buildConfirmationContent({
key_signatures: [
{ key_signature: 'C major' },
{ key_signature: 'G major', beat: 8 },
],
})).toBe('Allow rebuilding the global Signature track with 2 key signatures from beat 8 to beat 8?');
});
it('writes a single global key signature across the full song', async () => {
const project = new KGProject('single-write-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
mockCore(project);
const tool = new WriteKeySignatureTool();
const result = await tool.execute({
key_signatures: [{ key_signature: 'E minor' }],
});
const track = getSignatureTrack(project);
const regions = track.getRegions() as KGKeySignatureRegion[];
expect(result.success).toBe(true);
expect(regions).toHaveLength(1);
expect(regions[0].getKeySignature()).toBe('E minor');
expect(regions[0].getStartBar()).toBe(0);
expect(regions[0].getLengthBars()).toBe(8);
expect(tool.buildToolHistoryContent({}, result)).toBe(result.result);
expect(tool.buildToolResultDisplayContent({}, result)).toBe(result.result);
});
it('rewrites the signature track from explicit beat entries and keeps it gapless', async () => {
const project = new KGProject('explicit-write-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
const track = getSignatureTrack(project);
track.setRegions([
new KGKeySignatureRegion('existing-1', track.getId(), track.getTrackIndex(), 'D major', 0, 2, 4),
new KGKeySignatureRegion('existing-2', track.getId(), track.getTrackIndex(), 'A major', 2, 6, 4),
]);
mockCore(project);
const tool = new WriteKeySignatureTool();
const result = await tool.execute({
key_signatures: [
{ key_signature: 'G major' },
{ key_signature: 'D major', beat: 8 },
{ key_signature: 'A major', beat: 16 },
],
});
expect(result.success).toBe(true);
expect((track.getRegions() as KGKeySignatureRegion[]).map(region => ({
keySignature: region.getKeySignature(),
startBar: region.getStartBar(),
lengthBars: region.getLengthBars(),
}))).toEqual([
{ keySignature: 'G major', startBar: 0, lengthBars: 2 },
{ keySignature: 'D major', startBar: 2, lengthBars: 2 },
{ keySignature: 'A major', startBar: 4, lengthBars: 4 },
]);
});
it('rejects an empty key-signature list', async () => {
const project = new KGProject('empty-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
mockCore(project);
const tool = new WriteKeySignatureTool();
const result = await tool.execute({ key_signatures: [] });
expect(result.success).toBe(false);
expect(result.result).toContain('must contain at least one key-signature entry');
});
it('rejects invalid key-signature strings', async () => {
const project = new KGProject('bad-key-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
mockCore(project);
const tool = new WriteKeySignatureTool();
const result = await tool.execute({ key_signatures: [{ key_signature: 'not-a-key' }] });
expect(result.success).toBe(false);
expect(result.result).toContain('invalid "key_signature"');
});
it('rejects invalid beats', async () => {
const project = new KGProject('bad-beat-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
mockCore(project);
const tool = new WriteKeySignatureTool();
const badBeatResult = await tool.execute({ key_signatures: [{ key_signature: 'C major', beat: -1 }] });
const outOfRangeResult = await tool.execute({ key_signatures: [{ key_signature: 'C major', beat: 32 }] });
expect(badBeatResult.success).toBe(false);
expect(badBeatResult.result).toContain('invalid "beat"');
expect(outOfRangeResult.success).toBe(false);
expect(outOfRangeResult.result).toContain('within the song range');
});
it('rejects duplicate global/default entries', async () => {
const project = new KGProject('duplicate-default-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
mockCore(project);
const tool = new WriteKeySignatureTool();
const result = await tool.execute({
key_signatures: [
{ key_signature: 'C major' },
{ key_signature: 'G major', beat: '' as const },
],
});
expect(result.success).toBe(false);
expect(result.result).toContain('Only one global/default key signature entry');
});
it('rejects entries that collapse into the same bar after normalization', async () => {
const project = new KGProject('same-bar-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
mockCore(project);
const tool = new WriteKeySignatureTool();
const result = await tool.execute({
key_signatures: [
{ key_signature: 'C major' },
{ key_signature: 'G major', beat: 4 },
{ key_signature: 'D major', beat: 7 },
],
});
expect(result.success).toBe(false);
expect(result.result).toContain('after bar alignment');
});
});
+197
View File
@@ -0,0 +1,197 @@
import { BaseTool } from './BaseTool';
import type { ToolParameter, ToolResult } from './BaseTool';
import { KEY_SIGNATURE_MAP } from '../../constants/coreConstants';
import type { KeySignature } from '../../core/KGProject';
import {
WriteKeySignatureTrackCommand,
type WriteKeySignatureEntry,
} from '../../core/commands/global-region/WriteKeySignatureTrackCommand';
interface RequestedKeySignatureEntry {
key_signature: string;
beat?: number | null | '';
}
interface NormalizedExplicitEntry extends WriteKeySignatureEntry {
inputBeat: number;
}
interface NormalizedPayload {
baseKeySignature: KeySignature;
explicitEntries: NormalizedExplicitEntry[];
}
export class WriteKeySignatureTool extends BaseTool {
readonly name = 'write_key_signature';
readonly description = 'Write key-signature changes to the global Signature track using the same canonical key-signature format as the key-signature picker. Use exact picker values such as "C major", "F# minor", or "Bb major". This fully rebuilds the global Signature track as a gapless song-wide key plan.';
readonly parameters: Record<string, ToolParameter> = {
key_signatures: {
type: 'array',
description: 'The complete key-signature plan to write. Use exact key-signature picker values such as "C major", "F# minor", or "Bb major".',
required: true,
items: {
type: 'object',
description: 'One key-signature entry. Omit "beat", set it to null, or set it to an empty string to provide the global/default key signature.',
properties: {
key_signature: {
type: 'string',
description: 'Required canonical key signature exactly matching the key-signature picker. Examples: "C major", "F# minor", "Bb major".',
required: true,
},
beat: {
type: 'number',
description: 'Optional absolute beat on the project timeline. When omitted, null, or empty, this entry becomes the global/default key signature.',
required: false,
},
},
},
},
};
override isReadOnlyTool(): boolean {
return false;
}
override isAvailableInEfficientMode(): boolean {
return false;
}
override buildToolResultDisplayContent(_args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
return toolResult.result;
}
override buildToolHistoryContent(_args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
return toolResult.result;
}
override buildConfirmationContent(args: Record<string, unknown> | null): string | undefined {
if (!args) {
return undefined;
}
try {
const normalized = this.normalizePayload(args.key_signatures as RequestedKeySignatureEntry[]);
const explicitEntries = normalized.explicitEntries;
const writeCount = explicitEntries.length + 1;
if (explicitEntries.length === 0) {
return 'Allow rebuilding the global Signature track with 1 key signature across the full song?';
}
const firstBeat = explicitEntries[0].inputBeat;
const lastBeat = explicitEntries[explicitEntries.length - 1].inputBeat;
return `Allow rebuilding the global Signature track with ${writeCount} key signatures from beat ${firstBeat} to beat ${lastBeat}?`;
} catch {
return undefined;
}
}
async execute(params: Record<string, unknown>): Promise<ToolResult> {
try {
if (!Array.isArray(params.key_signatures)) {
throw new Error('Parameter "key_signatures" must be an array.');
}
const normalized = this.normalizePayload(params.key_signatures as RequestedKeySignatureEntry[]);
const command = new WriteKeySignatureTrackCommand(
normalized.baseKeySignature,
normalized.explicitEntries.map(entry => ({
startBeat: entry.startBeat,
keySignature: entry.keySignature,
})),
);
await this.executeCommand(command);
const details = normalized.explicitEntries.length === 0
? `base key signature "${normalized.baseKeySignature}" across the full song`
: [
`base key signature "${normalized.baseKeySignature}"`,
...normalized.explicitEntries.map(entry => `"${entry.keySignature}" from beat ${entry.startBeat}`),
].join(', ');
return this.createSuccessResult(
`Successfully rebuilt the global Signature track as a gapless full-song key plan using ${details}. All boundaries were normalized to bar starts.`,
);
} catch (error) {
return this.createErrorResult(`Failed to write key signature: ${error}`);
}
}
private normalizePayload(entries: RequestedKeySignatureEntry[]): NormalizedPayload {
if (!Array.isArray(entries) || entries.length === 0) {
throw new Error('Parameter "key_signatures" must contain at least one key-signature entry.');
}
const project = this.getCurrentProject();
const beatsPerBar = project.getTimeSignature().numerator;
const songEndBeat = project.getMaxBars() * beatsPerBar;
let baseKeySignature: KeySignature = project.getKeySignature();
let sawDefaultEntry = false;
const explicitEntries: NormalizedExplicitEntry[] = [];
entries.forEach((entry, index) => {
const keySignature = this.validateKeySignature(entry.key_signature, index);
const beat = entry.beat;
if (beat === undefined || beat === null || beat === '') {
if (sawDefaultEntry) {
throw new Error('Only one global/default key signature entry may omit the "beat" field.');
}
baseKeySignature = keySignature;
sawDefaultEntry = true;
return;
}
if (!Number.isFinite(beat)) {
throw new Error(`Key-signature entry ${index + 1} has invalid "beat": ${String(beat)}. Expected a finite number >= 0.`);
}
if (beat < 0) {
throw new Error(`Key-signature entry ${index + 1} has invalid "beat": ${beat}. Expected a value >= 0.`);
}
if (beat >= songEndBeat) {
throw new Error(`Key-signature entry ${index + 1} has invalid "beat": ${beat}. It must be within the song range.`);
}
explicitEntries.push({
keySignature,
startBeat: beat,
inputBeat: beat,
});
});
explicitEntries.sort((left, right) => left.startBeat - right.startBeat);
for (let index = 1; index < explicitEntries.length; index += 1) {
const previous = explicitEntries[index - 1];
const current = explicitEntries[index];
const previousBar = Math.floor(previous.startBeat / beatsPerBar);
const currentBar = Math.floor(current.startBeat / beatsPerBar);
if (currentBar <= previousBar) {
throw new Error(
`Key-signature entry ${index + 1} overlaps with or collapses into entry ${index} after bar alignment. Entry ${index} normalizes to bar ${previousBar + 1}, and entry ${index + 1} normalizes to bar ${currentBar + 1}.`,
);
}
}
return {
baseKeySignature,
explicitEntries,
};
}
private validateKeySignature(rawValue: string, index: number): KeySignature {
const trimmed = rawValue?.trim();
if (!trimmed) {
throw new Error(`Key-signature entry ${index + 1} has invalid "key_signature": expected a non-empty string.`);
}
if (!(trimmed in KEY_SIGNATURE_MAP)) {
throw new Error(
`Key-signature entry ${index + 1} has invalid "key_signature": "${trimmed}". Use an exact key-signature picker value such as "C major", "F# minor", or "Bb major".`,
);
}
return trimmed as KeySignature;
}
}
+155
View File
@@ -0,0 +1,155 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { WriteMarkersTool } from './WriteMarkersTool';
import { KGProject } from '../../core/KGProject';
import { KGCore } from '../../core/KGCore';
import { KGMarkerRegion } from '../../core/region/KGMarkerRegion';
import { findGlobalTrackByType } from '../../util/globalTrackUtil';
import { GlobalTrackType } from '../../core/global-track';
function mockCore(project: KGProject) {
vi.spyOn(KGCore, 'instance').mockReturnValue({
getCurrentProject: () => project,
getSelectedItems: () => [],
executeCommand: (command: { execute(): void }) => command.execute(),
} as unknown as KGCore);
}
function getMarkerTrack(project: KGProject) {
const markerTrack = findGlobalTrackByType(project, GlobalTrackType.Marker);
expect(markerTrack).not.toBeNull();
return markerTrack!;
}
describe('WriteMarkersTool', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('exposes the expected write-only availability and schema details', () => {
const project = new KGProject('tool-definition-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
mockCore(project);
const tool = new WriteMarkersTool();
const definition = tool.getDefinition();
expect(tool.isReadOnlyTool()).toBe(false);
expect(tool.isAvailableInEfficientMode()).toBe(false);
expect(definition.function.name).toBe('write_markers');
expect(definition.function.description).toContain('annotation-only');
expect(JSON.stringify(definition.function.parameters)).toContain('marker');
});
it('writes a single marker into an empty marker track and normalizes the label', async () => {
const project = new KGProject('single-write-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
mockCore(project);
const tool = new WriteMarkersTool();
const result = await tool.execute({
markers: [{ marker: ' Intro\nSection ', beat: 4, length: 4 }],
});
const markerTrack = getMarkerTrack(project);
expect(result.success).toBe(true);
expect((markerTrack.getRegions()[0] as KGMarkerRegion).getName()).toBe('Intro Section');
expect(tool.buildToolHistoryContent({}, result)).toBe(result.result);
expect(tool.buildToolResultDisplayContent({}, result)).toContain('Successfully wrote 1 marker annotation');
expect(result.result).toContain('[Beat: 4; Length: 4]: Intro Section');
});
it('builds a confirmation summary for the affected beat span', () => {
const project = new KGProject('confirmation-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
mockCore(project);
const tool = new WriteMarkersTool();
expect(tool.buildConfirmationContent({
markers: [
{ marker: 'Intro', beat: 0, length: 4 },
{ marker: 'Verse', beat: 8, length: 4 },
],
})).toBe('Allow writing 2 marker annotations to the global Marker track from beat 0 to beat 12?');
});
it('rejects an empty marker list', async () => {
const project = new KGProject('empty-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
mockCore(project);
const tool = new WriteMarkersTool();
const result = await tool.execute({ markers: [] });
expect(result.success).toBe(false);
expect(result.result).toContain('must contain at least one marker entry');
});
it('rejects empty marker labels', async () => {
const project = new KGProject('bad-marker-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
mockCore(project);
const tool = new WriteMarkersTool();
const result = await tool.execute({ markers: [{ marker: ' ', beat: 0, length: 4 }] });
expect(result.success).toBe(false);
expect(result.result).toContain('invalid "marker"');
});
it('rejects invalid beat and non-positive length values', async () => {
const project = new KGProject('bad-number-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
mockCore(project);
const tool = new WriteMarkersTool();
const badBeatResult = await tool.execute({ markers: [{ marker: 'Intro', beat: -1, length: 4 }] });
const badLengthResult = await tool.execute({ markers: [{ marker: 'Intro', beat: 0, length: 0 }] });
expect(badBeatResult.success).toBe(false);
expect(badBeatResult.result).toContain('invalid "beat"');
expect(badLengthResult.success).toBe(false);
expect(badLengthResult.result).toContain('invalid "length"');
});
it('rejects overlapping requested marker entries', async () => {
const project = new KGProject('overlap-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
mockCore(project);
const tool = new WriteMarkersTool();
const result = await tool.execute({
markers: [
{ marker: 'Intro', beat: 0, length: 4 },
{ marker: 'Verse', beat: 3, length: 4 },
],
});
expect(result.success).toBe(false);
expect(result.result).toContain('overlaps with marker entry 1');
});
it('replaces overlapping existing markers while preserving untouched regions', async () => {
const project = new KGProject('preserve-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
const markerTrack = getMarkerTrack(project);
markerTrack.setRegions([
new KGMarkerRegion('marker-1', markerTrack.getId(), markerTrack.getTrackIndex(), 'Long Intro', 0, 8),
new KGMarkerRegion('marker-2', markerTrack.getId(), markerTrack.getTrackIndex(), 'Outro', 8, 4),
]);
mockCore(project);
const tool = new WriteMarkersTool();
const result = await tool.execute({
markers: [
{ marker: 'Hit', beat: 3, length: 2 },
{ marker: 'Drop', beat: 10, length: 2 },
],
});
expect(result.success).toBe(true);
expect((markerTrack.getRegions() as KGMarkerRegion[]).map(region => ({
name: region.getName(),
start: region.getStartFromBeat(),
length: region.getLength(),
}))).toEqual([
{ name: 'Long Intro', start: 0, length: 3 },
{ name: 'Hit', start: 3, length: 2 },
{ name: 'Long Intro', start: 5, length: 3 },
{ name: 'Outro', start: 8, length: 2 },
{ name: 'Drop', start: 10, length: 2 },
]);
expect(result.result).toContain('annotation-only');
});
});
+156
View File
@@ -0,0 +1,156 @@
import { BaseTool } from './BaseTool';
import type { ToolParameter, ToolResult } from './BaseTool';
import {
WriteMarkersCommand,
type WriteMarkerEntry,
} from '../../core/commands/global-region/WriteMarkersCommand';
interface RequestedMarkerEntry {
marker: string;
beat: number;
length: number;
}
interface ValidatedMarkerEntry extends WriteMarkerEntry {
marker: string;
}
export class WriteMarkersTool extends BaseTool {
readonly name = 'write_markers';
readonly description = 'Write marker annotations to the global Marker track using absolute beat positions on the project timeline. Marker regions are annotation-only and do not affect playback.';
override isReadOnlyTool(): boolean {
return false;
}
override isAvailableInEfficientMode(): boolean {
return false;
}
readonly parameters: Record<string, ToolParameter> = {
markers: {
type: 'array',
description: 'Marker annotations to write to the global Marker track. Each entry uses an absolute beat start on the project timeline.',
required: true,
items: {
type: 'object',
description: 'A single marker annotation region.',
properties: {
marker: {
type: 'string',
description: 'Marker label text. Must be non-empty after trimming.',
required: true,
},
beat: {
type: 'number',
description: 'Start beat on the absolute project timeline. This is not relative to a clip or region.',
required: true,
},
length: {
type: 'number',
description: 'Marker duration in beats. Must be greater than 0.',
required: true,
},
},
},
},
};
override buildToolResultDisplayContent(_args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
return this.formatMultilineResult(toolResult.result);
}
override buildToolHistoryContent(_args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
return toolResult.result;
}
override buildConfirmationContent(args: Record<string, unknown> | null): string | undefined {
if (!args) {
return undefined;
}
try {
const validatedMarkers = this.validateAndNormalizeMarkers(args.markers as RequestedMarkerEntry[]);
const firstBeat = validatedMarkers[0].startBeat;
const lastBeatExclusive = Math.max(...validatedMarkers.map(marker => marker.startBeat + marker.length));
return `Allow writing ${validatedMarkers.length} marker ${validatedMarkers.length === 1 ? 'annotation' : 'annotations'} to the global Marker track from beat ${firstBeat} to beat ${lastBeatExclusive}?`;
} catch {
return undefined;
}
}
async execute(params: Record<string, unknown>): Promise<ToolResult> {
try {
this.validateParameters(params);
const validatedMarkers = this.validateAndNormalizeMarkers(params.markers as RequestedMarkerEntry[]);
await this.executeCommand(new WriteMarkersCommand(validatedMarkers.map(marker => ({
startBeat: marker.startBeat,
length: marker.length,
name: marker.marker,
}))));
const details = validatedMarkers
.map(marker => `[Beat: ${marker.startBeat}; Length: ${marker.length}]: ${marker.marker}`)
.join('\n');
return this.createSuccessResult(
`Successfully wrote ${validatedMarkers.length} marker ${validatedMarkers.length === 1 ? 'annotation' : 'annotations'} to the global Marker track. Markers are annotation-only and do not affect playback.\n${details}`,
);
} catch (error) {
return this.createErrorResult(`Failed to write markers: ${error}`);
}
}
private validateAndNormalizeMarkers(markers: RequestedMarkerEntry[]): ValidatedMarkerEntry[] {
if (markers.length === 0) {
throw new Error('Parameter "markers" must contain at least one marker entry.');
}
const validated = markers.map((marker, index) => this.validateMarkerEntry(marker, index));
validated.sort((left, right) => left.startBeat - right.startBeat);
for (let index = 1; index < validated.length; index += 1) {
const previous = validated[index - 1];
const current = validated[index];
if (current.startBeat < previous.startBeat + previous.length) {
throw new Error(
`Marker entry ${index + 1} overlaps with marker entry ${index}. Entry ${index} ends at beat ${previous.startBeat + previous.length}, but entry ${index + 1} starts at beat ${current.startBeat}.`,
);
}
}
return validated;
}
private validateMarkerEntry(marker: RequestedMarkerEntry, index: number): ValidatedMarkerEntry {
if (!Number.isFinite(marker.beat)) {
throw new Error(`Marker entry ${index + 1} has invalid "beat": ${String(marker.beat)}. Expected a finite number >= 0.`);
}
if (marker.beat < 0) {
throw new Error(`Marker entry ${index + 1} has invalid "beat": ${marker.beat}. Expected a value >= 0.`);
}
if (!Number.isFinite(marker.length)) {
throw new Error(`Marker entry ${index + 1} has invalid "length": ${String(marker.length)}. Expected a finite number > 0.`);
}
if (marker.length <= 0) {
throw new Error(`Marker entry ${index + 1} has invalid "length": ${marker.length}. Expected a value > 0.`);
}
const normalizedMarker = marker.marker?.replace(/\r?\n/g, ' ').trim();
if (!normalizedMarker) {
throw new Error(`Marker entry ${index + 1} has invalid "marker": expected a non-empty marker label.`);
}
return {
marker: normalizedMarker,
name: normalizedMarker,
startBeat: marker.beat,
length: marker.length,
};
}
private formatMultilineResult(result: string): string {
return result.replace(/\n/g, ' \n');
}
}
+30
View File
@@ -6,9 +6,19 @@ export type { ToolResult, ToolParameter, ToolDefinition, OpenAIToolDefinition, O
// Specific tools
import { AddNotesTool } from './AddNotesTool';
import { RemoveNotesTool } from './RemoveNotesTool';
import { RemoveChordProgressionTool } from './RemoveChordProgressionTool';
import { RemoveMarkersTool } from './RemoveMarkersTool';
import { RemoveKeySignatureTool } from './RemoveKeySignatureTool';
import { RemoveBpmTool } from './RemoveBpmTool';
import { ReadMusicTool } from './ReadMusicTool';
import { ReadMarkersTool } from './ReadMarkersTool';
import { ReadChordProgressionTool } from './ReadChordProgressionTool';
import { WriteChordProgressionTool } from './WriteChordProgressionTool';
import { WriteMarkersTool } from './WriteMarkersTool';
import { ReadKeySignatureTool } from './ReadKeySignatureTool';
import { ReadBpmTool } from './ReadBpmTool';
import { WriteKeySignatureTool } from './WriteKeySignatureTool';
import { WriteBpmTool } from './WriteBpmTool';
import { UpdateTodoListTool } from './UpdateTodoListTool';
import { GetUserSelectedMusicRangeAndTrackTool } from './GetUserSelectedMusicRangeAndTrackTool';
import { ListAllTracksTool } from './ListAllTracksTool';
@@ -20,9 +30,19 @@ import { DeleteTrackTool } from './DeleteTrackTool';
export {
AddNotesTool,
RemoveNotesTool,
RemoveChordProgressionTool,
RemoveMarkersTool,
RemoveKeySignatureTool,
RemoveBpmTool,
ReadMusicTool,
ReadMarkersTool,
ReadChordProgressionTool,
WriteChordProgressionTool,
WriteMarkersTool,
ReadKeySignatureTool,
ReadBpmTool,
WriteKeySignatureTool,
WriteBpmTool,
UpdateTodoListTool,
GetUserSelectedMusicRangeAndTrackTool,
ListAllTracksTool,
@@ -37,9 +57,19 @@ export const AVAILABLE_TOOLS = {
update_todo_list: UpdateTodoListTool,
add_notes: AddNotesTool,
remove_notes: RemoveNotesTool,
remove_chord_progression: RemoveChordProgressionTool,
remove_markers: RemoveMarkersTool,
remove_key_signature: RemoveKeySignatureTool,
remove_bpm: RemoveBpmTool,
read_music: ReadMusicTool,
read_markers: ReadMarkersTool,
read_chord_progression: ReadChordProgressionTool,
write_chord_progression: WriteChordProgressionTool,
write_markers: WriteMarkersTool,
read_key_signature: ReadKeySignatureTool,
read_bpm: ReadBpmTool,
write_key_signature: WriteKeySignatureTool,
write_bpm: WriteBpmTool,
get_user_selected_music_range_and_track: GetUserSelectedMusicRangeAndTrackTool,
list_all_tracks: ListAllTracksTool,
list_all_available_instruments: ListAllAvailableInstrumentsTool,
@@ -8,6 +8,7 @@ import { MoveGlobalRegionCommand } from './MoveGlobalRegionCommand';
import { ResizeGlobalRegionCommand } from './ResizeGlobalRegionCommand';
import { DeleteGlobalRegionCommand } from './DeleteGlobalRegionCommand';
import { UpdateGlobalRegionTextCommand } from './UpdateGlobalRegionTextCommand';
import { WriteMarkersCommand } from './WriteMarkersCommand';
describe('global marker region commands', () => {
beforeEach(() => {
@@ -106,4 +107,63 @@ describe('global marker region commands', () => {
expect(markerTrack.getRegions()).toHaveLength(1);
expect(markerTrack.getRegions()[0].getId()).toBe('marker');
});
it('writes a marker into the middle of an existing region and preserves both sides', () => {
const markerTrack = getMarkerTrack();
markerTrack.setRegions([
new KGMarkerRegion('base', markerTrack.getId(), markerTrack.getTrackIndex(), 'Intro', 0, 8),
]);
const command = new WriteMarkersCommand([
{ startBeat: 3, length: 2, name: 'Hit' },
]);
command.execute();
expect((getMarkerTrack().getRegions() as KGMarkerRegion[]).map(region => ({
name: region.getName(),
start: region.getStartFromBeat(),
length: region.getLength(),
}))).toEqual([
{ name: 'Intro', start: 0, length: 3 },
{ name: 'Hit', start: 3, length: 2 },
{ name: 'Intro', start: 5, length: 3 },
]);
command.undo();
expect((getMarkerTrack().getRegions() as KGMarkerRegion[]).map(region => ({
name: region.getName(),
start: region.getStartFromBeat(),
length: region.getLength(),
}))).toEqual([
{ name: 'Intro', start: 0, length: 8 },
]);
});
it('writes multiple non-contiguous marker spans while preserving untouched gaps', () => {
const markerTrack = getMarkerTrack();
markerTrack.setRegions([
new KGMarkerRegion('left', markerTrack.getId(), markerTrack.getTrackIndex(), 'Scene', 0, 12),
]);
const command = new WriteMarkersCommand([
{ startBeat: 2, length: 2, name: 'Rise' },
{ startBeat: 8, length: 2, name: 'Drop' },
]);
command.execute();
expect((getMarkerTrack().getRegions() as KGMarkerRegion[]).map(region => ({
name: region.getName(),
start: region.getStartFromBeat(),
length: region.getLength(),
}))).toEqual([
{ name: 'Scene', start: 0, length: 2 },
{ name: 'Rise', start: 2, length: 2 },
{ name: 'Scene', start: 4, length: 4 },
{ name: 'Drop', start: 8, length: 2 },
{ name: 'Scene', start: 10, length: 2 },
]);
});
});
@@ -0,0 +1,85 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { KGCore } from '../../KGCore';
import { KGProject } from '../../KGProject';
import { GlobalTrackType } from '../../global-track';
import { KGKeySignatureRegion } from '../../region/KGKeySignatureRegion';
import { WriteKeySignatureTrackCommand } from './WriteKeySignatureTrackCommand';
describe('WriteKeySignatureTrackCommand', () => {
beforeEach(() => {
const project = new KGProject('Signatures', 8, 0, 120);
const mockCore = KGCore.instance() as unknown as {
getCurrentProject: ReturnType<typeof vi.fn>;
};
mockCore.getCurrentProject.mockReturnValue(project);
});
const getSignatureTrack = () => {
const signatureTrack = KGCore.instance().getCurrentProject().getGlobalTracks()
.find(track => track.getType() === GlobalTrackType.Signature);
if (!signatureTrack) {
throw new Error('Signature track missing in test setup');
}
return signatureTrack;
};
it('rebuilds the full signature track from explicit entries', () => {
const command = new WriteKeySignatureTrackCommand('C major', [
{ startBeat: 8, keySignature: 'G major' },
{ startBeat: 16, keySignature: 'D major' },
]);
command.execute();
const regions = getSignatureTrack().getRegions() as KGKeySignatureRegion[];
expect(regions.map(region => ({
keySignature: region.getKeySignature(),
startBar: region.getStartBar(),
lengthBars: region.getLengthBars(),
}))).toEqual([
{ keySignature: 'C major', startBar: 0, lengthBars: 2 },
{ keySignature: 'G major', startBar: 2, lengthBars: 2 },
{ keySignature: 'D major', startBar: 4, lengthBars: 4 },
]);
});
it('replaces an existing multi-region track and restores it on undo', () => {
const signatureTrack = getSignatureTrack();
signatureTrack.setRegions([
new KGKeySignatureRegion('existing-1', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'F major', 0, 3, 4),
new KGKeySignatureRegion('existing-2', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'Bb major', 3, 5, 4),
]);
const command = new WriteKeySignatureTrackCommand('A minor', [
{ startBeat: 12, keySignature: 'E minor' },
]);
command.execute();
expect((signatureTrack.getRegions() as KGKeySignatureRegion[]).map(region => region.getKeySignature()))
.toEqual(['A minor', 'E minor']);
command.undo();
expect((signatureTrack.getRegions() as KGKeySignatureRegion[]).map(region => ({
keySignature: region.getKeySignature(),
startBar: region.getStartBar(),
lengthBars: region.getLengthBars(),
}))).toEqual([
{ keySignature: 'F major', startBar: 0, lengthBars: 3 },
{ keySignature: 'Bb major', startBar: 3, lengthBars: 5 },
]);
});
it('uses only the base key signature when no explicit entries are provided', () => {
const command = new WriteKeySignatureTrackCommand('E minor', []);
command.execute();
const regions = getSignatureTrack().getRegions() as KGKeySignatureRegion[];
expect(regions).toHaveLength(1);
expect(regions[0].getKeySignature()).toBe('E minor');
expect(regions[0].getStartBar()).toBe(0);
expect(regions[0].getLengthBars()).toBe(8);
});
});
@@ -0,0 +1,123 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import type { KeySignature } from '../../KGProject';
import { GlobalTrackType } from '../../global-track';
import { KGKeySignatureRegion } from '../../region/KGKeySignatureRegion';
import {
cloneKeySignatureRegions,
findGlobalTrackByType,
getSongEndBar,
getSortedKeySignatureRegions,
} from '../../../util/globalTrackUtil';
import { generateUniqueId } from '../../../util/miscUtil';
export interface WriteKeySignatureEntry {
startBeat: number;
keySignature: KeySignature;
}
function cloneRegions(regions: KGKeySignatureRegion[], beatsPerBar: number): KGKeySignatureRegion[] {
return cloneKeySignatureRegions(regions, beatsPerBar);
}
export class WriteKeySignatureTrackCommand extends KGCommand {
private readonly baseKeySignature: KeySignature;
private readonly replacements: WriteKeySignatureEntry[];
private previousRegions: KGKeySignatureRegion[] | null = null;
private nextRegions: KGKeySignatureRegion[] | null = null;
constructor(baseKeySignature: KeySignature, replacements: WriteKeySignatureEntry[]) {
super();
this.baseKeySignature = baseKeySignature;
this.replacements = replacements.map(replacement => ({
startBeat: replacement.startBeat,
keySignature: replacement.keySignature,
}));
}
execute(): void {
const project = KGCore.instance().getCurrentProject();
const beatsPerBar = project.getTimeSignature().numerator;
const track = findGlobalTrackByType(project, GlobalTrackType.Signature);
if (!track) {
throw new Error('Signature global track not found');
}
if (this.nextRegions) {
track.setRegions(cloneRegions(this.nextRegions, beatsPerBar));
return;
}
const currentRegions = getSortedKeySignatureRegions(track, beatsPerBar);
this.previousRegions = cloneRegions(currentRegions, beatsPerBar);
const songEndBar = getSongEndBar(project);
if (songEndBar <= 0) {
this.nextRegions = [];
track.setRegions([]);
return;
}
const normalizedReplacements = this.replacements
.map(replacement => ({
startBar: Math.floor(replacement.startBeat / beatsPerBar),
keySignature: replacement.keySignature,
}))
.sort((left, right) => left.startBar - right.startBar);
const nextRegions: KGKeySignatureRegion[] = [];
let currentStartBar = 0;
let currentKeySignature = this.baseKeySignature;
for (const replacement of normalizedReplacements) {
if (replacement.startBar > currentStartBar) {
nextRegions.push(new KGKeySignatureRegion(
generateUniqueId('KGKeySignatureRegion'),
track.getId(),
track.getTrackIndex(),
currentKeySignature,
currentStartBar,
replacement.startBar - currentStartBar,
beatsPerBar,
));
}
currentStartBar = replacement.startBar;
currentKeySignature = replacement.keySignature;
}
if (currentStartBar < songEndBar) {
nextRegions.push(new KGKeySignatureRegion(
generateUniqueId('KGKeySignatureRegion'),
track.getId(),
track.getTrackIndex(),
currentKeySignature,
currentStartBar,
songEndBar - currentStartBar,
beatsPerBar,
));
}
this.nextRegions = nextRegions;
track.setRegions(cloneRegions(this.nextRegions, beatsPerBar));
}
undo(): void {
if (!this.previousRegions) {
throw new Error('Cannot undo key signature write without original regions');
}
const project = KGCore.instance().getCurrentProject();
const beatsPerBar = project.getTimeSignature().numerator;
const track = findGlobalTrackByType(project, GlobalTrackType.Signature);
if (!track) {
throw new Error('Signature global track not found during undo');
}
track.setRegions(cloneRegions(this.previousRegions, beatsPerBar));
}
getDescription(): string {
return 'Write key signature track';
}
}
@@ -0,0 +1,139 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { GlobalTrackType } from '../../global-track';
import { KGMarkerRegion } from '../../region/KGMarkerRegion';
import { findGlobalTrackByType } from '../../../util/globalTrackUtil';
import { generateUniqueId } from '../../../util/miscUtil';
export interface WriteMarkerEntry {
startBeat: number;
length: number;
name: string;
}
function cloneMarkerRegion(region: KGMarkerRegion): KGMarkerRegion {
return new KGMarkerRegion(
region.getId(),
region.getTrackId(),
region.getTrackIndex(),
region.getName(),
region.getStartFromBeat(),
region.getLength(),
);
}
function cloneMarkerRegions(regions: KGMarkerRegion[]): KGMarkerRegion[] {
return regions.map(cloneMarkerRegion);
}
export class WriteMarkersCommand extends KGCommand {
private readonly replacements: WriteMarkerEntry[];
private originalRegions: KGMarkerRegion[] | null = null;
private nextRegions: KGMarkerRegion[] | null = null;
constructor(replacements: WriteMarkerEntry[]) {
super();
this.replacements = replacements.map(replacement => ({
startBeat: replacement.startBeat,
length: replacement.length,
name: replacement.name,
}));
}
execute(): void {
const project = KGCore.instance().getCurrentProject();
const markerTrack = findGlobalTrackByType(project, GlobalTrackType.Marker);
if (!markerTrack) {
throw new Error('Marker global track not found');
}
if (this.nextRegions) {
markerTrack.setRegions(cloneMarkerRegions(this.nextRegions));
return;
}
const currentRegions = markerTrack.getRegions()
.filter((region): region is KGMarkerRegion => region instanceof KGMarkerRegion)
.sort((left, right) => left.getStartFromBeat() - right.getStartFromBeat());
const sortedReplacements = [...this.replacements].sort((left, right) => left.startBeat - right.startBeat);
this.originalRegions = cloneMarkerRegions(currentRegions);
const preservedRegions: KGMarkerRegion[] = [];
for (const region of currentRegions) {
const regionStart = region.getStartFromBeat();
const regionEnd = regionStart + region.getLength();
const overlappingReplacements = sortedReplacements.filter(replacement => (
replacement.startBeat < regionEnd
&& replacement.startBeat + replacement.length > regionStart
));
if (overlappingReplacements.length === 0) {
preservedRegions.push(cloneMarkerRegion(region));
continue;
}
let cursor = regionStart;
let fragmentIndex = 0;
for (const replacement of overlappingReplacements) {
const replacementStart = Math.max(regionStart, replacement.startBeat);
const replacementEnd = Math.min(regionEnd, replacement.startBeat + replacement.length);
if (replacementStart > cursor) {
preservedRegions.push(new KGMarkerRegion(
fragmentIndex === 0 ? region.getId() : generateUniqueId('KGMarkerRegion'),
region.getTrackId(),
region.getTrackIndex(),
region.getName(),
cursor,
replacementStart - cursor,
));
fragmentIndex += 1;
}
cursor = Math.max(cursor, replacementEnd);
}
if (cursor < regionEnd) {
preservedRegions.push(new KGMarkerRegion(
fragmentIndex === 0 ? region.getId() : generateUniqueId('KGMarkerRegion'),
region.getTrackId(),
region.getTrackIndex(),
region.getName(),
cursor,
regionEnd - cursor,
));
}
}
const replacementRegions = sortedReplacements.map(replacement => new KGMarkerRegion(
generateUniqueId('KGMarkerRegion'),
markerTrack.getId(),
markerTrack.getTrackIndex(),
replacement.name,
replacement.startBeat,
replacement.length,
));
this.nextRegions = [...preservedRegions, ...replacementRegions]
.sort((left, right) => left.getStartFromBeat() - right.getStartFromBeat());
markerTrack.setRegions(cloneMarkerRegions(this.nextRegions));
}
undo(): void {
if (!this.originalRegions) {
throw new Error('Cannot undo marker write without original regions');
}
const project = KGCore.instance().getCurrentProject();
const markerTrack = findGlobalTrackByType(project, GlobalTrackType.Marker);
if (!markerTrack) {
throw new Error('Marker global track not found during undo');
}
markerTrack.setRegions(cloneMarkerRegions(this.originalRegions));
}
getDescription(): string {
return 'Write markers';
}
}
@@ -0,0 +1,93 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { KGCore } from '../../KGCore';
import { KGProject } from '../../KGProject';
import { GlobalTrackType } from '../../global-track';
import { KGTempoRegion } from '../../region/KGTempoRegion';
import { WriteTempoTrackCommand } from './WriteTempoTrackCommand';
describe('WriteTempoTrackCommand', () => {
beforeEach(() => {
const project = new KGProject('Tempo', 8, 0, 120);
const mockCore = KGCore.instance() as unknown as {
getCurrentProject: ReturnType<typeof vi.fn>;
};
mockCore.getCurrentProject.mockReturnValue(project);
});
const getTempoTrack = () => {
const tempoTrack = KGCore.instance().getCurrentProject().getGlobalTracks()
.find(track => track.getType() === GlobalTrackType.Tempo);
if (!tempoTrack) {
throw new Error('Tempo track missing in test setup');
}
return tempoTrack;
};
it('writes base BPM only by clearing explicit tempo regions and updating project BPM', () => {
const tempoTrack = getTempoTrack();
tempoTrack.setRegions([
new KGTempoRegion('existing-1', tempoTrack.getId(), tempoTrack.getTrackIndex(), 120, 0, 3, 4),
new KGTempoRegion('existing-2', tempoTrack.getId(), tempoTrack.getTrackIndex(), 140, 3, 5, 4),
]);
const command = new WriteTempoTrackCommand(96, []);
command.execute();
expect(KGCore.instance().getCurrentProject().getBpm()).toBe(96);
expect(tempoTrack.getRegions()).toEqual([]);
});
it('rebuilds explicit tempo regions into a gapless full-song plan', () => {
const command = new WriteTempoTrackCommand(100, [
{ startBeat: 8, bpm: 120 },
{ startBeat: 16, bpm: 140 },
]);
command.execute();
const project = KGCore.instance().getCurrentProject();
const regions = getTempoTrack().getRegions() as KGTempoRegion[];
expect(project.getBpm()).toBe(100);
expect(regions.map(region => ({
bpm: region.getBpm(),
startBar: region.getStartBar(),
lengthBars: region.getLengthBars(),
}))).toEqual([
{ bpm: 100, startBar: 0, lengthBars: 2 },
{ bpm: 120, startBar: 2, lengthBars: 2 },
{ bpm: 140, startBar: 4, lengthBars: 4 },
]);
});
it('restores both project BPM and prior tempo regions on undo', () => {
const project = KGCore.instance().getCurrentProject();
const tempoTrack = getTempoTrack();
tempoTrack.setRegions([
new KGTempoRegion('existing-1', tempoTrack.getId(), tempoTrack.getTrackIndex(), 120, 0, 2, 4),
new KGTempoRegion('existing-2', tempoTrack.getId(), tempoTrack.getTrackIndex(), 128, 2, 6, 4),
]);
const command = new WriteTempoTrackCommand(88, [
{ startBeat: 12, bpm: 144 },
]);
command.execute();
expect(project.getBpm()).toBe(88);
expect((tempoTrack.getRegions() as KGTempoRegion[]).map(region => region.getBpm())).toEqual([88, 144]);
command.undo();
expect(project.getBpm()).toBe(120);
expect((tempoTrack.getRegions() as KGTempoRegion[]).map(region => ({
bpm: region.getBpm(),
startBar: region.getStartBar(),
lengthBars: region.getLengthBars(),
}))).toEqual([
{ bpm: 120, startBar: 0, lengthBars: 2 },
{ bpm: 128, startBar: 2, lengthBars: 6 },
]);
});
});
@@ -0,0 +1,143 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { GlobalTrackType } from '../../global-track';
import { KGTempoRegion } from '../../region/KGTempoRegion';
import {
cloneTempoRegions,
findGlobalTrackByType,
getSongEndBar,
getSortedTempoRegions,
} from '../../../util/globalTrackUtil';
import { generateUniqueId } from '../../../util/miscUtil';
export interface WriteTempoEntry {
startBeat: number;
bpm: number;
}
function cloneRegions(regions: KGTempoRegion[], beatsPerBar: number): KGTempoRegion[] {
return cloneTempoRegions(regions, beatsPerBar);
}
export class WriteTempoTrackCommand extends KGCommand {
private readonly baseBpm: number;
private readonly replacements: WriteTempoEntry[];
private previousRegions: KGTempoRegion[] | null = null;
private previousProjectBpm: number | null = null;
private nextRegions: KGTempoRegion[] | null = null;
constructor(baseBpm: number, replacements: WriteTempoEntry[]) {
super();
this.baseBpm = baseBpm;
this.replacements = replacements.map(replacement => ({
startBeat: replacement.startBeat,
bpm: replacement.bpm,
}));
}
execute(): void {
const project = KGCore.instance().getCurrentProject();
const beatsPerBar = project.getTimeSignature().numerator;
const track = findGlobalTrackByType(project, GlobalTrackType.Tempo);
if (!track) {
throw new Error('Tempo global track not found');
}
if (this.nextRegions) {
project.setBpm(this.baseBpm);
track.setRegions(cloneRegions(this.nextRegions, beatsPerBar));
return;
}
const currentRegions = getSortedTempoRegions(track, beatsPerBar);
this.previousProjectBpm = project.getBpm();
this.previousRegions = cloneRegions(currentRegions, beatsPerBar);
project.setBpm(this.baseBpm);
if (this.replacements.length === 0) {
this.nextRegions = [];
track.setRegions([]);
return;
}
const songEndBar = getSongEndBar(project);
if (songEndBar <= 0) {
this.nextRegions = [];
track.setRegions([]);
return;
}
const normalizedReplacements = this.replacements
.map(replacement => ({
startBar: Math.floor(replacement.startBeat / beatsPerBar),
bpm: replacement.bpm,
}))
.sort((left, right) => left.startBar - right.startBar);
for (let index = 1; index < normalizedReplacements.length; index += 1) {
const previous = normalizedReplacements[index - 1];
const current = normalizedReplacements[index];
if (current.startBar <= previous.startBar) {
throw new Error(
`Tempo entry ${index + 1} overlaps with or collapses into entry ${index} after bar alignment. Entry ${index} normalizes to bar ${previous.startBar + 1}, and entry ${index + 1} normalizes to bar ${current.startBar + 1}.`,
);
}
}
const nextRegions: KGTempoRegion[] = [];
let currentStartBar = 0;
let currentBpm = this.baseBpm;
for (const replacement of normalizedReplacements) {
if (replacement.startBar > currentStartBar) {
nextRegions.push(new KGTempoRegion(
generateUniqueId('KGTempoRegion'),
track.getId(),
track.getTrackIndex(),
currentBpm,
currentStartBar,
replacement.startBar - currentStartBar,
beatsPerBar,
));
}
currentStartBar = replacement.startBar;
currentBpm = replacement.bpm;
}
if (currentStartBar < songEndBar) {
nextRegions.push(new KGTempoRegion(
generateUniqueId('KGTempoRegion'),
track.getId(),
track.getTrackIndex(),
currentBpm,
currentStartBar,
songEndBar - currentStartBar,
beatsPerBar,
));
}
this.nextRegions = nextRegions;
track.setRegions(cloneRegions(this.nextRegions, beatsPerBar));
}
undo(): void {
if (!this.previousRegions || this.previousProjectBpm === null) {
throw new Error('Cannot undo tempo write without original state');
}
const project = KGCore.instance().getCurrentProject();
const beatsPerBar = project.getTimeSignature().numerator;
const track = findGlobalTrackByType(project, GlobalTrackType.Tempo);
if (!track) {
throw new Error('Tempo global track not found during undo');
}
project.setBpm(this.previousProjectBpm);
track.setRegions(cloneRegions(this.previousRegions, beatsPerBar));
}
getDescription(): string {
return 'Write tempo track';
}
}
+12
View File
@@ -47,6 +47,18 @@ export {
WriteChordProgressionCommand,
type WriteChordProgressionEntry,
} from './global-region/WriteChordProgressionCommand';
export {
WriteMarkersCommand,
type WriteMarkerEntry,
} from './global-region/WriteMarkersCommand';
export {
WriteKeySignatureTrackCommand,
type WriteKeySignatureEntry,
} from './global-region/WriteKeySignatureTrackCommand';
export {
WriteTempoTrackCommand,
type WriteTempoEntry,
} from './global-region/WriteTempoTrackCommand';
export { CreateKeySignatureRegionCommand } from './global-region/CreateKeySignatureRegionCommand';
export { CreateTempoRegionCommand } from './global-region/CreateTempoRegionCommand';
export { MoveGlobalRegionCommand } from './global-region/MoveGlobalRegionCommand';