feat: added merge MIDI regions feature

This commit is contained in:
Xiaohan-Tian
2026-05-04 18:58:51 -07:00
parent a2f12ca961
commit 066946ea64
5 changed files with 485 additions and 2 deletions
+101 -1
View File
@@ -12,7 +12,7 @@ import {
FaUndo, FaRedo, FaMousePointer, FaStepBackward, FaUndo, FaRedo, FaMousePointer, FaStepBackward,
FaPlay, FaPause, FaComments, FaSync, FaPlay, FaPause, FaComments, FaSync,
FaFolderOpen, FaSave, FaDownload, FaUpload, FaPlus, FaFolderOpen, FaSave, FaDownload, FaUpload, FaPlus,
FaCog, FaMagnet, FaCut, FaCircle FaCog, FaMagnet, FaCut, FaCircle, FaCompress
} from 'react-icons/fa'; } from 'react-icons/fa';
import { KGProject, type KeySignature } from '../core/KGProject'; import { KGProject, type KeySignature } from '../core/KGProject';
import { KGMidiInput } from '../core/midi-input/KGMidiInput'; import { KGMidiInput } from '../core/midi-input/KGMidiInput';
@@ -22,6 +22,7 @@ import { FaPencil, FaCopy, FaPaste, FaTrash, FaWandMagicSparkles } from 'react-i
import { KGMainContentState } from '../core/state/KGMainContentState'; import { KGMainContentState } from '../core/state/KGMainContentState';
import { regionDeleteManager } from '../util/regionDeleteUtil'; import { regionDeleteManager } from '../util/regionDeleteUtil';
import { SplitRegionCommand } from '../core/commands/region/SplitRegionCommand'; import { SplitRegionCommand } from '../core/commands/region/SplitRegionCommand';
import { MergeMidiRegionsCommand } from '../core/commands/region/MergeMidiRegionsCommand';
import { handleCopyOperation, handlePasteOperation } from '../util/copyPasteUtil'; import { handleCopyOperation, handlePasteOperation } from '../util/copyPasteUtil';
import { convertProjectToMidi, convertMidiToProject } from '../util/midiUtil'; import { convertProjectToMidi, convertMidiToProject } from '../util/midiUtil';
import { KEY_SIGNATURE_MAP } from '../constants/coreConstants'; import { KEY_SIGNATURE_MAP } from '../constants/coreConstants';
@@ -762,6 +763,99 @@ const Toolbar: React.FC = () => {
} }
}; };
const handleMergeClick = async () => {
if (DEBUG_MODE.TOOLBAR) {
console.log('Merge button clicked');
}
if (selectedRegionIds.length < 2) {
await showAlert('Please select at least two MIDI regions on the same track to merge.');
return;
}
const tracks = KGCore.instance().getCurrentProject().getTracks();
const selectedRegionIdSet = new Set(selectedRegionIds);
const selectedMidiRegions: KGMidiRegion[] = [];
let targetTrackId: string | null = null;
for (const track of tracks) {
for (const region of track.getRegions()) {
if (!selectedRegionIdSet.has(region.getId())) {
continue;
}
if (!(region instanceof KGMidiRegion)) {
await showAlert('Only MIDI regions can be merged. Please adjust your selection and try again.');
return;
}
const regionTrackId = track.getId().toString();
if (targetTrackId && targetTrackId !== regionTrackId) {
await showAlert('Please select only MIDI regions from a single track before merging.');
return;
}
targetTrackId = regionTrackId;
selectedMidiRegions.push(region);
}
}
if (selectedMidiRegions.length !== selectedRegionIds.length || !targetTrackId) {
await showAlert('Some selected regions could not be found. Please reselect the MIDI regions and try again.');
return;
}
const sortedSelectedRegions = [...selectedMidiRegions].sort((a, b) => {
const startDelta = a.getStartFromBeat() - b.getStartFromBeat();
if (startDelta !== 0) return startDelta;
return a.getLength() - b.getLength();
});
let regionIdsToMerge = selectedRegionIds;
const firstSelectedRegion = sortedSelectedRegions[0];
const lastSelectedRegion = sortedSelectedRegions[sortedSelectedRegions.length - 1];
const spanStart = firstSelectedRegion.getStartFromBeat();
const spanEnd = lastSelectedRegion.getStartFromBeat() + lastSelectedRegion.getLength();
const targetTrack = tracks.find(track => track.getId().toString() === targetTrackId);
const inBetweenRegions = targetTrack
?.getRegions()
.filter(region => (
region instanceof KGMidiRegion &&
!selectedRegionIdSet.has(region.getId()) &&
region.getStartFromBeat() >= spanStart &&
region.getStartFromBeat() <= spanEnd
)) ?? [];
if (inBetweenRegions.length > 0) {
const shouldIncludeInBetweenRegions = await showConfirm(
'There are additional MIDI regions between the first and last selected regions on this track. Would you like KGStudio to merge those as well?',
{
confirmLabel: 'Merge All In Between',
cancelLabel: 'Stop',
}
);
if (!shouldIncludeInBetweenRegions) {
return;
}
regionIdsToMerge = Array.from(new Set([
...selectedRegionIds,
...inBetweenRegions.map(region => region.getId()),
]));
}
try {
const command = new MergeMidiRegionsCommand(regionIdsToMerge);
KGCore.instance().executeCommand(command, { rethrow: true });
refreshProjectState();
setStatus(`Merged ${regionIdsToMerge.length} MIDI regions`);
} catch (error) {
await showAlert(error instanceof Error ? error.message : 'Unable to merge the selected MIDI regions.');
}
};
// Handle undo button click // Handle undo button click
const handleUndoClick = async () => { const handleUndoClick = async () => {
if (DEBUG_MODE.TOOLBAR) { if (DEBUG_MODE.TOOLBAR) {
@@ -975,6 +1069,12 @@ const Toolbar: React.FC = () => {
> >
<FaCut /> <FaCut />
</button> </button>
<button
title="Merge Selected MIDI Regions"
onClick={handleMergeClick}
>
<FaCompress />
</button>
<button <button
title="Snap to Grid" title="Snap to Grid"
className={`tool-button ${isSnapping ? 'active' : ''}`} className={`tool-button ${isSnapping ? 'active' : ''}`}
+1
View File
@@ -26,6 +26,7 @@ export { ImportMidiClipCommand } from './region/ImportMidiClipCommand';
export { ImportStemsCommand } from './region/ImportStemsCommand'; export { ImportStemsCommand } from './region/ImportStemsCommand';
export type { StemImportEntry } from './region/ImportStemsCommand'; export type { StemImportEntry } from './region/ImportStemsCommand';
export { SplitRegionCommand } from './region/SplitRegionCommand'; export { SplitRegionCommand } from './region/SplitRegionCommand';
export { MergeMidiRegionsCommand } from './region/MergeMidiRegionsCommand';
// Note commands // Note commands
export { CreateNoteCommand } from './note/CreateNoteCommand'; export { CreateNoteCommand } from './note/CreateNoteCommand';
@@ -0,0 +1,166 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { KGCore } from '../../KGCore';
import { MergeMidiRegionsCommand } from './MergeMidiRegionsCommand';
import { createMockMidiNote, createMockMidiRegion, createMockMidiTrack, createMockProject } from '../../../test/utils/mock-data';
const storeState = {
showPianoRoll: false,
activeRegionId: null as string | null,
setShowPianoRoll: vi.fn(),
setActiveRegionId: vi.fn(),
};
vi.mock('../../KGCore', () => ({
KGCore: {
instance: vi.fn(),
}
}));
vi.mock('../../../stores/projectStore', () => ({
useProjectStore: {
getState: vi.fn(() => storeState),
}
}));
interface MockCore {
getCurrentProject: ReturnType<typeof vi.fn>;
getSelectedItems: ReturnType<typeof vi.fn>;
clearSelectedItems: ReturnType<typeof vi.fn>;
addSelectedItem: ReturnType<typeof vi.fn>;
addSelectedItems: ReturnType<typeof vi.fn>;
}
describe('MergeMidiRegionsCommand', () => {
let mockCore: MockCore;
beforeEach(() => {
vi.clearAllMocks();
storeState.showPianoRoll = false;
storeState.activeRegionId = null;
mockCore = {
getCurrentProject: vi.fn(),
getSelectedItems: vi.fn().mockReturnValue([]),
clearSelectedItems: vi.fn(),
addSelectedItem: vi.fn(),
addSelectedItems: vi.fn(),
};
vi.mocked(KGCore.instance).mockReturnValue(mockCore as unknown as KGCore);
});
it('merges multiple MIDI regions and preserves absolute note timing', () => {
const regionA = createMockMidiRegion({
id: 'region-a',
trackId: '1',
trackIndex: 0,
startFromBeat: 4,
length: 2,
notes: [createMockMidiNote({ id: 'note-a', startBeat: 0.5, endBeat: 1.5, pitch: 60 })],
});
const regionB = createMockMidiRegion({
id: 'region-b',
trackId: '1',
trackIndex: 0,
startFromBeat: 8,
length: 4,
notes: [createMockMidiNote({ id: 'note-b', startBeat: 1, endBeat: 2, pitch: 67 })],
});
const regionC = createMockMidiRegion({
id: 'region-c',
trackId: '1',
trackIndex: 0,
startFromBeat: 14,
length: 2,
notes: [createMockMidiNote({ id: 'note-c', startBeat: 0, endBeat: 1, pitch: 72 })],
});
const track = createMockMidiTrack({ id: 1, regions: [regionA, regionB, regionC] });
track.setTrackIndex(0);
mockCore.getCurrentProject.mockReturnValue(createMockProject({ tracks: [track] }));
const command = new MergeMidiRegionsCommand(['region-b', 'region-a', 'region-c']);
command.execute();
expect(track.getRegions()).toEqual([regionA]);
expect(regionA.getStartFromBeat()).toBe(4);
expect(regionA.getLength()).toBe(12);
const mergedNotes = regionA.getNotes();
expect(mergedNotes).toHaveLength(3);
expect(mergedNotes.map(note => [note.getId(), note.getStartBeat(), note.getEndBeat()])).toEqual([
['note-a', 0.5, 1.5],
['note-b', 5, 6],
['note-c', 10, 11],
]);
expect(mockCore.clearSelectedItems).toHaveBeenCalledTimes(1);
expect(mockCore.addSelectedItem).toHaveBeenCalledWith(regionA);
});
it('retargets the piano roll to the surviving region when the active region is removed', () => {
const regionA = createMockMidiRegion({ id: 'region-a', trackId: '1', trackIndex: 0, startFromBeat: 0, length: 4 });
const regionB = createMockMidiRegion({ id: 'region-b', trackId: '1', trackIndex: 0, startFromBeat: 6, length: 4 });
const track = createMockMidiTrack({ id: 1, regions: [regionA, regionB] });
track.setTrackIndex(0);
mockCore.getCurrentProject.mockReturnValue(createMockProject({ tracks: [track] }));
storeState.showPianoRoll = true;
storeState.activeRegionId = 'region-b';
const command = new MergeMidiRegionsCommand(['region-a', 'region-b']);
command.execute();
expect(storeState.setActiveRegionId).toHaveBeenCalledWith('region-a');
expect(storeState.setShowPianoRoll).toHaveBeenCalledWith(true);
});
it('undo restores original regions, notes, selection, and piano roll state', () => {
const regionA = createMockMidiRegion({
id: 'region-a',
trackId: '1',
trackIndex: 0,
startFromBeat: 4,
length: 4,
notes: [createMockMidiNote({ id: 'note-a', startBeat: 0, endBeat: 1 })],
});
const regionB = createMockMidiRegion({
id: 'region-b',
trackId: '1',
trackIndex: 0,
startFromBeat: 10,
length: 2,
notes: [createMockMidiNote({ id: 'note-b', startBeat: 0.5, endBeat: 1.5 })],
});
const otherRegion = createMockMidiRegion({
id: 'region-x',
trackId: '1',
trackIndex: 0,
startFromBeat: 20,
length: 2,
});
const track = createMockMidiTrack({ id: 1, regions: [regionA, regionB, otherRegion] });
track.setTrackIndex(0);
mockCore.getCurrentProject.mockReturnValue(createMockProject({ tracks: [track] }));
mockCore.getSelectedItems.mockReturnValue([regionA, regionB]);
storeState.showPianoRoll = true;
storeState.activeRegionId = 'region-b';
const command = new MergeMidiRegionsCommand(['region-a', 'region-b']);
command.execute();
command.undo();
expect(track.getRegions()).toEqual([regionA, regionB, otherRegion]);
expect(regionA.getLength()).toBe(4);
expect(regionA.getNotes().map(note => [note.getId(), note.getStartBeat(), note.getEndBeat()])).toEqual([
['note-a', 0, 1],
]);
expect(regionB.getLength()).toBe(2);
expect(regionB.getNotes().map(note => [note.getId(), note.getStartBeat(), note.getEndBeat()])).toEqual([
['note-b', 0.5, 1.5],
]);
expect(mockCore.addSelectedItems).toHaveBeenCalledWith([regionA, regionB]);
expect(storeState.setShowPianoRoll).toHaveBeenCalledWith(true);
expect(storeState.setActiveRegionId).toHaveBeenLastCalledWith('region-b');
});
});
@@ -0,0 +1,216 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { KGMidiRegion } from '../../region/KGMidiRegion';
import { KGMidiNote } from '../../midi/KGMidiNote';
import { KGTrack } from '../../track/KGTrack';
import { useProjectStore } from '../../../stores/projectStore';
interface RegionSnapshot {
regionId: string;
startFromBeat: number;
length: number;
notes: Array<{
id: string;
startBeat: number;
endBeat: number;
pitch: number;
velocity: number;
}>;
}
interface ResolvedRegion {
region: KGMidiRegion;
index: number;
}
function cloneNote(note: KGMidiNote, startBeat: number, endBeat: number): KGMidiNote {
return new KGMidiNote(
note.getId(),
startBeat,
endBeat,
note.getPitch(),
note.getVelocity()
);
}
export class MergeMidiRegionsCommand extends KGCommand {
private readonly regionIdsToMerge: string[];
private targetTrack: KGTrack | null = null;
private survivingRegion: KGMidiRegion | null = null;
private removedRegions: Array<{ region: KGMidiRegion; index: number }> = [];
private originalRegionSnapshots = new Map<string, RegionSnapshot>();
private originalSelectedItems = [] as ReturnType<KGCore['getSelectedItems']>;
private originalPianoRollState: { showPianoRoll: boolean; activeRegionId: string | null } | null = null;
constructor(regionIdsToMerge: string[]) {
super();
this.regionIdsToMerge = [...regionIdsToMerge];
}
execute(): void {
if (this.regionIdsToMerge.length < 2) {
throw new Error('At least two MIDI regions are required to merge.');
}
const core = KGCore.instance();
const tracks = core.getCurrentProject().getTracks();
const regionIdSet = new Set(this.regionIdsToMerge);
const resolvedRegions: ResolvedRegion[] = [];
let targetTrack: KGTrack | null = null;
for (const track of tracks) {
track.getRegions().forEach((region, index) => {
if (!regionIdSet.has(region.getId())) {
return;
}
if (!(region instanceof KGMidiRegion)) {
throw new Error('Only MIDI regions can be merged.');
}
if (targetTrack && targetTrack !== track) {
throw new Error('All MIDI regions must be on the same track to merge.');
}
targetTrack = track;
resolvedRegions.push({ region, index });
});
}
if (!targetTrack || resolvedRegions.length !== regionIdSet.size) {
throw new Error('One or more MIDI regions could not be found.');
}
resolvedRegions.sort((a, b) => {
const startDelta = a.region.getStartFromBeat() - b.region.getStartFromBeat();
if (startDelta !== 0) return startDelta;
return a.index - b.index;
});
this.targetTrack = targetTrack;
this.survivingRegion = resolvedRegions[0].region;
this.removedRegions = resolvedRegions.slice(1).map(({ region, index }) => ({ region, index }));
this.originalRegionSnapshots.clear();
resolvedRegions.forEach(({ region }) => {
this.originalRegionSnapshots.set(region.getId(), {
regionId: region.getId(),
startFromBeat: region.getStartFromBeat(),
length: region.getLength(),
notes: region.getNotes().map(note => ({
id: note.getId(),
startBeat: note.getStartBeat(),
endBeat: note.getEndBeat(),
pitch: note.getPitch(),
velocity: note.getVelocity(),
})),
});
});
this.originalSelectedItems = [...core.getSelectedItems()];
const { showPianoRoll, activeRegionId } = useProjectStore.getState();
this.originalPianoRollState = { showPianoRoll, activeRegionId };
const resolvedTargetTrack: KGTrack = this.targetTrack;
const survivingRegionStart = this.survivingRegion.getStartFromBeat();
const mergedEndBeat = resolvedRegions.reduce((maxEndBeat, { region }) => (
Math.max(maxEndBeat, region.getStartFromBeat() + region.getLength())
), survivingRegionStart + this.survivingRegion.getLength());
const mergedNotes = [...this.survivingRegion.getNotes()];
for (const { region } of resolvedRegions.slice(1)) {
const regionStart = region.getStartFromBeat();
region.getNotes().forEach(note => {
const absoluteStart = regionStart + note.getStartBeat();
const absoluteEnd = regionStart + note.getEndBeat();
mergedNotes.push(cloneNote(
note,
absoluteStart - survivingRegionStart,
absoluteEnd - survivingRegionStart
));
});
}
this.survivingRegion.setLength(mergedEndBeat - survivingRegionStart);
this.survivingRegion.setNotes(mergedNotes);
const removedRegionIds = new Set(this.removedRegions.map(({ region }) => region.getId()));
const nextRegions = resolvedTargetTrack.getRegions().filter(region => !removedRegionIds.has(region.getId()));
resolvedTargetTrack.setRegions(nextRegions);
core.clearSelectedItems();
core.addSelectedItem(this.survivingRegion);
if (showPianoRoll && activeRegionId && removedRegionIds.has(activeRegionId)) {
const { setActiveRegionId, setShowPianoRoll } = useProjectStore.getState();
setActiveRegionId(this.survivingRegion.getId());
setShowPianoRoll(true);
}
}
undo(): void {
if (!this.targetTrack || !this.survivingRegion || !this.originalPianoRollState) {
throw new Error('Cannot undo: merge was never executed.');
}
const survivingSnapshot = this.originalRegionSnapshots.get(this.survivingRegion.getId());
if (!survivingSnapshot) {
throw new Error('Cannot undo: missing surviving region snapshot.');
}
this.survivingRegion.setStartFromBeat(survivingSnapshot.startFromBeat);
this.survivingRegion.setLength(survivingSnapshot.length);
this.survivingRegion.setNotes(survivingSnapshot.notes.map(note => new KGMidiNote(
note.id,
note.startBeat,
note.endBeat,
note.pitch,
note.velocity
)));
for (const { region } of this.removedRegions) {
const snapshot = this.originalRegionSnapshots.get(region.getId());
if (!snapshot) {
continue;
}
region.setStartFromBeat(snapshot.startFromBeat);
region.setLength(snapshot.length);
region.setNotes(snapshot.notes.map(note => new KGMidiNote(
note.id,
note.startBeat,
note.endBeat,
note.pitch,
note.velocity
)));
}
const regions = [...this.targetTrack.getRegions()];
const survivingIndex = regions.findIndex(region => region.getId() === this.survivingRegion!.getId());
if (survivingIndex === -1) {
throw new Error('Cannot undo: surviving region is missing from the track.');
}
for (const { region, index } of [...this.removedRegions].sort((a, b) => a.index - b.index)) {
const insertIndex = Math.min(index, regions.length);
regions.splice(insertIndex, 0, region);
}
this.targetTrack.setRegions(regions);
const core = KGCore.instance();
core.clearSelectedItems();
if (this.originalSelectedItems.length > 0) {
core.addSelectedItems(this.originalSelectedItems);
}
const {
setShowPianoRoll,
setActiveRegionId,
} = useProjectStore.getState();
setShowPianoRoll(this.originalPianoRollState.showPianoRoll);
setActiveRegionId(this.originalPianoRollState.activeRegionId);
}
getDescription(): string {
return this.regionIdsToMerge.length === 2
? 'Merge 2 MIDI regions'
: `Merge ${this.regionIdsToMerge.length} MIDI regions`;
}
}
+1 -1
View File
@@ -27,7 +27,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 1005; z-index: 1005;
margin-left: -120px; margin-left: -140px;
} }
/* Tool buttons (used by Toolbar and PianoRollToolbar) */ /* Tool buttons (used by Toolbar and PianoRollToolbar) */