feat: implement global chord track

This commit is contained in:
Xiaohan-Tian
2026-05-24 23:06:08 -07:00
parent 77fb0e4c1f
commit 1879df8033
30 changed files with 2757 additions and 20 deletions
@@ -0,0 +1,68 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { GlobalTrackType } from '../../global-track';
import { KGChordRegion } from '../../region/KGChordRegion';
import { generateUniqueId } from '../../../util/miscUtil';
import { findGlobalTrackByType, findNonOverlappingNeighborBounds, getSongEndBeat } from '../../../util/globalTrackUtil';
export class CreateChordRegionCommand extends KGCommand {
private readonly startBeat: number;
private readonly preferredLength: number;
private readonly symbol: string;
private readonly regionId: string;
private createdRegion: KGChordRegion | null = null;
constructor(startBeat: number, preferredLength: number, symbol: string = 'C', regionId?: string) {
super();
this.startBeat = startBeat;
this.preferredLength = preferredLength;
this.symbol = symbol;
this.regionId = regionId ?? generateUniqueId('KGChordRegion');
}
execute(): void {
const project = KGCore.instance().getCurrentProject();
const chordTrack = findGlobalTrackByType(project, GlobalTrackType.Chord);
if (!chordTrack) {
throw new Error('Chord global track not found');
}
const { maxEndBeat } = findNonOverlappingNeighborBounds(project, GlobalTrackType.Chord, null, this.startBeat);
const songEndBeat = getSongEndBeat(project);
const allowedEndBeat = Math.min(maxEndBeat, songEndBeat);
const targetEndBeat = Math.min(this.startBeat + this.preferredLength, allowedEndBeat);
const length = Math.max(1, targetEndBeat - this.startBeat);
this.createdRegion = new KGChordRegion(
this.regionId,
chordTrack.getId(),
chordTrack.getTrackIndex(),
this.symbol,
this.startBeat,
length
);
chordTrack.setRegions(
[...chordTrack.getRegions(), this.createdRegion]
.sort((left, right) => left.getStartFromBeat() - right.getStartFromBeat())
);
}
undo(): void {
const project = KGCore.instance().getCurrentProject();
const chordTrack = findGlobalTrackByType(project, GlobalTrackType.Chord);
if (!chordTrack) {
throw new Error('Chord global track not found during undo');
}
chordTrack.removeRegion(this.regionId);
}
getDescription(): string {
return `Create chord "${this.symbol}"`;
}
public getCreatedRegion(): KGChordRegion | null {
return this.createdRegion;
}
}
@@ -49,7 +49,7 @@ export class DeleteGlobalRegionCommand extends KGCommand {
}
getDescription(): string {
return `Delete marker "${this.deletedRegion?.getName() ?? this.regionId}"`;
return `Delete global region "${this.deletedRegion?.getName() ?? this.regionId}"`;
}
}
@@ -104,6 +104,6 @@ export class DeleteMultipleGlobalRegionsCommand extends KGCommand {
}
getDescription(): string {
return this.regionIds.length === 1 ? 'Delete marker' : `Delete ${this.regionIds.length} markers`;
return this.regionIds.length === 1 ? 'Delete global region' : `Delete ${this.regionIds.length} global regions`;
}
}
@@ -0,0 +1,107 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { KGCore } from '../../KGCore';
import { KGProject } from '../../KGProject';
import { GlobalTrackType } from '../../global-track';
import { KGChordRegion } from '../../region/KGChordRegion';
import { CreateChordRegionCommand } from './CreateChordRegionCommand';
import { InsertChordRegionAtBeatCommand } from './InsertChordRegionAtBeatCommand';
import { MoveGlobalRegionCommand } from './MoveGlobalRegionCommand';
import { ResizeGlobalRegionCommand } from './ResizeGlobalRegionCommand';
import { UpdateChordRegionCommand } from './UpdateChordRegionCommand';
describe('global chord region commands', () => {
beforeEach(() => {
const project = new KGProject('Chords', 8, 0, 120);
const mockCore = KGCore.instance() as unknown as {
getCurrentProject: ReturnType<typeof vi.fn>;
getSelectedItems: ReturnType<typeof vi.fn>;
removeSelectedItem?: ReturnType<typeof vi.fn>;
};
mockCore.getCurrentProject.mockReturnValue(project);
mockCore.getSelectedItems.mockReturnValue([]);
if (!mockCore.removeSelectedItem) {
mockCore.removeSelectedItem = vi.fn();
} else {
mockCore.removeSelectedItem.mockReset();
}
});
const getChordTrack = () => {
const chordTrack = KGCore.instance().getCurrentProject().getGlobalTracks()
.find(track => track.getType() === GlobalTrackType.Chord);
if (!chordTrack) {
throw new Error('Chord track missing in test setup');
}
return chordTrack;
};
it('creates a chord region with a default one-bar length clamped by the next region', () => {
const chordTrack = getChordTrack();
chordTrack.addRegion(new KGChordRegion('existing', chordTrack.getId(), chordTrack.getTrackIndex(), 'Fmaj7', 6, 2));
const command = new CreateChordRegionCommand(4, 4, 'Cmaj7');
command.execute();
const created = command.getCreatedRegion();
expect(created?.getStartFromBeat()).toBe(4);
expect(created?.getLength()).toBe(2);
});
it('moves and resizes chord regions with beat snapping and no overlap', () => {
const chordTrack = getChordTrack();
const region = new KGChordRegion('middle', chordTrack.getId(), chordTrack.getTrackIndex(), 'Dm7', 4, 2);
chordTrack.setRegions([
new KGChordRegion('left', chordTrack.getId(), chordTrack.getTrackIndex(), 'C', 0, 4),
region,
new KGChordRegion('right', chordTrack.getId(), chordTrack.getTrackIndex(), 'G7', 10, 2),
]);
const moveCommand = new MoveGlobalRegionCommand('middle', 9);
moveCommand.execute();
expect(region.getStartFromBeat()).toBe(8);
const resizeCommand = new ResizeGlobalRegionCommand('middle', 'end', 12);
resizeCommand.execute();
expect(region.getLength()).toBe(2);
const resizeMinCommand = new ResizeGlobalRegionCommand('middle', 'start', 9);
resizeMinCommand.execute();
expect(region.getStartFromBeat()).toBe(9);
expect(region.getLength()).toBe(1);
});
it('updates chord symbols with undo support', () => {
const chordTrack = getChordTrack();
const region = new KGChordRegion('chord', chordTrack.getId(), chordTrack.getTrackIndex(), 'C', 0, 4);
chordTrack.setRegions([region]);
const command = new UpdateChordRegionCommand('chord', 'Bm7b5');
command.execute();
expect(region.getSymbol()).toBe('Bm7b5');
command.undo();
expect(region.getSymbol()).toBe('C');
});
it('inserts a new chord inside an existing region and shortens the original', () => {
const chordTrack = getChordTrack();
const region = new KGChordRegion('chord', chordTrack.getId(), chordTrack.getTrackIndex(), 'Am', 0, 8);
chordTrack.setRegions([region]);
const command = new InsertChordRegionAtBeatCommand(3, 'C');
command.execute();
const created = command.getCreatedRegion();
expect(created).not.toBeNull();
expect(region.getLength()).toBe(3);
expect(created?.getStartFromBeat()).toBe(3);
expect(created?.getLength()).toBe(5);
command.undo();
expect(chordTrack.getRegions()).toHaveLength(1);
expect(region.getLength()).toBe(8);
});
});
@@ -0,0 +1,86 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { GlobalTrackType } from '../../global-track';
import { KGChordRegion } from '../../region/KGChordRegion';
import { generateUniqueId } from '../../../util/miscUtil';
import { findChordRegionAtBeat, findGlobalTrackByType } from '../../../util/globalTrackUtil';
export class InsertChordRegionAtBeatCommand extends KGCommand {
private readonly insertBeat: number;
private readonly symbol: string;
private readonly regionId: string;
private createdRegion: KGChordRegion | null = null;
private targetRegionId: string | null = null;
private originalTargetLength = 0;
constructor(insertBeat: number, symbol: string = 'C', regionId?: string) {
super();
this.insertBeat = insertBeat;
this.symbol = symbol;
this.regionId = regionId ?? generateUniqueId('KGChordRegion');
}
execute(): void {
const project = KGCore.instance().getCurrentProject();
const chordTrack = findGlobalTrackByType(project, GlobalTrackType.Chord);
if (!chordTrack) {
throw new Error('Chord global track not found');
}
const occupiedRegion = findChordRegionAtBeat(project, this.insertBeat);
if (!occupiedRegion) {
throw new Error(`No chord region found at beat ${this.insertBeat}`);
}
const regionStart = occupiedRegion.getStartFromBeat();
const regionEnd = regionStart + occupiedRegion.getLength();
if (this.insertBeat <= regionStart || this.insertBeat >= regionEnd) {
throw new Error(`Cannot insert chord at beat ${this.insertBeat} without shrinking region below minimum length`);
}
this.targetRegionId = occupiedRegion.getId();
this.originalTargetLength = occupiedRegion.getLength();
occupiedRegion.setLength(this.insertBeat - regionStart);
this.createdRegion = new KGChordRegion(
this.regionId,
chordTrack.getId(),
chordTrack.getTrackIndex(),
this.symbol,
this.insertBeat,
regionEnd - this.insertBeat
);
chordTrack.setRegions(
[...chordTrack.getRegions(), this.createdRegion]
.sort((left, right) => left.getStartFromBeat() - right.getStartFromBeat())
);
}
undo(): void {
const project = KGCore.instance().getCurrentProject();
const chordTrack = findGlobalTrackByType(project, GlobalTrackType.Chord);
if (!chordTrack || !this.targetRegionId || !this.createdRegion) {
throw new Error('Cannot undo inserted chord region');
}
chordTrack.removeRegion(this.createdRegion.getId());
const targetRegion = chordTrack.getRegions().find((region): region is KGChordRegion => (
region instanceof KGChordRegion && region.getId() === this.targetRegionId
));
if (!targetRegion) {
throw new Error(`Chord region ${this.targetRegionId} not found during undo`);
}
targetRegion.setLength(this.originalTargetLength);
chordTrack.setRegions([...chordTrack.getRegions()].sort((left, right) => left.getStartFromBeat() - right.getStartFromBeat()));
}
getDescription(): string {
return `Insert chord "${this.symbol}"`;
}
public getCreatedRegion(): KGChordRegion | null {
return this.createdRegion;
}
}
@@ -2,7 +2,7 @@ import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { KGGlobalRegion } from '../../region/KGGlobalRegion';
import { GlobalTrackType } from '../../global-track';
import { findGlobalTrackContainingRegion, findMarkerNeighborBounds } from '../../../util/globalTrackUtil';
import { findGlobalTrackContainingRegion, findNonOverlappingNeighborBounds } from '../../../util/globalTrackUtil';
export class MoveGlobalRegionCommand extends KGCommand {
private readonly regionId: string;
@@ -26,12 +26,17 @@ export class MoveGlobalRegionCommand extends KGCommand {
this.targetRegion = result.region;
this.originalStartBeat = result.region.getStartFromBeat();
if (result.track.getType() !== GlobalTrackType.Marker) {
if (result.track.getType() !== GlobalTrackType.Marker && result.track.getType() !== GlobalTrackType.Chord) {
result.region.setStartFromBeat(this.desiredStartBeat);
return;
}
const { minStartBeat, maxEndBeat } = findMarkerNeighborBounds(project, this.regionId, this.desiredStartBeat);
const { minStartBeat, maxEndBeat } = findNonOverlappingNeighborBounds(
project,
result.track.getType() as GlobalTrackType.Marker | GlobalTrackType.Chord,
this.regionId,
this.desiredStartBeat
);
const maxStartBeat = Math.max(minStartBeat, maxEndBeat - result.region.getLength());
const clampedStartBeat = Math.max(minStartBeat, Math.min(this.desiredStartBeat, maxStartBeat));
result.region.setStartFromBeat(clampedStartBeat);
@@ -1,7 +1,7 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { GlobalTrackType } from '../../global-track';
import { findGlobalTrackContainingRegion, findMarkerNeighborBounds, getSongEndBeat } from '../../../util/globalTrackUtil';
import { findGlobalTrackContainingRegion, findNonOverlappingNeighborBounds, getSongEndBeat } from '../../../util/globalTrackUtil';
import { KGGlobalRegion } from '../../region/KGGlobalRegion';
export type GlobalRegionResizeEdge = 'start' | 'end';
@@ -32,12 +32,17 @@ export class ResizeGlobalRegionCommand extends KGCommand {
this.originalStartBeat = result.region.getStartFromBeat();
this.originalLength = result.region.getLength();
if (result.track.getType() !== GlobalTrackType.Marker) {
if (result.track.getType() !== GlobalTrackType.Marker && result.track.getType() !== GlobalTrackType.Chord) {
return;
}
const originalEndBeat = this.originalStartBeat + this.originalLength;
const { minStartBeat, maxEndBeat } = findMarkerNeighborBounds(project, this.regionId, this.originalStartBeat);
const { minStartBeat, maxEndBeat } = findNonOverlappingNeighborBounds(
project,
result.track.getType() as GlobalTrackType.Marker | GlobalTrackType.Chord,
this.regionId,
this.originalStartBeat
);
const songEndBeat = getSongEndBeat(project);
const absoluteMaxEndBeat = Math.min(maxEndBeat, songEndBeat);
@@ -0,0 +1,39 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { findGlobalTrackContainingRegion } from '../../../util/globalTrackUtil';
import { KGChordRegion } from '../../region/KGChordRegion';
export class UpdateChordRegionCommand extends KGCommand {
private readonly regionId: string;
private readonly nextSymbol: string;
private previousSymbol: string | null = null;
constructor(regionId: string, nextSymbol: string) {
super();
this.regionId = regionId;
this.nextSymbol = nextSymbol;
}
execute(): void {
const result = findGlobalTrackContainingRegion(KGCore.instance().getCurrentProject(), this.regionId);
if (!result || !(result.region instanceof KGChordRegion)) {
throw new Error(`Chord region with ID ${this.regionId} not found`);
}
this.previousSymbol = result.region.getSymbol();
result.region.setSymbol(this.nextSymbol);
}
undo(): void {
const result = findGlobalTrackContainingRegion(KGCore.instance().getCurrentProject(), this.regionId);
if (!result || !(result.region instanceof KGChordRegion) || this.previousSymbol === null) {
throw new Error(`Chord region with ID ${this.regionId} not found during undo`);
}
result.region.setSymbol(this.previousSymbol);
}
getDescription(): string {
return `Change chord to "${this.nextSymbol}"`;
}
}
+3
View File
@@ -36,6 +36,8 @@ export { MergeMidiRegionsCommand } from './region/MergeMidiRegionsCommand';
// Global region commands
export { CreateGlobalMarkerRegionCommand } from './global-region/CreateGlobalMarkerRegionCommand';
export { CreateChordRegionCommand } from './global-region/CreateChordRegionCommand';
export { InsertChordRegionAtBeatCommand } from './global-region/InsertChordRegionAtBeatCommand';
export { CreateKeySignatureRegionCommand } from './global-region/CreateKeySignatureRegionCommand';
export { CreateTempoRegionCommand } from './global-region/CreateTempoRegionCommand';
export { MoveGlobalRegionCommand } from './global-region/MoveGlobalRegionCommand';
@@ -44,6 +46,7 @@ export { ResizeKeySignatureRegionCommand } from './global-region/ResizeKeySignat
export { ResizeTempoRegionCommand } from './global-region/ResizeTempoRegionCommand';
export { DeleteGlobalRegionCommand, DeleteMultipleGlobalRegionsCommand } from './global-region/DeleteGlobalRegionCommand';
export { UpdateGlobalRegionTextCommand } from './global-region/UpdateGlobalRegionTextCommand';
export { UpdateChordRegionCommand } from './global-region/UpdateChordRegionCommand';
export { DeleteKeySignatureRegionCommand, DeleteMultipleKeySignatureRegionsCommand } from './global-region/DeleteKeySignatureRegionCommand';
export { UpdateKeySignatureRegionCommand } from './global-region/UpdateKeySignatureRegionCommand';
export { DeleteTempoRegionCommand, DeleteMultipleTempoRegionsCommand } from './global-region/DeleteTempoRegionCommand';