feat: added pitch bend support

This commit is contained in:
Xiaohan-Tian
2026-05-05 21:28:32 -07:00
parent 8f453f8f4c
commit 98cccf5c7a
28 changed files with 1704 additions and 507 deletions
@@ -0,0 +1,131 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { KGMidiNote } from '../../midi/KGMidiNote';
import { KGMidiPitchBend } from '../../midi/KGMidiPitchBend';
import { KGMidiRegion } from '../../region/KGMidiRegion';
import { KGTrack } from '../../track/KGTrack';
import { generateUniqueId } from '../../../util/miscUtil';
export interface NoteCreationData {
regionId: string;
startBeat: number;
endBeat: number;
pitch: number;
velocity: number;
noteId?: string;
}
export interface PitchBendCreationData {
regionId: string;
beat: number;
value: number;
pitchBendId?: string;
}
export class CreateMidiEventsCommand extends KGCommand {
private noteCreationData: NoteCreationData[];
private pitchBendCreationData: PitchBendCreationData[];
private createdNotes: Array<{ note: KGMidiNote; regionId: string }> = [];
private createdPitchBends: Array<{ pitchBend: KGMidiPitchBend; regionId: string }> = [];
constructor(noteCreationData: NoteCreationData[], pitchBendCreationData: PitchBendCreationData[] = []) {
super();
this.noteCreationData = noteCreationData.map(data => ({
...data,
noteId: data.noteId || generateUniqueId('KGMidiNote'),
}));
this.pitchBendCreationData = pitchBendCreationData.map(data => ({
...data,
pitchBendId: data.pitchBendId || generateUniqueId('KGMidiPitchBend'),
}));
}
execute(): void {
const tracks = KGCore.instance().getCurrentProject().getTracks();
this.createdNotes = [];
this.createdPitchBends = [];
for (const noteData of this.noteCreationData) {
const targetRegion = this.resolveRegion(tracks, noteData.regionId);
const newNote = new KGMidiNote(
noteData.noteId!,
noteData.startBeat,
noteData.endBeat,
noteData.pitch,
noteData.velocity
);
targetRegion.addNote(newNote);
this.createdNotes.push({ note: newNote, regionId: noteData.regionId });
}
for (const pitchBendData of this.pitchBendCreationData) {
const targetRegion = this.resolveRegion(tracks, pitchBendData.regionId);
const newPitchBend = new KGMidiPitchBend(
pitchBendData.pitchBendId!,
pitchBendData.beat,
pitchBendData.value
);
targetRegion.addPitchBend(newPitchBend);
this.createdPitchBends.push({ pitchBend: newPitchBend, regionId: pitchBendData.regionId });
}
}
undo(): void {
const core = KGCore.instance();
const tracks = core.getCurrentProject().getTracks();
for (const data of this.createdNotes) {
const region = this.resolveRegion(tracks, data.regionId);
region.removeNote(data.note.getId());
const selectedNote = core.getSelectedItems().find(item => item instanceof KGMidiNote && item.getId() === data.note.getId());
if (selectedNote) {
core.removeSelectedItem(selectedNote);
}
}
for (const data of this.createdPitchBends) {
const region = this.resolveRegion(tracks, data.regionId);
region.removePitchBend(data.pitchBend.getId());
const selectedPitchBend = core.getSelectedItems().find(item => item instanceof KGMidiPitchBend && item.getId() === data.pitchBend.getId());
if (selectedPitchBend) {
core.removeSelectedItem(selectedPitchBend);
}
}
}
getDescription(): string {
const noteCount = this.noteCreationData.length;
const pitchBendCount = this.pitchBendCreationData.length;
if (noteCount > 0 && pitchBendCount > 0) {
return `Create ${noteCount} note${noteCount === 1 ? '' : 's'} and ${pitchBendCount} pitch bend${pitchBendCount === 1 ? '' : 's'}`;
}
if (pitchBendCount > 0) {
return pitchBendCount === 1 ? 'Create pitch bend' : `Create ${pitchBendCount} pitch bends`;
}
return noteCount === 1 ? 'Create note' : `Create ${noteCount} notes`;
}
public getNoteCreationData(): NoteCreationData[] {
return this.noteCreationData;
}
public getCreatedNotes(): Array<{ note: KGMidiNote; regionId: string }> {
return this.createdNotes;
}
public getCreatedNoteIds(): string[] {
return this.noteCreationData.map(data => data.noteId!);
}
private resolveRegion(tracks: KGTrack[], regionId: string): KGMidiRegion {
for (const track of tracks) {
const region = track.getRegions().find(candidate => candidate.getId() === regionId);
if (region instanceof KGMidiRegion) {
return region;
}
}
throw new Error(`MIDI region with ID ${regionId} not found`);
}
}
+9 -145
View File
@@ -1,164 +1,28 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { KGMidiNote } from '../../midi/KGMidiNote';
import { KGMidiRegion } from '../../region/KGMidiRegion';
import { generateUniqueId } from '../../../util/miscUtil';
import { CreateMidiEventsCommand, type NoteCreationData } from './CreateMidiEventsCommand';
/**
* Data structure for a note to be created
*/
export interface NoteCreationData {
regionId: string;
startBeat: number;
endBeat: number;
pitch: number;
velocity: number;
noteId?: string;
}
export type { NoteCreationData } from './CreateMidiEventsCommand';
/**
* Command to create multiple MIDI notes in regions
* Handles bulk creation as a single undoable operation
*/
export class CreateNotesCommand extends KGCommand {
private noteCreationData: NoteCreationData[];
private createdNotes: Array<{
note: KGMidiNote;
regionId: string;
}> = [];
export class CreateNotesCommand extends CreateMidiEventsCommand {
constructor(noteCreationData: NoteCreationData[]) {
super();
this.noteCreationData = noteCreationData.map(data => ({
...data,
noteId: data.noteId || generateUniqueId('KGMidiNote')
}));
}
execute(): void {
const core = KGCore.instance();
const currentProject = core.getCurrentProject();
const tracks = currentProject.getTracks();
// Clear any existing created note data to prevent duplicates on re-execution
this.createdNotes = [];
// Create all notes
for (const noteData of this.noteCreationData) {
// Find the target region
let targetRegion: KGMidiRegion | null = null;
for (const track of tracks) {
const regions = track.getRegions();
const region = regions.find(r => r.getId() === noteData.regionId);
if (region && region instanceof KGMidiRegion) {
targetRegion = region;
break;
}
}
if (!targetRegion) {
throw new Error(`MIDI region with ID ${noteData.regionId} not found`);
}
// Create the new MIDI note
const newNote = new KGMidiNote(
noteData.noteId!,
noteData.startBeat,
noteData.endBeat,
noteData.pitch,
noteData.velocity
);
// Add the note to the region
targetRegion.addNote(newNote);
// Store for undo
this.createdNotes.push({
note: newNote,
regionId: noteData.regionId
});
}
const noteCount = this.createdNotes.length;
const regionCount = new Set(this.createdNotes.map(data => data.regionId)).size;
console.log(`Created ${noteCount} notes in ${regionCount} region${regionCount > 1 ? 's' : ''}`);
}
undo(): void {
if (this.createdNotes.length === 0) {
throw new Error('Cannot undo: no notes were created');
}
const core = KGCore.instance();
const currentProject = core.getCurrentProject();
const tracks = currentProject.getTracks();
// Remove all created notes from their regions
for (const data of this.createdNotes) {
// Find the region
for (const track of tracks) {
const regions = track.getRegions();
const region = regions.find(r => r.getId() === data.regionId);
if (region && region instanceof KGMidiRegion) {
region.removeNote(data.note.getId());
// Clear selection if this note was selected
const selectedItems = core.getSelectedItems();
const selectedNote = selectedItems.find(item =>
item instanceof KGMidiNote && item.getId() === data.note.getId()
);
if (selectedNote) {
core.removeSelectedItem(selectedNote);
}
break;
}
}
}
console.log(`Removed ${this.createdNotes.length} created notes from ${new Set(this.createdNotes.map(d => d.regionId)).size} regions`);
super(noteCreationData, []);
}
getDescription(): string {
if (this.noteCreationData.length === 1) {
const noteData = this.noteCreationData[0];
const noteCreationData = this.getNoteCreationData();
if (noteCreationData.length === 1) {
const noteData = noteCreationData[0];
// Convert MIDI pitch to note name for user-friendly description
const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
const octave = Math.floor(noteData.pitch / 12) - 1;
const noteName = noteNames[noteData.pitch % 12];
return `Create note ${noteName}${octave}`;
}
return `Create ${this.noteCreationData.length} notes`;
}
/**
* Get the note creation data that was/will be processed
*/
public getNoteCreationData(): NoteCreationData[] {
return this.noteCreationData;
}
/**
* Get the created note instances (only available after execute)
*/
public getCreatedNotes(): Array<{note: KGMidiNote; regionId: string}> {
return this.createdNotes;
}
/**
* Get the regions that were affected by this creation
*/
public getAffectedRegionIds(): string[] {
return Array.from(new Set(this.noteCreationData.map(data => data.regionId)));
}
/**
* Get the IDs of notes that were/will be created
*/
public getCreatedNoteIds(): string[] {
return this.noteCreationData.map(data => data.noteId!);
return `Create ${noteCreationData.length} notes`;
}
}
@@ -255,4 +119,4 @@ export class CreateNoteCommand extends CreateNotesCommand {
velocity
);
}
}
}
@@ -0,0 +1,82 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { KGMidiPitchBend } from '../../midi/KGMidiPitchBend';
import { KGMidiRegion } from '../../region/KGMidiRegion';
import { KGTrack } from '../../track/KGTrack';
interface PitchBendSnapshot {
pitchBendId: string;
beat: number;
value: number;
}
interface PitchBendUpdate {
pitchBendId: string;
beat?: number;
value?: number;
}
export class UpdatePitchBendPropertiesCommand extends KGCommand {
private regionId: string;
private snapshots: PitchBendSnapshot[];
private updates: PitchBendUpdate[];
private targetRegion: KGMidiRegion | null = null;
private parentTrack: KGTrack | null = null;
constructor(regionId: string, snapshots: PitchBendSnapshot[], updates: PitchBendUpdate[]) {
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`);
}
const pitchBends = this.targetRegion.getPitchBends();
for (const update of this.updates) {
const pitchBend = pitchBends.find(candidate => candidate.getId() === update.pitchBendId);
if (pitchBend) {
if (update.beat !== undefined) pitchBend.setBeat(update.beat);
if (update.value !== undefined) pitchBend.setValue(update.value);
}
}
}
undo(): void {
if (!this.targetRegion) {
throw new Error('Cannot undo: command was not executed');
}
const pitchBends = this.targetRegion.getPitchBends();
this.snapshots.forEach(snapshot => {
const pitchBend = pitchBends.find(candidate => candidate.getId() === snapshot.pitchBendId);
if (pitchBend) {
pitchBend.setBeat(snapshot.beat);
pitchBend.setValue(snapshot.value);
}
});
}
getDescription(): string {
const count = this.snapshots.length;
return count === 1 ? 'Update pitch bend properties' : `Update ${count} pitch bends' properties`;
}
public getParentTrack(): KGTrack | null {
return this.parentTrack;
}
}