From f3d26126fab85be62ad7fc81c16e632bd5c9588c Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Thu, 7 May 2026 22:12:06 -0700 Subject: [PATCH] feat: added MIDI CC recording and list event table operation support. --- src/components/ListEventPanel.test.tsx | 1 + src/components/ListEventPanel.tsx | 233 ++++++++++++++++-- src/core/KGCore.ts | 10 + src/core/KGProject.ts | 2 +- src/core/audio-interface/KGAudioBus.ts | 10 + .../audio-interface/KGAudioInterface.test.ts | 8 + src/core/audio-interface/KGAudioInterface.ts | 117 ++++++++- src/core/audio-interface/KGOfflineRenderer.ts | 93 ++++++- src/core/commands/index.ts | 8 +- .../commands/note/CreateMidiEventsCommand.ts | 70 +++++- .../commands/note/DeleteMidiEventsCommand.ts | 81 +++++- .../UpdateControllerEventPropertiesCommand.ts | 119 +++++++++ .../region/MergeMidiRegionsCommand.ts | 45 ++++ .../commands/region/PasteRegionsCommand.ts | 10 + .../commands/region/ResizeRegionCommand.ts | 26 ++ .../commands/region/SplitRegionCommand.ts | 17 ++ .../region/TransformRegionsCommand.ts | 27 ++ src/core/midi-input/KGMidiInput.ts | 11 +- src/core/midi/KGMidiControllerEvent.test.ts | 23 ++ src/core/midi/KGMidiControllerEvent.ts | 66 +++++ .../project-upgrader/KGProjectUpgrader.ts | 5 + src/core/project-upgrader/upgradeToV8.test.ts | 3 +- src/core/project-upgrader/upgradeToV9.ts | 23 ++ src/core/region/KGMidiRegion.test.ts | 40 +++ src/core/region/KGMidiRegion.ts | 68 +++++ src/stores/projectStore.ts | 74 +++++- src/test/utils/mock-data.ts | 20 ++ src/util/midiAutomationUtil.test.ts | 30 +++ src/util/midiAutomationUtil.ts | 57 ++++- src/util/midiUtil.ts | 6 + 30 files changed, 1250 insertions(+), 53 deletions(-) create mode 100644 src/core/commands/note/UpdateControllerEventPropertiesCommand.ts create mode 100644 src/core/midi/KGMidiControllerEvent.test.ts create mode 100644 src/core/midi/KGMidiControllerEvent.ts create mode 100644 src/core/project-upgrader/upgradeToV9.ts diff --git a/src/components/ListEventPanel.test.tsx b/src/components/ListEventPanel.test.tsx index e612ef7..99a7659 100644 --- a/src/components/ListEventPanel.test.tsx +++ b/src/components/ListEventPanel.test.tsx @@ -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(), diff --git a/src/components/ListEventPanel.tsx b/src/components/ListEventPanel.tsx index 78ed952..a96a4be 100644 --- a/src/components/ListEventPanel.tsx +++ b/src/components/ListEventPanel.tsx @@ -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 = ({ isVisible }) => { const { tracks, @@ -145,6 +189,7 @@ const ListEventPanel: React.FC = ({ isVisible }) => { timeSignature, selectedNoteIds, selectedPitchBendIds, + selectedControllerEventIds, playheadPosition, updateTrack, refreshProjectState @@ -152,6 +197,7 @@ const ListEventPanel: React.FC = ({ isVisible }) => { const [showNotes, setShowNotes] = useState(true); const [showPitchBends, setShowPitchBends] = useState(true); + const [showControllers, setShowControllers] = useState(true); const [quantPosition, setQuantPosition] = useState('1/8'); const [quantLength, setQuantLength] = useState('1/8'); const [addEventType, setAddEventType] = useState('note'); @@ -200,9 +246,20 @@ const ListEventPanel: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 0–127.'); + 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 = ({ 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 = ({ 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 = ({ 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 = ({ isVisible }) => { > Pitch Bends - + {!activeMidiRegion ? ( @@ -796,7 +987,13 @@ const ListEventPanel: React.FC = ({ isVisible }) => {