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
View File
@@ -22,6 +22,7 @@ vi.mock('../stores/projectStore', () => ({
timeSignature: { numerator: 4, denominator: 4 },
selectedNoteIds: [],
selectedPitchBendIds: [],
selectedControllerEventIds: [],
playheadPosition: 4,
updateTrack: vi.fn().mockResolvedValue(undefined),
refreshProjectState: vi.fn(),
+219 -14
View File
@@ -5,10 +5,12 @@ import KGDropdown from './common/KGDropdown';
import { useProjectStore } from '../stores/projectStore';
import { KGCore } from '../core/KGCore';
import { KGMidiRegion } from '../core/region/KGMidiRegion';
import { KGMidiControllerEvent } from '../core/midi/KGMidiControllerEvent';
import { KGMidiNote } from '../core/midi/KGMidiNote';
import { KGMidiPitchBend } from '../core/midi/KGMidiPitchBend';
import { KGPianoRollState } from '../core/state/KGPianoRollState';
import {
clampMidiControllerValue,
clampMidiPitchBendValue,
formatMidiEventLength,
formatMidiEventPosition,
@@ -29,6 +31,7 @@ import {
import { isModifierKeyPressed } from '../util/osUtil';
import { PIANO_ROLL_CONSTANTS } from '../constants';
import { CreateMidiEventsCommand, CreateNoteCommand, DeleteMidiEventsCommand } from '../core/commands';
import { UpdateControllerEventPropertiesCommand } from '../core/commands/note/UpdateControllerEventPropertiesCommand';
import { UpdateNotePropertiesCommand } from '../core/commands/note/UpdateNotePropertiesCommand';
import { UpdatePitchBendPropertiesCommand } from '../core/commands/note/UpdatePitchBendPropertiesCommand';
import { showAlert } from '../util/dialogUtil';
@@ -52,7 +55,15 @@ interface PitchBendRowData {
absoluteBeat: number;
}
type EventRowData = NoteRowData | PitchBendRowData;
interface ControllerRowData {
id: string;
type: 'controller';
controller: number;
controllerEvent: KGMidiControllerEvent;
absoluteBeat: number;
}
type EventRowData = NoteRowData | PitchBendRowData | ControllerRowData;
type EditableColumn = 'position' | 'num' | 'val' | 'length';
interface EditingCell {
@@ -61,11 +72,12 @@ interface EditingCell {
value: string;
}
type AddEventType = 'note' | 'pitch-bend';
type AddEventType = 'note' | 'pitch-bend' | 'controller';
const ADD_EVENT_TYPE_OPTIONS = [
{ label: 'Note', value: 'note' },
{ label: 'Pitch Bend', value: 'pitch-bend' },
{ label: 'Controller', value: 'controller' },
] as const;
const parseVelocityInput = (raw: string): { velocity: number } | { error: string } => {
@@ -137,6 +149,38 @@ const formatPitchBendInfo = (value: number): string => {
return `Raw ${value} | ${normalized.toFixed(3)} | ${semitones.toFixed(2)} st`;
};
const parseControllerNumberInput = (raw: string): { controller: number } | { error: string } => {
const trimmed = raw.trim();
if (!/^\d+$/.test(trimmed)) {
return { error: 'Controller number must be an integer between 0 and 127.' };
}
const controller = parseInt(trimmed, 10);
if (controller < 0 || controller > 127) {
return { error: 'Controller number must be between 0 and 127.' };
}
return { controller };
};
const parseControllerValueInput = (raw: string): { value: number } | { error: string } => {
const trimmed = raw.trim();
if (!/^\d+$/.test(trimmed)) {
return { error: 'Controller value must be an integer between 0 and 127.' };
}
return { value: clampMidiControllerValue(parseInt(trimmed, 10)) };
};
const parseControllerValueDeltaInput = (raw: string): { delta: number } | { error: string } => {
const trimmed = raw.trim();
if (!/^[+-]\d+$/.test(trimmed)) {
return { error: 'Use controller value delta like +10 or -5.' };
}
return { delta: parseInt(trimmed, 10) };
};
const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
const {
tracks,
@@ -145,6 +189,7 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
timeSignature,
selectedNoteIds,
selectedPitchBendIds,
selectedControllerEventIds,
playheadPosition,
updateTrack,
refreshProjectState
@@ -152,6 +197,7 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
const [showNotes, setShowNotes] = useState(true);
const [showPitchBends, setShowPitchBends] = useState(true);
const [showControllers, setShowControllers] = useState(true);
const [quantPosition, setQuantPosition] = useState<string>('1/8');
const [quantLength, setQuantLength] = useState<string>('1/8');
const [addEventType, setAddEventType] = useState<AddEventType>('note');
@@ -200,9 +246,20 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
}))
: [];
const controllerRows: ControllerRowData[] = activeMidiRegion
? activeMidiRegion.getAllControllerEventsFlattened().map(({ controller, event }) => ({
id: event.getId(),
type: 'controller',
controller,
controllerEvent: event,
absoluteBeat: activeMidiRegion!.getStartFromBeat() + event.getBeat(),
}))
: [];
const eventRows: EventRowData[] = [
...(showNotes ? noteRows : []),
...(showPitchBends ? pitchBendRows : []),
...(showControllers ? controllerRows : []),
].sort((a, b) => {
const beatDelta = (a.type === 'note' ? a.absoluteStartBeat : a.absoluteBeat)
- (b.type === 'note' ? b.absoluteStartBeat : b.absoluteBeat);
@@ -213,7 +270,8 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
const selectedNoteIdSet = new Set(selectedNoteIds);
const selectedPitchBendIdSet = new Set(selectedPitchBendIds);
const selectedEventIdSet = new Set([...selectedNoteIds, ...selectedPitchBendIds]);
const selectedControllerEventIdSet = new Set(selectedControllerEventIds);
const selectedEventIdSet = new Set([...selectedNoteIds, ...selectedPitchBendIds, ...selectedControllerEventIds]);
const visibleSelectedRows = eventRows.filter(row => selectedEventIdSet.has(row.id));
useEffect(() => {
@@ -236,7 +294,7 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
const selectedEvents = eventRows
.filter(row => nextSelectedIds.has(row.id))
.map(row => row.type === 'note' ? row.note : row.pitchBend);
.map(row => row.type === 'note' ? row.note : row.type === 'pitch-bend' ? row.pitchBend : row.controllerEvent);
const core = KGCore.instance();
activeMidiRegion.getNotes().forEach(note => {
@@ -247,6 +305,12 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
if (nextSelectedIds.has(pitchBend.getId())) pitchBend.select();
else pitchBend.deselect();
});
activeMidiRegion.getControllerEventsByType().forEach(events => {
events.forEach(controllerEvent => {
if (nextSelectedIds.has(controllerEvent.getId())) controllerEvent.select();
else controllerEvent.deselect();
});
});
core.clearSelectedItems();
if (selectedEvents.length > 0) {
@@ -447,7 +511,7 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
}
KGCore.instance().executeCommand(new UpdateNotePropertiesCommand(activeMidiRegion.getId(), snapshots, updates));
} else {
} else if (row.type === 'pitch-bend') {
const pitchBend = row.pitchBend;
const targetPitchBends = selectedPitchBendIdSet.has(pitchBend.getId()) && selectedPitchBendIds.length > 1
? activeMidiRegion.getPitchBends().filter(candidate => selectedPitchBendIdSet.has(candidate.getId()))
@@ -533,6 +597,103 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
}
KGCore.instance().executeCommand(new UpdatePitchBendPropertiesCommand(activeMidiRegion.getId(), snapshots, updates));
} else {
const controllerEvent = row.controllerEvent;
const targetControllerEvents = selectedControllerEventIdSet.has(controllerEvent.getId()) && selectedControllerEventIds.length > 1
? activeMidiRegion.getAllControllerEventsFlattened()
.filter(candidate => selectedControllerEventIdSet.has(candidate.event.getId()))
: [{ controller: row.controller, event: controllerEvent }];
const snapshots = targetControllerEvents.map(({ controller, event }) => ({
controllerEventId: event.getId(),
controller,
beat: event.getBeat(),
value: event.getValue(),
}));
const updates: Array<{ controllerEventId: string; controller?: number; beat?: number; value?: number }> = [];
if (editingCell.column === 'position') {
if (isDeltaEdit) {
const parsed = parseMidiEventPositionDelta(trimmedValue, timeSignature, MIDI_EVENT_TICKS_PER_BEAT);
if ('error' in parsed) {
await showAlert(parsed.error);
return;
}
for (const { event } of targetControllerEvents) {
const nextBeat = event.getBeat() + parsed.deltaBeats;
if (nextBeat < 0) {
await showAlert('Position delta would move one or more controller events before the start of the current MIDI region.');
return;
}
updates.push({ controllerEventId: event.getId(), beat: nextBeat });
}
} else {
const parsed = parseMidiEventPosition(trimmedValue, timeSignature, MIDI_EVENT_TICKS_PER_BEAT);
if ('error' in parsed) {
await showAlert(parsed.error);
return;
}
const relativeBeat = parsed.absoluteBeat - activeMidiRegion.getStartFromBeat();
if (relativeBeat < 0) {
await showAlert('Position cannot be earlier than the start of the current MIDI region.');
return;
}
for (const { event } of targetControllerEvents) {
updates.push({ controllerEventId: event.getId(), beat: relativeBeat });
}
}
}
if (editingCell.column === 'num') {
const parsed = parseControllerNumberInput(trimmedValue);
if ('error' in parsed) {
await showAlert(parsed.error);
return;
}
for (const { event } of targetControllerEvents) {
updates.push({ controllerEventId: event.getId(), controller: parsed.controller });
}
}
if (editingCell.column === 'val') {
if (isDeltaEdit) {
const parsed = parseControllerValueDeltaInput(trimmedValue);
if ('error' in parsed) {
await showAlert(parsed.error);
return;
}
for (const { event } of targetControllerEvents) {
const nextValue = event.getValue() + parsed.delta;
if (nextValue < 0 || nextValue > 127) {
await showAlert('Controller delta would move one or more events outside the valid range 0127.');
return;
}
updates.push({ controllerEventId: event.getId(), value: clampMidiControllerValue(nextValue) });
}
} else {
const parsed = parseControllerValueInput(trimmedValue);
if ('error' in parsed) {
await showAlert(parsed.error);
return;
}
for (const { event } of targetControllerEvents) {
updates.push({ controllerEventId: event.getId(), value: parsed.value });
}
}
}
if (updates.length === 0) {
setEditingCell(null);
return;
}
KGCore.instance().executeCommand(new UpdateControllerEventPropertiesCommand(activeMidiRegion.getId(), snapshots, updates));
}
await updateTrack(parentTrack);
@@ -721,7 +882,7 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
KGCore.instance().addSelectedItem(createdNote);
rangeAnchorEventIdRef.current = createdNote.getId();
}
} else {
} else if (addEventType === 'pitch-bend') {
const command = new CreateMidiEventsCommand([], [{
regionId: activeMidiRegion.getId(),
beat: regionRelativePlayhead,
@@ -736,6 +897,26 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
KGCore.instance().addSelectedItem(createdPitchBend);
rangeAnchorEventIdRef.current = createdPitchBend.getId();
}
} else {
const lastSelectedController = [...selectedControllerEventIds]
.reverse()
.map(id => activeMidiRegion.getAllControllerEventsFlattened().find(candidate => candidate.event.getId() === id))
.find(Boolean)?.controller ?? 11;
const command = new CreateMidiEventsCommand([], [], [{
regionId: activeMidiRegion.getId(),
controller: lastSelectedController,
beat: regionRelativePlayhead,
value: 127,
}]);
KGCore.instance().executeCommand(command);
const createdControllerEvent = command.getCreatedControllerEvents()[0]?.controllerEvent;
if (createdControllerEvent) {
createdControllerEvent.select();
KGCore.instance().clearSelectedItems();
KGCore.instance().addSelectedItem(createdControllerEvent);
rangeAnchorEventIdRef.current = createdControllerEvent.getId();
}
}
await updateTrack(parentTrack);
@@ -752,8 +933,11 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
const pitchBendIds = visibleSelectedRows
.filter((row): row is PitchBendRowData => row.type === 'pitch-bend')
.map(row => row.pitchBend.getId());
const controllerEventIds = visibleSelectedRows
.filter((row): row is ControllerRowData => row.type === 'controller')
.map(row => row.controllerEvent.getId());
KGCore.instance().executeCommand(new DeleteMidiEventsCommand(noteIds, pitchBendIds));
KGCore.instance().executeCommand(new DeleteMidiEventsCommand(noteIds, pitchBendIds, controllerEventIds));
rangeAnchorEventIdRef.current = null;
await updateTrack(parentTrack);
refreshProjectState();
@@ -783,7 +967,14 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
>
Pitch Bends
</button>
<button className="list-event-tab" aria-pressed="false" type="button" disabled>Controller</button>
<button
className={`list-event-tab${showControllers ? ' active' : ''}`}
aria-pressed={showControllers}
type="button"
onClick={() => setShowControllers(value => !value)}
>
Controller
</button>
</div>
{!activeMidiRegion ? (
@@ -796,7 +987,13 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
<div className="list-event-toolbar-group">
<button
className="list-event-add-button"
title={addEventType === 'note' ? 'Add note at playhead' : 'Add pitch bend at playhead'}
title={
addEventType === 'note'
? 'Add note at playhead'
: addEventType === 'pitch-bend'
? 'Add pitch bend at playhead'
: 'Add controller event at playhead'
}
type="button"
onClick={handleAddEvent}
>
@@ -865,14 +1062,22 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
{eventRows.map((row, index) => {
const absoluteBeat = row.type === 'note' ? row.absoluteStartBeat : row.absoluteBeat;
const positionText = formatMidiEventPosition(absoluteBeat, timeSignature, MIDI_EVENT_TICKS_PER_BEAT);
const statusText = row.type === 'note' ? 'Note' : 'Pitch Bend';
const numText = row.type === 'note' ? pitchToNoteNameString(row.note.getPitch()) : '';
const statusText = row.type === 'note' ? 'Note' : row.type === 'pitch-bend' ? 'Pitch Bend' : 'Controller';
const numText = row.type === 'note'
? pitchToNoteNameString(row.note.getPitch())
: row.type === 'controller'
? String(row.controller)
: '';
const valText = row.type === 'note'
? String(row.note.getVelocity())
: String(midiPitchBendToSignedValue(row.pitchBend.getValue()));
: row.type === 'pitch-bend'
? String(midiPitchBendToSignedValue(row.pitchBend.getValue()))
: String(row.controllerEvent.getValue());
const lengthText = row.type === 'note'
? formatMidiEventLength(row.durationBeats, MIDI_EVENT_TICKS_PER_BEAT)
: formatPitchBendInfo(row.pitchBend.getValue());
: row.type === 'pitch-bend'
? formatPitchBendInfo(row.pitchBend.getValue())
: `Raw ${row.controllerEvent.getValue()}`;
const isEditingPosition = editingCell?.eventId === row.id && editingCell.column === 'position';
const isEditingNum = editingCell?.eventId === row.id && editingCell.column === 'num';
const isEditingVal = editingCell?.eventId === row.id && editingCell.column === 'val';
@@ -912,7 +1117,7 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
<td
title={numText}
onDoubleClick={(event) => {
if (row.type !== 'note') return;
if (row.type === 'pitch-bend') return;
event.stopPropagation();
startEditingCell(row.id, 'num', numText);
}}