feat: added MIDI CC recording and list event table operation support.

This commit is contained in:
Xiaohan-Tian
2026-05-07 22:12:06 -07:00
parent 6305983df8
commit f3d26126fa
30 changed files with 1250 additions and 53 deletions
@@ -1,6 +1,7 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { KGMidiNote } from '../../midi/KGMidiNote';
import { KGMidiControllerEvent } from '../../midi/KGMidiControllerEvent';
import { KGMidiPitchBend } from '../../midi/KGMidiPitchBend';
import { KGMidiRegion } from '../../region/KGMidiRegion';
import { KGTrack } from '../../track/KGTrack';
@@ -22,13 +23,27 @@ export interface PitchBendCreationData {
pitchBendId?: string;
}
export interface ControllerEventCreationData {
regionId: string;
controller: number;
beat: number;
value: number;
controllerEventId?: string;
}
export class CreateMidiEventsCommand extends KGCommand {
private noteCreationData: NoteCreationData[];
private pitchBendCreationData: PitchBendCreationData[];
private controllerEventCreationData: ControllerEventCreationData[];
private createdNotes: Array<{ note: KGMidiNote; regionId: string }> = [];
private createdPitchBends: Array<{ pitchBend: KGMidiPitchBend; regionId: string }> = [];
private createdControllerEvents: Array<{ controller: number; controllerEvent: KGMidiControllerEvent; regionId: string }> = [];
constructor(noteCreationData: NoteCreationData[], pitchBendCreationData: PitchBendCreationData[] = []) {
constructor(
noteCreationData: NoteCreationData[],
pitchBendCreationData: PitchBendCreationData[] = [],
controllerEventCreationData: ControllerEventCreationData[] = []
) {
super();
this.noteCreationData = noteCreationData.map(data => ({
...data,
@@ -38,12 +53,17 @@ export class CreateMidiEventsCommand extends KGCommand {
...data,
pitchBendId: data.pitchBendId || generateUniqueId('KGMidiPitchBend'),
}));
this.controllerEventCreationData = controllerEventCreationData.map(data => ({
...data,
controllerEventId: data.controllerEventId || generateUniqueId('KGMidiControllerEvent'),
}));
}
execute(): void {
const tracks = KGCore.instance().getCurrentProject().getTracks();
this.createdNotes = [];
this.createdPitchBends = [];
this.createdControllerEvents = [];
for (const noteData of this.noteCreationData) {
const targetRegion = this.resolveRegion(tracks, noteData.regionId);
@@ -68,6 +88,21 @@ export class CreateMidiEventsCommand extends KGCommand {
targetRegion.addPitchBend(newPitchBend);
this.createdPitchBends.push({ pitchBend: newPitchBend, regionId: pitchBendData.regionId });
}
for (const controllerEventData of this.controllerEventCreationData) {
const targetRegion = this.resolveRegion(tracks, controllerEventData.regionId);
const newControllerEvent = new KGMidiControllerEvent(
controllerEventData.controllerEventId!,
controllerEventData.beat,
controllerEventData.value
);
targetRegion.addControllerEvent(controllerEventData.controller, newControllerEvent);
this.createdControllerEvents.push({
controller: controllerEventData.controller,
controllerEvent: newControllerEvent,
regionId: controllerEventData.regionId,
});
}
}
undo(): void {
@@ -91,19 +126,40 @@ export class CreateMidiEventsCommand extends KGCommand {
core.removeSelectedItem(selectedPitchBend);
}
}
for (const data of this.createdControllerEvents) {
const region = this.resolveRegion(tracks, data.regionId);
region.removeControllerEvent(data.controller, data.controllerEvent.getId());
const selectedControllerEvent = core.getSelectedItems().find(
item => item instanceof KGMidiControllerEvent && item.getId() === data.controllerEvent.getId()
);
if (selectedControllerEvent) {
core.removeSelectedItem(selectedControllerEvent);
}
}
}
getDescription(): string {
const noteCount = this.noteCreationData.length;
const pitchBendCount = this.pitchBendCreationData.length;
const controllerEventCount = this.controllerEventCreationData.length;
if (noteCount > 0 && pitchBendCount > 0) {
return `Create ${noteCount} note${noteCount === 1 ? '' : 's'} and ${pitchBendCount} pitch bend${pitchBendCount === 1 ? '' : 's'}`;
const parts: string[] = [];
if (noteCount > 0) {
parts.push(`${noteCount} note${noteCount === 1 ? '' : 's'}`);
}
if (pitchBendCount > 0) {
return pitchBendCount === 1 ? 'Create pitch bend' : `Create ${pitchBendCount} pitch bends`;
parts.push(`${pitchBendCount} pitch bend${pitchBendCount === 1 ? '' : 's'}`);
}
return noteCount === 1 ? 'Create note' : `Create ${noteCount} notes`;
if (controllerEventCount > 0) {
parts.push(`${controllerEventCount} controller event${controllerEventCount === 1 ? '' : 's'}`);
}
if (parts.length === 0) {
return 'Create MIDI events';
}
return `Create ${parts.join(' and ')}`;
}
public getNoteCreationData(): NoteCreationData[] {
@@ -118,6 +174,10 @@ export class CreateMidiEventsCommand extends KGCommand {
return this.createdPitchBends;
}
public getCreatedControllerEvents(): Array<{ controller: number; controllerEvent: KGMidiControllerEvent; regionId: string }> {
return this.createdControllerEvents;
}
public getCreatedNoteIds(): string[] {
return this.noteCreationData.map(data => data.noteId!);
}
@@ -1,5 +1,6 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { KGMidiControllerEvent } from '../../midi/KGMidiControllerEvent';
import { KGMidiNote } from '../../midi/KGMidiNote';
import { KGMidiPitchBend } from '../../midi/KGMidiPitchBend';
import { KGMidiRegion } from '../../region/KGMidiRegion';
@@ -16,16 +17,26 @@ interface DeletedPitchBendData {
originalIndex: number;
}
interface DeletedControllerEventData {
controller: number;
controllerEvent: KGMidiControllerEvent;
regionId: string;
originalIndex: number;
}
export class DeleteMidiEventsCommand extends KGCommand {
private noteIds: string[];
private pitchBendIds: string[];
private controllerEventIds: string[];
private deletedNoteData: DeletedNoteData[] = [];
private deletedPitchBendData: DeletedPitchBendData[] = [];
private deletedControllerEventData: DeletedControllerEventData[] = [];
constructor(noteIds: string[] = [], pitchBendIds: string[] = []) {
constructor(noteIds: string[] = [], pitchBendIds: string[] = [], controllerEventIds: string[] = []) {
super();
this.noteIds = noteIds;
this.pitchBendIds = pitchBendIds;
this.controllerEventIds = controllerEventIds;
}
execute(): void {
@@ -34,6 +45,7 @@ export class DeleteMidiEventsCommand extends KGCommand {
this.deletedNoteData = [];
this.deletedPitchBendData = [];
this.deletedControllerEventData = [];
for (const noteId of this.noteIds) {
for (const track of tracks) {
@@ -71,12 +83,35 @@ export class DeleteMidiEventsCommand extends KGCommand {
}
}
if (this.deletedNoteData.length === 0 && this.deletedPitchBendData.length === 0) {
for (const controllerEventId of this.controllerEventIds) {
for (const track of tracks) {
for (const region of track.getRegions()) {
if (!(region instanceof KGMidiRegion)) continue;
const flattened = region.getAllControllerEventsFlattened();
const flattenedIndex = flattened.findIndex(({ event }) => event.getId() === controllerEventId);
if (flattenedIndex === -1) continue;
const { controller, event } = flattened[flattenedIndex];
const originalIndex = region.getControllerEvents(controller).findIndex(candidate => candidate.getId() === controllerEventId);
this.deletedControllerEventData.push({
controller,
controllerEvent: event,
regionId: region.getId(),
originalIndex,
});
break;
}
}
}
if (this.deletedNoteData.length === 0 && this.deletedPitchBendData.length === 0 && this.deletedControllerEventData.length === 0) {
throw new Error('No MIDI events found to delete');
}
this.deletedNoteData.sort((a, b) => b.originalIndex - a.originalIndex);
this.deletedPitchBendData.sort((a, b) => b.originalIndex - a.originalIndex);
this.deletedControllerEventData.sort((a, b) => b.originalIndex - a.originalIndex);
for (const data of this.deletedNoteData) {
const region = this.resolveRegion(tracks, data.regionId);
@@ -99,6 +134,18 @@ export class DeleteMidiEventsCommand extends KGCommand {
core.removeSelectedItem(selectedPitchBend);
}
}
for (const data of this.deletedControllerEventData) {
const region = this.resolveRegion(tracks, data.regionId);
region.removeControllerEvent(data.controller, data.controllerEvent.getId());
const selectedControllerEvent = core.getSelectedItems().find(
item => item instanceof KGMidiControllerEvent && item.getId() === data.controllerEvent.getId()
);
if (selectedControllerEvent) {
core.removeSelectedItem(selectedControllerEvent);
}
}
}
undo(): void {
@@ -125,19 +172,39 @@ export class DeleteMidiEventsCommand extends KGCommand {
region.addPitchBend(data.pitchBend);
}
}
for (const data of [...this.deletedControllerEventData].sort((a, b) => a.originalIndex - b.originalIndex)) {
const region = this.resolveRegion(tracks, data.regionId);
const controllerEvents = region.getControllerEvents(data.controller);
if (data.originalIndex >= 0 && data.originalIndex <= controllerEvents.length) {
controllerEvents.splice(data.originalIndex, 0, data.controllerEvent);
region.setControllerEvents(data.controller, controllerEvents);
} else {
region.addControllerEvent(data.controller, data.controllerEvent);
}
}
}
getDescription(): string {
const noteCount = this.noteIds.length;
const pitchBendCount = this.pitchBendIds.length;
if (noteCount > 0 && pitchBendCount > 0) {
return `Delete ${noteCount} note${noteCount === 1 ? '' : 's'} and ${pitchBendCount} pitch bend${pitchBendCount === 1 ? '' : 's'}`;
const controllerEventCount = this.controllerEventIds.length;
const parts: string[] = [];
if (noteCount > 0) {
parts.push(`${noteCount} note${noteCount === 1 ? '' : 's'}`);
}
if (pitchBendCount > 0) {
return pitchBendCount === 1 ? 'Delete pitch bend' : `Delete ${pitchBendCount} pitch bends`;
parts.push(`${pitchBendCount} pitch bend${pitchBendCount === 1 ? '' : 's'}`);
}
return noteCount === 1 ? 'Delete note' : `Delete ${noteCount} notes`;
if (controllerEventCount > 0) {
parts.push(`${controllerEventCount} controller event${controllerEventCount === 1 ? '' : 's'}`);
}
if (parts.length === 0) {
return 'Delete MIDI events';
}
return `Delete ${parts.join(' and ')}`;
}
private resolveRegion(tracks: Array<{ getRegions(): unknown[] }>, regionId: string): KGMidiRegion {
@@ -0,0 +1,119 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { KGMidiControllerEvent } from '../../midi/KGMidiControllerEvent';
import { KGMidiRegion } from '../../region/KGMidiRegion';
import { KGTrack } from '../../track/KGTrack';
interface ControllerEventSnapshot {
controllerEventId: string;
controller: number;
beat: number;
value: number;
}
interface ControllerEventUpdate {
controllerEventId: string;
controller?: number;
beat?: number;
value?: number;
}
export class UpdateControllerEventPropertiesCommand extends KGCommand {
private regionId: string;
private snapshots: ControllerEventSnapshot[];
private updates: ControllerEventUpdate[];
private targetRegion: KGMidiRegion | null = null;
private parentTrack: KGTrack | null = null;
constructor(regionId: string, snapshots: ControllerEventSnapshot[], updates: ControllerEventUpdate[]) {
super();
this.regionId = regionId;
this.snapshots = [...snapshots];
this.updates = [...updates];
}
execute(): void {
const tracks = KGCore.instance().getCurrentProject().getTracks();
for (const track of tracks) {
const region = track.getRegions().find(r => r.getId() === this.regionId) as KGMidiRegion | undefined;
if (region) {
this.targetRegion = region;
this.parentTrack = track;
break;
}
}
if (!this.targetRegion) {
throw new Error(`Region with ID ${this.regionId} not found`);
}
for (const update of this.updates) {
const currentController = this.snapshots.find(snapshot => snapshot.controllerEventId === update.controllerEventId)?.controller;
if (currentController === undefined) {
continue;
}
const event = this.findControllerEvent(currentController, update.controllerEventId);
if (!event) {
continue;
}
if (update.controller !== undefined && update.controller !== currentController) {
this.targetRegion.removeControllerEvent(currentController, event.getId());
this.targetRegion.addControllerEvent(update.controller, event);
}
if (update.beat !== undefined) event.setBeat(update.beat);
if (update.value !== undefined) event.setValue(update.value);
}
}
undo(): void {
if (!this.targetRegion) {
throw new Error('Cannot undo: command was not executed');
}
this.snapshots.forEach(snapshot => {
const existing = this.findControllerEventAcrossBuckets(snapshot.controllerEventId);
if (!existing) {
return;
}
if (existing.controller !== snapshot.controller) {
this.targetRegion!.removeControllerEvent(existing.controller, existing.event.getId());
this.targetRegion!.addControllerEvent(snapshot.controller, existing.event);
}
existing.event.setBeat(snapshot.beat);
existing.event.setValue(snapshot.value);
});
}
getDescription(): string {
const count = this.snapshots.length;
return count === 1 ? 'Update controller event properties' : `Update ${count} controller events' properties`;
}
public getParentTrack(): KGTrack | null {
return this.parentTrack;
}
private findControllerEvent(controller: number, controllerEventId: string): KGMidiControllerEvent | undefined {
return this.targetRegion?.getControllerEvents(controller).find(candidate => candidate.getId() === controllerEventId);
}
private findControllerEventAcrossBuckets(controllerEventId: string): { controller: number; event: KGMidiControllerEvent } | null {
if (!this.targetRegion) {
return null;
}
for (const { controller, event } of this.targetRegion.getAllControllerEventsFlattened()) {
if (event.getId() === controllerEventId) {
return { controller, event };
}
}
return null;
}
}