feat: added split region feature

This commit is contained in:
Xiaohan-Tian
2026-04-16 22:36:31 -07:00
parent 9d9190413d
commit ebcebb5d3b
3 changed files with 263 additions and 2 deletions
+1
View File
@@ -24,6 +24,7 @@ export { ImportAudioCommand } from './region/ImportAudioCommand';
export { ImportMidiClipCommand } from './region/ImportMidiClipCommand';
export { ImportStemsCommand } from './region/ImportStemsCommand';
export type { StemImportEntry } from './region/ImportStemsCommand';
export { SplitRegionCommand } from './region/SplitRegionCommand';
// Note commands
export { CreateNoteCommand } from './note/CreateNoteCommand';
@@ -0,0 +1,205 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { KGRegion } from '../../region/KGRegion';
import { KGMidiRegion } from '../../region/KGMidiRegion';
import { KGAudioRegion } from '../../region/KGAudioRegion';
import { KGMidiNote } from '../../midi/KGMidiNote';
import { KGTrack } from '../../track/KGTrack';
import { generateUniqueId } from '../../../util/miscUtil';
import { useProjectStore } from '../../../stores/projectStore';
/**
* Command to split a region into two at the given beat position.
*
* MIDI regions: notes whose startBeat is before the split stay in region 1
* (endBeat is NOT clamped even if it crosses the boundary). Notes whose
* startBeat is at or after the split go to region 2 with their beat
* positions shifted so they are relative to the new region start.
*
* Audio regions: both halves share the same audioFileId / audioFileName /
* audioDurationSeconds (no file copy). Region 2 gets a new
* clipStartOffsetSeconds = original + splitOffsetSeconds.
*/
export class SplitRegionCommand extends KGCommand {
private regionId: string;
private splitAtBeat: number; // absolute beat position of the playhead
// Stored during execute(), used during undo()
private originalRegion: KGRegion | null = null;
private region1: KGRegion | null = null;
private region2: KGRegion | null = null;
private targetTrack: KGTrack | null = null;
private originalRegionIndex: number = -1;
constructor(regionId: string, splitAtBeat: number) {
super();
this.regionId = regionId;
this.splitAtBeat = splitAtBeat;
}
execute(): void {
const tracks = KGCore.instance().getCurrentProject().getTracks();
let targetTrack: KGTrack | null = null;
let originalRegion: KGRegion | null = null;
for (const track of tracks) {
const regions = track.getRegions();
const idx = regions.findIndex(r => r.getId() === this.regionId);
if (idx !== -1) {
targetTrack = track;
originalRegion = regions[idx];
this.originalRegionIndex = idx;
break;
}
}
if (!targetTrack || !originalRegion) {
throw new Error(`Region with ID ${this.regionId} not found`);
}
this.targetTrack = targetTrack;
this.originalRegion = originalRegion;
const regionStart = originalRegion.getStartFromBeat();
const regionLength = originalRegion.getLength();
const splitOffsetBeats = this.splitAtBeat - regionStart;
if (splitOffsetBeats <= 0 || splitOffsetBeats >= regionLength) {
throw new Error(
`Split point ${this.splitAtBeat} is not within region range [${regionStart}, ${regionStart + regionLength})`
);
}
const trackId = targetTrack.getId().toString();
const trackIdx = targetTrack.getTrackIndex();
if (originalRegion instanceof KGMidiRegion) {
const region1 = new KGMidiRegion(
generateUniqueId('KGMidiRegion'),
trackId,
trackIdx,
originalRegion.getName(),
regionStart,
splitOffsetBeats
);
const region2 = new KGMidiRegion(
generateUniqueId('KGMidiRegion'),
trackId,
trackIdx,
originalRegion.getName() + ' (2)',
this.splitAtBeat,
regionLength - splitOffsetBeats
);
for (const note of originalRegion.getNotes()) {
if (note.getStartBeat() < splitOffsetBeats) {
// Stays in region 1 — copy as-is, do not clamp endBeat
region1.addNote(new KGMidiNote(
generateUniqueId('KGMidiNote'),
note.getStartBeat(),
note.getEndBeat(),
note.getPitch(),
note.getVelocity()
));
} else {
// Goes into region 2 — shift both beats relative to new region start
region2.addNote(new KGMidiNote(
generateUniqueId('KGMidiNote'),
note.getStartBeat() - splitOffsetBeats,
note.getEndBeat() - splitOffsetBeats,
note.getPitch(),
note.getVelocity()
));
}
}
this.region1 = region1;
this.region2 = region2;
} else if (originalRegion instanceof KGAudioRegion) {
const bpm = KGCore.instance().getCurrentProject().getBpm();
const splitOffsetSeconds = splitOffsetBeats * (60 / bpm);
this.region1 = new KGAudioRegion(
generateUniqueId('KGAudioRegion'),
trackId,
trackIdx,
originalRegion.getName(),
regionStart,
splitOffsetBeats,
originalRegion.getAudioFileId(),
originalRegion.getAudioFileName(),
originalRegion.getAudioDurationSeconds(),
originalRegion.getClipStartOffsetSeconds()
);
this.region2 = new KGAudioRegion(
generateUniqueId('KGAudioRegion'),
trackId,
trackIdx,
originalRegion.getName() + ' (2)',
this.splitAtBeat,
regionLength - splitOffsetBeats,
originalRegion.getAudioFileId(),
originalRegion.getAudioFileName(),
originalRegion.getAudioDurationSeconds(),
originalRegion.getClipStartOffsetSeconds() + splitOffsetSeconds
);
} else {
throw new Error(`Unsupported region type for splitting: ${originalRegion.getCurrentType()}`);
}
// Replace original region with the two new regions
const regions = targetTrack.getRegions();
regions.splice(this.originalRegionIndex, 1, this.region1!, this.region2!);
targetTrack.setRegions(regions);
// Close piano roll if it is open for the region being split
const { activeRegionId, showPianoRoll, setShowPianoRoll, setActiveRegionId } = useProjectStore.getState();
if (showPianoRoll && activeRegionId === this.regionId) {
setShowPianoRoll(false);
setActiveRegionId(null);
console.log(`Closed piano roll because active region ${this.regionId} is being split`);
}
console.log(`Split region "${originalRegion.getName()}" at beat ${this.splitAtBeat} (offset ${splitOffsetBeats} beats)`);
}
undo(): void {
if (!this.targetTrack || !this.originalRegion || !this.region1 || !this.region2) {
throw new Error('Cannot undo: split was never executed');
}
// Find region1 by ID (robust to any reordering)
const regions = this.targetTrack.getRegions();
const idx1 = regions.findIndex(r => r.getId() === this.region1!.getId());
if (idx1 === -1) {
throw new Error('Cannot undo: split regions not found in track');
}
// Remove the two produced regions and restore the original
regions.splice(idx1, 2, this.originalRegion);
this.targetTrack.setRegions(regions);
console.log(`Restored region "${this.originalRegion.getName()}" (undo split)`);
}
getDescription(): string {
const name = this.originalRegion ? this.originalRegion.getName() : `Region ${this.regionId}`;
return `Split region "${name}"`;
}
public getRegionId(): string {
return this.regionId;
}
public getCreatedRegion1(): KGRegion | null {
return this.region1;
}
public getCreatedRegion2(): KGRegion | null {
return this.region2;
}
}