feat: implemented global marker track

This commit is contained in:
Xiaohan-Tian
2026-05-23 12:56:43 -07:00
parent 7ca2cb0d05
commit fb3c6cde70
27 changed files with 1566 additions and 38 deletions
+27 -2
View File
@@ -2,6 +2,7 @@ import { Expose, Type } from 'class-transformer';
import { KGTrack } from './track/KGTrack';
import { KGMidiTrack } from './track/KGMidiTrack';
import { KGAudioTrack } from './track/KGAudioTrack';
import { KGChordTrack, KGGlobalTrack, KGMarkerTrack, KGSignatureTrack, KGTempoTrack, createDefaultGlobalTracks } from './global-track';
import { type TimeSignature, WithDefault } from '../types/projectTypes';
import { TIME_CONSTANTS, KEY_SIGNATURE_MAP } from '../constants/coreConstants';
import { RESERVED_PROJECT_NAME } from '../util/projectNameUtil';
@@ -57,7 +58,7 @@ export class KGProject {
@WithDefault(0)
private projectStructureVersion: number = 0;
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 12;
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 13;
@Expose()
@Type(() => KGTrack, {
@@ -72,8 +73,23 @@ export class KGProject {
})
private tracks: KGTrack[] = [];
@Expose()
@Type(() => KGGlobalTrack, {
discriminator: {
property: '__type',
subTypes: [
{ value: KGGlobalTrack, name: 'KGGlobalTrack' },
{ value: KGMarkerTrack, name: 'KGMarkerTrack' },
{ value: KGTempoTrack, name: 'KGTempoTrack' },
{ value: KGSignatureTrack, name: 'KGSignatureTrack' },
{ value: KGChordTrack, name: 'KGChordTrack' },
],
},
})
private globalTracks: KGGlobalTrack[] = createDefaultGlobalTracks();
// Constructor
constructor(name: string = RESERVED_PROJECT_NAME, maxBars: number = 32, currentBars: number = 0, bpm: number = 125, timeSignature: TimeSignature = { numerator: 4, denominator: 4 }, keySignature: KeySignature = "C major", selectedMode: string = "ionian", isLooping: boolean = false, loopingRange: [number, number] = [0, 0], barWidthMultiplier: number = 1, tracks: KGTrack[] = [], projectStructureVersion: number = KGProject.CURRENT_PROJECT_STRUCTURE_VERSION, pianoRollZoom: number = 1) {
constructor(name: string = RESERVED_PROJECT_NAME, maxBars: number = 32, currentBars: number = 0, bpm: number = 125, timeSignature: TimeSignature = { numerator: 4, denominator: 4 }, keySignature: KeySignature = "C major", selectedMode: string = "ionian", isLooping: boolean = false, loopingRange: [number, number] = [0, 0], barWidthMultiplier: number = 1, tracks: KGTrack[] = [], projectStructureVersion: number = KGProject.CURRENT_PROJECT_STRUCTURE_VERSION, pianoRollZoom: number = 1, globalTracks: KGGlobalTrack[] = createDefaultGlobalTracks()) {
this.name = name;
this.maxBars = maxBars;
this.currentBars = currentBars;
@@ -87,6 +103,7 @@ export class KGProject {
this.tracks = tracks;
this.projectStructureVersion = projectStructureVersion;
this.pianoRollZoom = pianoRollZoom;
this.globalTracks = globalTracks;
}
// Getters
@@ -126,6 +143,10 @@ export class KGProject {
return this.tracks;
}
public getGlobalTracks(): KGGlobalTrack[] {
return this.globalTracks;
}
// Setters
public setName(name: string): void {
this.name = name;
@@ -155,6 +176,10 @@ export class KGProject {
this.tracks = tracks;
}
public setGlobalTracks(globalTracks: KGGlobalTrack[]): void {
this.globalTracks = globalTracks;
}
public setProjectStructureVersion(projectStructureVersion: number): void {
this.projectStructureVersion = projectStructureVersion;
}
@@ -0,0 +1,78 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { KGMarkerRegion } from '../../region/KGMarkerRegion';
import { GlobalTrackType } from '../../global-track';
import { generateUniqueId } from '../../../util/miscUtil';
import {
DEFAULT_MARKER_REGION_NAME,
findGlobalTrackByType,
findMarkerNeighborBounds,
getSongEndBeat,
} from '../../../util/globalTrackUtil';
export class CreateGlobalMarkerRegionCommand extends KGCommand {
private readonly startBeat: number;
private readonly preferredLength: number;
private readonly regionId: string;
private readonly initialName: string;
private createdRegion: KGMarkerRegion | null = null;
private originalRegionIndex = -1;
constructor(startBeat: number, preferredLength: number, initialName: string = DEFAULT_MARKER_REGION_NAME, regionId?: string) {
super();
this.startBeat = startBeat;
this.preferredLength = preferredLength;
this.initialName = initialName;
this.regionId = regionId ?? generateUniqueId('KGMarkerRegion');
}
execute(): void {
const project = KGCore.instance().getCurrentProject();
const markerTrack = findGlobalTrackByType(project, GlobalTrackType.Marker);
if (!markerTrack) {
throw new Error('Marker global track not found');
}
const { maxEndBeat } = findMarkerNeighborBounds(project, 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 KGMarkerRegion(
this.regionId,
markerTrack.getId(),
markerTrack.getTrackIndex(),
this.initialName,
this.startBeat,
length
);
const regions = [...markerTrack.getRegions(), this.createdRegion]
.sort((left, right) => left.getStartFromBeat() - right.getStartFromBeat());
this.originalRegionIndex = regions.findIndex(region => region.getId() === this.regionId);
markerTrack.setRegions(regions);
}
undo(): void {
if (!this.createdRegion) {
throw new Error('Cannot undo: no global marker region was created');
}
const project = KGCore.instance().getCurrentProject();
const markerTrack = findGlobalTrackByType(project, GlobalTrackType.Marker);
if (!markerTrack) {
throw new Error('Marker global track not found during undo');
}
markerTrack.removeRegion(this.regionId);
}
getDescription(): string {
return `Create marker "${this.initialName}"`;
}
public getCreatedRegion(): KGMarkerRegion | null {
return this.createdRegion;
}
}
@@ -0,0 +1,109 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { KGGlobalRegion } from '../../region/KGGlobalRegion';
import { findGlobalTrackContainingRegion } from '../../../util/globalTrackUtil';
export class DeleteGlobalRegionCommand extends KGCommand {
private readonly regionId: string;
private deletedRegion: KGGlobalRegion | null = null;
private trackId: string | null = null;
private originalIndex = -1;
constructor(regionId: string) {
super();
this.regionId = regionId;
}
execute(): void {
const project = KGCore.instance().getCurrentProject();
const result = findGlobalTrackContainingRegion(project, this.regionId);
if (!result) {
throw new Error(`Global region with ID ${this.regionId} not found`);
}
this.deletedRegion = result.region;
this.trackId = result.track.getId();
this.originalIndex = result.regionIndex;
result.track.removeRegion(this.regionId);
const selectedItem = KGCore.instance().getSelectedItems().find(item => item.getId() === this.regionId);
if (selectedItem) {
KGCore.instance().removeSelectedItem(selectedItem);
}
}
undo(): void {
if (!this.deletedRegion || !this.trackId) {
throw new Error('Cannot undo: no deleted global region stored');
}
const project = KGCore.instance().getCurrentProject();
const track = project.getGlobalTracks().find(candidate => candidate.getId() === this.trackId);
if (!track) {
throw new Error(`Global track ${this.trackId} not found during undo`);
}
const regions = [...track.getRegions()];
regions.splice(this.originalIndex, 0, this.deletedRegion);
track.setRegions(regions);
}
getDescription(): string {
return `Delete marker "${this.deletedRegion?.getName() ?? this.regionId}"`;
}
}
export class DeleteMultipleGlobalRegionsCommand extends KGCommand {
private readonly regionIds: string[];
private deletedRegions: Array<{ region: KGGlobalRegion; trackId: string; originalIndex: number }> = [];
constructor(regionIds: string[]) {
super();
this.regionIds = regionIds;
}
execute(): void {
const project = KGCore.instance().getCurrentProject();
this.deletedRegions = [];
for (const regionId of this.regionIds) {
const result = findGlobalTrackContainingRegion(project, regionId);
if (!result) continue;
this.deletedRegions.push({
region: result.region,
trackId: result.track.getId(),
originalIndex: result.regionIndex,
});
}
this.deletedRegions
.slice()
.sort((left, right) => right.originalIndex - left.originalIndex)
.forEach(({ region, trackId }) => {
const track = project.getGlobalTracks().find(candidate => candidate.getId() === trackId);
track?.removeRegion(region.getId());
const selectedItem = KGCore.instance().getSelectedItems().find(item => item.getId() === region.getId());
if (selectedItem) {
KGCore.instance().removeSelectedItem(selectedItem);
}
});
}
undo(): void {
const project = KGCore.instance().getCurrentProject();
this.deletedRegions
.slice()
.sort((left, right) => left.originalIndex - right.originalIndex)
.forEach(({ region, trackId, originalIndex }) => {
const track = project.getGlobalTracks().find(candidate => candidate.getId() === trackId);
if (!track) return;
const regions = [...track.getRegions()];
regions.splice(originalIndex, 0, region);
track.setRegions(regions);
});
}
getDescription(): string {
return this.regionIds.length === 1 ? 'Delete marker' : `Delete ${this.regionIds.length} markers`;
}
}
@@ -0,0 +1,109 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { KGCore } from '../../KGCore';
import { KGProject } from '../../KGProject';
import { GlobalTrackType } from '../../global-track';
import { KGMarkerRegion } from '../../region/KGMarkerRegion';
import { CreateGlobalMarkerRegionCommand } from './CreateGlobalMarkerRegionCommand';
import { MoveGlobalRegionCommand } from './MoveGlobalRegionCommand';
import { ResizeGlobalRegionCommand } from './ResizeGlobalRegionCommand';
import { DeleteGlobalRegionCommand } from './DeleteGlobalRegionCommand';
import { UpdateGlobalRegionTextCommand } from './UpdateGlobalRegionTextCommand';
describe('global marker region commands', () => {
beforeEach(() => {
const project = new KGProject('Markers', 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 getMarkerTrack = () => {
const markerTrack = KGCore.instance().getCurrentProject().getGlobalTracks()
.find(track => track.getType() === GlobalTrackType.Marker);
if (!markerTrack) {
throw new Error('Marker track missing in test setup');
}
return markerTrack;
};
it('creates a marker region clamped to the next marker start', () => {
const markerTrack = getMarkerTrack();
markerTrack.addRegion(new KGMarkerRegion('existing', markerTrack.getId(), markerTrack.getTrackIndex(), 'Verse', 10, 4));
const command = new CreateGlobalMarkerRegionCommand(4, 32, 'Intro');
command.execute();
const created = command.getCreatedRegion();
expect(created).not.toBeNull();
expect(created?.getStartFromBeat()).toBe(4);
expect(created?.getLength()).toBe(6);
});
it('moves a marker region with beat snapping and neighbor clamping', () => {
const markerTrack = getMarkerTrack();
const region = new KGMarkerRegion('middle', markerTrack.getId(), markerTrack.getTrackIndex(), 'Middle', 4, 2);
markerTrack.setRegions([
new KGMarkerRegion('left', markerTrack.getId(), markerTrack.getTrackIndex(), 'Left', 0, 4),
region,
new KGMarkerRegion('right', markerTrack.getId(), markerTrack.getTrackIndex(), 'Right', 10, 2),
]);
const command = new MoveGlobalRegionCommand('middle', 9);
command.execute();
expect(region.getStartFromBeat()).toBe(8);
command.undo();
expect(region.getStartFromBeat()).toBe(4);
});
it('resizes a marker region with a minimum length of one beat', () => {
const markerTrack = getMarkerTrack();
const region = new KGMarkerRegion('marker', markerTrack.getId(), markerTrack.getTrackIndex(), 'Marker', 4, 4);
markerTrack.setRegions([region]);
const resizeStartCommand = new ResizeGlobalRegionCommand('marker', 'start', 7);
resizeStartCommand.execute();
expect(region.getStartFromBeat()).toBe(7);
expect(region.getLength()).toBe(1);
resizeStartCommand.undo();
expect(region.getStartFromBeat()).toBe(4);
expect(region.getLength()).toBe(4);
const resizeEndCommand = new ResizeGlobalRegionCommand('marker', 'end', 5);
resizeEndCommand.execute();
expect(region.getLength()).toBe(1);
});
it('updates text and deletes with undo support', () => {
const markerTrack = getMarkerTrack();
const region = new KGMarkerRegion('marker', markerTrack.getId(), markerTrack.getTrackIndex(), 'Old', 0, 4);
markerTrack.setRegions([region]);
const renameCommand = new UpdateGlobalRegionTextCommand('marker', 'New');
renameCommand.execute();
expect(region.getName()).toBe('New');
renameCommand.undo();
expect(region.getName()).toBe('Old');
const deleteCommand = new DeleteGlobalRegionCommand('marker');
deleteCommand.execute();
expect(markerTrack.getRegions()).toHaveLength(0);
deleteCommand.undo();
expect(markerTrack.getRegions()).toHaveLength(1);
expect(markerTrack.getRegions()[0].getId()).toBe('marker');
});
});
@@ -0,0 +1,53 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { KGGlobalRegion } from '../../region/KGGlobalRegion';
import { GlobalTrackType } from '../../global-track';
import { findGlobalTrackContainingRegion, findMarkerNeighborBounds } from '../../../util/globalTrackUtil';
export class MoveGlobalRegionCommand extends KGCommand {
private readonly regionId: string;
private readonly desiredStartBeat: number;
private targetRegion: KGGlobalRegion | null = null;
private originalStartBeat = 0;
constructor(regionId: string, desiredStartBeat: number) {
super();
this.regionId = regionId;
this.desiredStartBeat = desiredStartBeat;
}
execute(): void {
const project = KGCore.instance().getCurrentProject();
const result = findGlobalTrackContainingRegion(project, this.regionId);
if (!result) {
throw new Error(`Global region with ID ${this.regionId} not found`);
}
this.targetRegion = result.region;
this.originalStartBeat = result.region.getStartFromBeat();
if (result.track.getType() !== GlobalTrackType.Marker) {
result.region.setStartFromBeat(this.desiredStartBeat);
return;
}
const { minStartBeat, maxEndBeat } = findMarkerNeighborBounds(project, 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);
result.track.setRegions([...result.track.getRegions()].sort((left, right) => left.getStartFromBeat() - right.getStartFromBeat()));
}
undo(): void {
if (!this.targetRegion) {
throw new Error('Cannot undo: no global region was moved');
}
this.targetRegion.setStartFromBeat(this.originalStartBeat);
}
getDescription(): string {
return `Move global region "${this.targetRegion?.getName() ?? this.regionId}"`;
}
}
@@ -0,0 +1,67 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { GlobalTrackType } from '../../global-track';
import { findGlobalTrackContainingRegion, findMarkerNeighborBounds, getSongEndBeat } from '../../../util/globalTrackUtil';
import { KGGlobalRegion } from '../../region/KGGlobalRegion';
export type GlobalRegionResizeEdge = 'start' | 'end';
export class ResizeGlobalRegionCommand extends KGCommand {
private readonly regionId: string;
private readonly edge: GlobalRegionResizeEdge;
private readonly desiredBeat: number;
private targetRegion: KGGlobalRegion | null = null;
private originalStartBeat = 0;
private originalLength = 0;
constructor(regionId: string, edge: GlobalRegionResizeEdge, desiredBeat: number) {
super();
this.regionId = regionId;
this.edge = edge;
this.desiredBeat = desiredBeat;
}
execute(): void {
const project = KGCore.instance().getCurrentProject();
const result = findGlobalTrackContainingRegion(project, this.regionId);
if (!result) {
throw new Error(`Global region with ID ${this.regionId} not found`);
}
this.targetRegion = result.region;
this.originalStartBeat = result.region.getStartFromBeat();
this.originalLength = result.region.getLength();
if (result.track.getType() !== GlobalTrackType.Marker) {
return;
}
const originalEndBeat = this.originalStartBeat + this.originalLength;
const { minStartBeat, maxEndBeat } = findMarkerNeighborBounds(project, this.regionId, this.originalStartBeat);
const songEndBeat = getSongEndBeat(project);
const absoluteMaxEndBeat = Math.min(maxEndBeat, songEndBeat);
if (this.edge === 'start') {
const clampedStartBeat = Math.max(minStartBeat, Math.min(this.desiredBeat, originalEndBeat - 1));
result.region.setStartFromBeat(clampedStartBeat);
result.region.setLength(Math.max(1, originalEndBeat - clampedStartBeat));
return;
}
const clampedEndBeat = Math.max(this.originalStartBeat + 1, Math.min(this.desiredBeat, absoluteMaxEndBeat));
result.region.setLength(Math.max(1, clampedEndBeat - this.originalStartBeat));
}
undo(): void {
if (!this.targetRegion) {
throw new Error('Cannot undo: no global region was resized');
}
this.targetRegion.setStartFromBeat(this.originalStartBeat);
this.targetRegion.setLength(this.originalLength);
}
getDescription(): string {
return `Resize global region "${this.targetRegion?.getName() ?? this.regionId}"`;
}
}
@@ -0,0 +1,38 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { findGlobalTrackContainingRegion } from '../../../util/globalTrackUtil';
export class UpdateGlobalRegionTextCommand extends KGCommand {
private readonly regionId: string;
private readonly nextText: string;
private previousText = '';
constructor(regionId: string, nextText: string) {
super();
this.regionId = regionId;
this.nextText = nextText;
}
execute(): void {
const result = findGlobalTrackContainingRegion(KGCore.instance().getCurrentProject(), this.regionId);
if (!result) {
throw new Error(`Global region with ID ${this.regionId} not found`);
}
this.previousText = result.region.getName();
result.region.setName(this.nextText);
}
undo(): void {
const result = findGlobalTrackContainingRegion(KGCore.instance().getCurrentProject(), this.regionId);
if (!result) {
throw new Error(`Global region with ID ${this.regionId} not found during undo`);
}
result.region.setName(this.previousText);
}
getDescription(): string {
return `Rename marker to "${this.nextText}"`;
}
}
+7
View File
@@ -34,6 +34,13 @@ export type { StemImportEntry } from './region/ImportStemsCommand';
export { SplitRegionCommand } from './region/SplitRegionCommand';
export { MergeMidiRegionsCommand } from './region/MergeMidiRegionsCommand';
// Global region commands
export { CreateGlobalMarkerRegionCommand } from './global-region/CreateGlobalMarkerRegionCommand';
export { MoveGlobalRegionCommand } from './global-region/MoveGlobalRegionCommand';
export { ResizeGlobalRegionCommand, type GlobalRegionResizeEdge } from './global-region/ResizeGlobalRegionCommand';
export { DeleteGlobalRegionCommand, DeleteMultipleGlobalRegionsCommand } from './global-region/DeleteGlobalRegionCommand';
export { UpdateGlobalRegionTextCommand } from './global-region/UpdateGlobalRegionTextCommand';
// Note commands
export { CreateNoteCommand } from './note/CreateNoteCommand';
export { DeleteMidiEventsCommand } from './note/DeleteMidiEventsCommand';
+16
View File
@@ -0,0 +1,16 @@
import { Expose } from 'class-transformer';
import { GlobalTrackType, KGGlobalTrack } from './KGGlobalTrack';
export class KGChordTrack extends KGGlobalTrack {
@Expose()
protected override __type: string = 'KGChordTrack';
constructor(id: string = 'global-chord', trackIndex: number = 3, name: string = 'Chord') {
super(id, trackIndex, GlobalTrackType.Chord, name, []);
this.__type = 'KGChordTrack';
}
public override getCurrentType(): string {
return 'KGChordTrack';
}
}
+115
View File
@@ -0,0 +1,115 @@
import { Expose, Type } from 'class-transformer';
import { KGGlobalRegion } from '../region/KGGlobalRegion';
import { KGMarkerRegion } from '../region/KGMarkerRegion';
export enum GlobalTrackType {
Marker = 'marker',
Tempo = 'tempo',
Signature = 'signature',
Chord = 'chord',
}
export class KGGlobalTrack {
@Expose()
protected __type: string = 'KGGlobalTrack';
@Expose()
protected id: string = '';
@Expose()
protected trackIndex: number = 0;
@Expose()
protected type: GlobalTrackType = GlobalTrackType.Marker;
@Expose()
protected name: string = '';
@Expose()
@Type(() => KGGlobalRegion, {
discriminator: {
property: '__type',
subTypes: [
{ value: KGGlobalRegion, name: 'KGGlobalRegion' },
{ value: KGMarkerRegion, name: 'KGMarkerRegion' },
],
},
})
protected regions: KGGlobalRegion[] = [];
constructor(
id: string = '',
trackIndex: number = 0,
type: GlobalTrackType = GlobalTrackType.Marker,
name: string = '',
regions: KGGlobalRegion[] = []
) {
this.id = id;
this.trackIndex = trackIndex;
this.type = type;
this.name = name;
this.regions = regions;
}
public getId(): string {
return this.id;
}
public setId(id: string): void {
this.id = id;
}
public getTrackIndex(): number {
return this.trackIndex;
}
public setTrackIndex(trackIndex: number): void {
this.trackIndex = trackIndex;
this.regions.forEach(region => {
region.setTrackIndex(trackIndex);
region.setTrackId(this.id);
});
}
public getType(): GlobalTrackType {
return this.type;
}
public setType(type: GlobalTrackType): void {
this.type = type;
}
public getName(): string {
return this.name;
}
public setName(name: string): void {
this.name = name;
}
public getRegions(): KGGlobalRegion[] {
return this.regions;
}
public setRegions(regions: KGGlobalRegion[]): void {
this.regions = regions.map(region => {
region.setTrackId(this.id);
region.setTrackIndex(this.trackIndex);
return region;
});
}
public addRegion(region: KGGlobalRegion): void {
region.setTrackId(this.id);
region.setTrackIndex(this.trackIndex);
this.regions.push(region);
}
public removeRegion(regionId: string): void {
this.regions = this.regions.filter(region => region.getId() !== regionId);
}
public getCurrentType(): string {
return 'KGGlobalTrack';
}
}
+16
View File
@@ -0,0 +1,16 @@
import { Expose } from 'class-transformer';
import { GlobalTrackType, KGGlobalTrack } from './KGGlobalTrack';
export class KGMarkerTrack extends KGGlobalTrack {
@Expose()
protected override __type: string = 'KGMarkerTrack';
constructor(id: string = 'global-marker', trackIndex: number = 0, name: string = 'Marker') {
super(id, trackIndex, GlobalTrackType.Marker, name, []);
this.__type = 'KGMarkerTrack';
}
public override getCurrentType(): string {
return 'KGMarkerTrack';
}
}
+16
View File
@@ -0,0 +1,16 @@
import { Expose } from 'class-transformer';
import { GlobalTrackType, KGGlobalTrack } from './KGGlobalTrack';
export class KGSignatureTrack extends KGGlobalTrack {
@Expose()
protected override __type: string = 'KGSignatureTrack';
constructor(id: string = 'global-signature', trackIndex: number = 2, name: string = 'Signature') {
super(id, trackIndex, GlobalTrackType.Signature, name, []);
this.__type = 'KGSignatureTrack';
}
public override getCurrentType(): string {
return 'KGSignatureTrack';
}
}
+16
View File
@@ -0,0 +1,16 @@
import { Expose } from 'class-transformer';
import { GlobalTrackType, KGGlobalTrack } from './KGGlobalTrack';
export class KGTempoTrack extends KGGlobalTrack {
@Expose()
protected override __type: string = 'KGTempoTrack';
constructor(id: string = 'global-tempo', trackIndex: number = 1, name: string = 'Tempo') {
super(id, trackIndex, GlobalTrackType.Tempo, name, []);
this.__type = 'KGTempoTrack';
}
public override getCurrentType(): string {
return 'KGTempoTrack';
}
}
@@ -0,0 +1,14 @@
import { KGChordTrack } from './KGChordTrack';
import { KGGlobalTrack } from './KGGlobalTrack';
import { KGMarkerTrack } from './KGMarkerTrack';
import { KGSignatureTrack } from './KGSignatureTrack';
import { KGTempoTrack } from './KGTempoTrack';
export function createDefaultGlobalTracks(): KGGlobalTrack[] {
return [
new KGMarkerTrack(),
new KGTempoTrack(),
new KGSignatureTrack(),
new KGChordTrack(),
];
}
+6
View File
@@ -0,0 +1,6 @@
export { KGGlobalTrack, GlobalTrackType } from './KGGlobalTrack';
export { KGMarkerTrack } from './KGMarkerTrack';
export { KGTempoTrack } from './KGTempoTrack';
export { KGSignatureTrack } from './KGSignatureTrack';
export { KGChordTrack } from './KGChordTrack';
export { createDefaultGlobalTracks } from './createDefaultGlobalTracks';
+20
View File
@@ -1,6 +1,8 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { KGProjectStorage, DuplicateEntryError } from './KGProjectStorage';
import { KGProject } from '../KGProject';
import { GlobalTrackType } from '../global-track';
import { KGMarkerRegion } from '../region/KGMarkerRegion';
import { KGTrack } from '../track/KGTrack';
// --- OPFS mock infrastructure ---
@@ -159,6 +161,24 @@ describe('KGProjectStorage', () => {
expect(loaded!.getPianoRollZoom()).toBe(5);
});
it('preserves global tracks and marker regions when saving and loading', async () => {
const project = createTestProject('Marker Song');
const markerTrack = project.getGlobalTracks().find(track => track.getType() === GlobalTrackType.Marker);
expect(markerTrack).toBeDefined();
markerTrack?.addRegion(new KGMarkerRegion('marker-1', markerTrack.getId(), markerTrack.getTrackIndex(), 'Intro', 0, 8));
await storage.save('Marker Song', project);
const loaded = await storage.load('Marker Song');
const loadedMarkerTrack = loaded?.getGlobalTracks().find(track => track.getType() === GlobalTrackType.Marker);
expect(loadedMarkerTrack).toBeDefined();
expect(loadedMarkerTrack?.getRegions()).toHaveLength(1);
expect(loadedMarkerTrack?.getRegions()[0]).toBeInstanceOf(KGMarkerRegion);
expect(loadedMarkerTrack?.getRegions()[0].getName()).toBe('Intro');
});
it('creates meta.json and media/ directory on save', async () => {
const project = createTestProject('My Song');
await storage.save('My Song', project);
@@ -11,6 +11,7 @@ import { upgradeToV9 } from './upgradeToV9';
import { upgradeToV10 } from './upgradeToV10';
import { upgradeToV11 } from './upgradeToV11';
import { upgradeToV12 } from './upgradeToV12';
import { upgradeToV13 } from './upgradeToV13';
/**
* Upgrade the given project to the latest structure version, one version at a time.
@@ -78,6 +79,10 @@ export function upgradeProjectToLatest(project: KGProject): KGProject {
workingProject = upgradeToV12(workingProject);
break;
}
case 13: {
workingProject = upgradeToV13(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}`);
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { KGProject } from '../KGProject';
import { GlobalTrackType } from '../global-track';
import { upgradeProjectToLatest } from './KGProjectUpgrader';
import { upgradeToV13 } from './upgradeToV13';
describe('upgradeToV13', () => {
it('adds the default global tracks to legacy projects', () => {
const project = new KGProject('Legacy', 32, 0, 125, undefined, undefined, undefined, undefined, [0, 0], 1, [], 12, 1, []);
upgradeToV13(project);
expect(project.getProjectStructureVersion()).toBe(13);
expect(project.getGlobalTracks()).toHaveLength(4);
expect(project.getGlobalTracks().map(track => track.getType())).toEqual([
GlobalTrackType.Marker,
GlobalTrackType.Tempo,
GlobalTrackType.Signature,
GlobalTrackType.Chord,
]);
});
it('runs through the main upgrader path', () => {
const project = new KGProject('Legacy', 32, 0, 125, undefined, undefined, undefined, undefined, [0, 0], 1, [], 12, 1, []);
const upgraded = upgradeProjectToLatest(project);
expect(upgraded.getProjectStructureVersion()).toBe(KGProject.CURRENT_PROJECT_STRUCTURE_VERSION);
expect(upgraded.getGlobalTracks()).toHaveLength(4);
});
});
+12
View File
@@ -0,0 +1,12 @@
import { KGProject } from '../KGProject';
import { ensureDefaultGlobalTracks } from '../../util/globalTrackUtil';
export function upgradeToV13(project: KGProject): KGProject {
try {
ensureDefaultGlobalTracks(project);
} finally {
project.setProjectStructureVersion(13);
}
return project;
}
+27
View File
@@ -0,0 +1,27 @@
import { Expose } from 'class-transformer';
import { KGRegion } from './KGRegion';
export class KGGlobalRegion extends KGRegion {
@Expose()
protected override __type: string = 'KGGlobalRegion';
constructor(
id: string,
trackId: string,
trackIndex: number,
name: string,
startFromBeat: number = 0,
length: number = 0
) {
super(id, trackId, trackIndex, name, startFromBeat, length);
this.__type = 'KGGlobalRegion';
}
public override getRootType(): string {
return 'KGRegion';
}
public override getCurrentType(): string {
return 'KGGlobalRegion';
}
}
+23
View File
@@ -0,0 +1,23 @@
import { Expose } from 'class-transformer';
import { KGGlobalRegion } from './KGGlobalRegion';
export class KGMarkerRegion extends KGGlobalRegion {
@Expose()
protected override __type: string = 'KGMarkerRegion';
constructor(
id: string,
trackId: string,
trackIndex: number,
name: string,
startFromBeat: number = 0,
length: number = 0
) {
super(id, trackId, trackIndex, name, startFromBeat, length);
this.__type = 'KGMarkerRegion';
}
public override getCurrentType(): string {
return 'KGMarkerRegion';
}
}