From 8f453f8f4c5c8d786344048c98418089c01988a2 Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Tue, 5 May 2026 17:55:06 -0700 Subject: [PATCH] feat: implemented inline edit for the list event panel --- src/components/ListEventPanel.css | 14 + src/components/ListEventPanel.tsx | 491 ++++++++++++++++-- .../note/UpdateNotePropertiesCommand.ts | 2 + src/util/midiUtil.test.ts | 64 ++- src/util/midiUtil.ts | 216 +++++++- 5 files changed, 740 insertions(+), 47 deletions(-) diff --git a/src/components/ListEventPanel.css b/src/components/ListEventPanel.css index d0119f5..4c09cb1 100644 --- a/src/components/ListEventPanel.css +++ b/src/components/ListEventPanel.css @@ -187,3 +187,17 @@ text-overflow: ellipsis; 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; +} diff --git a/src/components/ListEventPanel.tsx b/src/components/ListEventPanel.tsx index 3db4fbd..cf2a1f1 100644 --- a/src/components/ListEventPanel.tsx +++ b/src/components/ListEventPanel.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import './ListEventPanel.css'; import { FaPlus } from 'react-icons/fa'; import KGDropdown from './common/KGDropdown'; @@ -7,9 +7,22 @@ import { KGCore } from '../core/KGCore'; import { KGMidiRegion } from '../core/region/KGMidiRegion'; import { KGMidiNote } from '../core/midi/KGMidiNote'; 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 { PIANO_ROLL_CONSTANTS } from '../constants'; +import { CreateNoteCommand } from '../core/commands'; +import { UpdateNotePropertiesCommand } from '../core/commands/note/UpdateNotePropertiesCommand'; +import { showAlert } from '../util/dialogUtil'; interface ListEventPanelProps { isVisible: boolean; @@ -22,45 +35,54 @@ interface NoteRowData { 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_POSITION_TICKS_PER_BEAT = 480; -const formatEventPosition = ( - beats: number, - timeSignature: { numerator: number; denominator: number } -): string => { - 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; +const parseVelocityInput = (raw: string): { velocity: number } | { error: string } => { + const trimmed = raw.trim(); + if (!/^\d+$/.test(trimmed)) { + return { error: 'Velocity must be an integer between 0 and 127.' }; } - if (normalizedBeat >= timeSignature.numerator) { - normalizedBeat = 0; - normalizedBar += 1; + const velocity = parseInt(trimmed, 10); + if (velocity < 0 || velocity > 127) { + return { error: 'Velocity must be between 0 and 127.' }; } - return `${normalizedBar + 1} ${normalizedBeat + 1} ${tick}`; + return { velocity }; }; -const formatEventLength = ( - beats: number -): string => { - const fullBeats = Math.floor(beats); - 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; +const parseVelocityDeltaInput = (raw: string): { delta: number } | { error: string } => { + const trimmed = raw.trim(); + if (!/^[+-]\d+$/.test(trimmed)) { + return { error: 'Use velocity delta like +10 or -5.' }; } - 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 = ({ isVisible }) => { @@ -70,6 +92,7 @@ const ListEventPanel: React.FC = ({ isVisible }) => { selectedRegionIds, timeSignature, selectedNoteIds, + playheadPosition, updateTrack, refreshProjectState } = useProjectStore(); @@ -77,7 +100,11 @@ const ListEventPanel: React.FC = ({ isVisible }) => { const [eventType, setEventType] = useState('notes'); const [quantPosition, setQuantPosition] = useState('1/8'); const [quantLength, setQuantLength] = useState('1/8'); + const [editingCell, setEditingCell] = useState(null); const rangeAnchorNoteIdRef = useRef(null); + const editInputRef = useRef(null); + const suppressBlurCommitRef = useRef(false); + const pendingSingleClickSelectionRef = useRef(null); const resolvedRegionId = selectedRegionIds.length > 1 ? activeRegionId @@ -116,6 +143,21 @@ const ListEventPanel: React.FC = ({ isVisible }) => { 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) => { if (!activeMidiRegion || !parentTrack) return; @@ -138,13 +180,212 @@ const ListEventPanel: React.FC = ({ isVisible }) => { 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) => { + event.stopPropagation(); + if (editingCell) return; if (!activeMidiRegion) return; const isModifierPressed = isModifierKeyPressed(event); const nextSelectedIds = new Set(selectedNoteIdSet); + const isAlreadySelected = selectedNoteIdSet.has(noteId); + const hasMultiSelection = selectedNoteIds.length > 1; if (event.shiftKey) { + clearPendingSingleClickSelection(); const anchorIndex = noteRows.findIndex(row => row.id === rangeAnchorNoteIdRef.current); const rangeStartIndex = anchorIndex >= 0 ? Math.min(anchorIndex, rowIndex) : rowIndex; const rangeEndIndex = anchorIndex >= 0 ? Math.max(anchorIndex, rowIndex) : rowIndex; @@ -157,6 +398,7 @@ const ListEventPanel: React.FC = ({ isVisible }) => { nextSelectedIds.add(noteRows[index].id); } } else if (isModifierPressed) { + clearPendingSingleClickSelection(); if (nextSelectedIds.has(noteId)) { nextSelectedIds.delete(noteId); } else { @@ -164,6 +406,18 @@ const ListEventPanel: React.FC = ({ isVisible }) => { } rangeAnchorNoteIdRef.current = noteId; } else { + if (isAlreadySelected && hasMultiSelection) { + clearPendingSingleClickSelection(); + pendingSingleClickSelectionRef.current = window.setTimeout(() => { + const delayedSelection = new Set([noteId]); + rangeAnchorNoteIdRef.current = noteId; + commitSelection(delayedSelection); + pendingSingleClickSelectionRef.current = null; + }, 220); + return; + } + + clearPendingSingleClickSelection(); nextSelectedIds.clear(); nextSelectedIds.add(noteId); rangeAnchorNoteIdRef.current = noteId; @@ -177,11 +431,41 @@ const ListEventPanel: React.FC = ({ isVisible }) => { }; const handleTableBackgroundMouseDown = (event: React.MouseEvent) => { + event.stopPropagation(); if (event.target !== event.currentTarget) return; + clearPendingSingleClickSelection(); rangeAnchorNoteIdRef.current = null; commitSelection(new Set()); }; + const handleTableShellClick = (event: React.MouseEvent) => { + event.stopPropagation(); + }; + + const handleEditInputKeyDown = async (event: React.KeyboardEvent) => { + 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) => { if (!activeMidiRegion || !parentTrack) return; @@ -235,6 +519,45 @@ const ListEventPanel: React.FC = ({ isVisible }) => { refreshProjectState(); }; + const handleAddNote = async (event: React.MouseEvent) => { + 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 (
@@ -258,8 +581,9 @@ const ListEventPanel: React.FC = ({ isVisible }) => {
@@ -297,7 +621,12 @@ const ListEventPanel: React.FC = ({ isVisible }) => {
-
+
@@ -311,23 +640,107 @@ const ListEventPanel: React.FC = ({ isVisible }) => { {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 noteText = pitchToNoteNameString(row.note.getPitch()); 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 ( handleRowClick(row.id, index, event)} + onDoubleClick={(event) => { + event.stopPropagation(); + clearPendingSingleClickSelection(); + }} > - + - - - + + + ); })() diff --git a/src/core/commands/note/UpdateNotePropertiesCommand.ts b/src/core/commands/note/UpdateNotePropertiesCommand.ts index b357d08..6a05a8d 100644 --- a/src/core/commands/note/UpdateNotePropertiesCommand.ts +++ b/src/core/commands/note/UpdateNotePropertiesCommand.ts @@ -16,6 +16,7 @@ interface NoteUpdate { noteId: string; pitch?: number; velocity?: number; + startBeat?: number; endBeat?: number; } @@ -56,6 +57,7 @@ export class UpdateNotePropertiesCommand extends KGCommand { if (note) { if (update.pitch !== undefined) note.setPitch(update.pitch); if (update.velocity !== undefined) note.setVelocity(update.velocity); + if (update.startBeat !== undefined) note.setStartBeat(update.startBeat); if (update.endBeat !== undefined) note.setEndBeat(update.endBeat); } } diff --git a/src/util/midiUtil.test.ts b/src/util/midiUtil.test.ts index 30139ea..7229894 100644 --- a/src/util/midiUtil.test.ts +++ b/src/util/midiUtil.test.ts @@ -1,6 +1,13 @@ import { describe, it, expect } from 'vitest'; import { beatsToBar, + formatMidiEventLength, + formatMidiEventPosition, + MIDI_EVENT_TICKS_PER_BEAT, + parseMidiEventLength, + parseMidiEventLengthDelta, + parseMidiEventPosition, + parseMidiEventPositionDelta, pitchToNoteNameString, pitchToNoteName, pianoRollIndexToPitch, @@ -127,14 +134,67 @@ describe('midiUtil', () => { expect(noteNameToPitch('F#4')).toBe(66); 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', () => { - expect(() => noteNameToPitch('Db4')).toThrow('Invalid note name: Db4'); // Flats not supported expect(() => noteNameToPitch('H4')).toThrow('Invalid note name: H4'); // Invalid note 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', () => { it('should handle negative values gracefully', () => { expect(() => pitchToNoteNameString(-1)).not.toThrow(); @@ -183,4 +243,4 @@ describe('midiUtil', () => { expect(convertedPitch).toBe(originalPitch); }); }); -}); \ No newline at end of file +}); diff --git a/src/util/midiUtil.ts b/src/util/midiUtil.ts index d79d652..e86344b 100644 --- a/src/util/midiUtil.ts +++ b/src/util/midiUtil.ts @@ -21,6 +21,8 @@ export const pianoRollIndexToPitch = (index: number) => { return 107 /* MIDI note B7 */ - index; }; +export const MIDI_EVENT_TICKS_PER_BEAT = 480; + export const pitchToNoteName = (pitch: number) => { const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; return { @@ -36,12 +38,13 @@ export const pitchToNoteNameString = (pitch: number) => { export const noteNameToPitch = (noteName: string): number => { const noteMap: { [key: string]: number } = { - 'C': 0, 'C#': 1, 'D': 2, 'D#': 3, 'E': 4, 'F': 5, - 'F#': 6, 'G': 7, 'G#': 8, 'A': 9, 'A#': 10, 'B': 11 + 'C': 0, 'C#': 1, 'Cb': -1, 'D': 2, 'D#': 3, 'Db': 1, 'E': 4, 'E#': 5, 'Eb': 3, + '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") - const match = noteName.match(/^([A-G]#?)(\d+)$/); + // Parse note name (e.g., "C4", "F#2", "Cb3", "A#7") + const match = noteName.trim().match(/^([A-G](?:#|b)?)(-?\d+)$/); if (!match) { throw new Error(`Invalid note name: ${noteName}`); } @@ -53,7 +56,11 @@ export const noteNameToPitch = (noteName: string): number => { 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) => { @@ -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 = { 35: { fullName: 'Acoustic Bass Drum', shortName: 'Ac.Bass' }, 36: { fullName: 'Bass Drum 1', shortName: 'BassDrum' }, @@ -1096,4 +1300,4 @@ function groupNotesIntoRegions(notes: ParsedMidiNote[], timeSignature: TimeSigna } return regions; -} \ No newline at end of file +}
{positionText} { + event.stopPropagation(); + startEditingCell(row.id, 'position', positionText); + }} + > + {isEditingPosition ? ( + setEditingCell({ ...editingCell, value: event.target.value })} + onBlur={handleEditInputBlur} + onClick={(event) => event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { void handleEditInputKeyDown(event); }} + /> + ) : positionText} + {statusText}{noteText}{velocityText}{lengthText} { + event.stopPropagation(); + startEditingCell(row.id, 'num', noteText); + }} + > + {isEditingNum ? ( + setEditingCell({ ...editingCell, value: event.target.value })} + onBlur={handleEditInputBlur} + onClick={(event) => event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { void handleEditInputKeyDown(event); }} + /> + ) : noteText} + { + event.stopPropagation(); + startEditingCell(row.id, 'val', velocityText); + }} + > + {isEditingVal ? ( + setEditingCell({ ...editingCell, value: event.target.value })} + onBlur={handleEditInputBlur} + onClick={(event) => event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { void handleEditInputKeyDown(event); }} + /> + ) : velocityText} + { + event.stopPropagation(); + startEditingCell(row.id, 'length', lengthText); + }} + > + {isEditingLength ? ( + setEditingCell({ ...editingCell, value: event.target.value })} + onBlur={handleEditInputBlur} + onClick={(event) => event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { void handleEditInputKeyDown(event); }} + /> + ) : lengthText} +