feat: implemented inline edit for the list event panel
This commit is contained in:
@@ -187,3 +187,17 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
max-width: 0;
|
max-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.list-event-cell-input {
|
||||||
|
width: calc(100% + 8px);
|
||||||
|
height: 16px;
|
||||||
|
margin: 0 -4px;
|
||||||
|
padding: 0 4px;
|
||||||
|
border: 1px solid #6d94ca;
|
||||||
|
border-radius: 3px;
|
||||||
|
background-color: #1f1f1f;
|
||||||
|
color: #f3f6fb;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 16px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import './ListEventPanel.css';
|
import './ListEventPanel.css';
|
||||||
import { FaPlus } from 'react-icons/fa';
|
import { FaPlus } from 'react-icons/fa';
|
||||||
import KGDropdown from './common/KGDropdown';
|
import KGDropdown from './common/KGDropdown';
|
||||||
@@ -7,9 +7,22 @@ import { KGCore } from '../core/KGCore';
|
|||||||
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
||||||
import { KGMidiNote } from '../core/midi/KGMidiNote';
|
import { KGMidiNote } from '../core/midi/KGMidiNote';
|
||||||
import { KGPianoRollState } from '../core/state/KGPianoRollState';
|
import { KGPianoRollState } from '../core/state/KGPianoRollState';
|
||||||
import { beatsToBar, pitchToNoteNameString } from '../util/midiUtil';
|
import {
|
||||||
|
formatMidiEventLength,
|
||||||
|
formatMidiEventPosition,
|
||||||
|
MIDI_EVENT_TICKS_PER_BEAT,
|
||||||
|
noteNameToPitch,
|
||||||
|
parseMidiEventLengthDelta,
|
||||||
|
parseMidiEventLength,
|
||||||
|
parseMidiEventPositionDelta,
|
||||||
|
parseMidiEventPosition,
|
||||||
|
pitchToNoteNameString
|
||||||
|
} from '../util/midiUtil';
|
||||||
import { isModifierKeyPressed } from '../util/osUtil';
|
import { isModifierKeyPressed } from '../util/osUtil';
|
||||||
import { PIANO_ROLL_CONSTANTS } from '../constants';
|
import { PIANO_ROLL_CONSTANTS } from '../constants';
|
||||||
|
import { CreateNoteCommand } from '../core/commands';
|
||||||
|
import { UpdateNotePropertiesCommand } from '../core/commands/note/UpdateNotePropertiesCommand';
|
||||||
|
import { showAlert } from '../util/dialogUtil';
|
||||||
|
|
||||||
interface ListEventPanelProps {
|
interface ListEventPanelProps {
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
@@ -22,45 +35,54 @@ interface NoteRowData {
|
|||||||
durationBeats: number;
|
durationBeats: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type EditableColumn = 'position' | 'num' | 'val' | 'length';
|
||||||
|
|
||||||
|
interface EditingCell {
|
||||||
|
noteId: string;
|
||||||
|
column: EditableColumn;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
const EVENT_TYPE_OPTIONS = [{ label: 'Notes', value: 'notes' }];
|
const EVENT_TYPE_OPTIONS = [{ label: 'Notes', value: 'notes' }];
|
||||||
const EVENT_POSITION_TICKS_PER_BEAT = 480;
|
|
||||||
|
|
||||||
const formatEventPosition = (
|
const parseVelocityInput = (raw: string): { velocity: number } | { error: string } => {
|
||||||
beats: number,
|
const trimmed = raw.trim();
|
||||||
timeSignature: { numerator: number; denominator: number }
|
if (!/^\d+$/.test(trimmed)) {
|
||||||
): string => {
|
return { error: 'Velocity must be an integer between 0 and 127.' };
|
||||||
const { bar, beatInBar } = beatsToBar(beats, timeSignature);
|
|
||||||
const beatInteger = Math.floor(beatInBar);
|
|
||||||
let tick = Math.round((beatInBar - beatInteger) * EVENT_POSITION_TICKS_PER_BEAT);
|
|
||||||
let normalizedBeat = beatInteger;
|
|
||||||
let normalizedBar = bar;
|
|
||||||
|
|
||||||
if (tick >= EVENT_POSITION_TICKS_PER_BEAT) {
|
|
||||||
tick = 0;
|
|
||||||
normalizedBeat += 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedBeat >= timeSignature.numerator) {
|
const velocity = parseInt(trimmed, 10);
|
||||||
normalizedBeat = 0;
|
if (velocity < 0 || velocity > 127) {
|
||||||
normalizedBar += 1;
|
return { error: 'Velocity must be between 0 and 127.' };
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${normalizedBar + 1} ${normalizedBeat + 1} ${tick}`;
|
return { velocity };
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatEventLength = (
|
const parseVelocityDeltaInput = (raw: string): { delta: number } | { error: string } => {
|
||||||
beats: number
|
const trimmed = raw.trim();
|
||||||
): string => {
|
if (!/^[+-]\d+$/.test(trimmed)) {
|
||||||
const fullBeats = Math.floor(beats);
|
return { error: 'Use velocity delta like +10 or -5.' };
|
||||||
let tick = Math.round((beats - fullBeats) * EVENT_POSITION_TICKS_PER_BEAT);
|
|
||||||
let normalizedBeats = fullBeats;
|
|
||||||
|
|
||||||
if (tick >= EVENT_POSITION_TICKS_PER_BEAT) {
|
|
||||||
tick = 0;
|
|
||||||
normalizedBeats += 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${normalizedBeats} ${tick}`;
|
return { delta: parseInt(trimmed, 10) };
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseNoteNameInput = (raw: string): { pitch: number } | { error: string } => {
|
||||||
|
try {
|
||||||
|
return { pitch: noteNameToPitch(raw.trim()) };
|
||||||
|
} catch {
|
||||||
|
return { error: 'Use note names like C3, C#3, or Cb3.' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const parsePitchDeltaInput = (raw: string): { delta: number } | { error: string } => {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!/^[+-]\d+$/.test(trimmed)) {
|
||||||
|
return { error: 'Use note delta like +2 or -1 when editing Num in delta mode.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { delta: parseInt(trimmed, 10) };
|
||||||
};
|
};
|
||||||
|
|
||||||
const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
||||||
@@ -70,6 +92,7 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
selectedRegionIds,
|
selectedRegionIds,
|
||||||
timeSignature,
|
timeSignature,
|
||||||
selectedNoteIds,
|
selectedNoteIds,
|
||||||
|
playheadPosition,
|
||||||
updateTrack,
|
updateTrack,
|
||||||
refreshProjectState
|
refreshProjectState
|
||||||
} = useProjectStore();
|
} = useProjectStore();
|
||||||
@@ -77,7 +100,11 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
const [eventType, setEventType] = useState('notes');
|
const [eventType, setEventType] = useState('notes');
|
||||||
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 [editingCell, setEditingCell] = useState<EditingCell | null>(null);
|
||||||
const rangeAnchorNoteIdRef = useRef<string | null>(null);
|
const rangeAnchorNoteIdRef = useRef<string | null>(null);
|
||||||
|
const editInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const suppressBlurCommitRef = useRef(false);
|
||||||
|
const pendingSingleClickSelectionRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const resolvedRegionId = selectedRegionIds.length > 1
|
const resolvedRegionId = selectedRegionIds.length > 1
|
||||||
? activeRegionId
|
? activeRegionId
|
||||||
@@ -116,6 +143,21 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
|
|
||||||
const selectedNoteIdSet = new Set(selectedNoteIds);
|
const selectedNoteIdSet = new Set(selectedNoteIds);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editingCell) {
|
||||||
|
editInputRef.current?.focus();
|
||||||
|
editInputRef.current?.select();
|
||||||
|
}
|
||||||
|
}, [editingCell?.noteId, editingCell?.column]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (pendingSingleClickSelectionRef.current !== null) {
|
||||||
|
window.clearTimeout(pendingSingleClickSelectionRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const commitSelection = (nextSelectedIds: Set<string>) => {
|
const commitSelection = (nextSelectedIds: Set<string>) => {
|
||||||
if (!activeMidiRegion || !parentTrack) return;
|
if (!activeMidiRegion || !parentTrack) return;
|
||||||
|
|
||||||
@@ -138,13 +180,212 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
void updateTrack(parentTrack);
|
void updateTrack(parentTrack);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearPendingSingleClickSelection = () => {
|
||||||
|
if (pendingSingleClickSelectionRef.current !== null) {
|
||||||
|
window.clearTimeout(pendingSingleClickSelectionRef.current);
|
||||||
|
pendingSingleClickSelectionRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEditingCell = (noteId: string, column: EditableColumn, value: string) => {
|
||||||
|
clearPendingSingleClickSelection();
|
||||||
|
setEditingCell({ noteId, column, value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEditingCell = () => {
|
||||||
|
setEditingCell(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const commitEditingCell = async () => {
|
||||||
|
if (!editingCell || !activeMidiRegion || !parentTrack) return;
|
||||||
|
|
||||||
|
const note = activeMidiRegion.getNotes().find(candidate => candidate.getId() === editingCell.noteId);
|
||||||
|
if (!note) {
|
||||||
|
setEditingCell(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetNotes = selectedNoteIdSet.has(note.getId()) && selectedNoteIds.length > 1
|
||||||
|
? activeMidiRegion.getNotes().filter(candidate => selectedNoteIdSet.has(candidate.getId()))
|
||||||
|
: [note];
|
||||||
|
|
||||||
|
const snapshots = targetNotes.map(targetNote => ({
|
||||||
|
noteId: targetNote.getId(),
|
||||||
|
pitch: targetNote.getPitch(),
|
||||||
|
velocity: targetNote.getVelocity(),
|
||||||
|
startBeat: targetNote.getStartBeat(),
|
||||||
|
endBeat: targetNote.getEndBeat()
|
||||||
|
}));
|
||||||
|
|
||||||
|
const updates: Array<{ noteId: string; pitch?: number; velocity?: number; startBeat?: number; endBeat?: number }> = [];
|
||||||
|
const trimmedValue = editingCell.value.trim();
|
||||||
|
const isDeltaEdit = trimmedValue.startsWith('+') || trimmedValue.startsWith('-');
|
||||||
|
|
||||||
|
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 targetNote of targetNotes) {
|
||||||
|
const currentDuration = targetNote.getEndBeat() - targetNote.getStartBeat();
|
||||||
|
const nextStartBeat = targetNote.getStartBeat() + parsed.deltaBeats;
|
||||||
|
if (nextStartBeat < 0) {
|
||||||
|
await showAlert('Position delta would move one or more notes before the start of the current MIDI region.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.push({
|
||||||
|
noteId: targetNote.getId(),
|
||||||
|
startBeat: nextStartBeat,
|
||||||
|
endBeat: nextStartBeat + currentDuration
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const parsed = parseMidiEventPosition(trimmedValue, timeSignature, MIDI_EVENT_TICKS_PER_BEAT);
|
||||||
|
if ('error' in parsed) {
|
||||||
|
await showAlert(parsed.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relativeStartBeat = parsed.absoluteBeat - activeMidiRegion.getStartFromBeat();
|
||||||
|
if (relativeStartBeat < 0) {
|
||||||
|
await showAlert('Position cannot be earlier than the start of the current MIDI region.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const targetNote of targetNotes) {
|
||||||
|
const currentDuration = targetNote.getEndBeat() - targetNote.getStartBeat();
|
||||||
|
updates.push({
|
||||||
|
noteId: targetNote.getId(),
|
||||||
|
startBeat: relativeStartBeat,
|
||||||
|
endBeat: relativeStartBeat + currentDuration
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingCell.column === 'num') {
|
||||||
|
if (isDeltaEdit) {
|
||||||
|
const parsed = parsePitchDeltaInput(trimmedValue);
|
||||||
|
if ('error' in parsed) {
|
||||||
|
await showAlert(parsed.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const targetNote of targetNotes) {
|
||||||
|
const nextPitch = targetNote.getPitch() + parsed.delta;
|
||||||
|
if (nextPitch < 0 || nextPitch > 127) {
|
||||||
|
await showAlert('Num delta would move one or more notes outside the MIDI pitch range 0–127.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updates.push({ noteId: targetNote.getId(), pitch: nextPitch });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const parsed = parseNoteNameInput(trimmedValue);
|
||||||
|
if ('error' in parsed) {
|
||||||
|
await showAlert(parsed.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const targetNote of targetNotes) {
|
||||||
|
updates.push({ noteId: targetNote.getId(), pitch: parsed.pitch });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingCell.column === 'val') {
|
||||||
|
if (isDeltaEdit) {
|
||||||
|
const parsed = parseVelocityDeltaInput(trimmedValue);
|
||||||
|
if ('error' in parsed) {
|
||||||
|
await showAlert(parsed.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const targetNote of targetNotes) {
|
||||||
|
const nextVelocity = targetNote.getVelocity() + parsed.delta;
|
||||||
|
if (nextVelocity < 0 || nextVelocity > 127) {
|
||||||
|
await showAlert('Velocity delta would move one or more notes outside the valid range 0–127.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updates.push({ noteId: targetNote.getId(), velocity: nextVelocity });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const parsed = parseVelocityInput(trimmedValue);
|
||||||
|
if ('error' in parsed) {
|
||||||
|
await showAlert(parsed.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const targetNote of targetNotes) {
|
||||||
|
updates.push({ noteId: targetNote.getId(), velocity: parsed.velocity });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingCell.column === 'length') {
|
||||||
|
if (isDeltaEdit) {
|
||||||
|
const parsed = parseMidiEventLengthDelta(trimmedValue, MIDI_EVENT_TICKS_PER_BEAT);
|
||||||
|
if ('error' in parsed) {
|
||||||
|
await showAlert(parsed.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const targetNote of targetNotes) {
|
||||||
|
const currentDuration = targetNote.getEndBeat() - targetNote.getStartBeat();
|
||||||
|
const nextDuration = currentDuration + parsed.deltaBeats;
|
||||||
|
if (nextDuration <= 0) {
|
||||||
|
await showAlert('Length delta would make one or more notes non-positive in duration.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.push({
|
||||||
|
noteId: targetNote.getId(),
|
||||||
|
endBeat: targetNote.getStartBeat() + nextDuration
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const parsed = parseMidiEventLength(trimmedValue, MIDI_EVENT_TICKS_PER_BEAT);
|
||||||
|
if ('error' in parsed) {
|
||||||
|
await showAlert(parsed.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const targetNote of targetNotes) {
|
||||||
|
updates.push({
|
||||||
|
noteId: targetNote.getId(),
|
||||||
|
endBeat: targetNote.getStartBeat() + parsed.duration
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.length === 0) {
|
||||||
|
setEditingCell(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = new UpdateNotePropertiesCommand(activeMidiRegion.getId(), snapshots, updates);
|
||||||
|
KGCore.instance().executeCommand(command);
|
||||||
|
await updateTrack(parentTrack);
|
||||||
|
refreshProjectState();
|
||||||
|
setEditingCell(null);
|
||||||
|
};
|
||||||
|
|
||||||
const handleRowClick = (noteId: string, rowIndex: number, event: React.MouseEvent<HTMLTableRowElement>) => {
|
const handleRowClick = (noteId: string, rowIndex: number, event: React.MouseEvent<HTMLTableRowElement>) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (editingCell) return;
|
||||||
if (!activeMidiRegion) return;
|
if (!activeMidiRegion) return;
|
||||||
|
|
||||||
const isModifierPressed = isModifierKeyPressed(event);
|
const isModifierPressed = isModifierKeyPressed(event);
|
||||||
const nextSelectedIds = new Set(selectedNoteIdSet);
|
const nextSelectedIds = new Set(selectedNoteIdSet);
|
||||||
|
const isAlreadySelected = selectedNoteIdSet.has(noteId);
|
||||||
|
const hasMultiSelection = selectedNoteIds.length > 1;
|
||||||
|
|
||||||
if (event.shiftKey) {
|
if (event.shiftKey) {
|
||||||
|
clearPendingSingleClickSelection();
|
||||||
const anchorIndex = noteRows.findIndex(row => row.id === rangeAnchorNoteIdRef.current);
|
const anchorIndex = noteRows.findIndex(row => row.id === rangeAnchorNoteIdRef.current);
|
||||||
const rangeStartIndex = anchorIndex >= 0 ? Math.min(anchorIndex, rowIndex) : rowIndex;
|
const rangeStartIndex = anchorIndex >= 0 ? Math.min(anchorIndex, rowIndex) : rowIndex;
|
||||||
const rangeEndIndex = anchorIndex >= 0 ? Math.max(anchorIndex, rowIndex) : rowIndex;
|
const rangeEndIndex = anchorIndex >= 0 ? Math.max(anchorIndex, rowIndex) : rowIndex;
|
||||||
@@ -157,6 +398,7 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
nextSelectedIds.add(noteRows[index].id);
|
nextSelectedIds.add(noteRows[index].id);
|
||||||
}
|
}
|
||||||
} else if (isModifierPressed) {
|
} else if (isModifierPressed) {
|
||||||
|
clearPendingSingleClickSelection();
|
||||||
if (nextSelectedIds.has(noteId)) {
|
if (nextSelectedIds.has(noteId)) {
|
||||||
nextSelectedIds.delete(noteId);
|
nextSelectedIds.delete(noteId);
|
||||||
} else {
|
} else {
|
||||||
@@ -164,6 +406,18 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
}
|
}
|
||||||
rangeAnchorNoteIdRef.current = noteId;
|
rangeAnchorNoteIdRef.current = noteId;
|
||||||
} else {
|
} else {
|
||||||
|
if (isAlreadySelected && hasMultiSelection) {
|
||||||
|
clearPendingSingleClickSelection();
|
||||||
|
pendingSingleClickSelectionRef.current = window.setTimeout(() => {
|
||||||
|
const delayedSelection = new Set<string>([noteId]);
|
||||||
|
rangeAnchorNoteIdRef.current = noteId;
|
||||||
|
commitSelection(delayedSelection);
|
||||||
|
pendingSingleClickSelectionRef.current = null;
|
||||||
|
}, 220);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearPendingSingleClickSelection();
|
||||||
nextSelectedIds.clear();
|
nextSelectedIds.clear();
|
||||||
nextSelectedIds.add(noteId);
|
nextSelectedIds.add(noteId);
|
||||||
rangeAnchorNoteIdRef.current = noteId;
|
rangeAnchorNoteIdRef.current = noteId;
|
||||||
@@ -177,11 +431,41 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleTableBackgroundMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
|
const handleTableBackgroundMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
event.stopPropagation();
|
||||||
if (event.target !== event.currentTarget) return;
|
if (event.target !== event.currentTarget) return;
|
||||||
|
clearPendingSingleClickSelection();
|
||||||
rangeAnchorNoteIdRef.current = null;
|
rangeAnchorNoteIdRef.current = null;
|
||||||
commitSelection(new Set());
|
commitSelection(new Set());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTableShellClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditInputKeyDown = async (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
await commitEditingCell();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
suppressBlurCommitRef.current = true;
|
||||||
|
cancelEditingCell();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditInputBlur = () => {
|
||||||
|
if (suppressBlurCommitRef.current) {
|
||||||
|
suppressBlurCommitRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void commitEditingCell();
|
||||||
|
};
|
||||||
|
|
||||||
const quantizeSelectedNotes = (quantValue: string) => {
|
const quantizeSelectedNotes = (quantValue: string) => {
|
||||||
if (!activeMidiRegion || !parentTrack) return;
|
if (!activeMidiRegion || !parentTrack) return;
|
||||||
|
|
||||||
@@ -235,6 +519,45 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
refreshProjectState();
|
refreshProjectState();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAddNote = async (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (!activeMidiRegion || !parentTrack) return;
|
||||||
|
|
||||||
|
const lastSelectedNoteId = [...selectedNoteIds]
|
||||||
|
.reverse()
|
||||||
|
.find(noteId => activeMidiRegion.getNotes().some(note => note.getId() === noteId));
|
||||||
|
const lastSelectedNote = lastSelectedNoteId
|
||||||
|
? activeMidiRegion.getNotes().find(note => note.getId() === lastSelectedNoteId) ?? null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const defaultLength = lastSelectedNote
|
||||||
|
? lastSelectedNote.getEndBeat() - lastSelectedNote.getStartBeat()
|
||||||
|
: KGPianoRollState.instance().getLastEditedNoteLength();
|
||||||
|
const defaultPitch = lastSelectedNote ? lastSelectedNote.getPitch() : noteNameToPitch('C4');
|
||||||
|
const defaultVelocity = lastSelectedNote ? lastSelectedNote.getVelocity() : 127;
|
||||||
|
|
||||||
|
const regionRelativePlayhead = Math.max(0, playheadPosition - activeMidiRegion.getStartFromBeat());
|
||||||
|
const command = new CreateNoteCommand(
|
||||||
|
activeMidiRegion.getId(),
|
||||||
|
regionRelativePlayhead,
|
||||||
|
regionRelativePlayhead + defaultLength,
|
||||||
|
defaultPitch,
|
||||||
|
defaultVelocity
|
||||||
|
);
|
||||||
|
|
||||||
|
KGCore.instance().executeCommand(command);
|
||||||
|
KGPianoRollState.instance().setLastEditedNoteLength(defaultLength);
|
||||||
|
const createdNote = command.getCreatedNote();
|
||||||
|
if (createdNote) {
|
||||||
|
createdNote.select();
|
||||||
|
KGCore.instance().clearSelectedItems();
|
||||||
|
KGCore.instance().addSelectedItem(createdNote);
|
||||||
|
rangeAnchorNoteIdRef.current = createdNote.getId();
|
||||||
|
}
|
||||||
|
await updateTrack(parentTrack);
|
||||||
|
refreshProjectState();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`list-event-panel${isVisible ? '' : ' is-hidden'}`}>
|
<div className={`list-event-panel${isVisible ? '' : ' is-hidden'}`}>
|
||||||
<div className="list-event-panel-header">
|
<div className="list-event-panel-header">
|
||||||
@@ -258,8 +581,9 @@ 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="Add note UI only for now"
|
title="Add note at playhead"
|
||||||
type="button"
|
type="button"
|
||||||
|
onClick={handleAddNote}
|
||||||
>
|
>
|
||||||
<FaPlus />
|
<FaPlus />
|
||||||
</button>
|
</button>
|
||||||
@@ -297,7 +621,12 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="list-event-table-shell" onMouseDown={handleTableBackgroundMouseDown}>
|
<div
|
||||||
|
className="list-event-table-shell"
|
||||||
|
onMouseDown={handleTableBackgroundMouseDown}
|
||||||
|
onClick={handleTableShellClick}
|
||||||
|
onDoubleClick={handleTableShellClick}
|
||||||
|
>
|
||||||
<table className="list-event-table">
|
<table className="list-event-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -311,23 +640,107 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{noteRows.map((row, index) => (
|
{noteRows.map((row, index) => (
|
||||||
(() => {
|
(() => {
|
||||||
const positionText = formatEventPosition(row.absoluteStartBeat, timeSignature);
|
const positionText = formatMidiEventPosition(row.absoluteStartBeat, timeSignature, MIDI_EVENT_TICKS_PER_BEAT);
|
||||||
const statusText = 'Note';
|
const statusText = 'Note';
|
||||||
const noteText = pitchToNoteNameString(row.note.getPitch());
|
const noteText = pitchToNoteNameString(row.note.getPitch());
|
||||||
const velocityText = String(row.note.getVelocity());
|
const velocityText = String(row.note.getVelocity());
|
||||||
const lengthText = formatEventLength(row.durationBeats);
|
const lengthText = formatMidiEventLength(row.durationBeats, MIDI_EVENT_TICKS_PER_BEAT);
|
||||||
|
const isEditingPosition = editingCell?.noteId === row.id && editingCell.column === 'position';
|
||||||
|
const isEditingNum = editingCell?.noteId === row.id && editingCell.column === 'num';
|
||||||
|
const isEditingVal = editingCell?.noteId === row.id && editingCell.column === 'val';
|
||||||
|
const isEditingLength = editingCell?.noteId === row.id && editingCell.column === 'length';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={row.id}
|
key={row.id}
|
||||||
className={selectedNoteIdSet.has(row.id) ? 'selected' : ''}
|
className={selectedNoteIdSet.has(row.id) ? 'selected' : ''}
|
||||||
onClick={(event) => handleRowClick(row.id, index, event)}
|
onClick={(event) => handleRowClick(row.id, index, event)}
|
||||||
|
onDoubleClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
clearPendingSingleClickSelection();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<td title={positionText}>{positionText}</td>
|
<td
|
||||||
|
title={positionText}
|
||||||
|
onDoubleClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
startEditingCell(row.id, 'position', positionText);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isEditingPosition ? (
|
||||||
|
<input
|
||||||
|
ref={editInputRef}
|
||||||
|
className="list-event-cell-input"
|
||||||
|
value={editingCell.value}
|
||||||
|
onChange={(event) => setEditingCell({ ...editingCell, value: event.target.value })}
|
||||||
|
onBlur={handleEditInputBlur}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onDoubleClick={(event) => event.stopPropagation()}
|
||||||
|
onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
|
||||||
|
/>
|
||||||
|
) : positionText}
|
||||||
|
</td>
|
||||||
<td title={statusText}>{statusText}</td>
|
<td title={statusText}>{statusText}</td>
|
||||||
<td title={noteText}>{noteText}</td>
|
<td
|
||||||
<td title={velocityText}>{velocityText}</td>
|
title={noteText}
|
||||||
<td title={lengthText}>{lengthText}</td>
|
onDoubleClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
startEditingCell(row.id, 'num', noteText);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isEditingNum ? (
|
||||||
|
<input
|
||||||
|
ref={editInputRef}
|
||||||
|
className="list-event-cell-input"
|
||||||
|
value={editingCell.value}
|
||||||
|
onChange={(event) => setEditingCell({ ...editingCell, value: event.target.value })}
|
||||||
|
onBlur={handleEditInputBlur}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onDoubleClick={(event) => event.stopPropagation()}
|
||||||
|
onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
|
||||||
|
/>
|
||||||
|
) : noteText}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
title={velocityText}
|
||||||
|
onDoubleClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
startEditingCell(row.id, 'val', velocityText);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isEditingVal ? (
|
||||||
|
<input
|
||||||
|
ref={editInputRef}
|
||||||
|
className="list-event-cell-input"
|
||||||
|
value={editingCell.value}
|
||||||
|
onChange={(event) => setEditingCell({ ...editingCell, value: event.target.value })}
|
||||||
|
onBlur={handleEditInputBlur}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onDoubleClick={(event) => event.stopPropagation()}
|
||||||
|
onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
|
||||||
|
/>
|
||||||
|
) : velocityText}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
title={lengthText}
|
||||||
|
onDoubleClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
startEditingCell(row.id, 'length', lengthText);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isEditingLength ? (
|
||||||
|
<input
|
||||||
|
ref={editInputRef}
|
||||||
|
className="list-event-cell-input"
|
||||||
|
value={editingCell.value}
|
||||||
|
onChange={(event) => setEditingCell({ ...editingCell, value: event.target.value })}
|
||||||
|
onBlur={handleEditInputBlur}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onDoubleClick={(event) => event.stopPropagation()}
|
||||||
|
onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
|
||||||
|
/>
|
||||||
|
) : lengthText}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})()
|
})()
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ interface NoteUpdate {
|
|||||||
noteId: string;
|
noteId: string;
|
||||||
pitch?: number;
|
pitch?: number;
|
||||||
velocity?: number;
|
velocity?: number;
|
||||||
|
startBeat?: number;
|
||||||
endBeat?: number;
|
endBeat?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +57,7 @@ export class UpdateNotePropertiesCommand extends KGCommand {
|
|||||||
if (note) {
|
if (note) {
|
||||||
if (update.pitch !== undefined) note.setPitch(update.pitch);
|
if (update.pitch !== undefined) note.setPitch(update.pitch);
|
||||||
if (update.velocity !== undefined) note.setVelocity(update.velocity);
|
if (update.velocity !== undefined) note.setVelocity(update.velocity);
|
||||||
|
if (update.startBeat !== undefined) note.setStartBeat(update.startBeat);
|
||||||
if (update.endBeat !== undefined) note.setEndBeat(update.endBeat);
|
if (update.endBeat !== undefined) note.setEndBeat(update.endBeat);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import {
|
import {
|
||||||
beatsToBar,
|
beatsToBar,
|
||||||
|
formatMidiEventLength,
|
||||||
|
formatMidiEventPosition,
|
||||||
|
MIDI_EVENT_TICKS_PER_BEAT,
|
||||||
|
parseMidiEventLength,
|
||||||
|
parseMidiEventLengthDelta,
|
||||||
|
parseMidiEventPosition,
|
||||||
|
parseMidiEventPositionDelta,
|
||||||
pitchToNoteNameString,
|
pitchToNoteNameString,
|
||||||
pitchToNoteName,
|
pitchToNoteName,
|
||||||
pianoRollIndexToPitch,
|
pianoRollIndexToPitch,
|
||||||
@@ -128,13 +135,66 @@ describe('midiUtil', () => {
|
|||||||
expect(noteNameToPitch('G#4')).toBe(68);
|
expect(noteNameToPitch('G#4')).toBe(68);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should handle flats', () => {
|
||||||
|
expect(noteNameToPitch('Cb3')).toBe(47);
|
||||||
|
expect(noteNameToPitch('Db4')).toBe(61);
|
||||||
|
expect(noteNameToPitch('Bb4')).toBe(70);
|
||||||
|
});
|
||||||
|
|
||||||
it('should handle invalid note names', () => {
|
it('should handle invalid note names', () => {
|
||||||
expect(() => noteNameToPitch('Db4')).toThrow('Invalid note name: Db4'); // Flats not supported
|
|
||||||
expect(() => noteNameToPitch('H4')).toThrow('Invalid note name: H4'); // Invalid note
|
expect(() => noteNameToPitch('H4')).toThrow('Invalid note name: H4'); // Invalid note
|
||||||
expect(() => noteNameToPitch('C')).toThrow('Invalid note name: C'); // Missing octave
|
expect(() => noteNameToPitch('C')).toThrow('Invalid note name: C'); // Missing octave
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('midi event position helpers', () => {
|
||||||
|
it('should format event positions with 480 ticks per beat', () => {
|
||||||
|
expect(formatMidiEventPosition(0, { numerator: 4, denominator: 4 })).toBe('1 1 0');
|
||||||
|
expect(formatMidiEventPosition(1.5, { numerator: 4, denominator: 4 })).toBe('1 2 240');
|
||||||
|
expect(formatMidiEventPosition(3.999, { numerator: 4, denominator: 4 }, MIDI_EVENT_TICKS_PER_BEAT)).toBe('2 1 0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse event positions including tick 480 rollover', () => {
|
||||||
|
expect(parseMidiEventPosition('4 2 120', { numerator: 4, denominator: 4 })).toEqual({ absoluteBeat: 13.25 });
|
||||||
|
expect(parseMidiEventPosition('1 4 480', { numerator: 4, denominator: 4 })).toEqual({ absoluteBeat: 4 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse event position deltas', () => {
|
||||||
|
expect(parseMidiEventPositionDelta('+0 1 120', { numerator: 4, denominator: 4 })).toEqual({ deltaBeats: 1.25 });
|
||||||
|
expect(parseMidiEventPositionDelta('-1 0 0', { numerator: 4, denominator: 4 })).toEqual({ deltaBeats: -4 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject invalid event positions', () => {
|
||||||
|
expect(parseMidiEventPosition('1 5 0', { numerator: 4, denominator: 4 })).toEqual({
|
||||||
|
error: 'Beat must be between 1 and 4 for the current time signature.'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('midi event length helpers', () => {
|
||||||
|
it('should format midi event lengths with beat and tick', () => {
|
||||||
|
expect(formatMidiEventLength(0.5)).toBe('0 240');
|
||||||
|
expect(formatMidiEventLength(1)).toBe('1 0');
|
||||||
|
expect(formatMidiEventLength(1.5)).toBe('1 240');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse midi event lengths including tick 480 rollover', () => {
|
||||||
|
expect(parseMidiEventLength('1 240')).toEqual({ duration: 1.5 });
|
||||||
|
expect(parseMidiEventLength('0 480')).toEqual({ duration: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse midi event length deltas', () => {
|
||||||
|
expect(parseMidiEventLengthDelta('+1 240')).toEqual({ deltaBeats: 1.5 });
|
||||||
|
expect(parseMidiEventLengthDelta('-0 120')).toEqual({ deltaBeats: -0.25 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject invalid midi event lengths', () => {
|
||||||
|
expect(parseMidiEventLength('0 0')).toEqual({
|
||||||
|
error: 'Length must be greater than 0.'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('edge cases and error handling', () => {
|
describe('edge cases and error handling', () => {
|
||||||
it('should handle negative values gracefully', () => {
|
it('should handle negative values gracefully', () => {
|
||||||
expect(() => pitchToNoteNameString(-1)).not.toThrow();
|
expect(() => pitchToNoteNameString(-1)).not.toThrow();
|
||||||
|
|||||||
+209
-5
@@ -21,6 +21,8 @@ export const pianoRollIndexToPitch = (index: number) => {
|
|||||||
return 107 /* MIDI note B7 */ - index;
|
return 107 /* MIDI note B7 */ - index;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const MIDI_EVENT_TICKS_PER_BEAT = 480;
|
||||||
|
|
||||||
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'];
|
||||||
return {
|
return {
|
||||||
@@ -36,12 +38,13 @@ export const pitchToNoteNameString = (pitch: number) => {
|
|||||||
|
|
||||||
export const noteNameToPitch = (noteName: string): number => {
|
export const noteNameToPitch = (noteName: string): number => {
|
||||||
const noteMap: { [key: string]: number } = {
|
const noteMap: { [key: string]: number } = {
|
||||||
'C': 0, 'C#': 1, 'D': 2, 'D#': 3, 'E': 4, 'F': 5,
|
'C': 0, 'C#': 1, 'Cb': -1, 'D': 2, 'D#': 3, 'Db': 1, 'E': 4, 'E#': 5, 'Eb': 3,
|
||||||
'F#': 6, 'G': 7, 'G#': 8, 'A': 9, 'A#': 10, 'B': 11
|
'F': 5, 'F#': 6, 'Fb': 4, 'G': 7, 'G#': 8, 'Gb': 6, 'A': 9, 'A#': 10, 'Ab': 8,
|
||||||
|
'B': 11, 'B#': 12, 'Bb': 10
|
||||||
};
|
};
|
||||||
|
|
||||||
// Parse note name (e.g., "C4", "F#2", "A#7")
|
// Parse note name (e.g., "C4", "F#2", "Cb3", "A#7")
|
||||||
const match = noteName.match(/^([A-G]#?)(\d+)$/);
|
const match = noteName.trim().match(/^([A-G](?:#|b)?)(-?\d+)$/);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
throw new Error(`Invalid note name: ${noteName}`);
|
throw new Error(`Invalid note name: ${noteName}`);
|
||||||
}
|
}
|
||||||
@@ -53,7 +56,11 @@ export const noteNameToPitch = (noteName: string): number => {
|
|||||||
throw new Error(`Invalid note: ${note}`);
|
throw new Error(`Invalid note: ${note}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return noteMap[note] + (octave + 1) * 12;
|
const pitch = noteMap[note] + (octave + 1) * 12;
|
||||||
|
if (pitch < 0 || pitch > 127) {
|
||||||
|
throw new Error(`Note out of MIDI range: ${noteName}`);
|
||||||
|
}
|
||||||
|
return pitch;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const beatsToBar = (beats: number, timeSignature: TimeSignature) => {
|
export const beatsToBar = (beats: number, timeSignature: TimeSignature) => {
|
||||||
@@ -63,6 +70,203 @@ export const beatsToBar = (beats: number, timeSignature: TimeSignature) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const formatMidiEventPosition = (
|
||||||
|
beats: number,
|
||||||
|
timeSignature: TimeSignature,
|
||||||
|
ticksPerBeat: number = MIDI_EVENT_TICKS_PER_BEAT
|
||||||
|
): string => {
|
||||||
|
const { bar, beatInBar } = beatsToBar(beats, timeSignature);
|
||||||
|
const beatInteger = Math.floor(beatInBar);
|
||||||
|
let tick = Math.round((beatInBar - beatInteger) * ticksPerBeat);
|
||||||
|
let normalizedBeat = beatInteger;
|
||||||
|
let normalizedBar = bar;
|
||||||
|
|
||||||
|
if (tick >= ticksPerBeat) {
|
||||||
|
tick = 0;
|
||||||
|
normalizedBeat += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedBeat >= timeSignature.numerator) {
|
||||||
|
normalizedBeat = 0;
|
||||||
|
normalizedBar += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${normalizedBar + 1} ${normalizedBeat + 1} ${tick}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatMidiEventLength = (
|
||||||
|
beats: number,
|
||||||
|
ticksPerBeat: number = MIDI_EVENT_TICKS_PER_BEAT
|
||||||
|
): string => {
|
||||||
|
const fullBeats = Math.floor(beats);
|
||||||
|
let tick = Math.round((beats - fullBeats) * ticksPerBeat);
|
||||||
|
let normalizedBeats = fullBeats;
|
||||||
|
|
||||||
|
if (tick >= ticksPerBeat) {
|
||||||
|
tick = 0;
|
||||||
|
normalizedBeats += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${normalizedBeats} ${tick}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MidiEventPositionParseResult =
|
||||||
|
| { absoluteBeat: number }
|
||||||
|
| { error: string };
|
||||||
|
|
||||||
|
export const parseMidiEventPosition = (
|
||||||
|
raw: string,
|
||||||
|
timeSignature: TimeSignature,
|
||||||
|
ticksPerBeat: number = MIDI_EVENT_TICKS_PER_BEAT
|
||||||
|
): MidiEventPositionParseResult => {
|
||||||
|
const match = raw.trim().match(/^(\d+)\s+(\d+)\s+(\d+)$/);
|
||||||
|
if (!match) {
|
||||||
|
return { error: 'Use Position as "bar beat tick", for example "4 2 120".' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const bar = parseInt(match[1], 10);
|
||||||
|
const beat = parseInt(match[2], 10);
|
||||||
|
let tick = parseInt(match[3], 10);
|
||||||
|
|
||||||
|
if (bar < 1) {
|
||||||
|
return { error: 'Bar number must be 1 or greater.' };
|
||||||
|
}
|
||||||
|
if (beat < 1 || beat > timeSignature.numerator) {
|
||||||
|
return { error: `Beat must be between 1 and ${timeSignature.numerator} for the current time signature.` };
|
||||||
|
}
|
||||||
|
if (tick < 0 || tick > ticksPerBeat) {
|
||||||
|
return { error: `Tick must be between 0 and ${ticksPerBeat}.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalizedBar = bar;
|
||||||
|
let normalizedBeat = beat;
|
||||||
|
if (tick === ticksPerBeat) {
|
||||||
|
tick = 0;
|
||||||
|
normalizedBeat += 1;
|
||||||
|
if (normalizedBeat > timeSignature.numerator) {
|
||||||
|
normalizedBeat = 1;
|
||||||
|
normalizedBar += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const absoluteBeat = ((normalizedBar - 1) * timeSignature.numerator) +
|
||||||
|
(normalizedBeat - 1) +
|
||||||
|
(tick / ticksPerBeat);
|
||||||
|
|
||||||
|
return { absoluteBeat };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MidiEventPositionDeltaParseResult =
|
||||||
|
| { deltaBeats: number }
|
||||||
|
| { error: string };
|
||||||
|
|
||||||
|
export const parseMidiEventPositionDelta = (
|
||||||
|
raw: string,
|
||||||
|
timeSignature: TimeSignature,
|
||||||
|
ticksPerBeat: number = MIDI_EVENT_TICKS_PER_BEAT
|
||||||
|
): MidiEventPositionDeltaParseResult => {
|
||||||
|
const match = raw.trim().match(/^([+-])(\d+)\s+(\d+)\s+(\d+)$/);
|
||||||
|
if (!match) {
|
||||||
|
return { error: 'Use position delta as "+bars beats tick" or "-bars beats tick", for example "+0 1 120".' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sign = match[1] === '-' ? -1 : 1;
|
||||||
|
const bars = parseInt(match[2], 10);
|
||||||
|
const beats = parseInt(match[3], 10);
|
||||||
|
let tick = parseInt(match[4], 10);
|
||||||
|
|
||||||
|
if (beats < 0 || beats > timeSignature.numerator) {
|
||||||
|
return { error: `Delta beat component must be between 0 and ${timeSignature.numerator}.` };
|
||||||
|
}
|
||||||
|
if (tick < 0 || tick > ticksPerBeat) {
|
||||||
|
return { error: `Delta tick must be between 0 and ${ticksPerBeat}.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalizedBars = bars;
|
||||||
|
let normalizedBeats = beats;
|
||||||
|
if (tick === ticksPerBeat) {
|
||||||
|
tick = 0;
|
||||||
|
normalizedBeats += 1;
|
||||||
|
if (normalizedBeats >= timeSignature.numerator) {
|
||||||
|
normalizedBars += Math.floor(normalizedBeats / timeSignature.numerator);
|
||||||
|
normalizedBeats = normalizedBeats % timeSignature.numerator;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaBeats = sign * (
|
||||||
|
(normalizedBars * timeSignature.numerator) +
|
||||||
|
normalizedBeats +
|
||||||
|
(tick / ticksPerBeat)
|
||||||
|
);
|
||||||
|
|
||||||
|
return { deltaBeats };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MidiEventLengthParseResult =
|
||||||
|
| { duration: number }
|
||||||
|
| { error: string };
|
||||||
|
|
||||||
|
export const parseMidiEventLength = (
|
||||||
|
raw: string,
|
||||||
|
ticksPerBeat: number = MIDI_EVENT_TICKS_PER_BEAT
|
||||||
|
): MidiEventLengthParseResult => {
|
||||||
|
const match = raw.trim().match(/^(\d+)\s+(\d+)$/);
|
||||||
|
if (!match) {
|
||||||
|
return { error: 'Use Length as "beats tick", for example "1 240".' };
|
||||||
|
}
|
||||||
|
|
||||||
|
let beats = parseInt(match[1], 10);
|
||||||
|
let tick = parseInt(match[2], 10);
|
||||||
|
|
||||||
|
if (beats < 0) {
|
||||||
|
return { error: 'Length beats must be 0 or greater.' };
|
||||||
|
}
|
||||||
|
if (tick < 0 || tick > ticksPerBeat) {
|
||||||
|
return { error: `Length tick must be between 0 and ${ticksPerBeat}.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tick === ticksPerBeat) {
|
||||||
|
tick = 0;
|
||||||
|
beats += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = beats + (tick / ticksPerBeat);
|
||||||
|
if (duration <= 0) {
|
||||||
|
return { error: 'Length must be greater than 0.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { duration };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MidiEventLengthDeltaParseResult =
|
||||||
|
| { deltaBeats: number }
|
||||||
|
| { error: string };
|
||||||
|
|
||||||
|
export const parseMidiEventLengthDelta = (
|
||||||
|
raw: string,
|
||||||
|
ticksPerBeat: number = MIDI_EVENT_TICKS_PER_BEAT
|
||||||
|
): MidiEventLengthDeltaParseResult => {
|
||||||
|
const match = raw.trim().match(/^([+-])(\d+)\s+(\d+)$/);
|
||||||
|
if (!match) {
|
||||||
|
return { error: 'Use length delta as "+beats tick" or "-beats tick", for example "+1 240".' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sign = match[1] === '-' ? -1 : 1;
|
||||||
|
let beats = parseInt(match[2], 10);
|
||||||
|
let tick = parseInt(match[3], 10);
|
||||||
|
|
||||||
|
if (tick < 0 || tick > ticksPerBeat) {
|
||||||
|
return { error: `Length delta tick must be between 0 and ${ticksPerBeat}.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tick === ticksPerBeat) {
|
||||||
|
tick = 0;
|
||||||
|
beats += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { deltaBeats: sign * (beats + (tick / ticksPerBeat)) };
|
||||||
|
};
|
||||||
|
|
||||||
export const midiPercussionKeyMap: Record<number, { fullName: string; shortName: string }> = {
|
export const midiPercussionKeyMap: Record<number, { fullName: string; shortName: string }> = {
|
||||||
35: { fullName: 'Acoustic Bass Drum', shortName: 'Ac.Bass' },
|
35: { fullName: 'Acoustic Bass Drum', shortName: 'Ac.Bass' },
|
||||||
36: { fullName: 'Bass Drum 1', shortName: 'BassDrum' },
|
36: { fullName: 'Bass Drum 1', shortName: 'BassDrum' },
|
||||||
|
|||||||
Reference in New Issue
Block a user