feat: implemented inline edit for the list event panel

This commit is contained in:
Xiaohan-Tian
2026-05-05 17:55:06 -07:00
parent 3743f9cab2
commit 8f453f8f4c
5 changed files with 740 additions and 47 deletions
+62 -2
View File
@@ -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);
});
});
});
});
+210 -6
View File
@@ -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<number, { fullName: string; shortName: string }> = {
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;
}
}