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:
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user