feat: added MIDI CC recording and list event table operation support.
This commit is contained in:
@@ -22,6 +22,7 @@ vi.mock('../stores/projectStore', () => ({
|
|||||||
timeSignature: { numerator: 4, denominator: 4 },
|
timeSignature: { numerator: 4, denominator: 4 },
|
||||||
selectedNoteIds: [],
|
selectedNoteIds: [],
|
||||||
selectedPitchBendIds: [],
|
selectedPitchBendIds: [],
|
||||||
|
selectedControllerEventIds: [],
|
||||||
playheadPosition: 4,
|
playheadPosition: 4,
|
||||||
updateTrack: vi.fn().mockResolvedValue(undefined),
|
updateTrack: vi.fn().mockResolvedValue(undefined),
|
||||||
refreshProjectState: vi.fn(),
|
refreshProjectState: vi.fn(),
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import KGDropdown from './common/KGDropdown';
|
|||||||
import { useProjectStore } from '../stores/projectStore';
|
import { useProjectStore } from '../stores/projectStore';
|
||||||
import { KGCore } from '../core/KGCore';
|
import { KGCore } from '../core/KGCore';
|
||||||
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
||||||
|
import { KGMidiControllerEvent } from '../core/midi/KGMidiControllerEvent';
|
||||||
import { KGMidiNote } from '../core/midi/KGMidiNote';
|
import { KGMidiNote } from '../core/midi/KGMidiNote';
|
||||||
import { KGMidiPitchBend } from '../core/midi/KGMidiPitchBend';
|
import { KGMidiPitchBend } from '../core/midi/KGMidiPitchBend';
|
||||||
import { KGPianoRollState } from '../core/state/KGPianoRollState';
|
import { KGPianoRollState } from '../core/state/KGPianoRollState';
|
||||||
import {
|
import {
|
||||||
|
clampMidiControllerValue,
|
||||||
clampMidiPitchBendValue,
|
clampMidiPitchBendValue,
|
||||||
formatMidiEventLength,
|
formatMidiEventLength,
|
||||||
formatMidiEventPosition,
|
formatMidiEventPosition,
|
||||||
@@ -29,6 +31,7 @@ import {
|
|||||||
import { isModifierKeyPressed } from '../util/osUtil';
|
import { isModifierKeyPressed } from '../util/osUtil';
|
||||||
import { PIANO_ROLL_CONSTANTS } from '../constants';
|
import { PIANO_ROLL_CONSTANTS } from '../constants';
|
||||||
import { CreateMidiEventsCommand, CreateNoteCommand, DeleteMidiEventsCommand } from '../core/commands';
|
import { CreateMidiEventsCommand, CreateNoteCommand, DeleteMidiEventsCommand } from '../core/commands';
|
||||||
|
import { UpdateControllerEventPropertiesCommand } from '../core/commands/note/UpdateControllerEventPropertiesCommand';
|
||||||
import { UpdateNotePropertiesCommand } from '../core/commands/note/UpdateNotePropertiesCommand';
|
import { UpdateNotePropertiesCommand } from '../core/commands/note/UpdateNotePropertiesCommand';
|
||||||
import { UpdatePitchBendPropertiesCommand } from '../core/commands/note/UpdatePitchBendPropertiesCommand';
|
import { UpdatePitchBendPropertiesCommand } from '../core/commands/note/UpdatePitchBendPropertiesCommand';
|
||||||
import { showAlert } from '../util/dialogUtil';
|
import { showAlert } from '../util/dialogUtil';
|
||||||
@@ -52,7 +55,15 @@ interface PitchBendRowData {
|
|||||||
absoluteBeat: number;
|
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';
|
type EditableColumn = 'position' | 'num' | 'val' | 'length';
|
||||||
|
|
||||||
interface EditingCell {
|
interface EditingCell {
|
||||||
@@ -61,11 +72,12 @@ interface EditingCell {
|
|||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type AddEventType = 'note' | 'pitch-bend';
|
type AddEventType = 'note' | 'pitch-bend' | 'controller';
|
||||||
|
|
||||||
const ADD_EVENT_TYPE_OPTIONS = [
|
const ADD_EVENT_TYPE_OPTIONS = [
|
||||||
{ label: 'Note', value: 'note' },
|
{ label: 'Note', value: 'note' },
|
||||||
{ label: 'Pitch Bend', value: 'pitch-bend' },
|
{ label: 'Pitch Bend', value: 'pitch-bend' },
|
||||||
|
{ label: 'Controller', value: 'controller' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const parseVelocityInput = (raw: string): { velocity: number } | { error: string } => {
|
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`;
|
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 ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
||||||
const {
|
const {
|
||||||
tracks,
|
tracks,
|
||||||
@@ -145,6 +189,7 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
timeSignature,
|
timeSignature,
|
||||||
selectedNoteIds,
|
selectedNoteIds,
|
||||||
selectedPitchBendIds,
|
selectedPitchBendIds,
|
||||||
|
selectedControllerEventIds,
|
||||||
playheadPosition,
|
playheadPosition,
|
||||||
updateTrack,
|
updateTrack,
|
||||||
refreshProjectState
|
refreshProjectState
|
||||||
@@ -152,6 +197,7 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
|
|
||||||
const [showNotes, setShowNotes] = useState(true);
|
const [showNotes, setShowNotes] = useState(true);
|
||||||
const [showPitchBends, setShowPitchBends] = useState(true);
|
const [showPitchBends, setShowPitchBends] = useState(true);
|
||||||
|
const [showControllers, setShowControllers] = useState(true);
|
||||||
const [quantPosition, setQuantPosition] = useState<string>('1/8');
|
const [quantPosition, setQuantPosition] = useState<string>('1/8');
|
||||||
const [quantLength, setQuantLength] = useState<string>('1/8');
|
const [quantLength, setQuantLength] = useState<string>('1/8');
|
||||||
const [addEventType, setAddEventType] = useState<AddEventType>('note');
|
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[] = [
|
const eventRows: EventRowData[] = [
|
||||||
...(showNotes ? noteRows : []),
|
...(showNotes ? noteRows : []),
|
||||||
...(showPitchBends ? pitchBendRows : []),
|
...(showPitchBends ? pitchBendRows : []),
|
||||||
|
...(showControllers ? controllerRows : []),
|
||||||
].sort((a, b) => {
|
].sort((a, b) => {
|
||||||
const beatDelta = (a.type === 'note' ? a.absoluteStartBeat : a.absoluteBeat)
|
const beatDelta = (a.type === 'note' ? a.absoluteStartBeat : a.absoluteBeat)
|
||||||
- (b.type === 'note' ? b.absoluteStartBeat : b.absoluteBeat);
|
- (b.type === 'note' ? b.absoluteStartBeat : b.absoluteBeat);
|
||||||
@@ -213,7 +270,8 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
|
|
||||||
const selectedNoteIdSet = new Set(selectedNoteIds);
|
const selectedNoteIdSet = new Set(selectedNoteIds);
|
||||||
const selectedPitchBendIdSet = new Set(selectedPitchBendIds);
|
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));
|
const visibleSelectedRows = eventRows.filter(row => selectedEventIdSet.has(row.id));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -236,7 +294,7 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
|
|
||||||
const selectedEvents = eventRows
|
const selectedEvents = eventRows
|
||||||
.filter(row => nextSelectedIds.has(row.id))
|
.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();
|
const core = KGCore.instance();
|
||||||
|
|
||||||
activeMidiRegion.getNotes().forEach(note => {
|
activeMidiRegion.getNotes().forEach(note => {
|
||||||
@@ -247,6 +305,12 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
if (nextSelectedIds.has(pitchBend.getId())) pitchBend.select();
|
if (nextSelectedIds.has(pitchBend.getId())) pitchBend.select();
|
||||||
else pitchBend.deselect();
|
else pitchBend.deselect();
|
||||||
});
|
});
|
||||||
|
activeMidiRegion.getControllerEventsByType().forEach(events => {
|
||||||
|
events.forEach(controllerEvent => {
|
||||||
|
if (nextSelectedIds.has(controllerEvent.getId())) controllerEvent.select();
|
||||||
|
else controllerEvent.deselect();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
core.clearSelectedItems();
|
core.clearSelectedItems();
|
||||||
if (selectedEvents.length > 0) {
|
if (selectedEvents.length > 0) {
|
||||||
@@ -447,7 +511,7 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
KGCore.instance().executeCommand(new UpdateNotePropertiesCommand(activeMidiRegion.getId(), snapshots, updates));
|
KGCore.instance().executeCommand(new UpdateNotePropertiesCommand(activeMidiRegion.getId(), snapshots, updates));
|
||||||
} else {
|
} else if (row.type === 'pitch-bend') {
|
||||||
const pitchBend = row.pitchBend;
|
const pitchBend = row.pitchBend;
|
||||||
const targetPitchBends = selectedPitchBendIdSet.has(pitchBend.getId()) && selectedPitchBendIds.length > 1
|
const targetPitchBends = selectedPitchBendIdSet.has(pitchBend.getId()) && selectedPitchBendIds.length > 1
|
||||||
? activeMidiRegion.getPitchBends().filter(candidate => selectedPitchBendIdSet.has(candidate.getId()))
|
? 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));
|
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);
|
await updateTrack(parentTrack);
|
||||||
@@ -721,7 +882,7 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
KGCore.instance().addSelectedItem(createdNote);
|
KGCore.instance().addSelectedItem(createdNote);
|
||||||
rangeAnchorEventIdRef.current = createdNote.getId();
|
rangeAnchorEventIdRef.current = createdNote.getId();
|
||||||
}
|
}
|
||||||
} else {
|
} else if (addEventType === 'pitch-bend') {
|
||||||
const command = new CreateMidiEventsCommand([], [{
|
const command = new CreateMidiEventsCommand([], [{
|
||||||
regionId: activeMidiRegion.getId(),
|
regionId: activeMidiRegion.getId(),
|
||||||
beat: regionRelativePlayhead,
|
beat: regionRelativePlayhead,
|
||||||
@@ -736,6 +897,26 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
KGCore.instance().addSelectedItem(createdPitchBend);
|
KGCore.instance().addSelectedItem(createdPitchBend);
|
||||||
rangeAnchorEventIdRef.current = createdPitchBend.getId();
|
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);
|
await updateTrack(parentTrack);
|
||||||
@@ -752,8 +933,11 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
const pitchBendIds = visibleSelectedRows
|
const pitchBendIds = visibleSelectedRows
|
||||||
.filter((row): row is PitchBendRowData => row.type === 'pitch-bend')
|
.filter((row): row is PitchBendRowData => row.type === 'pitch-bend')
|
||||||
.map(row => row.pitchBend.getId());
|
.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;
|
rangeAnchorEventIdRef.current = null;
|
||||||
await updateTrack(parentTrack);
|
await updateTrack(parentTrack);
|
||||||
refreshProjectState();
|
refreshProjectState();
|
||||||
@@ -783,7 +967,14 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
>
|
>
|
||||||
Pitch Bends
|
Pitch Bends
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{!activeMidiRegion ? (
|
{!activeMidiRegion ? (
|
||||||
@@ -796,7 +987,13 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
<div className="list-event-toolbar-group">
|
<div className="list-event-toolbar-group">
|
||||||
<button
|
<button
|
||||||
className="list-event-add-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"
|
type="button"
|
||||||
onClick={handleAddEvent}
|
onClick={handleAddEvent}
|
||||||
>
|
>
|
||||||
@@ -865,14 +1062,22 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
{eventRows.map((row, index) => {
|
{eventRows.map((row, index) => {
|
||||||
const absoluteBeat = row.type === 'note' ? row.absoluteStartBeat : row.absoluteBeat;
|
const absoluteBeat = row.type === 'note' ? row.absoluteStartBeat : row.absoluteBeat;
|
||||||
const positionText = formatMidiEventPosition(absoluteBeat, timeSignature, MIDI_EVENT_TICKS_PER_BEAT);
|
const positionText = formatMidiEventPosition(absoluteBeat, timeSignature, MIDI_EVENT_TICKS_PER_BEAT);
|
||||||
const statusText = row.type === 'note' ? 'Note' : 'Pitch Bend';
|
const statusText = row.type === 'note' ? 'Note' : row.type === 'pitch-bend' ? 'Pitch Bend' : 'Controller';
|
||||||
const numText = row.type === 'note' ? pitchToNoteNameString(row.note.getPitch()) : '';
|
const numText = row.type === 'note'
|
||||||
|
? pitchToNoteNameString(row.note.getPitch())
|
||||||
|
: row.type === 'controller'
|
||||||
|
? String(row.controller)
|
||||||
|
: '';
|
||||||
const valText = row.type === 'note'
|
const valText = row.type === 'note'
|
||||||
? String(row.note.getVelocity())
|
? 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'
|
const lengthText = row.type === 'note'
|
||||||
? formatMidiEventLength(row.durationBeats, MIDI_EVENT_TICKS_PER_BEAT)
|
? 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 isEditingPosition = editingCell?.eventId === row.id && editingCell.column === 'position';
|
||||||
const isEditingNum = editingCell?.eventId === row.id && editingCell.column === 'num';
|
const isEditingNum = editingCell?.eventId === row.id && editingCell.column === 'num';
|
||||||
const isEditingVal = editingCell?.eventId === row.id && editingCell.column === 'val';
|
const isEditingVal = editingCell?.eventId === row.id && editingCell.column === 'val';
|
||||||
@@ -912,7 +1117,7 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
<td
|
<td
|
||||||
title={numText}
|
title={numText}
|
||||||
onDoubleClick={(event) => {
|
onDoubleClick={(event) => {
|
||||||
if (row.type !== 'note') return;
|
if (row.type === 'pitch-bend') return;
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
startEditingCell(row.id, 'num', numText);
|
startEditingCell(row.id, 'num', numText);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { KGProjectStorage } from './io/KGProjectStorage';
|
|||||||
import { KGConfigUpgrader } from './config-upgrader/KGConfigUpgrader';
|
import { KGConfigUpgrader } from './config-upgrader/KGConfigUpgrader';
|
||||||
import { KGMidiRegion } from './region/KGMidiRegion';
|
import { KGMidiRegion } from './region/KGMidiRegion';
|
||||||
import { KGMidiNote } from './midi/KGMidiNote';
|
import { KGMidiNote } from './midi/KGMidiNote';
|
||||||
|
import { KGMidiControllerEvent } from './midi/KGMidiControllerEvent';
|
||||||
import { KGMidiPitchBend } from './midi/KGMidiPitchBend';
|
import { KGMidiPitchBend } from './midi/KGMidiPitchBend';
|
||||||
import { KGRegion } from './region/KGRegion';
|
import { KGRegion } from './region/KGRegion';
|
||||||
import { generateUniqueId } from '../util/miscUtil';
|
import { generateUniqueId } from '../util/miscUtil';
|
||||||
@@ -559,6 +560,15 @@ export class KGCore {
|
|||||||
pitchBend.getValue()
|
pitchBend.getValue()
|
||||||
));
|
));
|
||||||
});
|
});
|
||||||
|
region.getControllerEventsByType().forEach((events, controller) => {
|
||||||
|
events.forEach(controllerEvent => {
|
||||||
|
clonedRegion.addControllerEvent(controller, new KGMidiControllerEvent(
|
||||||
|
generateUniqueId('KGMidiControllerEvent'),
|
||||||
|
controllerEvent.getBeat(),
|
||||||
|
controllerEvent.getValue()
|
||||||
|
));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
clonedItems.push(clonedRegion);
|
clonedItems.push(clonedRegion);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export class KGProject {
|
|||||||
@WithDefault(0)
|
@WithDefault(0)
|
||||||
private projectStructureVersion: number = 0;
|
private projectStructureVersion: number = 0;
|
||||||
|
|
||||||
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 8;
|
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 9;
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
@Type(() => KGTrack, {
|
@Type(() => KGTrack, {
|
||||||
|
|||||||
@@ -255,6 +255,16 @@ export class KGAudioBus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public scheduleLiveMidiExpression(normalizedValue: number, time: number): void {
|
||||||
|
this.liveExpressionNormalized = Math.max(0, Math.min(1, normalizedValue));
|
||||||
|
|
||||||
|
for (const activeSources of this.liveMidiSources.values()) {
|
||||||
|
activeSources.forEach(({ gainNode }) => {
|
||||||
|
this.setGainValue(gainNode, this.liveExpressionNormalized, time);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public setLiveMidiSustain(isDown: boolean, time?: number): void {
|
public setLiveMidiSustain(isDown: boolean, time?: number): void {
|
||||||
this.sustainPedalDown = isDown;
|
this.sustainPedalDown = isDown;
|
||||||
if (isDown) {
|
if (isDown) {
|
||||||
|
|||||||
@@ -129,6 +129,8 @@ describe('KGAudioInterface preroll playback', () => {
|
|||||||
resetLiveMidiPitchBend: vi.fn(),
|
resetLiveMidiPitchBend: vi.fn(),
|
||||||
setLiveMidiPitchBend: vi.fn(),
|
setLiveMidiPitchBend: vi.fn(),
|
||||||
scheduleLiveMidiPitchBend: vi.fn(),
|
scheduleLiveMidiPitchBend: vi.fn(),
|
||||||
|
setLiveMidiExpression: vi.fn(),
|
||||||
|
setLiveMidiSustain: vi.fn(),
|
||||||
shouldPlayWithSolo: vi.fn().mockReturnValue(true),
|
shouldPlayWithSolo: vi.fn().mockReturnValue(true),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -154,6 +156,8 @@ describe('KGAudioInterface preroll playback', () => {
|
|||||||
resetLiveMidiPitchBend: vi.fn(),
|
resetLiveMidiPitchBend: vi.fn(),
|
||||||
setLiveMidiPitchBend: vi.fn(),
|
setLiveMidiPitchBend: vi.fn(),
|
||||||
scheduleLiveMidiPitchBend: vi.fn(),
|
scheduleLiveMidiPitchBend: vi.fn(),
|
||||||
|
setLiveMidiExpression: vi.fn(),
|
||||||
|
setLiveMidiSustain: vi.fn(),
|
||||||
shouldPlayWithSolo: vi.fn().mockReturnValue(true),
|
shouldPlayWithSolo: vi.fn().mockReturnValue(true),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -178,6 +182,8 @@ describe('KGAudioInterface preroll playback', () => {
|
|||||||
resetLiveMidiPitchBend: vi.fn(),
|
resetLiveMidiPitchBend: vi.fn(),
|
||||||
setLiveMidiPitchBend: vi.fn(),
|
setLiveMidiPitchBend: vi.fn(),
|
||||||
scheduleLiveMidiPitchBend: vi.fn(),
|
scheduleLiveMidiPitchBend: vi.fn(),
|
||||||
|
setLiveMidiExpression: vi.fn(),
|
||||||
|
setLiveMidiSustain: vi.fn(),
|
||||||
shouldPlayWithSolo: vi.fn().mockReturnValue(true),
|
shouldPlayWithSolo: vi.fn().mockReturnValue(true),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -204,6 +210,8 @@ describe('KGAudioInterface preroll playback', () => {
|
|||||||
resetLiveMidiPitchBend: vi.fn(),
|
resetLiveMidiPitchBend: vi.fn(),
|
||||||
setLiveMidiPitchBend: vi.fn(),
|
setLiveMidiPitchBend: vi.fn(),
|
||||||
scheduleLiveMidiPitchBend: vi.fn(),
|
scheduleLiveMidiPitchBend: vi.fn(),
|
||||||
|
setLiveMidiExpression: vi.fn(),
|
||||||
|
setLiveMidiSustain: vi.fn(),
|
||||||
shouldPlayWithSolo: vi.fn().mockReturnValue(true),
|
shouldPlayWithSolo: vi.fn().mockReturnValue(true),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { KGMidiPitchBend } from '../midi/KGMidiPitchBend';
|
|||||||
import { TIME_CONSTANTS, AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
|
import { TIME_CONSTANTS, AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
|
||||||
import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
|
import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
|
||||||
import {
|
import {
|
||||||
|
clampMidiControllerValue,
|
||||||
MIDI_PITCH_BEND_CENTER,
|
MIDI_PITCH_BEND_CENTER,
|
||||||
midiPitchBendToNormalized,
|
midiPitchBendToNormalized,
|
||||||
pitchToNoteNameString,
|
pitchToNoteNameString,
|
||||||
@@ -11,7 +12,9 @@ import {
|
|||||||
import {
|
import {
|
||||||
bakeMidiAutomationPointsInWindow,
|
bakeMidiAutomationPointsInWindow,
|
||||||
collectRegionMidiAutomationPoints,
|
collectRegionMidiAutomationPoints,
|
||||||
|
normalizeMidiAutomationPoints,
|
||||||
resolveMidiAutomationValueAtBeat,
|
resolveMidiAutomationValueAtBeat,
|
||||||
|
resolveSustainExtendedEndBeat,
|
||||||
} from '../../util/midiAutomationUtil';
|
} from '../../util/midiAutomationUtil';
|
||||||
import * as Tone from 'tone';
|
import * as Tone from 'tone';
|
||||||
import { KGAudioBus } from './KGAudioBus';
|
import { KGAudioBus } from './KGAudioBus';
|
||||||
@@ -503,6 +506,26 @@ export class KGAudioInterface {
|
|||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
const trackControllerEvents = Array.from({ length: 128 }, (_, controller) => (
|
||||||
|
collectRegionMidiAutomationPoints(
|
||||||
|
track.getRegions()
|
||||||
|
.filter(region => region.getCurrentType() === 'KGMidiRegion')
|
||||||
|
.map(region => {
|
||||||
|
const midiRegion = region as unknown as { getControllerEvents: (controller: number) => Array<{ getBeat: () => number; getValue: () => number }> };
|
||||||
|
return {
|
||||||
|
startBeat: region.getStartFromBeat(),
|
||||||
|
points: midiRegion.getControllerEvents(controller).map(event => ({
|
||||||
|
beat: event.getBeat(),
|
||||||
|
value: event.getValue(),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
)
|
||||||
|
));
|
||||||
|
const expressionControllers = [1, 2, 7, 11];
|
||||||
|
const mergedExpressionEvents = normalizeMidiAutomationPoints(
|
||||||
|
expressionControllers.flatMap(controller => trackControllerEvents[controller])
|
||||||
|
);
|
||||||
|
|
||||||
track.getRegions().forEach(region => {
|
track.getRegions().forEach(region => {
|
||||||
console.log(`Region ${region.getId().toString()}: type: ${region.getCurrentType()}`);
|
console.log(`Region ${region.getId().toString()}: type: ${region.getCurrentType()}`);
|
||||||
@@ -517,9 +540,14 @@ export class KGAudioInterface {
|
|||||||
// Calculate absolute note timing in beats (note position + region start position)
|
// Calculate absolute note timing in beats (note position + region start position)
|
||||||
const noteStartBeat = note.getStartBeat() + regionStartBeat;
|
const noteStartBeat = note.getStartBeat() + regionStartBeat;
|
||||||
const noteEndBeat = note.getEndBeat() + regionStartBeat;
|
const noteEndBeat = note.getEndBeat() + regionStartBeat;
|
||||||
|
const sustainedEndBeat = resolveSustainExtendedEndBeat(
|
||||||
|
trackControllerEvents[64],
|
||||||
|
noteEndBeat,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
// Skip notes outside loop range when looping
|
// Skip notes outside loop range when looping
|
||||||
if (noteStartBeat >= scheduleEndBeat || noteEndBeat <= scheduleStartBeat) {
|
if (noteStartBeat >= scheduleEndBeat || sustainedEndBeat <= scheduleStartBeat) {
|
||||||
return; // Skip notes outside the loop range
|
return; // Skip notes outside the loop range
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -530,7 +558,7 @@ export class KGAudioInterface {
|
|||||||
trackNotes.push({
|
trackNotes.push({
|
||||||
note,
|
note,
|
||||||
absoluteStartBeat: noteStartBeat,
|
absoluteStartBeat: noteStartBeat,
|
||||||
absoluteEndBeat: noteEndBeat,
|
absoluteEndBeat: sustainedEndBeat,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -544,6 +572,20 @@ export class KGAudioInterface {
|
|||||||
MIDI_PITCH_BEND_CENTER
|
MIDI_PITCH_BEND_CENTER
|
||||||
);
|
);
|
||||||
audioBus.setLiveMidiPitchBend(midiPitchBendToNormalized(initialPitchBendValue));
|
audioBus.setLiveMidiPitchBend(midiPitchBendToNormalized(initialPitchBendValue));
|
||||||
|
const expressionInitialValue = resolveMidiAutomationValueAtBeat(
|
||||||
|
mergedExpressionEvents,
|
||||||
|
initialPitchBendBeat,
|
||||||
|
127,
|
||||||
|
'linear'
|
||||||
|
);
|
||||||
|
audioBus.setLiveMidiExpression(clampMidiControllerValue(expressionInitialValue) / 127);
|
||||||
|
const sustainInitialValue = resolveMidiAutomationValueAtBeat(
|
||||||
|
trackControllerEvents[64],
|
||||||
|
initialPitchBendBeat,
|
||||||
|
0,
|
||||||
|
'step'
|
||||||
|
);
|
||||||
|
audioBus.setLiveMidiSustain(sustainInitialValue >= 64);
|
||||||
|
|
||||||
const pitchBendWindowStartBeat = isLooping ? scheduleStartBeat : Math.max(startPosition, 0);
|
const pitchBendWindowStartBeat = isLooping ? scheduleStartBeat : Math.max(startPosition, 0);
|
||||||
const bakedTrackPitchBends = bakeMidiAutomationPointsInWindow(
|
const bakedTrackPitchBends = bakeMidiAutomationPointsInWindow(
|
||||||
@@ -572,6 +614,62 @@ export class KGAudioInterface {
|
|||||||
this.scheduledEvents.add(eventId);
|
this.scheduledEvents.add(eventId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const bakedExpressionEvents = bakeMidiAutomationPointsInWindow(
|
||||||
|
mergedExpressionEvents,
|
||||||
|
pitchBendWindowStartBeat,
|
||||||
|
scheduleEndBeat,
|
||||||
|
{
|
||||||
|
maxIntervalMs: interpolationIntervalMs,
|
||||||
|
bpm: project.getBpm(),
|
||||||
|
defaultValue: 127,
|
||||||
|
interpolationMode: 'linear',
|
||||||
|
quantizeValue: clampMidiControllerValue,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
bakedExpressionEvents.forEach(({ beat, value }) => {
|
||||||
|
if (!isLooping && beat <= pitchBendWindowStartBeat) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventId = Tone.Transport.schedule((time) => {
|
||||||
|
const hasSoloedTracks = this.hasSoloedTracks();
|
||||||
|
if (audioBus.shouldPlayWithSolo(hasSoloedTracks)) {
|
||||||
|
audioBus.scheduleLiveMidiExpression(value / 127, time);
|
||||||
|
}
|
||||||
|
}, this.beatsToToneTime(beat));
|
||||||
|
|
||||||
|
this.scheduledEvents.add(eventId);
|
||||||
|
});
|
||||||
|
|
||||||
|
const bakedSustainEvents = bakeMidiAutomationPointsInWindow(
|
||||||
|
trackControllerEvents[64],
|
||||||
|
pitchBendWindowStartBeat,
|
||||||
|
scheduleEndBeat,
|
||||||
|
{
|
||||||
|
maxIntervalMs: interpolationIntervalMs,
|
||||||
|
bpm: project.getBpm(),
|
||||||
|
defaultValue: 0,
|
||||||
|
interpolationMode: 'step',
|
||||||
|
quantizeValue: clampMidiControllerValue,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
bakedSustainEvents.forEach(({ beat, value }) => {
|
||||||
|
if (!isLooping && beat <= pitchBendWindowStartBeat) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventId = Tone.Transport.schedule((time) => {
|
||||||
|
const hasSoloedTracks = this.hasSoloedTracks();
|
||||||
|
if (audioBus.shouldPlayWithSolo(hasSoloedTracks)) {
|
||||||
|
audioBus.setLiveMidiSustain(value >= 64, time);
|
||||||
|
}
|
||||||
|
}, this.beatsToToneTime(beat));
|
||||||
|
|
||||||
|
this.scheduledEvents.add(eventId);
|
||||||
|
});
|
||||||
|
|
||||||
trackNotes.forEach(({ note, absoluteStartBeat, absoluteEndBeat }) => {
|
trackNotes.forEach(({ note, absoluteStartBeat, absoluteEndBeat }) => {
|
||||||
const noteDurationBeats = absoluteEndBeat - absoluteStartBeat;
|
const noteDurationBeats = absoluteEndBeat - absoluteStartBeat;
|
||||||
const noteStartTime = this.beatsToToneTime(absoluteStartBeat);
|
const noteStartTime = this.beatsToToneTime(absoluteStartBeat);
|
||||||
@@ -929,6 +1027,21 @@ export class KGAudioInterface {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public scheduleLiveMidiExpression(trackId: string, normalizedValue: number, time: number): void {
|
||||||
|
try {
|
||||||
|
const audioBus = this.trackAudioBuses.get(trackId);
|
||||||
|
if (!audioBus) {
|
||||||
|
console.warn(`No audio bus found for track ${trackId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
audioBus.scheduleLiveMidiExpression(normalizedValue, time);
|
||||||
|
console.log(`Scheduled live MIDI expression to ${normalizedValue} on track ${trackId}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error scheduling live MIDI expression for track ${trackId}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public setLiveMidiSustain(trackId: string, isDown: boolean, time?: number): void {
|
public setLiveMidiSustain(trackId: string, isDown: boolean, time?: number): void {
|
||||||
try {
|
try {
|
||||||
const audioBus = this.trackAudioBuses.get(trackId);
|
const audioBus = this.trackAudioBuses.get(trackId);
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import type { KGAudioRegion } from '../region/KGAudioRegion';
|
|||||||
import type { InstrumentType } from '../track/KGMidiTrack';
|
import type { InstrumentType } from '../track/KGMidiTrack';
|
||||||
import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
|
import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
|
||||||
import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
|
import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
|
||||||
import { MIDI_PITCH_BEND_CENTER, midiPitchBendToNormalized } from '../../util/midiUtil';
|
import { clampMidiControllerValue, MIDI_PITCH_BEND_CENTER, midiPitchBendToNormalized } from '../../util/midiUtil';
|
||||||
import {
|
import {
|
||||||
bakeMidiAutomationPointsInWindow,
|
bakeMidiAutomationPointsInWindow,
|
||||||
collectRegionMidiAutomationPoints,
|
collectRegionMidiAutomationPoints,
|
||||||
|
normalizeMidiAutomationPoints,
|
||||||
resolveMidiAutomationValueAtBeat,
|
resolveMidiAutomationValueAtBeat,
|
||||||
|
resolveSustainExtendedEndBeat,
|
||||||
type BakedMidiAutomationPoint,
|
type BakedMidiAutomationPoint,
|
||||||
type MidiAutomationPoint,
|
type MidiAutomationPoint,
|
||||||
} from '../../util/midiAutomationUtil';
|
} from '../../util/midiAutomationUtil';
|
||||||
@@ -124,6 +126,7 @@ export class KGOfflineRenderer {
|
|||||||
notes: Array<{ startBeat: number; endBeat: number; durationBeats: number; pitch: number; velocity: number }>;
|
notes: Array<{ startBeat: number; endBeat: number; durationBeats: number; pitch: number; velocity: number }>;
|
||||||
}>;
|
}>;
|
||||||
pitchBends: MidiAutomationPoint[];
|
pitchBends: MidiAutomationPoint[];
|
||||||
|
controllerEventsByType: MidiAutomationPoint[][];
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
const audioTrackData: Array<{
|
const audioTrackData: Array<{
|
||||||
@@ -187,8 +190,24 @@ export class KGOfflineRenderer {
|
|||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
const controllerEventsByType = Array.from({ length: 128 }, (_, controller) => (
|
||||||
|
collectRegionMidiAutomationPoints(
|
||||||
|
track.getRegions()
|
||||||
|
.filter(region => region.getCurrentType() === 'KGMidiRegion')
|
||||||
|
.map(region => {
|
||||||
|
const midiRegion = region as unknown as { getControllerEvents: (controller: number) => Array<{ getBeat: () => number; getValue: () => number }> };
|
||||||
|
return {
|
||||||
|
startBeat: region.getStartFromBeat(),
|
||||||
|
points: midiRegion.getControllerEvents(controller).map(event => ({
|
||||||
|
beat: event.getBeat(),
|
||||||
|
value: event.getValue(),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
)
|
||||||
|
));
|
||||||
|
|
||||||
midiTrackData.push({ trackId, instrumentName, volume, muted, solo, regions, pitchBends });
|
midiTrackData.push({ trackId, instrumentName, volume, muted, solo, regions, pitchBends, controllerEventsByType });
|
||||||
} else if (track.getType() === 'Wave') {
|
} else if (track.getType() === 'Wave') {
|
||||||
const volume = audioInterface.getTrackVolume(trackId);
|
const volume = audioInterface.getTrackVolume(trackId);
|
||||||
const muted = audioInterface.getTrackMuted(trackId);
|
const muted = audioInterface.getTrackMuted(trackId);
|
||||||
@@ -285,6 +304,9 @@ export class KGOfflineRenderer {
|
|||||||
// Track volumes are stored in dB across the app, with 0 meaning unity gain.
|
// Track volumes are stored in dB across the app, with 0 meaning unity gain.
|
||||||
sampler.volume.value = getOfflineTrackVolumeDb(trackInfo.volume, trackInfo.muted);
|
sampler.volume.value = getOfflineTrackVolumeDb(trackInfo.volume, trackInfo.muted);
|
||||||
sampler.connect(masterGain);
|
sampler.connect(masterGain);
|
||||||
|
const mergedExpressionEvents = normalizeMidiAutomationPoints(
|
||||||
|
[1, 2, 7, 11].flatMap(controller => trackInfo.controllerEventsByType[controller])
|
||||||
|
);
|
||||||
const bakedTrackPitchBends = bakeMidiAutomationPointsInWindow(
|
const bakedTrackPitchBends = bakeMidiAutomationPointsInWindow(
|
||||||
trackInfo.pitchBends,
|
trackInfo.pitchBends,
|
||||||
renderStartBeat,
|
renderStartBeat,
|
||||||
@@ -295,6 +317,18 @@ export class KGOfflineRenderer {
|
|||||||
defaultValue: MIDI_PITCH_BEND_CENTER,
|
defaultValue: MIDI_PITCH_BEND_CENTER,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
const bakedExpressionEvents = bakeMidiAutomationPointsInWindow(
|
||||||
|
mergedExpressionEvents,
|
||||||
|
renderStartBeat,
|
||||||
|
renderEndBeat,
|
||||||
|
{
|
||||||
|
maxIntervalMs: interpolationIntervalMs,
|
||||||
|
bpm: project.getBpm(),
|
||||||
|
defaultValue: 127,
|
||||||
|
interpolationMode: 'linear',
|
||||||
|
quantizeValue: clampMidiControllerValue,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Schedule all notes for this track
|
// Schedule all notes for this track
|
||||||
for (const regionInfo of trackInfo.regions) {
|
for (const regionInfo of trackInfo.regions) {
|
||||||
@@ -304,27 +338,42 @@ export class KGOfflineRenderer {
|
|||||||
|
|
||||||
const offsetBeat = note.startBeat - renderStartBeat;
|
const offsetBeat = note.startBeat - renderStartBeat;
|
||||||
const noteStartTime = offsetBeat * secondsPerBeat;
|
const noteStartTime = offsetBeat * secondsPerBeat;
|
||||||
const noteDuration = note.durationBeats * secondsPerBeat;
|
const sustainedEndBeat = resolveSustainExtendedEndBeat(
|
||||||
|
trackInfo.controllerEventsByType[64],
|
||||||
|
note.endBeat,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const noteDuration = Math.max(0, sustainedEndBeat - note.startBeat) * secondsPerBeat;
|
||||||
const velocity = note.velocity / 127;
|
const velocity = note.velocity / 127;
|
||||||
const initialNormalizedPitchBend = midiPitchBendToNormalized(
|
const initialNormalizedPitchBend = midiPitchBendToNormalized(
|
||||||
resolveMidiAutomationValueAtBeat(trackInfo.pitchBends, note.startBeat, MIDI_PITCH_BEND_CENTER)
|
resolveMidiAutomationValueAtBeat(trackInfo.pitchBends, note.startBeat, MIDI_PITCH_BEND_CENTER)
|
||||||
);
|
);
|
||||||
|
const initialExpression = clampMidiControllerValue(
|
||||||
|
resolveMidiAutomationValueAtBeat(mergedExpressionEvents, note.startBeat, 127, 'linear')
|
||||||
|
) / 127;
|
||||||
const offlineSource = createOfflinePitchBendAwareSource(
|
const offlineSource = createOfflinePitchBendAwareSource(
|
||||||
sampler,
|
sampler,
|
||||||
audioBuffers,
|
audioBuffers,
|
||||||
trackInfo.instrumentName,
|
trackInfo.instrumentName,
|
||||||
note.pitch,
|
note.pitch,
|
||||||
initialNormalizedPitchBend
|
initialNormalizedPitchBend,
|
||||||
|
initialExpression
|
||||||
);
|
);
|
||||||
if (!offlineSource) {
|
if (!offlineSource) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { source, basePlaybackRate } = offlineSource;
|
const { source, basePlaybackRate, gainNode } = offlineSource;
|
||||||
applyOfflinePitchBendAutomation(
|
applyOfflinePitchBendAutomation(
|
||||||
source,
|
source,
|
||||||
basePlaybackRate,
|
basePlaybackRate,
|
||||||
bakedTrackPitchBends.filter(point => point.beat > note.startBeat && point.beat < note.endBeat),
|
bakedTrackPitchBends.filter(point => point.beat > note.startBeat && point.beat < sustainedEndBeat),
|
||||||
|
renderStartBeat,
|
||||||
|
secondsPerBeat
|
||||||
|
);
|
||||||
|
applyOfflineExpressionAutomation(
|
||||||
|
gainNode,
|
||||||
|
bakedExpressionEvents.filter(point => point.beat > note.startBeat && point.beat < sustainedEndBeat),
|
||||||
renderStartBeat,
|
renderStartBeat,
|
||||||
secondsPerBeat
|
secondsPerBeat
|
||||||
);
|
);
|
||||||
@@ -494,13 +543,23 @@ function setOfflinePlaybackRate(
|
|||||||
playbackRate.value = value;
|
playbackRate.value = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setOfflineGainValue(gainNode: Tone.Gain, value: number, time: number): void {
|
||||||
|
if (typeof gainNode.gain.setValueAtTime === 'function') {
|
||||||
|
gainNode.gain.setValueAtTime(value, time);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
gainNode.gain.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
function createOfflinePitchBendAwareSource(
|
function createOfflinePitchBendAwareSource(
|
||||||
sampler: Tone.Sampler,
|
sampler: Tone.Sampler,
|
||||||
audioBuffers: Tone.ToneAudioBuffers,
|
audioBuffers: Tone.ToneAudioBuffers,
|
||||||
instrumentName: InstrumentType,
|
instrumentName: InstrumentType,
|
||||||
pitch: number,
|
pitch: number,
|
||||||
initialNormalizedPitchBend: number
|
initialNormalizedPitchBend: number,
|
||||||
): { source: Tone.ToneBufferSource; basePlaybackRate: number } | null {
|
initialExpression: number
|
||||||
|
): { source: Tone.ToneBufferSource; gainNode: Tone.Gain; basePlaybackRate: number } | null {
|
||||||
const closestPitch = KGAudioBus.findClosestBufferedPitch(instrumentName, audioBuffers, pitch);
|
const closestPitch = KGAudioBus.findClosestBufferedPitch(instrumentName, audioBuffers, pitch);
|
||||||
if (closestPitch === null) {
|
if (closestPitch === null) {
|
||||||
return null;
|
return null;
|
||||||
@@ -513,20 +572,22 @@ function createOfflinePitchBendAwareSource(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const basePlaybackRate = Math.pow(2, (pitch - closestPitch) / 12);
|
const basePlaybackRate = Math.pow(2, (pitch - closestPitch) / 12);
|
||||||
|
const gainNode = new Tone.Gain(initialExpression).connect(sampler.output);
|
||||||
const source = new Tone.ToneBufferSource({
|
const source = new Tone.ToneBufferSource({
|
||||||
url: buffer,
|
url: buffer,
|
||||||
fadeIn: sampler.attack,
|
fadeIn: sampler.attack,
|
||||||
fadeOut: sampler.release,
|
fadeOut: sampler.release,
|
||||||
curve: sampler.curve,
|
curve: sampler.curve,
|
||||||
playbackRate: KGAudioBus.applyNormalizedPitchBendToPlaybackRate(basePlaybackRate, initialNormalizedPitchBend),
|
playbackRate: KGAudioBus.applyNormalizedPitchBendToPlaybackRate(basePlaybackRate, initialNormalizedPitchBend),
|
||||||
}).connect(sampler.output);
|
}).connect(gainNode);
|
||||||
setOfflinePlaybackRate(
|
setOfflinePlaybackRate(
|
||||||
source,
|
source,
|
||||||
KGAudioBus.applyNormalizedPitchBendToPlaybackRate(basePlaybackRate, initialNormalizedPitchBend),
|
KGAudioBus.applyNormalizedPitchBendToPlaybackRate(basePlaybackRate, initialNormalizedPitchBend),
|
||||||
0
|
0
|
||||||
);
|
);
|
||||||
|
setOfflineGainValue(gainNode, initialExpression, 0);
|
||||||
|
|
||||||
return { source, basePlaybackRate };
|
return { source, gainNode, basePlaybackRate };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyOfflinePitchBendAutomation(
|
export function applyOfflinePitchBendAutomation(
|
||||||
@@ -546,6 +607,18 @@ export function applyOfflinePitchBendAutomation(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function applyOfflineExpressionAutomation(
|
||||||
|
gainNode: Tone.Gain,
|
||||||
|
bakedExpressionEvents: BakedMidiAutomationPoint[],
|
||||||
|
renderStartBeat: number,
|
||||||
|
secondsPerBeat: number
|
||||||
|
): void {
|
||||||
|
bakedExpressionEvents.forEach(point => {
|
||||||
|
const automationTime = (point.beat - renderStartBeat) * secondsPerBeat;
|
||||||
|
setOfflineGainValue(gainNode, clampMidiControllerValue(point.value) / 127, automationTime);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function getOfflineTrackVolumeDb(volumeDb: number, muted: boolean): number {
|
export function getOfflineTrackVolumeDb(volumeDb: number, muted: boolean): number {
|
||||||
const isSilent = muted || volumeDb <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
|
const isSilent = muted || volumeDb <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
|
||||||
return isSilent ? -Infinity : volumeDb;
|
return isSilent ? -Infinity : volumeDb;
|
||||||
|
|||||||
@@ -37,7 +37,13 @@ export { MoveNotesCommand } from './note/MoveNotesCommand';
|
|||||||
export { PasteNotesCommand } from './note/PasteNotesCommand';
|
export { PasteNotesCommand } from './note/PasteNotesCommand';
|
||||||
export { UpdateNotePropertiesCommand } from './note/UpdateNotePropertiesCommand';
|
export { UpdateNotePropertiesCommand } from './note/UpdateNotePropertiesCommand';
|
||||||
export { UpdatePitchBendPropertiesCommand } from './note/UpdatePitchBendPropertiesCommand';
|
export { UpdatePitchBendPropertiesCommand } from './note/UpdatePitchBendPropertiesCommand';
|
||||||
export { CreateMidiEventsCommand, type PitchBendCreationData, type NoteCreationData } from './note/CreateMidiEventsCommand';
|
export { UpdateControllerEventPropertiesCommand } from './note/UpdateControllerEventPropertiesCommand';
|
||||||
|
export {
|
||||||
|
CreateMidiEventsCommand,
|
||||||
|
type PitchBendCreationData,
|
||||||
|
type NoteCreationData,
|
||||||
|
type ControllerEventCreationData
|
||||||
|
} from './note/CreateMidiEventsCommand';
|
||||||
|
|
||||||
// Project commands
|
// Project commands
|
||||||
export { ChangeProjectPropertyCommand, type ProjectUpdateProperties } from './project/ChangeProjectPropertyCommand';
|
export { ChangeProjectPropertyCommand, type ProjectUpdateProperties } from './project/ChangeProjectPropertyCommand';
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { KGCommand } from '../KGCommand';
|
import { KGCommand } from '../KGCommand';
|
||||||
import { KGCore } from '../../KGCore';
|
import { KGCore } from '../../KGCore';
|
||||||
import { KGMidiNote } from '../../midi/KGMidiNote';
|
import { KGMidiNote } from '../../midi/KGMidiNote';
|
||||||
|
import { KGMidiControllerEvent } from '../../midi/KGMidiControllerEvent';
|
||||||
import { KGMidiPitchBend } from '../../midi/KGMidiPitchBend';
|
import { KGMidiPitchBend } from '../../midi/KGMidiPitchBend';
|
||||||
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
||||||
import { KGTrack } from '../../track/KGTrack';
|
import { KGTrack } from '../../track/KGTrack';
|
||||||
@@ -22,13 +23,27 @@ export interface PitchBendCreationData {
|
|||||||
pitchBendId?: string;
|
pitchBendId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ControllerEventCreationData {
|
||||||
|
regionId: string;
|
||||||
|
controller: number;
|
||||||
|
beat: number;
|
||||||
|
value: number;
|
||||||
|
controllerEventId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class CreateMidiEventsCommand extends KGCommand {
|
export class CreateMidiEventsCommand extends KGCommand {
|
||||||
private noteCreationData: NoteCreationData[];
|
private noteCreationData: NoteCreationData[];
|
||||||
private pitchBendCreationData: PitchBendCreationData[];
|
private pitchBendCreationData: PitchBendCreationData[];
|
||||||
|
private controllerEventCreationData: ControllerEventCreationData[];
|
||||||
private createdNotes: Array<{ note: KGMidiNote; regionId: string }> = [];
|
private createdNotes: Array<{ note: KGMidiNote; regionId: string }> = [];
|
||||||
private createdPitchBends: Array<{ pitchBend: KGMidiPitchBend; regionId: string }> = [];
|
private createdPitchBends: Array<{ pitchBend: KGMidiPitchBend; regionId: string }> = [];
|
||||||
|
private createdControllerEvents: Array<{ controller: number; controllerEvent: KGMidiControllerEvent; regionId: string }> = [];
|
||||||
|
|
||||||
constructor(noteCreationData: NoteCreationData[], pitchBendCreationData: PitchBendCreationData[] = []) {
|
constructor(
|
||||||
|
noteCreationData: NoteCreationData[],
|
||||||
|
pitchBendCreationData: PitchBendCreationData[] = [],
|
||||||
|
controllerEventCreationData: ControllerEventCreationData[] = []
|
||||||
|
) {
|
||||||
super();
|
super();
|
||||||
this.noteCreationData = noteCreationData.map(data => ({
|
this.noteCreationData = noteCreationData.map(data => ({
|
||||||
...data,
|
...data,
|
||||||
@@ -38,12 +53,17 @@ export class CreateMidiEventsCommand extends KGCommand {
|
|||||||
...data,
|
...data,
|
||||||
pitchBendId: data.pitchBendId || generateUniqueId('KGMidiPitchBend'),
|
pitchBendId: data.pitchBendId || generateUniqueId('KGMidiPitchBend'),
|
||||||
}));
|
}));
|
||||||
|
this.controllerEventCreationData = controllerEventCreationData.map(data => ({
|
||||||
|
...data,
|
||||||
|
controllerEventId: data.controllerEventId || generateUniqueId('KGMidiControllerEvent'),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
execute(): void {
|
execute(): void {
|
||||||
const tracks = KGCore.instance().getCurrentProject().getTracks();
|
const tracks = KGCore.instance().getCurrentProject().getTracks();
|
||||||
this.createdNotes = [];
|
this.createdNotes = [];
|
||||||
this.createdPitchBends = [];
|
this.createdPitchBends = [];
|
||||||
|
this.createdControllerEvents = [];
|
||||||
|
|
||||||
for (const noteData of this.noteCreationData) {
|
for (const noteData of this.noteCreationData) {
|
||||||
const targetRegion = this.resolveRegion(tracks, noteData.regionId);
|
const targetRegion = this.resolveRegion(tracks, noteData.regionId);
|
||||||
@@ -68,6 +88,21 @@ export class CreateMidiEventsCommand extends KGCommand {
|
|||||||
targetRegion.addPitchBend(newPitchBend);
|
targetRegion.addPitchBend(newPitchBend);
|
||||||
this.createdPitchBends.push({ pitchBend: newPitchBend, regionId: pitchBendData.regionId });
|
this.createdPitchBends.push({ pitchBend: newPitchBend, regionId: pitchBendData.regionId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const controllerEventData of this.controllerEventCreationData) {
|
||||||
|
const targetRegion = this.resolveRegion(tracks, controllerEventData.regionId);
|
||||||
|
const newControllerEvent = new KGMidiControllerEvent(
|
||||||
|
controllerEventData.controllerEventId!,
|
||||||
|
controllerEventData.beat,
|
||||||
|
controllerEventData.value
|
||||||
|
);
|
||||||
|
targetRegion.addControllerEvent(controllerEventData.controller, newControllerEvent);
|
||||||
|
this.createdControllerEvents.push({
|
||||||
|
controller: controllerEventData.controller,
|
||||||
|
controllerEvent: newControllerEvent,
|
||||||
|
regionId: controllerEventData.regionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
undo(): void {
|
undo(): void {
|
||||||
@@ -91,19 +126,40 @@ export class CreateMidiEventsCommand extends KGCommand {
|
|||||||
core.removeSelectedItem(selectedPitchBend);
|
core.removeSelectedItem(selectedPitchBend);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const data of this.createdControllerEvents) {
|
||||||
|
const region = this.resolveRegion(tracks, data.regionId);
|
||||||
|
region.removeControllerEvent(data.controller, data.controllerEvent.getId());
|
||||||
|
const selectedControllerEvent = core.getSelectedItems().find(
|
||||||
|
item => item instanceof KGMidiControllerEvent && item.getId() === data.controllerEvent.getId()
|
||||||
|
);
|
||||||
|
if (selectedControllerEvent) {
|
||||||
|
core.removeSelectedItem(selectedControllerEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getDescription(): string {
|
getDescription(): string {
|
||||||
const noteCount = this.noteCreationData.length;
|
const noteCount = this.noteCreationData.length;
|
||||||
const pitchBendCount = this.pitchBendCreationData.length;
|
const pitchBendCount = this.pitchBendCreationData.length;
|
||||||
|
const controllerEventCount = this.controllerEventCreationData.length;
|
||||||
|
|
||||||
if (noteCount > 0 && pitchBendCount > 0) {
|
const parts: string[] = [];
|
||||||
return `Create ${noteCount} note${noteCount === 1 ? '' : 's'} and ${pitchBendCount} pitch bend${pitchBendCount === 1 ? '' : 's'}`;
|
if (noteCount > 0) {
|
||||||
|
parts.push(`${noteCount} note${noteCount === 1 ? '' : 's'}`);
|
||||||
}
|
}
|
||||||
if (pitchBendCount > 0) {
|
if (pitchBendCount > 0) {
|
||||||
return pitchBendCount === 1 ? 'Create pitch bend' : `Create ${pitchBendCount} pitch bends`;
|
parts.push(`${pitchBendCount} pitch bend${pitchBendCount === 1 ? '' : 's'}`);
|
||||||
}
|
}
|
||||||
return noteCount === 1 ? 'Create note' : `Create ${noteCount} notes`;
|
if (controllerEventCount > 0) {
|
||||||
|
parts.push(`${controllerEventCount} controller event${controllerEventCount === 1 ? '' : 's'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length === 0) {
|
||||||
|
return 'Create MIDI events';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Create ${parts.join(' and ')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getNoteCreationData(): NoteCreationData[] {
|
public getNoteCreationData(): NoteCreationData[] {
|
||||||
@@ -118,6 +174,10 @@ export class CreateMidiEventsCommand extends KGCommand {
|
|||||||
return this.createdPitchBends;
|
return this.createdPitchBends;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getCreatedControllerEvents(): Array<{ controller: number; controllerEvent: KGMidiControllerEvent; regionId: string }> {
|
||||||
|
return this.createdControllerEvents;
|
||||||
|
}
|
||||||
|
|
||||||
public getCreatedNoteIds(): string[] {
|
public getCreatedNoteIds(): string[] {
|
||||||
return this.noteCreationData.map(data => data.noteId!);
|
return this.noteCreationData.map(data => data.noteId!);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { KGCommand } from '../KGCommand';
|
import { KGCommand } from '../KGCommand';
|
||||||
import { KGCore } from '../../KGCore';
|
import { KGCore } from '../../KGCore';
|
||||||
|
import { KGMidiControllerEvent } from '../../midi/KGMidiControllerEvent';
|
||||||
import { KGMidiNote } from '../../midi/KGMidiNote';
|
import { KGMidiNote } from '../../midi/KGMidiNote';
|
||||||
import { KGMidiPitchBend } from '../../midi/KGMidiPitchBend';
|
import { KGMidiPitchBend } from '../../midi/KGMidiPitchBend';
|
||||||
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
||||||
@@ -16,16 +17,26 @@ interface DeletedPitchBendData {
|
|||||||
originalIndex: number;
|
originalIndex: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DeletedControllerEventData {
|
||||||
|
controller: number;
|
||||||
|
controllerEvent: KGMidiControllerEvent;
|
||||||
|
regionId: string;
|
||||||
|
originalIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
export class DeleteMidiEventsCommand extends KGCommand {
|
export class DeleteMidiEventsCommand extends KGCommand {
|
||||||
private noteIds: string[];
|
private noteIds: string[];
|
||||||
private pitchBendIds: string[];
|
private pitchBendIds: string[];
|
||||||
|
private controllerEventIds: string[];
|
||||||
private deletedNoteData: DeletedNoteData[] = [];
|
private deletedNoteData: DeletedNoteData[] = [];
|
||||||
private deletedPitchBendData: DeletedPitchBendData[] = [];
|
private deletedPitchBendData: DeletedPitchBendData[] = [];
|
||||||
|
private deletedControllerEventData: DeletedControllerEventData[] = [];
|
||||||
|
|
||||||
constructor(noteIds: string[] = [], pitchBendIds: string[] = []) {
|
constructor(noteIds: string[] = [], pitchBendIds: string[] = [], controllerEventIds: string[] = []) {
|
||||||
super();
|
super();
|
||||||
this.noteIds = noteIds;
|
this.noteIds = noteIds;
|
||||||
this.pitchBendIds = pitchBendIds;
|
this.pitchBendIds = pitchBendIds;
|
||||||
|
this.controllerEventIds = controllerEventIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
execute(): void {
|
execute(): void {
|
||||||
@@ -34,6 +45,7 @@ export class DeleteMidiEventsCommand extends KGCommand {
|
|||||||
|
|
||||||
this.deletedNoteData = [];
|
this.deletedNoteData = [];
|
||||||
this.deletedPitchBendData = [];
|
this.deletedPitchBendData = [];
|
||||||
|
this.deletedControllerEventData = [];
|
||||||
|
|
||||||
for (const noteId of this.noteIds) {
|
for (const noteId of this.noteIds) {
|
||||||
for (const track of tracks) {
|
for (const track of tracks) {
|
||||||
@@ -71,12 +83,35 @@ export class DeleteMidiEventsCommand extends KGCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.deletedNoteData.length === 0 && this.deletedPitchBendData.length === 0) {
|
for (const controllerEventId of this.controllerEventIds) {
|
||||||
|
for (const track of tracks) {
|
||||||
|
for (const region of track.getRegions()) {
|
||||||
|
if (!(region instanceof KGMidiRegion)) continue;
|
||||||
|
|
||||||
|
const flattened = region.getAllControllerEventsFlattened();
|
||||||
|
const flattenedIndex = flattened.findIndex(({ event }) => event.getId() === controllerEventId);
|
||||||
|
if (flattenedIndex === -1) continue;
|
||||||
|
|
||||||
|
const { controller, event } = flattened[flattenedIndex];
|
||||||
|
const originalIndex = region.getControllerEvents(controller).findIndex(candidate => candidate.getId() === controllerEventId);
|
||||||
|
this.deletedControllerEventData.push({
|
||||||
|
controller,
|
||||||
|
controllerEvent: event,
|
||||||
|
regionId: region.getId(),
|
||||||
|
originalIndex,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.deletedNoteData.length === 0 && this.deletedPitchBendData.length === 0 && this.deletedControllerEventData.length === 0) {
|
||||||
throw new Error('No MIDI events found to delete');
|
throw new Error('No MIDI events found to delete');
|
||||||
}
|
}
|
||||||
|
|
||||||
this.deletedNoteData.sort((a, b) => b.originalIndex - a.originalIndex);
|
this.deletedNoteData.sort((a, b) => b.originalIndex - a.originalIndex);
|
||||||
this.deletedPitchBendData.sort((a, b) => b.originalIndex - a.originalIndex);
|
this.deletedPitchBendData.sort((a, b) => b.originalIndex - a.originalIndex);
|
||||||
|
this.deletedControllerEventData.sort((a, b) => b.originalIndex - a.originalIndex);
|
||||||
|
|
||||||
for (const data of this.deletedNoteData) {
|
for (const data of this.deletedNoteData) {
|
||||||
const region = this.resolveRegion(tracks, data.regionId);
|
const region = this.resolveRegion(tracks, data.regionId);
|
||||||
@@ -99,6 +134,18 @@ export class DeleteMidiEventsCommand extends KGCommand {
|
|||||||
core.removeSelectedItem(selectedPitchBend);
|
core.removeSelectedItem(selectedPitchBend);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const data of this.deletedControllerEventData) {
|
||||||
|
const region = this.resolveRegion(tracks, data.regionId);
|
||||||
|
region.removeControllerEvent(data.controller, data.controllerEvent.getId());
|
||||||
|
|
||||||
|
const selectedControllerEvent = core.getSelectedItems().find(
|
||||||
|
item => item instanceof KGMidiControllerEvent && item.getId() === data.controllerEvent.getId()
|
||||||
|
);
|
||||||
|
if (selectedControllerEvent) {
|
||||||
|
core.removeSelectedItem(selectedControllerEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
undo(): void {
|
undo(): void {
|
||||||
@@ -125,19 +172,39 @@ export class DeleteMidiEventsCommand extends KGCommand {
|
|||||||
region.addPitchBend(data.pitchBend);
|
region.addPitchBend(data.pitchBend);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const data of [...this.deletedControllerEventData].sort((a, b) => a.originalIndex - b.originalIndex)) {
|
||||||
|
const region = this.resolveRegion(tracks, data.regionId);
|
||||||
|
const controllerEvents = region.getControllerEvents(data.controller);
|
||||||
|
if (data.originalIndex >= 0 && data.originalIndex <= controllerEvents.length) {
|
||||||
|
controllerEvents.splice(data.originalIndex, 0, data.controllerEvent);
|
||||||
|
region.setControllerEvents(data.controller, controllerEvents);
|
||||||
|
} else {
|
||||||
|
region.addControllerEvent(data.controller, data.controllerEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getDescription(): string {
|
getDescription(): string {
|
||||||
const noteCount = this.noteIds.length;
|
const noteCount = this.noteIds.length;
|
||||||
const pitchBendCount = this.pitchBendIds.length;
|
const pitchBendCount = this.pitchBendIds.length;
|
||||||
|
const controllerEventCount = this.controllerEventIds.length;
|
||||||
if (noteCount > 0 && pitchBendCount > 0) {
|
const parts: string[] = [];
|
||||||
return `Delete ${noteCount} note${noteCount === 1 ? '' : 's'} and ${pitchBendCount} pitch bend${pitchBendCount === 1 ? '' : 's'}`;
|
if (noteCount > 0) {
|
||||||
|
parts.push(`${noteCount} note${noteCount === 1 ? '' : 's'}`);
|
||||||
}
|
}
|
||||||
if (pitchBendCount > 0) {
|
if (pitchBendCount > 0) {
|
||||||
return pitchBendCount === 1 ? 'Delete pitch bend' : `Delete ${pitchBendCount} pitch bends`;
|
parts.push(`${pitchBendCount} pitch bend${pitchBendCount === 1 ? '' : 's'}`);
|
||||||
}
|
}
|
||||||
return noteCount === 1 ? 'Delete note' : `Delete ${noteCount} notes`;
|
if (controllerEventCount > 0) {
|
||||||
|
parts.push(`${controllerEventCount} controller event${controllerEventCount === 1 ? '' : 's'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length === 0) {
|
||||||
|
return 'Delete MIDI events';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Delete ${parts.join(' and ')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveRegion(tracks: Array<{ getRegions(): unknown[] }>, regionId: string): KGMidiRegion {
|
private resolveRegion(tracks: Array<{ getRegions(): unknown[] }>, regionId: string): KGMidiRegion {
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { KGCommand } from '../KGCommand';
|
||||||
|
import { KGCore } from '../../KGCore';
|
||||||
|
import { KGMidiControllerEvent } from '../../midi/KGMidiControllerEvent';
|
||||||
|
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
||||||
|
import { KGTrack } from '../../track/KGTrack';
|
||||||
|
|
||||||
|
interface ControllerEventSnapshot {
|
||||||
|
controllerEventId: string;
|
||||||
|
controller: number;
|
||||||
|
beat: number;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ControllerEventUpdate {
|
||||||
|
controllerEventId: string;
|
||||||
|
controller?: number;
|
||||||
|
beat?: number;
|
||||||
|
value?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateControllerEventPropertiesCommand extends KGCommand {
|
||||||
|
private regionId: string;
|
||||||
|
private snapshots: ControllerEventSnapshot[];
|
||||||
|
private updates: ControllerEventUpdate[];
|
||||||
|
private targetRegion: KGMidiRegion | null = null;
|
||||||
|
private parentTrack: KGTrack | null = null;
|
||||||
|
|
||||||
|
constructor(regionId: string, snapshots: ControllerEventSnapshot[], updates: ControllerEventUpdate[]) {
|
||||||
|
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`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const update of this.updates) {
|
||||||
|
const currentController = this.snapshots.find(snapshot => snapshot.controllerEventId === update.controllerEventId)?.controller;
|
||||||
|
if (currentController === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = this.findControllerEvent(currentController, update.controllerEventId);
|
||||||
|
if (!event) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (update.controller !== undefined && update.controller !== currentController) {
|
||||||
|
this.targetRegion.removeControllerEvent(currentController, event.getId());
|
||||||
|
this.targetRegion.addControllerEvent(update.controller, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (update.beat !== undefined) event.setBeat(update.beat);
|
||||||
|
if (update.value !== undefined) event.setValue(update.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(): void {
|
||||||
|
if (!this.targetRegion) {
|
||||||
|
throw new Error('Cannot undo: command was not executed');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.snapshots.forEach(snapshot => {
|
||||||
|
const existing = this.findControllerEventAcrossBuckets(snapshot.controllerEventId);
|
||||||
|
if (!existing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.controller !== snapshot.controller) {
|
||||||
|
this.targetRegion!.removeControllerEvent(existing.controller, existing.event.getId());
|
||||||
|
this.targetRegion!.addControllerEvent(snapshot.controller, existing.event);
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.event.setBeat(snapshot.beat);
|
||||||
|
existing.event.setValue(snapshot.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getDescription(): string {
|
||||||
|
const count = this.snapshots.length;
|
||||||
|
return count === 1 ? 'Update controller event properties' : `Update ${count} controller events' properties`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getParentTrack(): KGTrack | null {
|
||||||
|
return this.parentTrack;
|
||||||
|
}
|
||||||
|
|
||||||
|
private findControllerEvent(controller: number, controllerEventId: string): KGMidiControllerEvent | undefined {
|
||||||
|
return this.targetRegion?.getControllerEvents(controller).find(candidate => candidate.getId() === controllerEventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private findControllerEventAcrossBuckets(controllerEventId: string): { controller: number; event: KGMidiControllerEvent } | null {
|
||||||
|
if (!this.targetRegion) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { controller, event } of this.targetRegion.getAllControllerEventsFlattened()) {
|
||||||
|
if (event.getId() === controllerEventId) {
|
||||||
|
return { controller, event };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { KGCommand } from '../KGCommand';
|
import { KGCommand } from '../KGCommand';
|
||||||
import { KGCore } from '../../KGCore';
|
import { KGCore } from '../../KGCore';
|
||||||
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
||||||
|
import { KGMidiControllerEvent } from '../../midi/KGMidiControllerEvent';
|
||||||
import { KGMidiNote } from '../../midi/KGMidiNote';
|
import { KGMidiNote } from '../../midi/KGMidiNote';
|
||||||
import { KGMidiPitchBend } from '../../midi/KGMidiPitchBend';
|
import { KGMidiPitchBend } from '../../midi/KGMidiPitchBend';
|
||||||
import { KGTrack } from '../../track/KGTrack';
|
import { KGTrack } from '../../track/KGTrack';
|
||||||
@@ -22,6 +23,11 @@ interface RegionSnapshot {
|
|||||||
beat: number;
|
beat: number;
|
||||||
value: number;
|
value: number;
|
||||||
}>;
|
}>;
|
||||||
|
controllerEventsByType: Array<Array<{
|
||||||
|
id: string;
|
||||||
|
beat: number;
|
||||||
|
value: number;
|
||||||
|
}>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ResolvedRegion {
|
interface ResolvedRegion {
|
||||||
@@ -47,6 +53,14 @@ function clonePitchBend(pitchBend: KGMidiPitchBend, beat: number): KGMidiPitchBe
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cloneControllerEvent(controllerEvent: KGMidiControllerEvent, beat: number): KGMidiControllerEvent {
|
||||||
|
return new KGMidiControllerEvent(
|
||||||
|
controllerEvent.getId(),
|
||||||
|
beat,
|
||||||
|
controllerEvent.getValue()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export class MergeMidiRegionsCommand extends KGCommand {
|
export class MergeMidiRegionsCommand extends KGCommand {
|
||||||
private readonly regionIdsToMerge: string[];
|
private readonly regionIdsToMerge: string[];
|
||||||
private targetTrack: KGTrack | null = null;
|
private targetTrack: KGTrack | null = null;
|
||||||
@@ -121,6 +135,13 @@ export class MergeMidiRegionsCommand extends KGCommand {
|
|||||||
beat: pitchBend.getBeat(),
|
beat: pitchBend.getBeat(),
|
||||||
value: pitchBend.getValue(),
|
value: pitchBend.getValue(),
|
||||||
})),
|
})),
|
||||||
|
controllerEventsByType: region.getControllerEventsByType().map(events => (
|
||||||
|
events.map(event => ({
|
||||||
|
id: event.getId(),
|
||||||
|
beat: event.getBeat(),
|
||||||
|
value: event.getValue(),
|
||||||
|
}))
|
||||||
|
)),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -136,6 +157,7 @@ export class MergeMidiRegionsCommand extends KGCommand {
|
|||||||
|
|
||||||
const mergedNotes = [...this.survivingRegion.getNotes()];
|
const mergedNotes = [...this.survivingRegion.getNotes()];
|
||||||
const mergedPitchBends = [...this.survivingRegion.getPitchBends()];
|
const mergedPitchBends = [...this.survivingRegion.getPitchBends()];
|
||||||
|
const mergedControllerEventsByType = this.survivingRegion.getControllerEventsByType().map(events => [...events]);
|
||||||
for (const { region } of resolvedRegions.slice(1)) {
|
for (const { region } of resolvedRegions.slice(1)) {
|
||||||
const regionStart = region.getStartFromBeat();
|
const regionStart = region.getStartFromBeat();
|
||||||
region.getNotes().forEach(note => {
|
region.getNotes().forEach(note => {
|
||||||
@@ -153,11 +175,20 @@ export class MergeMidiRegionsCommand extends KGCommand {
|
|||||||
regionStart + pitchBend.getBeat() - survivingRegionStart
|
regionStart + pitchBend.getBeat() - survivingRegionStart
|
||||||
));
|
));
|
||||||
});
|
});
|
||||||
|
region.getControllerEventsByType().forEach((events, controller) => {
|
||||||
|
events.forEach(event => {
|
||||||
|
mergedControllerEventsByType[controller].push(cloneControllerEvent(
|
||||||
|
event,
|
||||||
|
regionStart + event.getBeat() - survivingRegionStart
|
||||||
|
));
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.survivingRegion.setLength(mergedEndBeat - survivingRegionStart);
|
this.survivingRegion.setLength(mergedEndBeat - survivingRegionStart);
|
||||||
this.survivingRegion.setNotes(mergedNotes);
|
this.survivingRegion.setNotes(mergedNotes);
|
||||||
this.survivingRegion.setPitchBends(mergedPitchBends);
|
this.survivingRegion.setPitchBends(mergedPitchBends);
|
||||||
|
this.survivingRegion.setControllerEventsByType(mergedControllerEventsByType);
|
||||||
|
|
||||||
const removedRegionIds = new Set(this.removedRegions.map(({ region }) => region.getId()));
|
const removedRegionIds = new Set(this.removedRegions.map(({ region }) => region.getId()));
|
||||||
const nextRegions = resolvedTargetTrack.getRegions().filter(region => !removedRegionIds.has(region.getId()));
|
const nextRegions = resolvedTargetTrack.getRegions().filter(region => !removedRegionIds.has(region.getId()));
|
||||||
@@ -197,6 +228,13 @@ export class MergeMidiRegionsCommand extends KGCommand {
|
|||||||
pitchBend.beat,
|
pitchBend.beat,
|
||||||
pitchBend.value
|
pitchBend.value
|
||||||
)));
|
)));
|
||||||
|
this.survivingRegion.setControllerEventsByType(survivingSnapshot.controllerEventsByType.map(events => (
|
||||||
|
events.map(event => new KGMidiControllerEvent(
|
||||||
|
event.id,
|
||||||
|
event.beat,
|
||||||
|
event.value
|
||||||
|
))
|
||||||
|
)));
|
||||||
|
|
||||||
for (const { region } of this.removedRegions) {
|
for (const { region } of this.removedRegions) {
|
||||||
const snapshot = this.originalRegionSnapshots.get(region.getId());
|
const snapshot = this.originalRegionSnapshots.get(region.getId());
|
||||||
@@ -217,6 +255,13 @@ export class MergeMidiRegionsCommand extends KGCommand {
|
|||||||
pitchBend.beat,
|
pitchBend.beat,
|
||||||
pitchBend.value
|
pitchBend.value
|
||||||
)));
|
)));
|
||||||
|
region.setControllerEventsByType(snapshot.controllerEventsByType.map(events => (
|
||||||
|
events.map(event => new KGMidiControllerEvent(
|
||||||
|
event.id,
|
||||||
|
event.beat,
|
||||||
|
event.value
|
||||||
|
))
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
const regions = [...this.targetTrack.getRegions()];
|
const regions = [...this.targetTrack.getRegions()];
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { KGCommand } from '../KGCommand';
|
|||||||
import { KGCore } from '../../KGCore';
|
import { KGCore } from '../../KGCore';
|
||||||
import { KGRegion } from '../../region/KGRegion';
|
import { KGRegion } from '../../region/KGRegion';
|
||||||
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
||||||
|
import { KGMidiControllerEvent } from '../../midi/KGMidiControllerEvent';
|
||||||
import { KGMidiNote } from '../../midi/KGMidiNote';
|
import { KGMidiNote } from '../../midi/KGMidiNote';
|
||||||
import { KGMidiPitchBend } from '../../midi/KGMidiPitchBend';
|
import { KGMidiPitchBend } from '../../midi/KGMidiPitchBend';
|
||||||
import { KGTrack } from '../../track/KGTrack';
|
import { KGTrack } from '../../track/KGTrack';
|
||||||
@@ -90,6 +91,15 @@ export class PasteRegionsCommand extends KGCommand {
|
|||||||
pitchBend.getValue()
|
pitchBend.getValue()
|
||||||
));
|
));
|
||||||
});
|
});
|
||||||
|
originalRegion.getControllerEventsByType().forEach((events, controller) => {
|
||||||
|
events.forEach(controllerEvent => {
|
||||||
|
(newRegion as KGMidiRegion).addControllerEvent(controller, new KGMidiControllerEvent(
|
||||||
|
generateUniqueId('KGMidiControllerEvent'),
|
||||||
|
controllerEvent.getBeat(),
|
||||||
|
controllerEvent.getValue()
|
||||||
|
));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
console.log(`Created MIDI region "${newRegion.getName()}" with ${originalNotes.length} notes`);
|
console.log(`Created MIDI region "${newRegion.getName()}" with ${originalNotes.length} notes`);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { KGCore } from '../../KGCore';
|
|||||||
import { KGRegion } from '../../region/KGRegion';
|
import { KGRegion } from '../../region/KGRegion';
|
||||||
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
||||||
import { KGAudioRegion } from '../../region/KGAudioRegion';
|
import { KGAudioRegion } from '../../region/KGAudioRegion';
|
||||||
|
import { KGMidiControllerEvent } from '../../midi/KGMidiControllerEvent';
|
||||||
import { KGMidiNote } from '../../midi/KGMidiNote';
|
import { KGMidiNote } from '../../midi/KGMidiNote';
|
||||||
import { KGMidiPitchBend } from '../../midi/KGMidiPitchBend';
|
import { KGMidiPitchBend } from '../../midi/KGMidiPitchBend';
|
||||||
|
|
||||||
@@ -28,6 +29,11 @@ export class ResizeRegionCommand extends KGCommand {
|
|||||||
pitchBendId: string;
|
pitchBendId: string;
|
||||||
originalBeat: number;
|
originalBeat: number;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
private controllerEventAdjustments: Array<{
|
||||||
|
controller: number;
|
||||||
|
controllerEventId: string;
|
||||||
|
originalBeat: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
// Audio region clip offset support
|
// Audio region clip offset support
|
||||||
private newClipStartOffsetSeconds?: number;
|
private newClipStartOffsetSeconds?: number;
|
||||||
@@ -93,6 +99,16 @@ export class ResizeRegionCommand extends KGCommand {
|
|||||||
});
|
});
|
||||||
pitchBend.setBeat(pitchBend.getBeat() - beatOffset);
|
pitchBend.setBeat(pitchBend.getBeat() - beatOffset);
|
||||||
});
|
});
|
||||||
|
targetRegion.getControllerEventsByType().forEach((events, controller) => {
|
||||||
|
events.forEach((controllerEvent: KGMidiControllerEvent) => {
|
||||||
|
this.controllerEventAdjustments.push({
|
||||||
|
controller,
|
||||||
|
controllerEventId: controllerEvent.getId(),
|
||||||
|
originalBeat: controllerEvent.getBeat(),
|
||||||
|
});
|
||||||
|
controllerEvent.setBeat(controllerEvent.getBeat() - beatOffset);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
console.log(`Adjusted ${notes.length} notes by offset ${-beatOffset} beats to maintain absolute positions`);
|
console.log(`Adjusted ${notes.length} notes by offset ${-beatOffset} beats to maintain absolute positions`);
|
||||||
}
|
}
|
||||||
@@ -143,6 +159,16 @@ export class ResizeRegionCommand extends KGCommand {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (this.controllerEventAdjustments.length > 0 && this.targetRegion instanceof KGMidiRegion) {
|
||||||
|
const midiRegion = this.targetRegion;
|
||||||
|
this.controllerEventAdjustments.forEach(adjustment => {
|
||||||
|
const controllerEvent = midiRegion.getControllerEvents(adjustment.controller)
|
||||||
|
.find(candidate => candidate.getId() === adjustment.controllerEventId);
|
||||||
|
if (controllerEvent) {
|
||||||
|
controllerEvent.setBeat(adjustment.originalBeat);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Restore clip offset for audio regions
|
// Restore clip offset for audio regions
|
||||||
if (this.targetRegion instanceof KGAudioRegion && this.newClipStartOffsetSeconds !== undefined) {
|
if (this.targetRegion instanceof KGAudioRegion && this.newClipStartOffsetSeconds !== undefined) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { KGCore } from '../../KGCore';
|
|||||||
import { KGRegion } from '../../region/KGRegion';
|
import { KGRegion } from '../../region/KGRegion';
|
||||||
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
||||||
import { KGAudioRegion } from '../../region/KGAudioRegion';
|
import { KGAudioRegion } from '../../region/KGAudioRegion';
|
||||||
|
import { KGMidiControllerEvent } from '../../midi/KGMidiControllerEvent';
|
||||||
import { KGMidiNote } from '../../midi/KGMidiNote';
|
import { KGMidiNote } from '../../midi/KGMidiNote';
|
||||||
import { KGMidiPitchBend } from '../../midi/KGMidiPitchBend';
|
import { KGMidiPitchBend } from '../../midi/KGMidiPitchBend';
|
||||||
import { KGTrack } from '../../track/KGTrack';
|
import { KGTrack } from '../../track/KGTrack';
|
||||||
@@ -131,6 +132,22 @@ export class SplitRegionCommand extends KGCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const { controller, event } of originalRegion.getAllControllerEventsFlattened()) {
|
||||||
|
if (event.getBeat() < splitOffsetBeats) {
|
||||||
|
region1.addControllerEvent(controller, new KGMidiControllerEvent(
|
||||||
|
generateUniqueId('KGMidiControllerEvent'),
|
||||||
|
event.getBeat(),
|
||||||
|
event.getValue()
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
region2.addControllerEvent(controller, new KGMidiControllerEvent(
|
||||||
|
generateUniqueId('KGMidiControllerEvent'),
|
||||||
|
event.getBeat() - splitOffsetBeats,
|
||||||
|
event.getValue()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.region1 = region1;
|
this.region1 = region1;
|
||||||
this.region2 = region2;
|
this.region2 = region2;
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ interface PitchBendAdjustment {
|
|||||||
originalBeat: number;
|
originalBeat: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ControllerEventAdjustment {
|
||||||
|
controller: number;
|
||||||
|
controllerEventId: string;
|
||||||
|
originalBeat: number;
|
||||||
|
}
|
||||||
|
|
||||||
const EPSILON = 1e-9;
|
const EPSILON = 1e-9;
|
||||||
|
|
||||||
function getRegionById(tracks: KGTrack[], regionId: string): { region: KGRegion; track: KGTrack } | null {
|
function getRegionById(tracks: KGTrack[], regionId: string): { region: KGRegion; track: KGTrack } | null {
|
||||||
@@ -174,6 +180,7 @@ export class ResizeMultipleRegionsCommand extends KGCommand {
|
|||||||
private targetRegions: KGRegion[] = [];
|
private targetRegions: KGRegion[] = [];
|
||||||
private noteAdjustments = new Map<string, NoteAdjustment[]>();
|
private noteAdjustments = new Map<string, NoteAdjustment[]>();
|
||||||
private pitchBendAdjustments = new Map<string, PitchBendAdjustment[]>();
|
private pitchBendAdjustments = new Map<string, PitchBendAdjustment[]>();
|
||||||
|
private controllerEventAdjustments = new Map<string, ControllerEventAdjustment[]>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
primaryRegionId: string,
|
primaryRegionId: string,
|
||||||
@@ -272,6 +279,8 @@ export class ResizeMultipleRegionsCommand extends KGCommand {
|
|||||||
}));
|
}));
|
||||||
this.targetRegions = resolvedRegions.map(({ region }) => region);
|
this.targetRegions = resolvedRegions.map(({ region }) => region);
|
||||||
this.noteAdjustments.clear();
|
this.noteAdjustments.clear();
|
||||||
|
this.pitchBendAdjustments.clear();
|
||||||
|
this.controllerEventAdjustments.clear();
|
||||||
|
|
||||||
projectedStates.forEach(projectedState => {
|
projectedStates.forEach(projectedState => {
|
||||||
const region = projectedState.region;
|
const region = projectedState.region;
|
||||||
@@ -295,6 +304,16 @@ export class ResizeMultipleRegionsCommand extends KGCommand {
|
|||||||
region.getPitchBends().forEach(pitchBend => {
|
region.getPitchBends().forEach(pitchBend => {
|
||||||
pitchBend.setBeat(pitchBend.getBeat() - beatOffset);
|
pitchBend.setBeat(pitchBend.getBeat() - beatOffset);
|
||||||
});
|
});
|
||||||
|
this.controllerEventAdjustments.set(region.getId(), region.getAllControllerEventsFlattened().map(({ controller, event }) => ({
|
||||||
|
controller,
|
||||||
|
controllerEventId: event.getId(),
|
||||||
|
originalBeat: event.getBeat(),
|
||||||
|
})));
|
||||||
|
region.getControllerEventsByType().forEach(events => {
|
||||||
|
events.forEach(event => {
|
||||||
|
event.setBeat(event.getBeat() - beatOffset);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (region instanceof KGAudioRegion && projectedState.clipStartOffsetSeconds !== undefined) {
|
if (region instanceof KGAudioRegion && projectedState.clipStartOffsetSeconds !== undefined) {
|
||||||
@@ -335,6 +354,14 @@ export class ResizeMultipleRegionsCommand extends KGCommand {
|
|||||||
pitchBend.setBeat(adjustment.originalBeat);
|
pitchBend.setBeat(adjustment.originalBeat);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
const controllerEventAdjustments = this.controllerEventAdjustments.get(region.getId()) ?? [];
|
||||||
|
controllerEventAdjustments.forEach(adjustment => {
|
||||||
|
const controllerEvent = region.getControllerEvents(adjustment.controller)
|
||||||
|
.find(candidate => candidate.getId() === adjustment.controllerEventId);
|
||||||
|
if (controllerEvent) {
|
||||||
|
controllerEvent.setBeat(adjustment.originalBeat);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (region instanceof KGAudioRegion && originalState.clipStartOffsetSeconds !== undefined) {
|
if (region instanceof KGAudioRegion && originalState.clipStartOffsetSeconds !== undefined) {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export class KGMidiInput {
|
|||||||
private onRecordNoteOn: ((pitch: number, velocity: number) => void) | null = null;
|
private onRecordNoteOn: ((pitch: number, velocity: number) => void) | null = null;
|
||||||
private onRecordNoteOff: ((pitch: number) => void) | null = null;
|
private onRecordNoteOff: ((pitch: number) => void) | null = null;
|
||||||
private onRecordPitchBend: ((value: number) => void) | null = null;
|
private onRecordPitchBend: ((value: number) => void) | null = null;
|
||||||
|
private onRecordControlChange: ((controller: number, value: number) => void) | null = null;
|
||||||
private liveNoteTrackOwnership: Map<number, string[]> = new Map();
|
private liveNoteTrackOwnership: Map<number, string[]> = new Map();
|
||||||
private sustainPolarityInverted: boolean | null = null;
|
private sustainPolarityInverted: boolean | null = null;
|
||||||
|
|
||||||
@@ -288,13 +289,17 @@ export class KGMidiInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (controller === KGMidiInput.CONTROL_CHANGE_SUSTAIN) {
|
if (controller === KGMidiInput.CONTROL_CHANGE_SUSTAIN) {
|
||||||
|
const isPressed = this.normalizeSustainPedalValue(value);
|
||||||
|
this.onRecordControlChange?.(controller, isPressed ? 127 : 0);
|
||||||
audioInterface.setLiveMidiSustain(
|
audioInterface.setLiveMidiSustain(
|
||||||
selectedTrackId,
|
selectedTrackId,
|
||||||
this.normalizeSustainPedalValue(value)
|
isPressed
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.onRecordControlChange?.(controller, value);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
controller === KGMidiInput.CONTROL_CHANGE_MODULATION ||
|
controller === KGMidiInput.CONTROL_CHANGE_MODULATION ||
|
||||||
controller === KGMidiInput.CONTROL_CHANGE_BREATH ||
|
controller === KGMidiInput.CONTROL_CHANGE_BREATH ||
|
||||||
@@ -381,11 +386,13 @@ export class KGMidiInput {
|
|||||||
public setRecordingCallbacks(
|
public setRecordingCallbacks(
|
||||||
onNoteOn: ((pitch: number, velocity: number) => void) | null,
|
onNoteOn: ((pitch: number, velocity: number) => void) | null,
|
||||||
onNoteOff: ((pitch: number) => void) | null,
|
onNoteOff: ((pitch: number) => void) | null,
|
||||||
onPitchBend: ((value: number) => void) | null = null
|
onPitchBend: ((value: number) => void) | null = null,
|
||||||
|
onControlChange: ((controller: number, value: number) => void) | null = null
|
||||||
): void {
|
): void {
|
||||||
this.onRecordNoteOn = onNoteOn;
|
this.onRecordNoteOn = onNoteOn;
|
||||||
this.onRecordNoteOff = onNoteOff;
|
this.onRecordNoteOff = onNoteOff;
|
||||||
this.onRecordPitchBend = onPitchBend;
|
this.onRecordPitchBend = onPitchBend;
|
||||||
|
this.onRecordControlChange = onControlChange;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== GETTERS =====
|
// ===== GETTERS =====
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { KGMidiControllerEvent } from './KGMidiControllerEvent';
|
||||||
|
|
||||||
|
describe('KGMidiControllerEvent', () => {
|
||||||
|
it('stores beat and raw controller value', () => {
|
||||||
|
const event = new KGMidiControllerEvent('cc-1', 1.5, 64);
|
||||||
|
|
||||||
|
expect(event.getId()).toBe('cc-1');
|
||||||
|
expect(event.getBeat()).toBe(1.5);
|
||||||
|
expect(event.getValue()).toBe(64);
|
||||||
|
expect(event.getCurrentType()).toBe('KGMidiControllerEvent');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports selection state', () => {
|
||||||
|
const event = new KGMidiControllerEvent('cc-1', 0, 127);
|
||||||
|
|
||||||
|
expect(event.isSelected()).toBe(false);
|
||||||
|
event.select();
|
||||||
|
expect(event.isSelected()).toBe(true);
|
||||||
|
event.deselect();
|
||||||
|
expect(event.isSelected()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { Expose } from 'class-transformer';
|
||||||
|
import type { Selectable } from '../../components/interfaces';
|
||||||
|
|
||||||
|
export class KGMidiControllerEvent implements Selectable {
|
||||||
|
@Expose()
|
||||||
|
private id: string = '';
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
private beat: number = 0;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
private value: number = 0;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
private selected: boolean = false;
|
||||||
|
|
||||||
|
constructor(id: string, beat: number = 0, value: number = 0) {
|
||||||
|
this.id = id;
|
||||||
|
this.beat = beat;
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getId(): string {
|
||||||
|
return this.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getBeat(): number {
|
||||||
|
return this.beat;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getValue(): number {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setId(id: string): void {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setBeat(beat: number): void {
|
||||||
|
this.beat = beat;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setValue(value: number): void {
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public select(): void {
|
||||||
|
this.selected = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public deselect(): void {
|
||||||
|
this.selected = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public isSelected(): boolean {
|
||||||
|
return this.selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getRootType(): string {
|
||||||
|
return 'KGMidiControllerEvent';
|
||||||
|
}
|
||||||
|
|
||||||
|
public getCurrentType(): string {
|
||||||
|
return 'KGMidiControllerEvent';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { upgradeToV5 } from './upgradeToV5';
|
|||||||
import { upgradeToV6 } from './upgradeToV6';
|
import { upgradeToV6 } from './upgradeToV6';
|
||||||
import { upgradeToV7 } from './upgradeToV7';
|
import { upgradeToV7 } from './upgradeToV7';
|
||||||
import { upgradeToV8 } from './upgradeToV8';
|
import { upgradeToV8 } from './upgradeToV8';
|
||||||
|
import { upgradeToV9 } from './upgradeToV9';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upgrade the given project to the latest structure version, one version at a time.
|
* Upgrade the given project to the latest structure version, one version at a time.
|
||||||
@@ -58,6 +59,10 @@ export function upgradeProjectToLatest(project: KGProject): KGProject {
|
|||||||
workingProject = upgradeToV8(workingProject);
|
workingProject = upgradeToV8(workingProject);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 9: {
|
||||||
|
workingProject = upgradeToV9(workingProject);
|
||||||
|
break;
|
||||||
|
}
|
||||||
default: {
|
default: {
|
||||||
// If an upgrader is missing, throw to prevent loading incompatible structures
|
// If an upgrader is missing, throw to prevent loading incompatible structures
|
||||||
throw new Error(`No upgrader found for project structure version ${nextVersion}`);
|
throw new Error(`No upgrader found for project structure version ${nextVersion}`);
|
||||||
|
|||||||
@@ -57,7 +57,8 @@ describe('upgradeToV8', () => {
|
|||||||
|
|
||||||
const upgraded = upgradeProjectToLatest(project);
|
const upgraded = upgradeProjectToLatest(project);
|
||||||
|
|
||||||
expect(upgraded.getProjectStructureVersion()).toBe(8);
|
expect(upgraded.getProjectStructureVersion()).toBe(9);
|
||||||
expect((upgraded.getTracks()[0].getRegions()[0] as KGMidiRegion).getPitchBends()).toEqual([]);
|
expect((upgraded.getTracks()[0].getRegions()[0] as KGMidiRegion).getPitchBends()).toEqual([]);
|
||||||
|
expect((upgraded.getTracks()[0].getRegions()[0] as KGMidiRegion).getControllerEventsByType()).toHaveLength(128);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { KGProject } from '../KGProject';
|
||||||
|
import { KGMidiRegion } from '../region/KGMidiRegion';
|
||||||
|
|
||||||
|
export function upgradeToV9(project: KGProject): KGProject {
|
||||||
|
try {
|
||||||
|
for (const track of project.getTracks()) {
|
||||||
|
for (const region of track.getRegions()) {
|
||||||
|
if (region instanceof KGMidiRegion) {
|
||||||
|
const candidate = (region as unknown as { controllerEventsByType?: unknown }).controllerEventsByType;
|
||||||
|
if (!Array.isArray(candidate) || candidate.length !== 128) {
|
||||||
|
region.setControllerEventsByType([]);
|
||||||
|
} else {
|
||||||
|
region.setControllerEventsByType(candidate as never);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
project.setProjectStructureVersion(9);
|
||||||
|
}
|
||||||
|
|
||||||
|
return project;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { KGMidiRegion } from './KGMidiRegion';
|
|||||||
import { KGRegion } from './KGRegion';
|
import { KGRegion } from './KGRegion';
|
||||||
import { KGMidiNote } from '../midi/KGMidiNote';
|
import { KGMidiNote } from '../midi/KGMidiNote';
|
||||||
import { KGMidiPitchBend } from '../midi/KGMidiPitchBend';
|
import { KGMidiPitchBend } from '../midi/KGMidiPitchBend';
|
||||||
|
import { KGMidiControllerEvent } from '../midi/KGMidiControllerEvent';
|
||||||
import { createMockMidiNote } from '../../test/utils/mock-data';
|
import { createMockMidiNote } from '../../test/utils/mock-data';
|
||||||
|
|
||||||
describe('KGMidiRegion', () => {
|
describe('KGMidiRegion', () => {
|
||||||
@@ -249,6 +250,40 @@ describe('KGMidiRegion', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('controller event management', () => {
|
||||||
|
let controllerEvent1: KGMidiControllerEvent;
|
||||||
|
let controllerEvent2: KGMidiControllerEvent;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
controllerEvent1 = new KGMidiControllerEvent('cc-1', 0.5, 127);
|
||||||
|
controllerEvent2 = new KGMidiControllerEvent('cc-2', 1.5, 32);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds and returns controller events by controller number', () => {
|
||||||
|
region.addControllerEvent(11, controllerEvent1);
|
||||||
|
region.addControllerEvent(11, controllerEvent2);
|
||||||
|
|
||||||
|
expect(region.getControllerEvents(11)).toEqual([controllerEvent1, controllerEvent2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes controller events by id within a controller bucket', () => {
|
||||||
|
region.setControllerEvents(11, [controllerEvent1, controllerEvent2]);
|
||||||
|
region.removeControllerEvent(11, 'cc-1');
|
||||||
|
|
||||||
|
expect(region.getControllerEvents(11)).toEqual([controllerEvent2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flattens controller events with their controller numbers for UI use', () => {
|
||||||
|
region.addControllerEvent(2, controllerEvent1);
|
||||||
|
region.addControllerEvent(11, controllerEvent2);
|
||||||
|
|
||||||
|
expect(region.getAllControllerEventsFlattened()).toEqual([
|
||||||
|
{ controller: 2, event: controllerEvent1 },
|
||||||
|
{ controller: 11, event: controllerEvent2 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('inheritance from KGRegion', () => {
|
describe('inheritance from KGRegion', () => {
|
||||||
it('should inherit all base region properties', () => {
|
it('should inherit all base region properties', () => {
|
||||||
expect(region.getId()).toBe('test-region-1');
|
expect(region.getId()).toBe('test-region-1');
|
||||||
@@ -388,6 +423,7 @@ describe('KGMidiRegion', () => {
|
|||||||
it('preserves pitch bends through class-transformer serialization', () => {
|
it('preserves pitch bends through class-transformer serialization', () => {
|
||||||
region.addNote(createMockMidiNote({ id: 'note-1', pitch: 60, startBeat: 0, endBeat: 1 }));
|
region.addNote(createMockMidiNote({ id: 'note-1', pitch: 60, startBeat: 0, endBeat: 1 }));
|
||||||
region.addPitchBend(new KGMidiPitchBend('bend-1', 0.5, 12288));
|
region.addPitchBend(new KGMidiPitchBend('bend-1', 0.5, 12288));
|
||||||
|
region.addControllerEvent(11, new KGMidiControllerEvent('cc-1', 0.25, 96));
|
||||||
|
|
||||||
const plain = instanceToPlain(region);
|
const plain = instanceToPlain(region);
|
||||||
const restored = plainToInstance(KGMidiRegion, plain);
|
const restored = plainToInstance(KGMidiRegion, plain);
|
||||||
@@ -396,6 +432,9 @@ describe('KGMidiRegion', () => {
|
|||||||
expect(restored.getPitchBends()).toHaveLength(1);
|
expect(restored.getPitchBends()).toHaveLength(1);
|
||||||
expect(restored.getPitchBends()[0]).toBeInstanceOf(KGMidiPitchBend);
|
expect(restored.getPitchBends()[0]).toBeInstanceOf(KGMidiPitchBend);
|
||||||
expect(restored.getPitchBends()[0].getValue()).toBe(12288);
|
expect(restored.getPitchBends()[0].getValue()).toBe(12288);
|
||||||
|
expect(restored.getControllerEvents(11)).toHaveLength(1);
|
||||||
|
expect(restored.getControllerEvents(11)[0]).toBeInstanceOf(KGMidiControllerEvent);
|
||||||
|
expect(restored.getControllerEvents(11)[0].getValue()).toBe(96);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('defaults missing legacy pitch bend data to an empty array', () => {
|
it('defaults missing legacy pitch bend data to an empty array', () => {
|
||||||
@@ -411,6 +450,7 @@ describe('KGMidiRegion', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(restored.getPitchBends()).toEqual([]);
|
expect(restored.getPitchBends()).toEqual([]);
|
||||||
|
expect(restored.getControllerEventsByType()).toHaveLength(128);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,22 @@ import { Expose, Type } from 'class-transformer';
|
|||||||
import { KGRegion } from './KGRegion';
|
import { KGRegion } from './KGRegion';
|
||||||
import { KGMidiNote } from '../midi/KGMidiNote';
|
import { KGMidiNote } from '../midi/KGMidiNote';
|
||||||
import { KGMidiPitchBend } from '../midi/KGMidiPitchBend';
|
import { KGMidiPitchBend } from '../midi/KGMidiPitchBend';
|
||||||
|
import { KGMidiControllerEvent } from '../midi/KGMidiControllerEvent';
|
||||||
|
|
||||||
|
export interface FlattenedControllerEvent {
|
||||||
|
controller: number;
|
||||||
|
event: KGMidiControllerEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIDI_CONTROLLER_COUNT = 128;
|
||||||
|
|
||||||
|
function createEmptyControllerBuckets(): KGMidiControllerEvent[][] {
|
||||||
|
return Array.from({ length: MIDI_CONTROLLER_COUNT }, () => []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeController(controller: number): number {
|
||||||
|
return Math.max(0, Math.min(MIDI_CONTROLLER_COUNT - 1, Math.floor(controller)));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* KGMidiRegion - Class representing a MIDI region in the DAW
|
* KGMidiRegion - Class representing a MIDI region in the DAW
|
||||||
@@ -19,6 +35,10 @@ export class KGMidiRegion extends KGRegion {
|
|||||||
@Type(() => KGMidiPitchBend)
|
@Type(() => KGMidiPitchBend)
|
||||||
protected pitchBends: KGMidiPitchBend[] = [];
|
protected pitchBends: KGMidiPitchBend[] = [];
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
@Type(() => KGMidiControllerEvent)
|
||||||
|
protected controllerEventsByType: KGMidiControllerEvent[][] = createEmptyControllerBuckets();
|
||||||
|
|
||||||
constructor(id: string, trackId: string, trackIndex: number, name: string, startFromBeat: number = 0, length: number = 0) {
|
constructor(id: string, trackId: string, trackIndex: number, name: string, startFromBeat: number = 0, length: number = 0) {
|
||||||
super(id, trackId, trackIndex, name, startFromBeat, length);
|
super(id, trackId, trackIndex, name, startFromBeat, length);
|
||||||
this.__type = 'KGMidiRegion';
|
this.__type = 'KGMidiRegion';
|
||||||
@@ -48,6 +68,54 @@ export class KGMidiRegion extends KGRegion {
|
|||||||
this.pitchBends = pitchBends ?? [];
|
this.pitchBends = pitchBends ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getControllerEventsByType(): KGMidiControllerEvent[][] {
|
||||||
|
if (!Array.isArray(this.controllerEventsByType) || this.controllerEventsByType.length !== MIDI_CONTROLLER_COUNT) {
|
||||||
|
this.setControllerEventsByType(this.controllerEventsByType ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.controllerEventsByType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setControllerEventsByType(controllerEventsByType: KGMidiControllerEvent[][]): void {
|
||||||
|
const normalizedBuckets = createEmptyControllerBuckets();
|
||||||
|
if (Array.isArray(controllerEventsByType)) {
|
||||||
|
controllerEventsByType.forEach((bucket, controller) => {
|
||||||
|
if (controller < 0 || controller >= MIDI_CONTROLLER_COUNT || !Array.isArray(bucket)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedBuckets[controller] = bucket;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.controllerEventsByType = normalizedBuckets;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getControllerEvents(controller: number): KGMidiControllerEvent[] {
|
||||||
|
return this.getControllerEventsByType()[normalizeController(controller)];
|
||||||
|
}
|
||||||
|
|
||||||
|
public setControllerEvents(controller: number, events: KGMidiControllerEvent[]): void {
|
||||||
|
this.getControllerEventsByType()[normalizeController(controller)] = events ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public addControllerEvent(controller: number, event: KGMidiControllerEvent): void {
|
||||||
|
this.getControllerEvents(controller).push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
public removeControllerEvent(controller: number, controllerEventId: string): void {
|
||||||
|
this.setControllerEvents(
|
||||||
|
controller,
|
||||||
|
this.getControllerEvents(controller).filter(event => event.getId() !== controllerEventId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getAllControllerEventsFlattened(): FlattenedControllerEvent[] {
|
||||||
|
return this.getControllerEventsByType().flatMap((events, controller) => (
|
||||||
|
events.map(event => ({ controller, event }))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
// Add a single note
|
// Add a single note
|
||||||
public addNote(note: KGMidiNote): void {
|
public addNote(note: KGMidiNote): void {
|
||||||
this.getNotes().push(note);
|
this.getNotes().push(note);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { beatsToTimeString } from '../util/timeUtil';
|
|||||||
import { KGAudioInterface } from '../core/audio-interface/KGAudioInterface';
|
import { KGAudioInterface } from '../core/audio-interface/KGAudioInterface';
|
||||||
import { KGPianoRollState } from '../core/state/KGPianoRollState';
|
import { KGPianoRollState } from '../core/state/KGPianoRollState';
|
||||||
import { KGMidiNote } from '../core/midi/KGMidiNote';
|
import { KGMidiNote } from '../core/midi/KGMidiNote';
|
||||||
|
import { KGMidiControllerEvent } from '../core/midi/KGMidiControllerEvent';
|
||||||
import { KGRegion } from '../core/region/KGRegion';
|
import { KGRegion } from '../core/region/KGRegion';
|
||||||
import { AddTrackCommand, AddAudioTrackCommand, RemoveTrackCommand, ReorderTracksCommand, UpdateTrackCommand, type TrackUpdateProperties, PasteRegionsCommand, PasteNotesCommand, ChangeProjectPropertyCommand, ImportAudioCommand } from '../core/commands';
|
import { AddTrackCommand, AddAudioTrackCommand, RemoveTrackCommand, ReorderTracksCommand, UpdateTrackCommand, type TrackUpdateProperties, PasteRegionsCommand, PasteNotesCommand, ChangeProjectPropertyCommand, ImportAudioCommand } from '../core/commands';
|
||||||
import { KGAudioTrack } from '../core/track/KGAudioTrack';
|
import { KGAudioTrack } from '../core/track/KGAudioTrack';
|
||||||
@@ -21,7 +22,7 @@ import * as Tone from 'tone';
|
|||||||
import { KGMidiInput } from '../core/midi-input/KGMidiInput';
|
import { KGMidiInput } from '../core/midi-input/KGMidiInput';
|
||||||
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
||||||
import { KGMidiPitchBend } from '../core/midi/KGMidiPitchBend';
|
import { KGMidiPitchBend } from '../core/midi/KGMidiPitchBend';
|
||||||
import { CreateMidiEventsCommand, type NoteCreationData, type PitchBendCreationData } from '../core/commands/note/CreateMidiEventsCommand';
|
import { CreateMidiEventsCommand, type NoteCreationData, type PitchBendCreationData, type ControllerEventCreationData } from '../core/commands/note/CreateMidiEventsCommand';
|
||||||
import { MIDI_PITCH_BEND_CENTER } from '../util/midiUtil';
|
import { MIDI_PITCH_BEND_CENTER } from '../util/midiUtil';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -75,6 +76,7 @@ interface ProjectState {
|
|||||||
// Selection state for UI reactivity
|
// Selection state for UI reactivity
|
||||||
selectedNoteIds: string[];
|
selectedNoteIds: string[];
|
||||||
selectedPitchBendIds: string[];
|
selectedPitchBendIds: string[];
|
||||||
|
selectedControllerEventIds: string[];
|
||||||
selectedRegionIds: string[];
|
selectedRegionIds: string[];
|
||||||
selectedTrackId: string | null;
|
selectedTrackId: string | null;
|
||||||
|
|
||||||
@@ -109,6 +111,7 @@ interface ProjectState {
|
|||||||
recordingTargetRegionId: string | null;
|
recordingTargetRegionId: string | null;
|
||||||
recordingNotes: Array<{ pitch: number; startBeat: number; endBeat: number; velocity: number }>;
|
recordingNotes: Array<{ pitch: number; startBeat: number; endBeat: number; velocity: number }>;
|
||||||
recordingPitchBends: Array<{ beat: number; value: number }>;
|
recordingPitchBends: Array<{ beat: number; value: number }>;
|
||||||
|
recordingControllerEventsByType: Array<Array<{ beat: number; value: number }>>;
|
||||||
recordingOriginalPlayhead: number;
|
recordingOriginalPlayhead: number;
|
||||||
|
|
||||||
// Undo/redo state
|
// Undo/redo state
|
||||||
@@ -212,6 +215,11 @@ interface ProjectState {
|
|||||||
let _recordingActiveNotes: Map<number, { startBeat: number; velocity: number }> = new Map(); // pitch → note-on data
|
let _recordingActiveNotes: Map<number, { startBeat: number; velocity: number }> = new Map(); // pitch → note-on data
|
||||||
let _recordingRegionStartBeat: number = 0;
|
let _recordingRegionStartBeat: number = 0;
|
||||||
let _lastRecordedPitchBendValue: number | null = null;
|
let _lastRecordedPitchBendValue: number | null = null;
|
||||||
|
let _lastRecordedControllerValues: Map<number, number> = new Map();
|
||||||
|
|
||||||
|
function createEmptyRecordedControllerBuckets(): Array<Array<{ beat: number; value: number }>> {
|
||||||
|
return Array.from({ length: 128 }, () => []);
|
||||||
|
}
|
||||||
|
|
||||||
function getRecordingLoopEndBeatRelative(): number | null {
|
function getRecordingLoopEndBeatRelative(): number | null {
|
||||||
const project = KGCore.instance().getCurrentProject();
|
const project = KGCore.instance().getCurrentProject();
|
||||||
@@ -278,11 +286,19 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
const pitchBendIds = selectedItems
|
const pitchBendIds = selectedItems
|
||||||
.filter(item => item instanceof KGMidiPitchBend)
|
.filter(item => item instanceof KGMidiPitchBend)
|
||||||
.map(item => item.getId());
|
.map(item => item.getId());
|
||||||
|
const controllerEventIds = selectedItems
|
||||||
|
.filter(item => item instanceof KGMidiControllerEvent)
|
||||||
|
.map(item => item.getId());
|
||||||
const regionIds = selectedItems
|
const regionIds = selectedItems
|
||||||
.filter(item => item instanceof KGRegion)
|
.filter(item => item instanceof KGRegion)
|
||||||
.map(item => item.getId());
|
.map(item => item.getId());
|
||||||
|
|
||||||
set({ selectedNoteIds: noteIds, selectedPitchBendIds: pitchBendIds, selectedRegionIds: regionIds });
|
set({
|
||||||
|
selectedNoteIds: noteIds,
|
||||||
|
selectedPitchBendIds: pitchBendIds,
|
||||||
|
selectedControllerEventIds: controllerEventIds,
|
||||||
|
selectedRegionIds: regionIds
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Register the sync callback with KGCore
|
// Register the sync callback with KGCore
|
||||||
@@ -349,6 +365,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
// Initial selection state
|
// Initial selection state
|
||||||
selectedNoteIds: [],
|
selectedNoteIds: [],
|
||||||
selectedPitchBendIds: [],
|
selectedPitchBendIds: [],
|
||||||
|
selectedControllerEventIds: [],
|
||||||
selectedRegionIds: [],
|
selectedRegionIds: [],
|
||||||
selectedTrackId: initialSelectedTrackId,
|
selectedTrackId: initialSelectedTrackId,
|
||||||
|
|
||||||
@@ -388,6 +405,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
recordingTargetRegionId: null,
|
recordingTargetRegionId: null,
|
||||||
recordingNotes: [],
|
recordingNotes: [],
|
||||||
recordingPitchBends: [],
|
recordingPitchBends: [],
|
||||||
|
recordingControllerEventsByType: createEmptyRecordedControllerBuckets(),
|
||||||
recordingOriginalPlayhead: 0,
|
recordingOriginalPlayhead: 0,
|
||||||
|
|
||||||
// Initial cross-component scroll request state
|
// Initial cross-component scroll request state
|
||||||
@@ -904,11 +922,13 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
_recordingRegionStartBeat = targetRegion.getStartFromBeat();
|
_recordingRegionStartBeat = targetRegion.getStartFromBeat();
|
||||||
_recordingActiveNotes = new Map();
|
_recordingActiveNotes = new Map();
|
||||||
_lastRecordedPitchBendValue = null;
|
_lastRecordedPitchBendValue = null;
|
||||||
|
_lastRecordedControllerValues = new Map();
|
||||||
|
|
||||||
set({
|
set({
|
||||||
isRecording: true,
|
isRecording: true,
|
||||||
recordingNotes: [],
|
recordingNotes: [],
|
||||||
recordingPitchBends: [],
|
recordingPitchBends: [],
|
||||||
|
recordingControllerEventsByType: createEmptyRecordedControllerBuckets(),
|
||||||
recordingTargetRegionId: activeRegionId,
|
recordingTargetRegionId: activeRegionId,
|
||||||
recordingOriginalPlayhead: playheadPosition,
|
recordingOriginalPlayhead: playheadPosition,
|
||||||
});
|
});
|
||||||
@@ -952,6 +972,19 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
set(state => ({
|
set(state => ({
|
||||||
recordingPitchBends: [...state.recordingPitchBends, { beat, value }],
|
recordingPitchBends: [...state.recordingPitchBends, { beat, value }],
|
||||||
}));
|
}));
|
||||||
|
},
|
||||||
|
(controller: number, value: number) => {
|
||||||
|
if (_lastRecordedControllerValues.get(controller) === value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastRecordedControllerValues.set(controller, value);
|
||||||
|
const beat = buildCorrectedBeat();
|
||||||
|
set(state => {
|
||||||
|
const nextRecordingControllerEventsByType = state.recordingControllerEventsByType.map(events => [...events]);
|
||||||
|
nextRecordingControllerEventsByType[controller].push({ beat, value });
|
||||||
|
return { recordingControllerEventsByType: nextRecordingControllerEventsByType };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -975,11 +1008,21 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
stopRecording: async () => {
|
stopRecording: async () => {
|
||||||
const { recordingNotes, recordingPitchBends, recordingTargetRegionId, recordingOriginalPlayhead, stopPlaying, setPlayheadPosition, refreshProjectState } = get();
|
const {
|
||||||
|
recordingNotes,
|
||||||
|
recordingPitchBends,
|
||||||
|
recordingControllerEventsByType,
|
||||||
|
recordingTargetRegionId,
|
||||||
|
recordingOriginalPlayhead,
|
||||||
|
stopPlaying,
|
||||||
|
setPlayheadPosition,
|
||||||
|
refreshProjectState
|
||||||
|
} = get();
|
||||||
|
|
||||||
// Finalize any held keys
|
// Finalize any held keys
|
||||||
const finalNotes = [...recordingNotes];
|
const finalNotes = [...recordingNotes];
|
||||||
const finalPitchBends = [...recordingPitchBends];
|
const finalPitchBends = [...recordingPitchBends];
|
||||||
|
const finalControllerEventsByType = recordingControllerEventsByType.map(events => [...events]);
|
||||||
const bpm = get().bpm;
|
const bpm = get().bpm;
|
||||||
const playbackDelaySec = (ConfigManager.instance().get('audio.playback_delay') as number) ?? 0.2;
|
const playbackDelaySec = (ConfigManager.instance().get('audio.playback_delay') as number) ?? 0.2;
|
||||||
const recordingOffsetSec = (ConfigManager.instance().get('audio.recording_offset') as number) ?? 0;
|
const recordingOffsetSec = (ConfigManager.instance().get('audio.recording_offset') as number) ?? 0;
|
||||||
@@ -1004,9 +1047,18 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
_lastRecordedPitchBendValue = MIDI_PITCH_BEND_CENTER;
|
_lastRecordedPitchBendValue = MIDI_PITCH_BEND_CENTER;
|
||||||
}
|
}
|
||||||
|
|
||||||
KGMidiInput.instance().setRecordingCallbacks(null, null, null);
|
if (_lastRecordedControllerValues.get(64) === 127) {
|
||||||
|
finalControllerEventsByType[64].push({
|
||||||
|
beat: endBeatForHeld,
|
||||||
|
value: 0,
|
||||||
|
});
|
||||||
|
_lastRecordedControllerValues.set(64, 0);
|
||||||
|
}
|
||||||
|
|
||||||
if ((finalNotes.length > 0 || finalPitchBends.length > 0) && recordingTargetRegionId) {
|
KGMidiInput.instance().setRecordingCallbacks(null, null, null, null);
|
||||||
|
|
||||||
|
const hasControllerEvents = finalControllerEventsByType.some(events => events.length > 0);
|
||||||
|
if ((finalNotes.length > 0 || finalPitchBends.length > 0 || hasControllerEvents) && recordingTargetRegionId) {
|
||||||
const noteData: NoteCreationData[] = finalNotes.map(n => ({
|
const noteData: NoteCreationData[] = finalNotes.map(n => ({
|
||||||
regionId: recordingTargetRegionId,
|
regionId: recordingTargetRegionId,
|
||||||
startBeat: n.startBeat,
|
startBeat: n.startBeat,
|
||||||
@@ -1019,7 +1071,15 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
beat: event.beat,
|
beat: event.beat,
|
||||||
value: event.value,
|
value: event.value,
|
||||||
}));
|
}));
|
||||||
const command = new CreateMidiEventsCommand(noteData, pitchBendData);
|
const controllerEventData: ControllerEventCreationData[] = finalControllerEventsByType.flatMap((events, controller) => (
|
||||||
|
events.map(event => ({
|
||||||
|
regionId: recordingTargetRegionId,
|
||||||
|
controller,
|
||||||
|
beat: event.beat,
|
||||||
|
value: event.value,
|
||||||
|
}))
|
||||||
|
));
|
||||||
|
const command = new CreateMidiEventsCommand(noteData, pitchBendData, controllerEventData);
|
||||||
KGCore.instance().executeCommand(command);
|
KGCore.instance().executeCommand(command);
|
||||||
refreshProjectState();
|
refreshProjectState();
|
||||||
}
|
}
|
||||||
@@ -1031,9 +1091,11 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
isPreparingPlayback: false,
|
isPreparingPlayback: false,
|
||||||
recordingNotes: [],
|
recordingNotes: [],
|
||||||
recordingPitchBends: [],
|
recordingPitchBends: [],
|
||||||
|
recordingControllerEventsByType: createEmptyRecordedControllerBuckets(),
|
||||||
recordingTargetRegionId: null
|
recordingTargetRegionId: null
|
||||||
});
|
});
|
||||||
_lastRecordedPitchBendValue = null;
|
_lastRecordedPitchBendValue = null;
|
||||||
|
_lastRecordedControllerValues = new Map();
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleLoop: () => {
|
toggleLoop: () => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { KGMidiNote } from '../../core/midi/KGMidiNote';
|
import { KGMidiNote } from '../../core/midi/KGMidiNote';
|
||||||
|
import { KGMidiControllerEvent } from '../../core/midi/KGMidiControllerEvent';
|
||||||
import { KGMidiPitchBend } from '../../core/midi/KGMidiPitchBend';
|
import { KGMidiPitchBend } from '../../core/midi/KGMidiPitchBend';
|
||||||
import { KGProject } from '../../core/KGProject';
|
import { KGProject } from '../../core/KGProject';
|
||||||
import { KGMidiTrack } from '../../core/track/KGMidiTrack';
|
import { KGMidiTrack } from '../../core/track/KGMidiTrack';
|
||||||
@@ -43,6 +44,7 @@ export const createMockMidiRegion = (overrides: Partial<{
|
|||||||
length: number
|
length: number
|
||||||
notes: KGMidiNote[]
|
notes: KGMidiNote[]
|
||||||
pitchBends: KGMidiPitchBend[]
|
pitchBends: KGMidiPitchBend[]
|
||||||
|
controllerEventsByType: KGMidiControllerEvent[][]
|
||||||
}> = {}): KGMidiRegion => {
|
}> = {}): KGMidiRegion => {
|
||||||
const defaults = {
|
const defaults = {
|
||||||
id: 'test-region-1',
|
id: 'test-region-1',
|
||||||
@@ -70,6 +72,9 @@ export const createMockMidiRegion = (overrides: Partial<{
|
|||||||
if (overrides.pitchBends) {
|
if (overrides.pitchBends) {
|
||||||
overrides.pitchBends.forEach(pitchBend => region.addPitchBend(pitchBend));
|
overrides.pitchBends.forEach(pitchBend => region.addPitchBend(pitchBend));
|
||||||
}
|
}
|
||||||
|
if (overrides.controllerEventsByType) {
|
||||||
|
region.setControllerEventsByType(overrides.controllerEventsByType);
|
||||||
|
}
|
||||||
|
|
||||||
return region;
|
return region;
|
||||||
};
|
};
|
||||||
@@ -89,6 +94,21 @@ export const createMockMidiPitchBend = (overrides: Partial<{
|
|||||||
return new KGMidiPitchBend(defaults.id, defaults.beat, defaults.value);
|
return new KGMidiPitchBend(defaults.id, defaults.beat, defaults.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const createMockMidiControllerEvent = (overrides: Partial<{
|
||||||
|
id: string
|
||||||
|
beat: number
|
||||||
|
value: number
|
||||||
|
}> = {}): KGMidiControllerEvent => {
|
||||||
|
const defaults = {
|
||||||
|
id: 'test-controller-1',
|
||||||
|
beat: 0,
|
||||||
|
value: 127,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
|
||||||
|
return new KGMidiControllerEvent(defaults.id, defaults.beat, defaults.value);
|
||||||
|
};
|
||||||
|
|
||||||
export const createMockMidiTrack = (overrides: Partial<{
|
export const createMockMidiTrack = (overrides: Partial<{
|
||||||
name: string
|
name: string
|
||||||
id: number
|
id: number
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
collectRegionMidiAutomationPoints,
|
collectRegionMidiAutomationPoints,
|
||||||
normalizeMidiAutomationPoints,
|
normalizeMidiAutomationPoints,
|
||||||
resolveMidiAutomationValueAtBeat,
|
resolveMidiAutomationValueAtBeat,
|
||||||
|
resolveSustainExtendedEndBeat,
|
||||||
type MidiAutomationPoint,
|
type MidiAutomationPoint,
|
||||||
} from './midiAutomationUtil';
|
} from './midiAutomationUtil';
|
||||||
|
|
||||||
@@ -159,4 +160,33 @@ describe('midiAutomationUtil', () => {
|
|||||||
expect(baked.some(point => point.beat === 5 && point.value === 6144)).toBe(true);
|
expect(baked.some(point => point.beat === 5 && point.value === 6144)).toBe(true);
|
||||||
expect(baked.some(point => point.beat === 6 && point.value === 8192)).toBe(true);
|
expect(baked.some(point => point.beat === 6 && point.value === 8192)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses step interpolation for switch-style automation', () => {
|
||||||
|
const points = [
|
||||||
|
{ beat: 1, value: 127 },
|
||||||
|
{ beat: 3, value: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(resolveMidiAutomationValueAtBeat(points, 2, 0, 'step')).toBe(127);
|
||||||
|
expect(bakeMidiAutomationPointsInWindow(points, 0, 4, {
|
||||||
|
...defaultOptions,
|
||||||
|
defaultValue: 0,
|
||||||
|
interpolationMode: 'step',
|
||||||
|
quantizeValue: (value) => value,
|
||||||
|
})).toEqual([
|
||||||
|
{ beat: 0, value: 0 },
|
||||||
|
{ beat: 1, value: 127 },
|
||||||
|
{ beat: 3, value: 0 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extends note ends until the next sustain release', () => {
|
||||||
|
const points = [
|
||||||
|
{ beat: 1, value: 127 },
|
||||||
|
{ beat: 4, value: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(resolveSustainExtendedEndBeat(points, 2, 0)).toBe(4);
|
||||||
|
expect(resolveSustainExtendedEndBeat(points, 5, 0)).toBe(5);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export interface MidiAutomationBakeOptions {
|
|||||||
maxIntervalMs: number;
|
maxIntervalMs: number;
|
||||||
bpm: number;
|
bpm: number;
|
||||||
defaultValue: number;
|
defaultValue: number;
|
||||||
|
interpolationMode?: 'linear' | 'step';
|
||||||
|
quantizeValue?: (value: number) => number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BEAT_EPSILON = 1e-9;
|
const BEAT_EPSILON = 1e-9;
|
||||||
@@ -22,6 +24,10 @@ function quantizeBakedValue(value: number): number {
|
|||||||
return clampMidiPitchBendValue(value);
|
return clampMidiPitchBendValue(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function quantizeValue(value: number, options: MidiAutomationBakeOptions): number {
|
||||||
|
return options.quantizeValue ? options.quantizeValue(value) : quantizeBakedValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
function appendPoint(points: BakedMidiAutomationPoint[], nextPoint: BakedMidiAutomationPoint): void {
|
function appendPoint(points: BakedMidiAutomationPoint[], nextPoint: BakedMidiAutomationPoint): void {
|
||||||
const lastPoint = points[points.length - 1];
|
const lastPoint = points[points.length - 1];
|
||||||
if (!lastPoint) {
|
if (!lastPoint) {
|
||||||
@@ -91,7 +97,8 @@ export function collectRegionMidiAutomationPoints(
|
|||||||
export function resolveMidiAutomationValueAtBeat(
|
export function resolveMidiAutomationValueAtBeat(
|
||||||
points: MidiAutomationPoint[],
|
points: MidiAutomationPoint[],
|
||||||
beat: number,
|
beat: number,
|
||||||
defaultValue: number
|
defaultValue: number,
|
||||||
|
interpolationMode: 'linear' | 'step' = 'linear'
|
||||||
): number {
|
): number {
|
||||||
const normalizedPoints = normalizeMidiAutomationPoints(points);
|
const normalizedPoints = normalizeMidiAutomationPoints(points);
|
||||||
if (normalizedPoints.length === 0) {
|
if (normalizedPoints.length === 0) {
|
||||||
@@ -105,6 +112,10 @@ export function resolveMidiAutomationValueAtBeat(
|
|||||||
return defaultValue;
|
return defaultValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (interpolationMode === 'step') {
|
||||||
|
return previousPoint.value;
|
||||||
|
}
|
||||||
|
|
||||||
return interpolateBetweenPoints(previousPoint, point, beat);
|
return interpolateBetweenPoints(previousPoint, point, beat);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,9 +132,13 @@ export function bakeMidiAutomationPointsInWindow(
|
|||||||
options: MidiAutomationBakeOptions
|
options: MidiAutomationBakeOptions
|
||||||
): BakedMidiAutomationPoint[] {
|
): BakedMidiAutomationPoint[] {
|
||||||
const normalizedPoints = normalizeMidiAutomationPoints(points);
|
const normalizedPoints = normalizeMidiAutomationPoints(points);
|
||||||
|
const interpolationMode = options.interpolationMode ?? 'linear';
|
||||||
const anchorPoint = {
|
const anchorPoint = {
|
||||||
beat: windowStartBeat,
|
beat: windowStartBeat,
|
||||||
value: quantizeBakedValue(resolveMidiAutomationValueAtBeat(normalizedPoints, windowStartBeat, options.defaultValue)),
|
value: quantizeValue(
|
||||||
|
resolveMidiAutomationValueAtBeat(normalizedPoints, windowStartBeat, options.defaultValue, interpolationMode),
|
||||||
|
options
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (windowEndBeat <= windowStartBeat) {
|
if (windowEndBeat <= windowStartBeat) {
|
||||||
@@ -137,7 +152,22 @@ export function bakeMidiAutomationPointsInWindow(
|
|||||||
|
|
||||||
const firstPoint = normalizedPoints[0];
|
const firstPoint = normalizedPoints[0];
|
||||||
if (windowStartBeat < firstPoint.beat && firstPoint.beat < windowEndBeat) {
|
if (windowStartBeat < firstPoint.beat && firstPoint.beat < windowEndBeat) {
|
||||||
appendPoint(bakedPoints, { beat: firstPoint.beat, value: quantizeBakedValue(firstPoint.value) });
|
appendPoint(bakedPoints, { beat: firstPoint.beat, value: quantizeValue(firstPoint.value, options) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interpolationMode === 'step') {
|
||||||
|
normalizedPoints.forEach(point => {
|
||||||
|
if (point.beat <= windowStartBeat + BEAT_EPSILON || point.beat >= windowEndBeat - BEAT_EPSILON) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
appendPoint(bakedPoints, {
|
||||||
|
beat: point.beat,
|
||||||
|
value: quantizeValue(point.value, options),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return bakedPoints;
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxIntervalBeats = getMaxIntervalBeats(options);
|
const maxIntervalBeats = getMaxIntervalBeats(options);
|
||||||
@@ -184,10 +214,29 @@ export function bakeMidiAutomationPointsInWindow(
|
|||||||
|
|
||||||
appendPoint(bakedPoints, {
|
appendPoint(bakedPoints, {
|
||||||
beat,
|
beat,
|
||||||
value: quantizeBakedValue(interpolateBetweenPoints(startPoint, endPoint, beat)),
|
value: quantizeValue(interpolateBetweenPoints(startPoint, endPoint, beat), options),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return bakedPoints;
|
return bakedPoints;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveSustainExtendedEndBeat(
|
||||||
|
points: MidiAutomationPoint[],
|
||||||
|
noteEndBeat: number,
|
||||||
|
defaultValue: number
|
||||||
|
): number {
|
||||||
|
const normalizedPoints = normalizeMidiAutomationPoints(points);
|
||||||
|
if (normalizedPoints.length === 0) {
|
||||||
|
return noteEndBeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sustainValueAtEnd = resolveMidiAutomationValueAtBeat(normalizedPoints, noteEndBeat, defaultValue, 'step');
|
||||||
|
if (sustainValueAtEnd < 64) {
|
||||||
|
return noteEndBeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
const releasePoint = normalizedPoints.find(point => point.beat > noteEndBeat && point.value < 64);
|
||||||
|
return releasePoint?.beat ?? noteEndBeat;
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export const MIDI_PITCH_BEND_CENTER = 8192;
|
|||||||
export const MIDI_PITCH_BEND_MAX = 16383;
|
export const MIDI_PITCH_BEND_MAX = 16383;
|
||||||
export const MIDI_PITCH_BEND_MIN_SIGNED = -8192;
|
export const MIDI_PITCH_BEND_MIN_SIGNED = -8192;
|
||||||
export const MIDI_PITCH_BEND_MAX_SIGNED = 8191;
|
export const MIDI_PITCH_BEND_MAX_SIGNED = 8191;
|
||||||
|
export const MIDI_CONTROLLER_MIN = 0;
|
||||||
|
export const MIDI_CONTROLLER_MAX = 127;
|
||||||
|
|
||||||
export const pitchToNoteName = (pitch: number) => {
|
export const pitchToNoteName = (pitch: number) => {
|
||||||
const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||||
@@ -45,6 +47,10 @@ export const clampMidiPitchBendValue = (value: number): number => (
|
|||||||
Math.max(MIDI_PITCH_BEND_MIN, Math.min(MIDI_PITCH_BEND_MAX, Math.round(value)))
|
Math.max(MIDI_PITCH_BEND_MIN, Math.min(MIDI_PITCH_BEND_MAX, Math.round(value)))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const clampMidiControllerValue = (value: number): number => (
|
||||||
|
Math.max(MIDI_CONTROLLER_MIN, Math.min(MIDI_CONTROLLER_MAX, Math.round(value)))
|
||||||
|
);
|
||||||
|
|
||||||
export const midiPitchBendToSignedValue = (value: number): number => (
|
export const midiPitchBendToSignedValue = (value: number): number => (
|
||||||
clampMidiPitchBendValue(value) - MIDI_PITCH_BEND_CENTER
|
clampMidiPitchBendValue(value) - MIDI_PITCH_BEND_CENTER
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user