feat: implemented track level automation

This commit is contained in:
Xiaohan-Tian
2026-05-08 15:44:07 -07:00
parent 6e5d9466f4
commit 31e02b4149
24 changed files with 1931 additions and 36 deletions
+6
View File
@@ -12,6 +12,12 @@ export { AddAudioTrackCommand } from './track/AddAudioTrackCommand';
export { RemoveTrackCommand } from './track/RemoveTrackCommand';
export { ReorderTracksCommand } from './track/ReorderTracksCommand';
export { UpdateTrackCommand, type TrackUpdateProperties } from './track/UpdateTrackCommand';
export {
CreateTrackAutomationPointsCommand,
type TrackAutomationPointCreationData,
} from './track/CreateTrackAutomationPointsCommand';
export { DeleteTrackAutomationPointsCommand } from './track/DeleteTrackAutomationPointsCommand';
export { UpdateTrackAutomationPointsCommand } from './track/UpdateTrackAutomationPointsCommand';
// Region commands
export { CreateRegionCommand } from './region/CreateRegionCommand';
@@ -0,0 +1,84 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { KGTrack } from '../../track/KGTrack';
import { KGTrackAutomationPoint, type TrackAutomationType } from '../../track/KGTrackAutomationPoint';
import { generateUniqueId } from '../../../util/miscUtil';
import { instantiateTrackAutomationPoints } from '../../../util/trackAutomationUtil';
export interface TrackAutomationPointCreationData {
beat: number;
value: number;
pointId?: string;
}
export class CreateTrackAutomationPointsCommand extends KGCommand {
private readonly trackId: number;
private readonly automationType: TrackAutomationType;
private readonly creationData: TrackAutomationPointCreationData[];
private targetTrack: KGTrack | null = null;
private originalPoints: KGTrackAutomationPoint[] = [];
private createdPointIds: string[] = [];
constructor(trackId: number, automationType: TrackAutomationType, creationData: TrackAutomationPointCreationData[]) {
super();
this.trackId = trackId;
this.automationType = automationType;
this.creationData = creationData.map(data => ({
...data,
pointId: data.pointId ?? generateUniqueId('KGTrackAutomationPoint'),
}));
}
execute(): void {
this.targetTrack = this.resolveTrack();
this.originalPoints = [...this.targetTrack.getAutomationPoints(this.automationType)];
const nextPoints = instantiateTrackAutomationPoints(this.automationType, [
...this.originalPoints.map(point => ({
id: point.getId(),
beat: point.getBeat(),
value: point.getValue(),
})),
...this.creationData.map(data => ({
id: data.pointId!,
beat: data.beat,
value: data.value,
})),
]);
this.createdPointIds = nextPoints
.filter(point => this.creationData.some(data => data.pointId === point.getId()))
.map(point => point.getId());
this.targetTrack.setAutomationPoints(this.automationType, nextPoints);
}
undo(): void {
if (!this.targetTrack) {
throw new Error('Cannot undo: command was not executed');
}
this.targetTrack.setAutomationPoints(this.automationType, this.originalPoints);
const core = KGCore.instance();
core.getSelectedItems()
.filter(item => item instanceof KGTrackAutomationPoint && this.createdPointIds.includes(item.getId()))
.forEach(item => core.removeSelectedItem(item));
}
getDescription(): string {
const count = this.creationData.length;
return count === 1
? `Create ${this.automationType} automation point`
: `Create ${count} ${this.automationType} automation points`;
}
public getCreatedPointIds(): string[] {
return this.createdPointIds;
}
private resolveTrack(): KGTrack {
const track = KGCore.instance().getCurrentProject().getTracks().find(candidate => candidate.getId() === this.trackId);
if (!track) {
throw new Error(`Track with ID ${this.trackId} not found`);
}
return track;
}
}
@@ -0,0 +1,58 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { KGTrack } from '../../track/KGTrack';
import { KGTrackAutomationPoint, type TrackAutomationType } from '../../track/KGTrackAutomationPoint';
export class DeleteTrackAutomationPointsCommand extends KGCommand {
private readonly trackId: number;
private readonly automationType: TrackAutomationType;
private readonly pointIds: string[];
private targetTrack: KGTrack | null = null;
private originalPoints: KGTrackAutomationPoint[] = [];
constructor(trackId: number, automationType: TrackAutomationType, pointIds: string[]) {
super();
this.trackId = trackId;
this.automationType = automationType;
this.pointIds = pointIds;
}
execute(): void {
this.targetTrack = this.resolveTrack();
this.originalPoints = [...this.targetTrack.getAutomationPoints(this.automationType)];
const remainingPoints = this.originalPoints.filter(point => !this.pointIds.includes(point.getId()));
if (remainingPoints.length === this.originalPoints.length) {
throw new Error('No track automation points found to delete');
}
this.targetTrack.setAutomationPoints(this.automationType, remainingPoints);
const core = KGCore.instance();
core.getSelectedItems()
.filter(item => item instanceof KGTrackAutomationPoint && this.pointIds.includes(item.getId()))
.forEach(item => core.removeSelectedItem(item));
}
undo(): void {
if (!this.targetTrack) {
throw new Error('Cannot undo: command was not executed');
}
this.targetTrack.setAutomationPoints(this.automationType, this.originalPoints);
}
getDescription(): string {
const count = this.pointIds.length;
return count === 1
? `Delete ${this.automationType} automation point`
: `Delete ${count} ${this.automationType} automation points`;
}
private resolveTrack(): KGTrack {
const track = KGCore.instance().getCurrentProject().getTracks().find(candidate => candidate.getId() === this.trackId);
if (!track) {
throw new Error(`Track with ID ${this.trackId} not found`);
}
return track;
}
}
@@ -0,0 +1,86 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { KGCore } from '../../KGCore';
import { KGProject } from '../../KGProject';
import { KGTrack } from '../../track/KGTrack';
import { CreateTrackAutomationPointsCommand } from './CreateTrackAutomationPointsCommand';
import { DeleteTrackAutomationPointsCommand } from './DeleteTrackAutomationPointsCommand';
import { UpdateTrackAutomationPointsCommand } from './UpdateTrackAutomationPointsCommand';
import { KGTrackAutomationPoint } from '../../track/KGTrackAutomationPoint';
vi.mock('../../KGCore', () => ({
KGCore: {
instance: vi.fn()
}
}));
describe('track automation commands', () => {
let track: KGTrack;
let project: KGProject;
const mockCore = {
getCurrentProject: vi.fn(),
getSelectedItems: vi.fn(() => []),
removeSelectedItem: vi.fn(),
};
beforeEach(() => {
track = new KGTrack('Track 1', 1);
project = new KGProject('Test', 32, 0, 125, undefined, undefined, undefined, undefined, undefined, 1, [track], 10);
mockCore.getCurrentProject.mockReturnValue(project);
mockCore.getSelectedItems.mockReturnValue([]);
mockCore.removeSelectedItem.mockReset();
vi.mocked(KGCore.instance).mockReturnValue(mockCore as unknown as KGCore);
});
it('creates and dedupes same-beat automation points', () => {
const command = new CreateTrackAutomationPointsCommand(1, 'volume', [
{ beat: 1, value: -6, pointId: 'point-1' },
{ beat: 1, value: -3, pointId: 'point-2' },
]);
command.execute();
expect(track.getVolumeAutomation()).toHaveLength(1);
expect(track.getVolumeAutomation()[0].getId()).toBe('point-2');
expect(track.getVolumeAutomation()[0].getValue()).toBe(-3);
});
it('restores deleted automation points on undo', () => {
track.setPanAutomation([
new KGTrackAutomationPoint('point-1', 1, -0.5),
new KGTrackAutomationPoint('point-2', 2, 0.5),
]);
const command = new DeleteTrackAutomationPointsCommand(1, 'pan', ['point-1']);
command.execute();
expect(track.getPanAutomation()).toHaveLength(1);
command.undo();
expect(track.getPanAutomation()).toHaveLength(2);
});
it('updates points and removes collisions caused by moves', () => {
track.setPanAutomation([
new KGTrackAutomationPoint('point-1', 1, -0.5),
new KGTrackAutomationPoint('point-2', 2, 0.5),
]);
const command = new UpdateTrackAutomationPointsCommand(
1,
'pan',
[
{ pointId: 'point-1', beat: 1, value: -0.5 },
{ pointId: 'point-2', beat: 2, value: 0.5 },
],
[
{ pointId: 'point-1', beat: 2, value: -0.25 },
]
);
command.execute();
expect(track.getPanAutomation()).toHaveLength(1);
expect(track.getPanAutomation()[0].getId()).toBe('point-2');
command.undo();
expect(track.getPanAutomation()).toHaveLength(2);
});
});
@@ -0,0 +1,77 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { KGTrack } from '../../track/KGTrack';
import { KGTrackAutomationPoint, type TrackAutomationType } from '../../track/KGTrackAutomationPoint';
import { instantiateTrackAutomationPoints } from '../../../util/trackAutomationUtil';
interface TrackAutomationPointSnapshot {
pointId: string;
beat: number;
value: number;
}
interface TrackAutomationPointUpdate {
pointId: string;
beat?: number;
value?: number;
}
export class UpdateTrackAutomationPointsCommand extends KGCommand {
private readonly trackId: number;
private readonly automationType: TrackAutomationType;
private readonly snapshots: TrackAutomationPointSnapshot[];
private readonly updates: TrackAutomationPointUpdate[];
private targetTrack: KGTrack | null = null;
private originalPoints: KGTrackAutomationPoint[] = [];
constructor(
trackId: number,
automationType: TrackAutomationType,
snapshots: TrackAutomationPointSnapshot[],
updates: TrackAutomationPointUpdate[]
) {
super();
this.trackId = trackId;
this.automationType = automationType;
this.snapshots = [...snapshots];
this.updates = [...updates];
}
execute(): void {
this.targetTrack = this.resolveTrack();
this.originalPoints = [...this.targetTrack.getAutomationPoints(this.automationType)];
const nextPoints = instantiateTrackAutomationPoints(this.automationType, this.originalPoints.map(point => {
const update = this.updates.find(candidate => candidate.pointId === point.getId());
return {
id: point.getId(),
beat: update?.beat ?? point.getBeat(),
value: update?.value ?? point.getValue(),
};
}));
this.targetTrack.setAutomationPoints(this.automationType, nextPoints);
}
undo(): void {
if (!this.targetTrack) {
throw new Error('Cannot undo: command was not executed');
}
this.targetTrack.setAutomationPoints(this.automationType, this.originalPoints);
}
getDescription(): string {
const count = this.snapshots.length;
return count === 1
? `Update ${this.automationType} automation point`
: `Update ${count} ${this.automationType} automation points`;
}
private resolveTrack(): KGTrack {
const track = KGCore.instance().getCurrentProject().getTracks().find(candidate => candidate.getId() === this.trackId);
if (!track) {
throw new Error(`Track with ID ${this.trackId} not found`);
}
return track;
}
}