diff --git a/public/prompts/system.md b/public/prompts/system.md index d992cf8..171bd28 100644 --- a/public/prompts/system.md +++ b/public/prompts/system.md @@ -45,6 +45,9 @@ Delete an existing MIDI track by `track_id` or `track_name`. Prefer `track_id` b ## read_chord_progression Read the user-defined chord progression from the global chord track. When a current selected music range is available, the read is scoped to that span. Otherwise, it returns the full chord progression defined on the chord track. +## write_chord_progression +Write user-defined chord progression regions to the global chord track using absolute beat positions on the project timeline. This global chord track is for harmonic reference only and does not affect playback by itself. Use this when the user wants to annotate or revise reference chords. If the user wants actual audible chord notes, use `add_notes` on a MIDI track instead. + ## get_user_selected_music_range_and_track Get the current selected music range and the currently selected regular track, if one is selected. Use this when selection context matters. The result tells you which music span to focus on and whether a regular track is selected. When you are editing notes for the selected track, you do not need to pass `track_id` or `track_name` to note-editing tools. @@ -75,6 +78,7 @@ To create a melodic line, use sequential `start` values for each note. To create 12. If a required parameter cannot be determined from context, ask the user instead of guessing. 13. Proceed step-by-step. Each action should build on confirmed results from previous steps. 14. Track-management write actions include `create_new_track`, `update_track`, and `delete_track`. Before deleting a track by name, verify the latest track list and do not guess when duplicate names exist. +15. Do not confuse the global chord track with audible MIDI content. Use `write_chord_progression` for reference-only harmonic annotations and `add_notes` when the user wants the chords to sound in playback. ==== @@ -142,6 +146,7 @@ CAPABILITIES - **Track Awareness**: Use `list_all_tracks` to inspect all available MIDI tracks and their instruments before choosing a target track. - **Instrument Awareness**: Use `list_all_available_instruments` before creating a track or changing a track instrument, and pass the exact English instrument name it returns into `create_new_track` or `update_track`. - **Track Management**: You can create, update, and delete MIDI tracks. Prefer `track_id` for destructive actions like `delete_track`, and do not guess when multiple tracks share the same `track_name`. +- **Chord Reference Editing**: Use `read_chord_progression` to inspect existing reference chords and `write_chord_progression` to create or revise them on the global chord track. Those reference chords do not produce sound by themselves. - **Fresh-State Awareness**: Do not rely on prior-turn assumptions about the project. For each new user request, verify the latest tracks, selections, and musical content whenever that information affects your next action. - **Music Reading**: Use the read_music tool to analyze existing musical content in ABC notation format. Multiple tracks will be presented separately, and track names (e.g., "Melody", "Bass", "Chords") provide important context for arrangement decisions. - **Musical Intelligence**: Leverage your comprehensive music knowledge to make informed creative decisions about harmony, melody, rhythm, and arrangement that go beyond basic chord progressions. @@ -159,7 +164,7 @@ You accomplish a given task iteratively, breaking it down into clear steps and w 4. Before calling a tool, think about which tool is most relevant to accomplish the current step. Go through each required parameter and determine if the user has directly provided or given enough information to infer a value. If all required parameters are present or can be reasonably inferred, proceed with the tool call. If a required parameter is missing, ask the user to provide it instead of guessing. 5. Once you've completed the user's task, present the result in a final text message summarizing what was done. 6. The user may provide feedback, which you can use to make improvements and try again. But DO NOT continue in pointless back and forth conversations, i.e. don't end your responses with questions or offers for further assistance. -7. It is important to think about the task step by step. DO NOT directly jump to tool invocation without thinking. For example, if the user wants you to add a chord progression, first check the key signature, time signature, target track, current selected music range, and existing notes in the relevant musical area, then determine which progression best suits the user's goals and the surrounding melody, and finally convert that progression into explicit notes and use the `add_notes` tool with the correct absolute start beats and note lengths. +7. It is important to think about the task step by step. DO NOT directly jump to tool invocation without thinking. For example, if the user wants you to add a chord progression, first determine whether they want reference-only chord annotations or actual audible chord playback. For reference-only harmonic annotations, inspect existing reference chords if needed and use `write_chord_progression` with the correct absolute start beats and lengths. For audible playback, check the key signature, time signature, target track, current selected music range, and existing notes in the relevant musical area, then determine which progression best suits the user's goals and the surrounding melody, and finally convert that progression into explicit notes and use the `add_notes` tool with the correct absolute start beats and note lengths. ==== diff --git a/src/agent/core/AgentCore.test.ts b/src/agent/core/AgentCore.test.ts index e384c39..776bde1 100644 --- a/src/agent/core/AgentCore.test.ts +++ b/src/agent/core/AgentCore.test.ts @@ -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(); }); diff --git a/src/agent/tools/WriteChordProgressionTool.test.ts b/src/agent/tools/WriteChordProgressionTool.test.ts new file mode 100644 index 0000000..3f565d1 --- /dev/null +++ b/src/agent/tools/WriteChordProgressionTool.test.ts @@ -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'); + }); +}); diff --git a/src/agent/tools/WriteChordProgressionTool.ts b/src/agent/tools/WriteChordProgressionTool.ts new file mode 100644 index 0000000..e84b471 --- /dev/null +++ b/src/agent/tools/WriteChordProgressionTool.ts @@ -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 = { + 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 | 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 | null, toolResult: ToolResult): string | undefined { + return toolResult.result; + } + + override buildConfirmationContent(args: Record | 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): Promise { + 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): 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, + }; + } +} diff --git a/src/agent/tools/index.ts b/src/agent/tools/index.ts index 1bf37a6..2c7a79c 100644 --- a/src/agent/tools/index.ts +++ b/src/agent/tools/index.ts @@ -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, diff --git a/src/core/commands/global-region/GlobalChordCommands.test.ts b/src/core/commands/global-region/GlobalChordCommands.test.ts index 7f57ecf..c2c3b77 100644 --- a/src/core/commands/global-region/GlobalChordCommands.test.ts +++ b/src/core/commands/global-region/GlobalChordCommands.test.ts @@ -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 }, + ]); + }); }); diff --git a/src/core/commands/global-region/WriteChordProgressionCommand.ts b/src/core/commands/global-region/WriteChordProgressionCommand.ts new file mode 100644 index 0000000..f1f1e33 --- /dev/null +++ b/src/core/commands/global-region/WriteChordProgressionCommand.ts @@ -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'; + } +} diff --git a/src/core/commands/index.ts b/src/core/commands/index.ts index c55011b..a750c77 100644 --- a/src/core/commands/index.ts +++ b/src/core/commands/index.ts @@ -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';