feat: added global key signature track, global chord progression track, global tempo(BPM) track, and global marker track related tools for AI Agent

This commit is contained in:
Xiaohan-Tian
2026-06-05 16:50:51 -07:00
parent 622dbbea83
commit c4f5d7b78e
29 changed files with 3277 additions and 8 deletions
@@ -8,6 +8,7 @@ import { MoveGlobalRegionCommand } from './MoveGlobalRegionCommand';
import { ResizeGlobalRegionCommand } from './ResizeGlobalRegionCommand';
import { DeleteGlobalRegionCommand } from './DeleteGlobalRegionCommand';
import { UpdateGlobalRegionTextCommand } from './UpdateGlobalRegionTextCommand';
import { WriteMarkersCommand } from './WriteMarkersCommand';
describe('global marker region commands', () => {
beforeEach(() => {
@@ -106,4 +107,63 @@ describe('global marker region commands', () => {
expect(markerTrack.getRegions()).toHaveLength(1);
expect(markerTrack.getRegions()[0].getId()).toBe('marker');
});
it('writes a marker into the middle of an existing region and preserves both sides', () => {
const markerTrack = getMarkerTrack();
markerTrack.setRegions([
new KGMarkerRegion('base', markerTrack.getId(), markerTrack.getTrackIndex(), 'Intro', 0, 8),
]);
const command = new WriteMarkersCommand([
{ startBeat: 3, length: 2, name: 'Hit' },
]);
command.execute();
expect((getMarkerTrack().getRegions() as KGMarkerRegion[]).map(region => ({
name: region.getName(),
start: region.getStartFromBeat(),
length: region.getLength(),
}))).toEqual([
{ name: 'Intro', start: 0, length: 3 },
{ name: 'Hit', start: 3, length: 2 },
{ name: 'Intro', start: 5, length: 3 },
]);
command.undo();
expect((getMarkerTrack().getRegions() as KGMarkerRegion[]).map(region => ({
name: region.getName(),
start: region.getStartFromBeat(),
length: region.getLength(),
}))).toEqual([
{ name: 'Intro', start: 0, length: 8 },
]);
});
it('writes multiple non-contiguous marker spans while preserving untouched gaps', () => {
const markerTrack = getMarkerTrack();
markerTrack.setRegions([
new KGMarkerRegion('left', markerTrack.getId(), markerTrack.getTrackIndex(), 'Scene', 0, 12),
]);
const command = new WriteMarkersCommand([
{ startBeat: 2, length: 2, name: 'Rise' },
{ startBeat: 8, length: 2, name: 'Drop' },
]);
command.execute();
expect((getMarkerTrack().getRegions() as KGMarkerRegion[]).map(region => ({
name: region.getName(),
start: region.getStartFromBeat(),
length: region.getLength(),
}))).toEqual([
{ name: 'Scene', start: 0, length: 2 },
{ name: 'Rise', start: 2, length: 2 },
{ name: 'Scene', start: 4, length: 4 },
{ name: 'Drop', start: 8, length: 2 },
{ name: 'Scene', start: 10, length: 2 },
]);
});
});
@@ -0,0 +1,85 @@
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 { WriteKeySignatureTrackCommand } from './WriteKeySignatureTrackCommand';
describe('WriteKeySignatureTrackCommand', () => {
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('rebuilds the full signature track from explicit entries', () => {
const command = new WriteKeySignatureTrackCommand('C major', [
{ startBeat: 8, keySignature: 'G major' },
{ startBeat: 16, keySignature: 'D major' },
]);
command.execute();
const regions = getSignatureTrack().getRegions() as KGKeySignatureRegion[];
expect(regions.map(region => ({
keySignature: region.getKeySignature(),
startBar: region.getStartBar(),
lengthBars: region.getLengthBars(),
}))).toEqual([
{ keySignature: 'C major', startBar: 0, lengthBars: 2 },
{ keySignature: 'G major', startBar: 2, lengthBars: 2 },
{ keySignature: 'D major', startBar: 4, lengthBars: 4 },
]);
});
it('replaces an existing multi-region track and restores it on undo', () => {
const signatureTrack = getSignatureTrack();
signatureTrack.setRegions([
new KGKeySignatureRegion('existing-1', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'F major', 0, 3, 4),
new KGKeySignatureRegion('existing-2', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'Bb major', 3, 5, 4),
]);
const command = new WriteKeySignatureTrackCommand('A minor', [
{ startBeat: 12, keySignature: 'E minor' },
]);
command.execute();
expect((signatureTrack.getRegions() as KGKeySignatureRegion[]).map(region => region.getKeySignature()))
.toEqual(['A minor', 'E minor']);
command.undo();
expect((signatureTrack.getRegions() as KGKeySignatureRegion[]).map(region => ({
keySignature: region.getKeySignature(),
startBar: region.getStartBar(),
lengthBars: region.getLengthBars(),
}))).toEqual([
{ keySignature: 'F major', startBar: 0, lengthBars: 3 },
{ keySignature: 'Bb major', startBar: 3, lengthBars: 5 },
]);
});
it('uses only the base key signature when no explicit entries are provided', () => {
const command = new WriteKeySignatureTrackCommand('E minor', []);
command.execute();
const regions = getSignatureTrack().getRegions() as KGKeySignatureRegion[];
expect(regions).toHaveLength(1);
expect(regions[0].getKeySignature()).toBe('E minor');
expect(regions[0].getStartBar()).toBe(0);
expect(regions[0].getLengthBars()).toBe(8);
});
});
@@ -0,0 +1,123 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import type { KeySignature } from '../../KGProject';
import { GlobalTrackType } from '../../global-track';
import { KGKeySignatureRegion } from '../../region/KGKeySignatureRegion';
import {
cloneKeySignatureRegions,
findGlobalTrackByType,
getSongEndBar,
getSortedKeySignatureRegions,
} from '../../../util/globalTrackUtil';
import { generateUniqueId } from '../../../util/miscUtil';
export interface WriteKeySignatureEntry {
startBeat: number;
keySignature: KeySignature;
}
function cloneRegions(regions: KGKeySignatureRegion[], beatsPerBar: number): KGKeySignatureRegion[] {
return cloneKeySignatureRegions(regions, beatsPerBar);
}
export class WriteKeySignatureTrackCommand extends KGCommand {
private readonly baseKeySignature: KeySignature;
private readonly replacements: WriteKeySignatureEntry[];
private previousRegions: KGKeySignatureRegion[] | null = null;
private nextRegions: KGKeySignatureRegion[] | null = null;
constructor(baseKeySignature: KeySignature, replacements: WriteKeySignatureEntry[]) {
super();
this.baseKeySignature = baseKeySignature;
this.replacements = replacements.map(replacement => ({
startBeat: replacement.startBeat,
keySignature: replacement.keySignature,
}));
}
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');
}
if (this.nextRegions) {
track.setRegions(cloneRegions(this.nextRegions, beatsPerBar));
return;
}
const currentRegions = getSortedKeySignatureRegions(track, beatsPerBar);
this.previousRegions = cloneRegions(currentRegions, beatsPerBar);
const songEndBar = getSongEndBar(project);
if (songEndBar <= 0) {
this.nextRegions = [];
track.setRegions([]);
return;
}
const normalizedReplacements = this.replacements
.map(replacement => ({
startBar: Math.floor(replacement.startBeat / beatsPerBar),
keySignature: replacement.keySignature,
}))
.sort((left, right) => left.startBar - right.startBar);
const nextRegions: KGKeySignatureRegion[] = [];
let currentStartBar = 0;
let currentKeySignature = this.baseKeySignature;
for (const replacement of normalizedReplacements) {
if (replacement.startBar > currentStartBar) {
nextRegions.push(new KGKeySignatureRegion(
generateUniqueId('KGKeySignatureRegion'),
track.getId(),
track.getTrackIndex(),
currentKeySignature,
currentStartBar,
replacement.startBar - currentStartBar,
beatsPerBar,
));
}
currentStartBar = replacement.startBar;
currentKeySignature = replacement.keySignature;
}
if (currentStartBar < songEndBar) {
nextRegions.push(new KGKeySignatureRegion(
generateUniqueId('KGKeySignatureRegion'),
track.getId(),
track.getTrackIndex(),
currentKeySignature,
currentStartBar,
songEndBar - currentStartBar,
beatsPerBar,
));
}
this.nextRegions = nextRegions;
track.setRegions(cloneRegions(this.nextRegions, beatsPerBar));
}
undo(): void {
if (!this.previousRegions) {
throw new Error('Cannot undo key signature write without original regions');
}
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 during undo');
}
track.setRegions(cloneRegions(this.previousRegions, beatsPerBar));
}
getDescription(): string {
return 'Write key signature track';
}
}
@@ -0,0 +1,139 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { GlobalTrackType } from '../../global-track';
import { KGMarkerRegion } from '../../region/KGMarkerRegion';
import { findGlobalTrackByType } from '../../../util/globalTrackUtil';
import { generateUniqueId } from '../../../util/miscUtil';
export interface WriteMarkerEntry {
startBeat: number;
length: number;
name: string;
}
function cloneMarkerRegion(region: KGMarkerRegion): KGMarkerRegion {
return new KGMarkerRegion(
region.getId(),
region.getTrackId(),
region.getTrackIndex(),
region.getName(),
region.getStartFromBeat(),
region.getLength(),
);
}
function cloneMarkerRegions(regions: KGMarkerRegion[]): KGMarkerRegion[] {
return regions.map(cloneMarkerRegion);
}
export class WriteMarkersCommand extends KGCommand {
private readonly replacements: WriteMarkerEntry[];
private originalRegions: KGMarkerRegion[] | null = null;
private nextRegions: KGMarkerRegion[] | null = null;
constructor(replacements: WriteMarkerEntry[]) {
super();
this.replacements = replacements.map(replacement => ({
startBeat: replacement.startBeat,
length: replacement.length,
name: replacement.name,
}));
}
execute(): void {
const project = KGCore.instance().getCurrentProject();
const markerTrack = findGlobalTrackByType(project, GlobalTrackType.Marker);
if (!markerTrack) {
throw new Error('Marker global track not found');
}
if (this.nextRegions) {
markerTrack.setRegions(cloneMarkerRegions(this.nextRegions));
return;
}
const currentRegions = markerTrack.getRegions()
.filter((region): region is KGMarkerRegion => region instanceof KGMarkerRegion)
.sort((left, right) => left.getStartFromBeat() - right.getStartFromBeat());
const sortedReplacements = [...this.replacements].sort((left, right) => left.startBeat - right.startBeat);
this.originalRegions = cloneMarkerRegions(currentRegions);
const preservedRegions: KGMarkerRegion[] = [];
for (const region of currentRegions) {
const regionStart = region.getStartFromBeat();
const regionEnd = regionStart + region.getLength();
const overlappingReplacements = sortedReplacements.filter(replacement => (
replacement.startBeat < regionEnd
&& replacement.startBeat + replacement.length > regionStart
));
if (overlappingReplacements.length === 0) {
preservedRegions.push(cloneMarkerRegion(region));
continue;
}
let cursor = regionStart;
let fragmentIndex = 0;
for (const replacement of overlappingReplacements) {
const replacementStart = Math.max(regionStart, replacement.startBeat);
const replacementEnd = Math.min(regionEnd, replacement.startBeat + replacement.length);
if (replacementStart > cursor) {
preservedRegions.push(new KGMarkerRegion(
fragmentIndex === 0 ? region.getId() : generateUniqueId('KGMarkerRegion'),
region.getTrackId(),
region.getTrackIndex(),
region.getName(),
cursor,
replacementStart - cursor,
));
fragmentIndex += 1;
}
cursor = Math.max(cursor, replacementEnd);
}
if (cursor < regionEnd) {
preservedRegions.push(new KGMarkerRegion(
fragmentIndex === 0 ? region.getId() : generateUniqueId('KGMarkerRegion'),
region.getTrackId(),
region.getTrackIndex(),
region.getName(),
cursor,
regionEnd - cursor,
));
}
}
const replacementRegions = sortedReplacements.map(replacement => new KGMarkerRegion(
generateUniqueId('KGMarkerRegion'),
markerTrack.getId(),
markerTrack.getTrackIndex(),
replacement.name,
replacement.startBeat,
replacement.length,
));
this.nextRegions = [...preservedRegions, ...replacementRegions]
.sort((left, right) => left.getStartFromBeat() - right.getStartFromBeat());
markerTrack.setRegions(cloneMarkerRegions(this.nextRegions));
}
undo(): void {
if (!this.originalRegions) {
throw new Error('Cannot undo marker write without original regions');
}
const project = KGCore.instance().getCurrentProject();
const markerTrack = findGlobalTrackByType(project, GlobalTrackType.Marker);
if (!markerTrack) {
throw new Error('Marker global track not found during undo');
}
markerTrack.setRegions(cloneMarkerRegions(this.originalRegions));
}
getDescription(): string {
return 'Write markers';
}
}
@@ -0,0 +1,93 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { KGCore } from '../../KGCore';
import { KGProject } from '../../KGProject';
import { GlobalTrackType } from '../../global-track';
import { KGTempoRegion } from '../../region/KGTempoRegion';
import { WriteTempoTrackCommand } from './WriteTempoTrackCommand';
describe('WriteTempoTrackCommand', () => {
beforeEach(() => {
const project = new KGProject('Tempo', 8, 0, 120);
const mockCore = KGCore.instance() as unknown as {
getCurrentProject: ReturnType<typeof vi.fn>;
};
mockCore.getCurrentProject.mockReturnValue(project);
});
const getTempoTrack = () => {
const tempoTrack = KGCore.instance().getCurrentProject().getGlobalTracks()
.find(track => track.getType() === GlobalTrackType.Tempo);
if (!tempoTrack) {
throw new Error('Tempo track missing in test setup');
}
return tempoTrack;
};
it('writes base BPM only by clearing explicit tempo regions and updating project BPM', () => {
const tempoTrack = getTempoTrack();
tempoTrack.setRegions([
new KGTempoRegion('existing-1', tempoTrack.getId(), tempoTrack.getTrackIndex(), 120, 0, 3, 4),
new KGTempoRegion('existing-2', tempoTrack.getId(), tempoTrack.getTrackIndex(), 140, 3, 5, 4),
]);
const command = new WriteTempoTrackCommand(96, []);
command.execute();
expect(KGCore.instance().getCurrentProject().getBpm()).toBe(96);
expect(tempoTrack.getRegions()).toEqual([]);
});
it('rebuilds explicit tempo regions into a gapless full-song plan', () => {
const command = new WriteTempoTrackCommand(100, [
{ startBeat: 8, bpm: 120 },
{ startBeat: 16, bpm: 140 },
]);
command.execute();
const project = KGCore.instance().getCurrentProject();
const regions = getTempoTrack().getRegions() as KGTempoRegion[];
expect(project.getBpm()).toBe(100);
expect(regions.map(region => ({
bpm: region.getBpm(),
startBar: region.getStartBar(),
lengthBars: region.getLengthBars(),
}))).toEqual([
{ bpm: 100, startBar: 0, lengthBars: 2 },
{ bpm: 120, startBar: 2, lengthBars: 2 },
{ bpm: 140, startBar: 4, lengthBars: 4 },
]);
});
it('restores both project BPM and prior tempo regions on undo', () => {
const project = KGCore.instance().getCurrentProject();
const tempoTrack = getTempoTrack();
tempoTrack.setRegions([
new KGTempoRegion('existing-1', tempoTrack.getId(), tempoTrack.getTrackIndex(), 120, 0, 2, 4),
new KGTempoRegion('existing-2', tempoTrack.getId(), tempoTrack.getTrackIndex(), 128, 2, 6, 4),
]);
const command = new WriteTempoTrackCommand(88, [
{ startBeat: 12, bpm: 144 },
]);
command.execute();
expect(project.getBpm()).toBe(88);
expect((tempoTrack.getRegions() as KGTempoRegion[]).map(region => region.getBpm())).toEqual([88, 144]);
command.undo();
expect(project.getBpm()).toBe(120);
expect((tempoTrack.getRegions() as KGTempoRegion[]).map(region => ({
bpm: region.getBpm(),
startBar: region.getStartBar(),
lengthBars: region.getLengthBars(),
}))).toEqual([
{ bpm: 120, startBar: 0, lengthBars: 2 },
{ bpm: 128, startBar: 2, lengthBars: 6 },
]);
});
});
@@ -0,0 +1,143 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { GlobalTrackType } from '../../global-track';
import { KGTempoRegion } from '../../region/KGTempoRegion';
import {
cloneTempoRegions,
findGlobalTrackByType,
getSongEndBar,
getSortedTempoRegions,
} from '../../../util/globalTrackUtil';
import { generateUniqueId } from '../../../util/miscUtil';
export interface WriteTempoEntry {
startBeat: number;
bpm: number;
}
function cloneRegions(regions: KGTempoRegion[], beatsPerBar: number): KGTempoRegion[] {
return cloneTempoRegions(regions, beatsPerBar);
}
export class WriteTempoTrackCommand extends KGCommand {
private readonly baseBpm: number;
private readonly replacements: WriteTempoEntry[];
private previousRegions: KGTempoRegion[] | null = null;
private previousProjectBpm: number | null = null;
private nextRegions: KGTempoRegion[] | null = null;
constructor(baseBpm: number, replacements: WriteTempoEntry[]) {
super();
this.baseBpm = baseBpm;
this.replacements = replacements.map(replacement => ({
startBeat: replacement.startBeat,
bpm: replacement.bpm,
}));
}
execute(): void {
const project = KGCore.instance().getCurrentProject();
const beatsPerBar = project.getTimeSignature().numerator;
const track = findGlobalTrackByType(project, GlobalTrackType.Tempo);
if (!track) {
throw new Error('Tempo global track not found');
}
if (this.nextRegions) {
project.setBpm(this.baseBpm);
track.setRegions(cloneRegions(this.nextRegions, beatsPerBar));
return;
}
const currentRegions = getSortedTempoRegions(track, beatsPerBar);
this.previousProjectBpm = project.getBpm();
this.previousRegions = cloneRegions(currentRegions, beatsPerBar);
project.setBpm(this.baseBpm);
if (this.replacements.length === 0) {
this.nextRegions = [];
track.setRegions([]);
return;
}
const songEndBar = getSongEndBar(project);
if (songEndBar <= 0) {
this.nextRegions = [];
track.setRegions([]);
return;
}
const normalizedReplacements = this.replacements
.map(replacement => ({
startBar: Math.floor(replacement.startBeat / beatsPerBar),
bpm: replacement.bpm,
}))
.sort((left, right) => left.startBar - right.startBar);
for (let index = 1; index < normalizedReplacements.length; index += 1) {
const previous = normalizedReplacements[index - 1];
const current = normalizedReplacements[index];
if (current.startBar <= previous.startBar) {
throw new Error(
`Tempo entry ${index + 1} overlaps with or collapses into entry ${index} after bar alignment. Entry ${index} normalizes to bar ${previous.startBar + 1}, and entry ${index + 1} normalizes to bar ${current.startBar + 1}.`,
);
}
}
const nextRegions: KGTempoRegion[] = [];
let currentStartBar = 0;
let currentBpm = this.baseBpm;
for (const replacement of normalizedReplacements) {
if (replacement.startBar > currentStartBar) {
nextRegions.push(new KGTempoRegion(
generateUniqueId('KGTempoRegion'),
track.getId(),
track.getTrackIndex(),
currentBpm,
currentStartBar,
replacement.startBar - currentStartBar,
beatsPerBar,
));
}
currentStartBar = replacement.startBar;
currentBpm = replacement.bpm;
}
if (currentStartBar < songEndBar) {
nextRegions.push(new KGTempoRegion(
generateUniqueId('KGTempoRegion'),
track.getId(),
track.getTrackIndex(),
currentBpm,
currentStartBar,
songEndBar - currentStartBar,
beatsPerBar,
));
}
this.nextRegions = nextRegions;
track.setRegions(cloneRegions(this.nextRegions, beatsPerBar));
}
undo(): void {
if (!this.previousRegions || this.previousProjectBpm === null) {
throw new Error('Cannot undo tempo write without original state');
}
const project = KGCore.instance().getCurrentProject();
const beatsPerBar = project.getTimeSignature().numerator;
const track = findGlobalTrackByType(project, GlobalTrackType.Tempo);
if (!track) {
throw new Error('Tempo global track not found during undo');
}
project.setBpm(this.previousProjectBpm);
track.setRegions(cloneRegions(this.previousRegions, beatsPerBar));
}
getDescription(): string {
return 'Write tempo track';
}
}
+12
View File
@@ -47,6 +47,18 @@ export {
WriteChordProgressionCommand,
type WriteChordProgressionEntry,
} from './global-region/WriteChordProgressionCommand';
export {
WriteMarkersCommand,
type WriteMarkerEntry,
} from './global-region/WriteMarkersCommand';
export {
WriteKeySignatureTrackCommand,
type WriteKeySignatureEntry,
} from './global-region/WriteKeySignatureTrackCommand';
export {
WriteTempoTrackCommand,
type WriteTempoEntry,
} from './global-region/WriteTempoTrackCommand';
export { CreateKeySignatureRegionCommand } from './global-region/CreateKeySignatureRegionCommand';
export { CreateTempoRegionCommand } from './global-region/CreateTempoRegionCommand';
export { MoveGlobalRegionCommand } from './global-region/MoveGlobalRegionCommand';