feat: implemented global key signature track
This commit is contained in:
@@ -58,7 +58,7 @@ export class KGProject {
|
||||
@WithDefault(0)
|
||||
private projectStructureVersion: number = 0;
|
||||
|
||||
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 13;
|
||||
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 14;
|
||||
|
||||
@Expose()
|
||||
@Type(() => KGTrack, {
|
||||
|
||||
@@ -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}"`;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Expose, Type } from 'class-transformer';
|
||||
import { KGGlobalRegion } from '../region/KGGlobalRegion';
|
||||
import { KGKeySignatureRegion } from '../region/KGKeySignatureRegion';
|
||||
import { KGMarkerRegion } from '../region/KGMarkerRegion';
|
||||
|
||||
export enum GlobalTrackType {
|
||||
@@ -32,6 +33,7 @@ export class KGGlobalTrack {
|
||||
subTypes: [
|
||||
{ value: KGGlobalRegion, name: 'KGGlobalRegion' },
|
||||
{ value: KGMarkerRegion, name: 'KGMarkerRegion' },
|
||||
{ value: KGKeySignatureRegion, name: 'KGKeySignatureRegion' },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ export class KGSignatureTrack extends KGGlobalTrack {
|
||||
@Expose()
|
||||
protected override __type: string = 'KGSignatureTrack';
|
||||
|
||||
constructor(id: string = 'global-signature', trackIndex: number = 2, name: string = 'Signature') {
|
||||
constructor(id: string = 'global-signature', trackIndex: number = 2, name: string = 'Key Signature') {
|
||||
super(id, trackIndex, GlobalTrackType.Signature, name, []);
|
||||
this.__type = 'KGSignatureTrack';
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { KGProjectStorage, DuplicateEntryError } from './KGProjectStorage';
|
||||
import { KGProject } from '../KGProject';
|
||||
import { GlobalTrackType } from '../global-track';
|
||||
import { KGKeySignatureRegion } from '../region/KGKeySignatureRegion';
|
||||
import { KGMarkerRegion } from '../region/KGMarkerRegion';
|
||||
import { KGTrack } from '../track/KGTrack';
|
||||
|
||||
@@ -179,6 +180,25 @@ describe('KGProjectStorage', () => {
|
||||
expect(loadedMarkerTrack?.getRegions()[0].getName()).toBe('Intro');
|
||||
});
|
||||
|
||||
it('preserves key signature regions when saving and loading', async () => {
|
||||
const project = createTestProject('Signature Song');
|
||||
const signatureTrack = project.getGlobalTracks().find(track => track.getType() === GlobalTrackType.Signature);
|
||||
|
||||
expect(signatureTrack).toBeDefined();
|
||||
signatureTrack?.addRegion(new KGKeySignatureRegion('signature-1', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'G major', 4, 12, 4));
|
||||
|
||||
await storage.save('Signature Song', project);
|
||||
|
||||
const loaded = await storage.load('Signature Song');
|
||||
const loadedSignatureTrack = loaded?.getGlobalTracks().find(track => track.getType() === GlobalTrackType.Signature);
|
||||
|
||||
expect(loadedSignatureTrack).toBeDefined();
|
||||
expect(loadedSignatureTrack?.getRegions()).toHaveLength(1);
|
||||
expect(loadedSignatureTrack?.getRegions()[0]).toBeInstanceOf(KGKeySignatureRegion);
|
||||
expect((loadedSignatureTrack?.getRegions()[0] as KGKeySignatureRegion).getKeySignature()).toBe('G major');
|
||||
expect((loadedSignatureTrack?.getRegions()[0] as KGKeySignatureRegion).getStartBar()).toBe(4);
|
||||
});
|
||||
|
||||
it('creates meta.json and media/ directory on save', async () => {
|
||||
const project = createTestProject('My Song');
|
||||
await storage.save('My Song', project);
|
||||
|
||||
@@ -12,6 +12,7 @@ import { upgradeToV10 } from './upgradeToV10';
|
||||
import { upgradeToV11 } from './upgradeToV11';
|
||||
import { upgradeToV12 } from './upgradeToV12';
|
||||
import { upgradeToV13 } from './upgradeToV13';
|
||||
import { upgradeToV14 } from './upgradeToV14';
|
||||
|
||||
/**
|
||||
* Upgrade the given project to the latest structure version, one version at a time.
|
||||
@@ -83,6 +84,10 @@ export function upgradeProjectToLatest(project: KGProject): KGProject {
|
||||
workingProject = upgradeToV13(workingProject);
|
||||
break;
|
||||
}
|
||||
case 14: {
|
||||
workingProject = upgradeToV14(workingProject);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// If an upgrader is missing, throw to prevent loading incompatible structures
|
||||
throw new Error(`No upgrader found for project structure version ${nextVersion}`);
|
||||
|
||||
@@ -18,6 +18,7 @@ describe('upgradeToV13', () => {
|
||||
GlobalTrackType.Signature,
|
||||
GlobalTrackType.Chord,
|
||||
]);
|
||||
expect(project.getGlobalTracks()[2].getName()).toBe('Key Signature');
|
||||
});
|
||||
|
||||
it('runs through the main upgrader path', () => {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { KGProject } from '../KGProject';
|
||||
import { upgradeProjectToLatest } from './KGProjectUpgrader';
|
||||
import { upgradeToV14 } from './upgradeToV14';
|
||||
|
||||
describe('upgradeToV14', () => {
|
||||
it('marks projects as upgraded even when no signature regions exist yet', () => {
|
||||
const project = new KGProject('Legacy', 32, 0, 125, undefined, undefined, undefined, undefined, [0, 0], 1, [], 13, 1, []);
|
||||
|
||||
upgradeToV14(project);
|
||||
|
||||
expect(project.getProjectStructureVersion()).toBe(14);
|
||||
expect(project.getGlobalTracks()).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('runs through the main upgrader path', () => {
|
||||
const project = new KGProject('Legacy', 32, 0, 125, undefined, undefined, undefined, undefined, [0, 0], 1, [], 13, 1, []);
|
||||
|
||||
const upgraded = upgradeProjectToLatest(project);
|
||||
|
||||
expect(upgraded.getProjectStructureVersion()).toBe(KGProject.CURRENT_PROJECT_STRUCTURE_VERSION);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { KGProject } from '../KGProject';
|
||||
import { GlobalTrackType } from '../global-track';
|
||||
import { ensureDefaultGlobalTracks, getSortedKeySignatureRegions } from '../../util/globalTrackUtil';
|
||||
|
||||
export function upgradeToV14(project: KGProject): KGProject {
|
||||
try {
|
||||
ensureDefaultGlobalTracks(project);
|
||||
|
||||
const signatureTrack = project.getGlobalTracks().find(track => track.getType() === GlobalTrackType.Signature);
|
||||
if (signatureTrack) {
|
||||
const beatsPerBar = project.getTimeSignature().numerator;
|
||||
const regions = getSortedKeySignatureRegions(signatureTrack, beatsPerBar);
|
||||
signatureTrack.setRegions(regions);
|
||||
}
|
||||
} finally {
|
||||
project.setProjectStructureVersion(14);
|
||||
}
|
||||
|
||||
return project;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Expose } from 'class-transformer';
|
||||
import type { KeySignature } from '../KGProject';
|
||||
import { KGGlobalRegion } from './KGGlobalRegion';
|
||||
|
||||
export class KGKeySignatureRegion extends KGGlobalRegion {
|
||||
@Expose()
|
||||
protected override __type: string = 'KGKeySignatureRegion';
|
||||
|
||||
@Expose()
|
||||
private keySignature: KeySignature = 'C major';
|
||||
|
||||
@Expose()
|
||||
private startBar: number = 0;
|
||||
|
||||
@Expose()
|
||||
private lengthBars: number = 1;
|
||||
|
||||
constructor(
|
||||
id: string,
|
||||
trackId: string,
|
||||
trackIndex: number,
|
||||
keySignature: KeySignature,
|
||||
startBar: number = 0,
|
||||
lengthBars: number = 1,
|
||||
beatsPerBar: number = 4
|
||||
) {
|
||||
super(id, trackId, trackIndex, keySignature, startBar * beatsPerBar, lengthBars * beatsPerBar);
|
||||
this.__type = 'KGKeySignatureRegion';
|
||||
this.keySignature = keySignature;
|
||||
this.startBar = startBar;
|
||||
this.lengthBars = lengthBars;
|
||||
this.syncBeatsFromBars(beatsPerBar);
|
||||
super.setName(keySignature);
|
||||
}
|
||||
|
||||
public getKeySignature(): KeySignature {
|
||||
return this.keySignature;
|
||||
}
|
||||
|
||||
public setKeySignature(keySignature: KeySignature): void {
|
||||
this.keySignature = keySignature;
|
||||
super.setName(keySignature);
|
||||
}
|
||||
|
||||
public getStartBar(): number {
|
||||
return this.startBar;
|
||||
}
|
||||
|
||||
public getLengthBars(): number {
|
||||
return this.lengthBars;
|
||||
}
|
||||
|
||||
public getEndBar(): number {
|
||||
return this.startBar + this.lengthBars;
|
||||
}
|
||||
|
||||
public setStartBar(startBar: number, beatsPerBar: number): void {
|
||||
this.startBar = startBar;
|
||||
this.syncBeatsFromBars(beatsPerBar);
|
||||
}
|
||||
|
||||
public setLengthBars(lengthBars: number, beatsPerBar: number): void {
|
||||
this.lengthBars = lengthBars;
|
||||
this.syncBeatsFromBars(beatsPerBar);
|
||||
}
|
||||
|
||||
public setBarRange(startBar: number, lengthBars: number, beatsPerBar: number): void {
|
||||
this.startBar = startBar;
|
||||
this.lengthBars = lengthBars;
|
||||
this.syncBeatsFromBars(beatsPerBar);
|
||||
}
|
||||
|
||||
public syncBeatsFromBars(beatsPerBar: number): void {
|
||||
super.setStartFromBeat(this.startBar * beatsPerBar);
|
||||
super.setLength(this.lengthBars * beatsPerBar);
|
||||
}
|
||||
|
||||
public syncBarsFromBeats(beatsPerBar: number): void {
|
||||
this.startBar = Math.floor(this.getStartFromBeat() / beatsPerBar);
|
||||
this.lengthBars = Math.max(1, Math.round(this.getLength() / beatsPerBar));
|
||||
super.setName(this.keySignature);
|
||||
}
|
||||
|
||||
public override getCurrentType(): string {
|
||||
return 'KGKeySignatureRegion';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user