feat: added tool write_chord_progression

This commit is contained in:
Xiaohan-Tian
2026-06-05 15:29:00 -07:00
parent 2e3f3623ea
commit 622dbbea83
8 changed files with 581 additions and 1 deletions
@@ -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';
}
}
+4
View File
@@ -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';