feat: implemented global key signature track

This commit is contained in:
Xiaohan-Tian
2026-05-23 14:44:48 -07:00
parent 1e816596e4
commit 8eb3f5d84d
30 changed files with 1689 additions and 57 deletions
@@ -0,0 +1,113 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import type { KeySignature } from '../../KGProject';
import { GlobalTrackType } from '../../global-track';
import { KGKeySignatureRegion } from '../../region/KGKeySignatureRegion';
import { generateUniqueId } from '../../../util/miscUtil';
import {
cloneKeySignatureRegions,
findGlobalTrackByType,
findKeySignatureRegionAtBar,
getSongEndBar,
getSortedKeySignatureRegions,
} from '../../../util/globalTrackUtil';
export class CreateKeySignatureRegionCommand extends KGCommand {
private readonly startBar: number;
private readonly regionId: string;
private createdRegion: KGKeySignatureRegion | null = null;
private previousRegions: KGKeySignatureRegion[] = [];
constructor(startBar: number, regionId?: string) {
super();
this.startBar = startBar;
this.regionId = regionId ?? generateUniqueId('KGKeySignatureRegion');
}
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');
}
const existingRegions = getSortedKeySignatureRegions(track, beatsPerBar);
this.previousRegions = cloneKeySignatureRegions(existingRegions, beatsPerBar);
const songEndBar = getSongEndBar(project);
const clampedStartBar = Math.max(0, Math.min(this.startBar, Math.max(0, songEndBar - 1)));
if (existingRegions.length === 0) {
const nextRegions: KGKeySignatureRegion[] = [];
if (clampedStartBar > 0) {
nextRegions.push(new KGKeySignatureRegion(
generateUniqueId('KGKeySignatureRegion'),
track.getId(),
track.getTrackIndex(),
project.getKeySignature(),
0,
clampedStartBar,
beatsPerBar
));
}
this.createdRegion = new KGKeySignatureRegion(
this.regionId,
track.getId(),
track.getTrackIndex(),
project.getKeySignature(),
clampedStartBar,
Math.max(1, songEndBar - clampedStartBar),
beatsPerBar
);
nextRegions.push(this.createdRegion);
track.setRegions(nextRegions);
return;
}
const containingRegion = findKeySignatureRegionAtBar(project, clampedStartBar);
if (!containingRegion) {
throw new Error(`No key signature region covers bar ${clampedStartBar}`);
}
const regionStartBar = containingRegion.getStartBar();
const regionEndBar = containingRegion.getEndBar();
if (clampedStartBar <= regionStartBar || clampedStartBar >= regionEndBar) {
throw new Error(`Bar ${clampedStartBar} is not a valid split point`);
}
containingRegion.setLengthBars(clampedStartBar - regionStartBar, beatsPerBar);
this.createdRegion = new KGKeySignatureRegion(
this.regionId,
track.getId(),
track.getTrackIndex(),
containingRegion.getKeySignature(),
clampedStartBar,
regionEndBar - clampedStartBar,
beatsPerBar
);
track.setRegions([...existingRegions, this.createdRegion].sort((left, right) => left.getStartBar() - right.getStartBar()));
}
undo(): void {
const project = KGCore.instance().getCurrentProject();
const track = findGlobalTrackByType(project, GlobalTrackType.Signature);
if (!track) {
throw new Error('Signature global track not found during undo');
}
const beatsPerBar = project.getTimeSignature().numerator;
track.setRegions(cloneKeySignatureRegions(this.previousRegions, beatsPerBar));
}
getDescription(): string {
return `Create key signature change at bar ${this.startBar + 1}`;
}
public getCreatedRegion(): KGKeySignatureRegion | null {
return this.createdRegion;
}
}
@@ -0,0 +1,145 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { GlobalTrackType } from '../../global-track';
import { KGKeySignatureRegion } from '../../region/KGKeySignatureRegion';
import {
cloneKeySignatureRegions,
findGlobalTrackByType,
getSortedKeySignatureRegions,
} from '../../../util/globalTrackUtil';
export class DeleteKeySignatureRegionCommand extends KGCommand {
private readonly regionId: string;
private previousRegions: KGKeySignatureRegion[] = [];
private deletedKeySignature = '';
constructor(regionId: string) {
super();
this.regionId = regionId;
}
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');
}
const regions = getSortedKeySignatureRegions(track, beatsPerBar);
this.previousRegions = cloneKeySignatureRegions(regions, beatsPerBar);
const targetIndex = regions.findIndex(region => region.getId() === this.regionId);
if (targetIndex === -1) {
throw new Error(`Key signature region with ID ${this.regionId} not found`);
}
const targetRegion = regions[targetIndex];
this.deletedKeySignature = targetRegion.getKeySignature();
if (regions.length === 1) {
track.setRegions([]);
return;
}
const nextRegions = [...regions];
const deletedLengthBars = targetRegion.getLengthBars();
if (targetIndex === 0) {
const nextRegion = nextRegions[1];
nextRegion.setBarRange(0, nextRegion.getLengthBars() + deletedLengthBars, beatsPerBar);
nextRegions.splice(0, 1);
track.setRegions(nextRegions);
return;
}
const previousRegion = nextRegions[targetIndex - 1];
previousRegion.setLengthBars(previousRegion.getLengthBars() + deletedLengthBars, beatsPerBar);
nextRegions.splice(targetIndex, 1);
track.setRegions(nextRegions);
}
undo(): void {
const project = KGCore.instance().getCurrentProject();
const track = findGlobalTrackByType(project, GlobalTrackType.Signature);
if (!track) {
throw new Error('Signature global track not found during undo');
}
const beatsPerBar = project.getTimeSignature().numerator;
track.setRegions(cloneKeySignatureRegions(this.previousRegions, beatsPerBar));
}
getDescription(): string {
return `Delete key signature "${this.deletedKeySignature || this.regionId}"`;
}
}
export class DeleteMultipleKeySignatureRegionsCommand extends KGCommand {
private readonly regionIds: string[];
private previousRegions: KGKeySignatureRegion[] = [];
constructor(regionIds: string[]) {
super();
this.regionIds = regionIds;
}
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');
}
const regions = getSortedKeySignatureRegions(track, beatsPerBar);
this.previousRegions = cloneKeySignatureRegions(regions, beatsPerBar);
const remainingIds = new Set(this.regionIds);
let workingRegions = cloneKeySignatureRegions(regions, beatsPerBar);
for (const regionId of this.regionIds) {
const targetIndex = workingRegions.findIndex(region => region.getId() === regionId);
if (targetIndex === -1) {
continue;
}
const deletedRegion = workingRegions[targetIndex];
const deletedLengthBars = deletedRegion.getLengthBars();
remainingIds.delete(regionId);
if (workingRegions.length === 1) {
workingRegions = [];
continue;
}
if (targetIndex === 0) {
const nextRegion = workingRegions[1];
nextRegion.setBarRange(0, nextRegion.getLengthBars() + deletedLengthBars, beatsPerBar);
workingRegions.splice(0, 1);
continue;
}
const previousRegion = workingRegions[targetIndex - 1];
previousRegion.setLengthBars(previousRegion.getLengthBars() + deletedLengthBars, beatsPerBar);
workingRegions.splice(targetIndex, 1);
}
track.setRegions(workingRegions);
}
undo(): void {
const project = KGCore.instance().getCurrentProject();
const track = findGlobalTrackByType(project, GlobalTrackType.Signature);
if (!track) {
throw new Error('Signature global track not found during undo');
}
const beatsPerBar = project.getTimeSignature().numerator;
track.setRegions(cloneKeySignatureRegions(this.previousRegions, beatsPerBar));
}
getDescription(): string {
return this.regionIds.length === 1 ? 'Delete key signature' : `Delete ${this.regionIds.length} key signatures`;
}
}
@@ -0,0 +1,140 @@
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 { CreateKeySignatureRegionCommand } from './CreateKeySignatureRegionCommand';
import { DeleteKeySignatureRegionCommand } from './DeleteKeySignatureRegionCommand';
import { ResizeKeySignatureRegionCommand } from './ResizeKeySignatureRegionCommand';
import { UpdateKeySignatureRegionCommand } from './UpdateKeySignatureRegionCommand';
describe('global key signature region commands', () => {
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('creates the first explicit region by splitting the project default coverage', () => {
const command = new CreateKeySignatureRegionCommand(3);
command.execute();
const signatureTrack = getSignatureTrack();
const regions = signatureTrack.getRegions() as KGKeySignatureRegion[];
expect(regions).toHaveLength(2);
expect(regions[0].getStartBar()).toBe(0);
expect(regions[0].getLengthBars()).toBe(3);
expect(regions[0].getKeySignature()).toBe('C major');
expect(regions[1].getStartBar()).toBe(3);
expect(regions[1].getLengthBars()).toBe(5);
expect(regions[1].getKeySignature()).toBe('C major');
});
it('creates additional regions by splitting the covered span and inheriting the key', () => {
const signatureTrack = getSignatureTrack();
signatureTrack.setRegions([
new KGKeySignatureRegion('left', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'C major', 0, 8, 4),
]);
const command = new CreateKeySignatureRegionCommand(5);
command.execute();
const regions = signatureTrack.getRegions() as KGKeySignatureRegion[];
expect(regions).toHaveLength(2);
expect(regions[0].getLengthBars()).toBe(5);
expect(regions[1].getStartBar()).toBe(5);
expect(regions[1].getLengthBars()).toBe(3);
expect(regions[1].getKeySignature()).toBe('C major');
});
it('resizes a shared boundary and keeps the track gapless', () => {
const signatureTrack = getSignatureTrack();
signatureTrack.setRegions([
new KGKeySignatureRegion('left', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'C major', 0, 4, 4),
new KGKeySignatureRegion('right', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'G major', 4, 4, 4),
]);
const command = new ResizeKeySignatureRegionCommand('left', 'end', 6);
command.execute();
const regions = signatureTrack.getRegions() as KGKeySignatureRegion[];
expect(regions[0].getLengthBars()).toBe(6);
expect(regions[1].getStartBar()).toBe(6);
expect(regions[1].getLengthBars()).toBe(2);
});
it('deletes a middle region by extending the previous region', () => {
const signatureTrack = getSignatureTrack();
signatureTrack.setRegions([
new KGKeySignatureRegion('first', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'C major', 0, 2, 4),
new KGKeySignatureRegion('middle', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'G major', 2, 3, 4),
new KGKeySignatureRegion('last', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'D major', 5, 3, 4),
]);
const command = new DeleteKeySignatureRegionCommand('middle');
command.execute();
const regions = signatureTrack.getRegions() as KGKeySignatureRegion[];
expect(regions).toHaveLength(2);
expect(regions[0].getLengthBars()).toBe(5);
expect(regions[1].getStartBar()).toBe(5);
});
it('deletes the first region by extending the next region leftward', () => {
const signatureTrack = getSignatureTrack();
signatureTrack.setRegions([
new KGKeySignatureRegion('first', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'C major', 0, 2, 4),
new KGKeySignatureRegion('next', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'G major', 2, 6, 4),
]);
const command = new DeleteKeySignatureRegionCommand('first');
command.execute();
const regions = signatureTrack.getRegions() as KGKeySignatureRegion[];
expect(regions).toHaveLength(1);
expect(regions[0].getStartBar()).toBe(0);
expect(regions[0].getLengthBars()).toBe(8);
});
it('allows deleting the last remaining region', () => {
const signatureTrack = getSignatureTrack();
signatureTrack.setRegions([
new KGKeySignatureRegion('only', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'C major', 0, 8, 4),
]);
const command = new DeleteKeySignatureRegionCommand('only');
command.execute();
expect(signatureTrack.getRegions()).toHaveLength(0);
command.undo();
expect(signatureTrack.getRegions()).toHaveLength(1);
});
it('updates the region key signature with undo support', () => {
const signatureTrack = getSignatureTrack();
signatureTrack.setRegions([
new KGKeySignatureRegion('region', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'C major', 0, 8, 4),
]);
const command = new UpdateKeySignatureRegionCommand('region', 'G major');
command.execute();
expect((signatureTrack.getRegions()[0] as KGKeySignatureRegion).getKeySignature()).toBe('G major');
command.undo();
expect((signatureTrack.getRegions()[0] as KGKeySignatureRegion).getKeySignature()).toBe('C major');
});
});
@@ -0,0 +1,88 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { GlobalTrackType } from '../../global-track';
import { KGKeySignatureRegion } from '../../region/KGKeySignatureRegion';
import type { GlobalRegionResizeEdge } from './ResizeGlobalRegionCommand';
import {
cloneKeySignatureRegions,
findGlobalTrackByType,
getSortedKeySignatureRegions,
} from '../../../util/globalTrackUtil';
export class ResizeKeySignatureRegionCommand extends KGCommand {
private readonly regionId: string;
private readonly edge: GlobalRegionResizeEdge;
private readonly desiredBar: number;
private previousRegions: KGKeySignatureRegion[] = [];
constructor(regionId: string, edge: GlobalRegionResizeEdge, desiredBar: number) {
super();
this.regionId = regionId;
this.edge = edge;
this.desiredBar = desiredBar;
}
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');
}
const regions = getSortedKeySignatureRegions(track, beatsPerBar);
this.previousRegions = cloneKeySignatureRegions(regions, beatsPerBar);
const targetIndex = regions.findIndex(region => region.getId() === this.regionId);
if (targetIndex === -1) {
throw new Error(`Key signature region with ID ${this.regionId} not found`);
}
const targetRegion = regions[targetIndex];
if (this.edge === 'start') {
if (targetIndex === 0) {
return;
}
const previousRegion = regions[targetIndex - 1];
const targetEndBar = targetRegion.getEndBar();
const clampedBoundaryBar = Math.max(
previousRegion.getStartBar() + 1,
Math.min(this.desiredBar, targetEndBar - 1)
);
previousRegion.setLengthBars(clampedBoundaryBar - previousRegion.getStartBar(), beatsPerBar);
targetRegion.setBarRange(clampedBoundaryBar, targetEndBar - clampedBoundaryBar, beatsPerBar);
return;
}
if (targetIndex === regions.length - 1) {
return;
}
const nextRegion = regions[targetIndex + 1];
const nextRegionEndBar = nextRegion.getEndBar();
const clampedBoundaryBar = Math.max(
targetRegion.getStartBar() + 1,
Math.min(this.desiredBar, nextRegionEndBar - 1)
);
targetRegion.setLengthBars(clampedBoundaryBar - targetRegion.getStartBar(), beatsPerBar);
nextRegion.setBarRange(clampedBoundaryBar, nextRegionEndBar - clampedBoundaryBar, beatsPerBar);
}
undo(): void {
const project = KGCore.instance().getCurrentProject();
const track = findGlobalTrackByType(project, GlobalTrackType.Signature);
if (!track) {
throw new Error('Signature global track not found during undo');
}
const beatsPerBar = project.getTimeSignature().numerator;
track.setRegions(cloneKeySignatureRegions(this.previousRegions, beatsPerBar));
}
getDescription(): string {
return `Resize key signature boundary for "${this.regionId}"`;
}
}
@@ -0,0 +1,40 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import type { KeySignature } from '../../KGProject';
import { findGlobalTrackContainingRegion } from '../../../util/globalTrackUtil';
import { KGKeySignatureRegion } from '../../region/KGKeySignatureRegion';
export class UpdateKeySignatureRegionCommand extends KGCommand {
private readonly regionId: string;
private readonly nextKeySignature: KeySignature;
private previousKeySignature: KeySignature | null = null;
constructor(regionId: string, nextKeySignature: KeySignature) {
super();
this.regionId = regionId;
this.nextKeySignature = nextKeySignature;
}
execute(): void {
const result = findGlobalTrackContainingRegion(KGCore.instance().getCurrentProject(), this.regionId);
if (!result || !(result.region instanceof KGKeySignatureRegion)) {
throw new Error(`Key signature region with ID ${this.regionId} not found`);
}
this.previousKeySignature = result.region.getKeySignature();
result.region.setKeySignature(this.nextKeySignature);
}
undo(): void {
const result = findGlobalTrackContainingRegion(KGCore.instance().getCurrentProject(), this.regionId);
if (!result || !(result.region instanceof KGKeySignatureRegion) || !this.previousKeySignature) {
throw new Error(`Key signature region with ID ${this.regionId} not found during undo`);
}
result.region.setKeySignature(this.previousKeySignature);
}
getDescription(): string {
return `Change key signature to "${this.nextKeySignature}"`;
}
}
+4
View File
@@ -36,10 +36,14 @@ export { MergeMidiRegionsCommand } from './region/MergeMidiRegionsCommand';
// Global region commands
export { CreateGlobalMarkerRegionCommand } from './global-region/CreateGlobalMarkerRegionCommand';
export { CreateKeySignatureRegionCommand } from './global-region/CreateKeySignatureRegionCommand';
export { MoveGlobalRegionCommand } from './global-region/MoveGlobalRegionCommand';
export { ResizeGlobalRegionCommand, type GlobalRegionResizeEdge } from './global-region/ResizeGlobalRegionCommand';
export { ResizeKeySignatureRegionCommand } from './global-region/ResizeKeySignatureRegionCommand';
export { DeleteGlobalRegionCommand, DeleteMultipleGlobalRegionsCommand } from './global-region/DeleteGlobalRegionCommand';
export { UpdateGlobalRegionTextCommand } from './global-region/UpdateGlobalRegionTextCommand';
export { DeleteKeySignatureRegionCommand, DeleteMultipleKeySignatureRegionsCommand } from './global-region/DeleteKeySignatureRegionCommand';
export { UpdateKeySignatureRegionCommand } from './global-region/UpdateKeySignatureRegionCommand';
// Note commands
export { CreateNoteCommand } from './note/CreateNoteCommand';