feat: implemented track level automation
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user