feat: added tool write_chord_progression
This commit is contained in:
@@ -266,6 +266,7 @@ describe('AgentCore todo integration', () => {
|
||||
expect(toolNames).toContain('list_all_available_instruments');
|
||||
expect(toolNames).toContain('create_new_track');
|
||||
expect(toolNames).toContain('update_track');
|
||||
expect(toolNames).toContain('write_chord_progression');
|
||||
});
|
||||
|
||||
it('uses the compact system prompt in efficient mode', async () => {
|
||||
@@ -310,6 +311,7 @@ describe('AgentCore todo integration', () => {
|
||||
expect(toolNames).not.toContain('list_all_available_instruments');
|
||||
expect(toolNames).not.toContain('create_new_track');
|
||||
expect(toolNames).not.toContain('update_track');
|
||||
expect(toolNames).not.toContain('write_chord_progression');
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { WriteChordProgressionTool } from './WriteChordProgressionTool';
|
||||
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(),
|
||||
} as unknown as KGCore);
|
||||
}
|
||||
|
||||
function getChordTrack(project: KGProject) {
|
||||
const chordTrack = findGlobalTrackByType(project, GlobalTrackType.Chord);
|
||||
expect(chordTrack).not.toBeNull();
|
||||
return chordTrack!;
|
||||
}
|
||||
|
||||
describe('WriteChordProgressionTool', () => {
|
||||
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 WriteChordProgressionTool();
|
||||
const definition = tool.getDefinition();
|
||||
|
||||
expect(tool.isReadOnlyTool()).toBe(false);
|
||||
expect(tool.isAvailableInEfficientMode()).toBe(false);
|
||||
expect(definition.function.name).toBe('write_chord_progression');
|
||||
expect(definition.function.description).toContain('reference only');
|
||||
expect(JSON.stringify(definition.function.parameters)).toContain('Bm7b5');
|
||||
});
|
||||
|
||||
it('writes a single chord into an empty chord track and canonicalizes the symbol', async () => {
|
||||
const project = new KGProject('single-write-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
mockCore(project);
|
||||
|
||||
const tool = new WriteChordProgressionTool();
|
||||
const result = await tool.execute({
|
||||
chords: [{ chord: ' bm7b5 ', start: 4, length: 4 }],
|
||||
});
|
||||
|
||||
const chordTrack = getChordTrack(project);
|
||||
expect(result.success).toBe(true);
|
||||
expect((chordTrack.getRegions()[0] as KGChordRegion).getSymbol()).toBe('Bm7b5');
|
||||
expect(tool.buildToolHistoryContent({}, result)).toBe(result.result);
|
||||
expect(tool.buildToolResultDisplayContent({ chords: [{ chord: 'Bm7b5', start: 4, length: 4 }] }, result))
|
||||
.toBe('Updated 1 chord reference on the global Chord Track across bar 2.');
|
||||
});
|
||||
|
||||
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');
|
||||
mockCore(project);
|
||||
|
||||
const tool = new WriteChordProgressionTool();
|
||||
expect(tool.buildConfirmationContent({
|
||||
chords: [
|
||||
{ chord: 'C', start: 0, length: 4 },
|
||||
{ chord: 'Dm', start: 4, length: 4 },
|
||||
],
|
||||
})).toBe('Allow updating 2 chord references on the global Chord Track across bars 1 to 2?');
|
||||
});
|
||||
|
||||
it('rejects an empty chord list', async () => {
|
||||
const project = new KGProject('empty-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
mockCore(project);
|
||||
|
||||
const tool = new WriteChordProgressionTool();
|
||||
const result = await tool.execute({ chords: [] });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.result).toContain('must contain at least one chord entry');
|
||||
});
|
||||
|
||||
it('rejects unparsable chord symbols with a field-specific error', async () => {
|
||||
const project = new KGProject('bad-chord-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
mockCore(project);
|
||||
|
||||
const tool = new WriteChordProgressionTool();
|
||||
const result = await tool.execute({ chords: [{ chord: 'not-a-chord', start: 0, length: 4 }] });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.result).toContain('Chord entry 1 has invalid "chord"');
|
||||
});
|
||||
|
||||
it('rejects negative start 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 WriteChordProgressionTool();
|
||||
const badStartResult = await tool.execute({ chords: [{ chord: 'C', start: -1, length: 4 }] });
|
||||
const badLengthResult = await tool.execute({ chords: [{ chord: 'C', start: 0, length: 0 }] });
|
||||
|
||||
expect(badStartResult.success).toBe(false);
|
||||
expect(badStartResult.result).toContain('invalid "start"');
|
||||
expect(badLengthResult.success).toBe(false);
|
||||
expect(badLengthResult.result).toContain('invalid "length"');
|
||||
});
|
||||
|
||||
it('rejects overlapping requested chord entries', async () => {
|
||||
const project = new KGProject('overlap-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
mockCore(project);
|
||||
|
||||
const tool = new WriteChordProgressionTool();
|
||||
const result = await tool.execute({
|
||||
chords: [
|
||||
{ chord: 'C', start: 0, length: 4 },
|
||||
{ chord: 'Dm', start: 3, length: 4 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.result).toContain('overlaps with chord entry 1');
|
||||
});
|
||||
|
||||
it('preserves untouched gaps and trims existing overlapping chord regions', async () => {
|
||||
const project = new KGProject('preserve-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
const chordTrack = getChordTrack(project);
|
||||
chordTrack.setRegions([
|
||||
new KGChordRegion('chord-1', chordTrack.getId(), chordTrack.getTrackIndex(), 'Am', 0, 8),
|
||||
new KGChordRegion('chord-2', chordTrack.getId(), chordTrack.getTrackIndex(), 'F', 8, 4),
|
||||
]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new WriteChordProgressionTool();
|
||||
const result = await tool.execute({
|
||||
chords: [
|
||||
{ chord: 'C', start: 3, length: 2 },
|
||||
{ chord: 'G', start: 10, length: 2 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect((chordTrack.getRegions() as KGChordRegion[]).map(region => ({
|
||||
symbol: region.getSymbol(),
|
||||
start: region.getStartFromBeat(),
|
||||
length: region.getLength(),
|
||||
}))).toEqual([
|
||||
{ symbol: 'Am', start: 0, length: 3 },
|
||||
{ symbol: 'C', start: 3, length: 2 },
|
||||
{ symbol: 'Am', start: 5, length: 3 },
|
||||
{ symbol: 'F', start: 8, length: 2 },
|
||||
{ symbol: 'G', start: 10, length: 2 },
|
||||
]);
|
||||
expect(result.result).toContain('harmonic reference only');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import { BaseTool } from './BaseTool';
|
||||
import type { ToolParameter, ToolResult } from './BaseTool';
|
||||
import {
|
||||
WriteChordProgressionCommand,
|
||||
type WriteChordProgressionEntry,
|
||||
} from '../../core/commands/global-region/WriteChordProgressionCommand';
|
||||
import { parseChordSymbol } from '../../util/chordUtil';
|
||||
|
||||
interface RequestedChordEntry {
|
||||
chord: string;
|
||||
start: number;
|
||||
length: number;
|
||||
}
|
||||
|
||||
interface ValidatedChordEntry extends WriteChordProgressionEntry {
|
||||
chord: string;
|
||||
}
|
||||
|
||||
interface ChordWriteSummaryData {
|
||||
chordCount: number;
|
||||
startBar: number;
|
||||
endBar: number;
|
||||
}
|
||||
|
||||
export class WriteChordProgressionTool extends BaseTool {
|
||||
readonly name = 'write_chord_progression';
|
||||
readonly description = 'Write chord-reference regions to the global chord track using absolute beat positions on the project timeline. The global chord track is for harmonic reference only and does not affect playback by itself. If the user wants audible chord playback, create notes on actual MIDI tracks with add_notes instead. Use chord symbols matching the chord input popup format, such as C, Dm, or Bm7b5.';
|
||||
|
||||
override isReadOnlyTool(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
override isAvailableInEfficientMode(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
readonly parameters: Record<string, ToolParameter> = {
|
||||
chords: {
|
||||
type: 'array',
|
||||
description: 'Chord-reference regions to write to the global chord track. Each entry uses an absolute beat start on the project timeline. Use chord symbols matching the chord input popup format, for example C, Dm, or Bm7b5.',
|
||||
required: true,
|
||||
items: {
|
||||
type: 'object',
|
||||
description: 'A single chord-reference region',
|
||||
properties: {
|
||||
chord: {
|
||||
type: 'string',
|
||||
description: 'Chord symbol in the app-accepted chord format. Examples: "C", "Dm", "Bm7b5".',
|
||||
required: true,
|
||||
},
|
||||
start: {
|
||||
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: 'Chord duration in beats. Must be greater than 0.',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
override buildToolResultDisplayContent(args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
|
||||
if (!args || !toolResult.success) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const summary = this.buildSummaryData(args);
|
||||
if (!summary) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `Updated ${summary.chordCount} chord ${summary.chordCount === 1 ? 'reference' : 'references'} on the global Chord Track across ${summary.startBar === summary.endBar ? `bar ${summary.startBar}` : `bars ${summary.startBar} to ${summary.endBar}`}.`;
|
||||
}
|
||||
|
||||
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 updating ${summary.chordCount} chord ${summary.chordCount === 1 ? 'reference' : 'references'} on the global Chord Track across ${summary.startBar === summary.endBar ? `bar ${summary.startBar}` : `bars ${summary.startBar} to ${summary.endBar}`}?`;
|
||||
}
|
||||
|
||||
async execute(params: Record<string, unknown>): Promise<ToolResult> {
|
||||
try {
|
||||
this.validateParameters(params);
|
||||
|
||||
const validatedChords = this.validateAndNormalizeChords(params.chords as RequestedChordEntry[]);
|
||||
const command = new WriteChordProgressionCommand(validatedChords.map(chord => ({
|
||||
startBeat: chord.startBeat,
|
||||
length: chord.length,
|
||||
symbol: chord.chord,
|
||||
})));
|
||||
await this.executeCommand(command);
|
||||
|
||||
const details = validatedChords
|
||||
.map(chord => `"${chord.chord}" from beat ${chord.startBeat} to beat ${chord.startBeat + chord.length}`)
|
||||
.join(', ');
|
||||
|
||||
return this.createSuccessResult(
|
||||
`Successfully wrote ${validatedChords.length} chord ${validatedChords.length === 1 ? 'reference' : 'references'} to the global chord track: ${details}. These chord regions are for harmonic reference only and do not change playback by themselves. If audible playback is needed, create notes on actual MIDI tracks with add_notes.`,
|
||||
);
|
||||
} catch (error) {
|
||||
return this.createErrorResult(`Failed to write chord progression: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
private buildSummaryData(args: Record<string, unknown>): ChordWriteSummaryData | null {
|
||||
const typedArgs = args as { chords?: Array<{ start: number; length: number }> };
|
||||
if (!Array.isArray(typedArgs.chords) || typedArgs.chords.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const beatsPerBar = this.getCurrentProject().getTimeSignature().numerator;
|
||||
const startBeat = Math.min(...typedArgs.chords.map(chord => chord.start));
|
||||
const endBeat = Math.max(...typedArgs.chords.map(chord => chord.start + chord.length));
|
||||
|
||||
return {
|
||||
chordCount: typedArgs.chords.length,
|
||||
startBar: Math.floor(startBeat / beatsPerBar) + 1,
|
||||
endBar: Math.max(1, Math.ceil(endBeat / beatsPerBar)),
|
||||
};
|
||||
}
|
||||
|
||||
private validateAndNormalizeChords(chords: RequestedChordEntry[]): ValidatedChordEntry[] {
|
||||
if (chords.length === 0) {
|
||||
throw new Error('Parameter "chords" must contain at least one chord entry.');
|
||||
}
|
||||
|
||||
const validated = chords.map((chord, index) => this.validateChordEntry(chord, 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(
|
||||
`Chord entry ${index + 1} overlaps with chord entry ${index}. Entry ${index} ends at beat ${previous.startBeat + previous.length}, but entry ${index + 1} starts at beat ${current.startBeat}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return validated;
|
||||
}
|
||||
|
||||
private validateChordEntry(chord: RequestedChordEntry, index: number): ValidatedChordEntry {
|
||||
if (!Number.isFinite(chord.start)) {
|
||||
throw new Error(`Chord entry ${index + 1} has invalid "start": ${String(chord.start)}. Expected a finite number >= 0.`);
|
||||
}
|
||||
if (chord.start < 0) {
|
||||
throw new Error(`Chord entry ${index + 1} has invalid "start": ${chord.start}. Expected a value >= 0.`);
|
||||
}
|
||||
if (!Number.isFinite(chord.length)) {
|
||||
throw new Error(`Chord entry ${index + 1} has invalid "length": ${String(chord.length)}. Expected a finite number > 0.`);
|
||||
}
|
||||
if (chord.length <= 0) {
|
||||
throw new Error(`Chord entry ${index + 1} has invalid "length": ${chord.length}. Expected a value > 0.`);
|
||||
}
|
||||
|
||||
const trimmedChord = chord.chord?.trim();
|
||||
if (!trimmedChord) {
|
||||
throw new Error(`Chord entry ${index + 1} has invalid "chord": expected a non-empty chord symbol.`);
|
||||
}
|
||||
|
||||
const parsed = parseChordSymbol(trimmedChord);
|
||||
if (!parsed) {
|
||||
throw new Error(`Chord entry ${index + 1} has invalid "chord": "${trimmedChord}". Use a chord symbol the app can parse, such as C, Dm, or Bm7b5.`);
|
||||
}
|
||||
|
||||
return {
|
||||
chord: parsed.symbol,
|
||||
symbol: parsed.symbol,
|
||||
startBeat: chord.start,
|
||||
length: chord.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { AddNotesTool } from './AddNotesTool';
|
||||
import { RemoveNotesTool } from './RemoveNotesTool';
|
||||
import { ReadMusicTool } from './ReadMusicTool';
|
||||
import { ReadChordProgressionTool } from './ReadChordProgressionTool';
|
||||
import { WriteChordProgressionTool } from './WriteChordProgressionTool';
|
||||
import { UpdateTodoListTool } from './UpdateTodoListTool';
|
||||
import { GetUserSelectedMusicRangeAndTrackTool } from './GetUserSelectedMusicRangeAndTrackTool';
|
||||
import { ListAllTracksTool } from './ListAllTracksTool';
|
||||
@@ -21,6 +22,7 @@ export {
|
||||
RemoveNotesTool,
|
||||
ReadMusicTool,
|
||||
ReadChordProgressionTool,
|
||||
WriteChordProgressionTool,
|
||||
UpdateTodoListTool,
|
||||
GetUserSelectedMusicRangeAndTrackTool,
|
||||
ListAllTracksTool,
|
||||
@@ -37,6 +39,7 @@ export const AVAILABLE_TOOLS = {
|
||||
remove_notes: RemoveNotesTool,
|
||||
read_music: ReadMusicTool,
|
||||
read_chord_progression: ReadChordProgressionTool,
|
||||
write_chord_progression: WriteChordProgressionTool,
|
||||
get_user_selected_music_range_and_track: GetUserSelectedMusicRangeAndTrackTool,
|
||||
list_all_tracks: ListAllTracksTool,
|
||||
list_all_available_instruments: ListAllAvailableInstrumentsTool,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { MoveGlobalRegionCommand } from './MoveGlobalRegionCommand';
|
||||
import { ReplaceChordRegionsInRangeCommand } from './ReplaceChordRegionsInRangeCommand';
|
||||
import { ResizeGlobalRegionCommand } from './ResizeGlobalRegionCommand';
|
||||
import { UpdateChordRegionCommand } from './UpdateChordRegionCommand';
|
||||
import { WriteChordProgressionCommand } from './WriteChordProgressionCommand';
|
||||
|
||||
describe('global chord region commands', () => {
|
||||
beforeEach(() => {
|
||||
@@ -147,4 +148,86 @@ describe('global chord region commands', () => {
|
||||
{ symbol: 'F', start: 8, length: 4 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('writes a chord into the middle of an existing region and preserves both sides', () => {
|
||||
const chordTrack = getChordTrack();
|
||||
chordTrack.setRegions([
|
||||
new KGChordRegion('base', chordTrack.getId(), chordTrack.getTrackIndex(), 'Am', 0, 8),
|
||||
]);
|
||||
|
||||
const command = new WriteChordProgressionCommand([
|
||||
{ startBeat: 3, length: 2, symbol: 'C' },
|
||||
]);
|
||||
|
||||
command.execute();
|
||||
|
||||
expect((getChordTrack().getRegions() as KGChordRegion[]).map(region => ({
|
||||
symbol: region.getSymbol(),
|
||||
start: region.getStartFromBeat(),
|
||||
length: region.getLength(),
|
||||
}))).toEqual([
|
||||
{ symbol: 'Am', start: 0, length: 3 },
|
||||
{ symbol: 'C', start: 3, length: 2 },
|
||||
{ symbol: 'Am', start: 5, length: 3 },
|
||||
]);
|
||||
|
||||
command.undo();
|
||||
|
||||
expect((getChordTrack().getRegions() as KGChordRegion[]).map(region => ({
|
||||
symbol: region.getSymbol(),
|
||||
start: region.getStartFromBeat(),
|
||||
length: region.getLength(),
|
||||
}))).toEqual([
|
||||
{ symbol: 'Am', start: 0, length: 8 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('writes multiple non-contiguous chord spans while preserving untouched gaps', () => {
|
||||
const chordTrack = getChordTrack();
|
||||
chordTrack.setRegions([
|
||||
new KGChordRegion('left', chordTrack.getId(), chordTrack.getTrackIndex(), 'Am', 0, 12),
|
||||
]);
|
||||
|
||||
const command = new WriteChordProgressionCommand([
|
||||
{ startBeat: 2, length: 2, symbol: 'C' },
|
||||
{ startBeat: 8, length: 2, symbol: 'G' },
|
||||
]);
|
||||
|
||||
command.execute();
|
||||
|
||||
expect((getChordTrack().getRegions() as KGChordRegion[]).map(region => ({
|
||||
symbol: region.getSymbol(),
|
||||
start: region.getStartFromBeat(),
|
||||
length: region.getLength(),
|
||||
}))).toEqual([
|
||||
{ symbol: 'Am', start: 0, length: 2 },
|
||||
{ symbol: 'C', start: 2, length: 2 },
|
||||
{ symbol: 'Am', start: 4, length: 4 },
|
||||
{ symbol: 'G', start: 8, length: 2 },
|
||||
{ symbol: 'Am', start: 10, length: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('writes adjacent chord spans without introducing overlap', () => {
|
||||
const chordTrack = getChordTrack();
|
||||
chordTrack.setRegions([
|
||||
new KGChordRegion('base', chordTrack.getId(), chordTrack.getTrackIndex(), 'Am', 0, 8),
|
||||
]);
|
||||
|
||||
const command = new WriteChordProgressionCommand([
|
||||
{ startBeat: 0, length: 4, symbol: 'C' },
|
||||
{ startBeat: 4, length: 4, symbol: 'F' },
|
||||
]);
|
||||
|
||||
command.execute();
|
||||
|
||||
expect((getChordTrack().getRegions() as KGChordRegion[]).map(region => ({
|
||||
symbol: region.getSymbol(),
|
||||
start: region.getStartFromBeat(),
|
||||
length: region.getLength(),
|
||||
}))).toEqual([
|
||||
{ symbol: 'C', start: 0, length: 4 },
|
||||
{ symbol: 'F', start: 4, length: 4 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { GlobalTrackType } from '../../global-track';
|
||||
import { KGChordRegion } from '../../region/KGChordRegion';
|
||||
import { findGlobalTrackByType } from '../../../util/globalTrackUtil';
|
||||
import { generateUniqueId } from '../../../util/miscUtil';
|
||||
|
||||
export interface WriteChordProgressionEntry {
|
||||
startBeat: number;
|
||||
length: number;
|
||||
symbol: string;
|
||||
}
|
||||
|
||||
function cloneChordRegion(region: KGChordRegion): KGChordRegion {
|
||||
return new KGChordRegion(
|
||||
region.getId(),
|
||||
region.getTrackId(),
|
||||
region.getTrackIndex(),
|
||||
region.getSymbol(),
|
||||
region.getStartFromBeat(),
|
||||
region.getLength(),
|
||||
);
|
||||
}
|
||||
|
||||
function cloneChordRegions(regions: KGChordRegion[]): KGChordRegion[] {
|
||||
return regions.map(cloneChordRegion);
|
||||
}
|
||||
|
||||
export class WriteChordProgressionCommand extends KGCommand {
|
||||
private readonly replacements: WriteChordProgressionEntry[];
|
||||
private originalRegions: KGChordRegion[] | null = null;
|
||||
private nextRegions: KGChordRegion[] | null = null;
|
||||
|
||||
constructor(replacements: WriteChordProgressionEntry[]) {
|
||||
super();
|
||||
this.replacements = replacements.map(replacement => ({
|
||||
startBeat: replacement.startBeat,
|
||||
length: replacement.length,
|
||||
symbol: replacement.symbol,
|
||||
}));
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
const chordTrack = findGlobalTrackByType(project, GlobalTrackType.Chord);
|
||||
if (!chordTrack) {
|
||||
throw new Error('Chord global track not found');
|
||||
}
|
||||
|
||||
if (this.nextRegions) {
|
||||
chordTrack.setRegions(cloneChordRegions(this.nextRegions));
|
||||
return;
|
||||
}
|
||||
|
||||
const currentRegions = chordTrack.getRegions()
|
||||
.filter((region): region is KGChordRegion => region instanceof KGChordRegion)
|
||||
.sort((left, right) => left.getStartFromBeat() - right.getStartFromBeat());
|
||||
|
||||
const sortedReplacements = [...this.replacements].sort((left, right) => left.startBeat - right.startBeat);
|
||||
this.originalRegions = cloneChordRegions(currentRegions);
|
||||
|
||||
const preservedRegions: KGChordRegion[] = [];
|
||||
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(cloneChordRegion(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 KGChordRegion(
|
||||
fragmentIndex === 0 ? region.getId() : generateUniqueId('KGChordRegion'),
|
||||
region.getTrackId(),
|
||||
region.getTrackIndex(),
|
||||
region.getSymbol(),
|
||||
cursor,
|
||||
replacementStart - cursor,
|
||||
));
|
||||
fragmentIndex += 1;
|
||||
}
|
||||
cursor = Math.max(cursor, replacementEnd);
|
||||
}
|
||||
|
||||
if (cursor < regionEnd) {
|
||||
preservedRegions.push(new KGChordRegion(
|
||||
fragmentIndex === 0 ? region.getId() : generateUniqueId('KGChordRegion'),
|
||||
region.getTrackId(),
|
||||
region.getTrackIndex(),
|
||||
region.getSymbol(),
|
||||
cursor,
|
||||
regionEnd - cursor,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
const replacementRegions = sortedReplacements.map(replacement => new KGChordRegion(
|
||||
generateUniqueId('KGChordRegion'),
|
||||
chordTrack.getId(),
|
||||
chordTrack.getTrackIndex(),
|
||||
replacement.symbol,
|
||||
replacement.startBeat,
|
||||
replacement.length,
|
||||
));
|
||||
|
||||
this.nextRegions = [...preservedRegions, ...replacementRegions]
|
||||
.sort((left, right) => left.getStartFromBeat() - right.getStartFromBeat());
|
||||
|
||||
chordTrack.setRegions(cloneChordRegions(this.nextRegions));
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.originalRegions) {
|
||||
throw new Error('Cannot undo chord progression write without original regions');
|
||||
}
|
||||
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
const chordTrack = findGlobalTrackByType(project, GlobalTrackType.Chord);
|
||||
if (!chordTrack) {
|
||||
throw new Error('Chord global track not found during undo');
|
||||
}
|
||||
|
||||
chordTrack.setRegions(cloneChordRegions(this.originalRegions));
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return 'Write chord progression';
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,10 @@ export {
|
||||
ReplaceChordRegionsInRangeCommand,
|
||||
type ChordRegionReplacementData,
|
||||
} from './global-region/ReplaceChordRegionsInRangeCommand';
|
||||
export {
|
||||
WriteChordProgressionCommand,
|
||||
type WriteChordProgressionEntry,
|
||||
} from './global-region/WriteChordProgressionCommand';
|
||||
export { CreateKeySignatureRegionCommand } from './global-region/CreateKeySignatureRegionCommand';
|
||||
export { CreateTempoRegionCommand } from './global-region/CreateTempoRegionCommand';
|
||||
export { MoveGlobalRegionCommand } from './global-region/MoveGlobalRegionCommand';
|
||||
|
||||
Reference in New Issue
Block a user