feat: added MIDI CC recording and list event table operation support.

This commit is contained in:
Xiaohan-Tian
2026-05-07 22:12:06 -07:00
parent 6305983df8
commit f3d26126fa
30 changed files with 1250 additions and 53 deletions
+30
View File
@@ -4,6 +4,7 @@ import {
collectRegionMidiAutomationPoints,
normalizeMidiAutomationPoints,
resolveMidiAutomationValueAtBeat,
resolveSustainExtendedEndBeat,
type MidiAutomationPoint,
} 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 === 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);
});
});
+53 -4
View File
@@ -14,6 +14,8 @@ export interface MidiAutomationBakeOptions {
maxIntervalMs: number;
bpm: number;
defaultValue: number;
interpolationMode?: 'linear' | 'step';
quantizeValue?: (value: number) => number;
}
const BEAT_EPSILON = 1e-9;
@@ -22,6 +24,10 @@ function quantizeBakedValue(value: number): number {
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 {
const lastPoint = points[points.length - 1];
if (!lastPoint) {
@@ -91,7 +97,8 @@ export function collectRegionMidiAutomationPoints(
export function resolveMidiAutomationValueAtBeat(
points: MidiAutomationPoint[],
beat: number,
defaultValue: number
defaultValue: number,
interpolationMode: 'linear' | 'step' = 'linear'
): number {
const normalizedPoints = normalizeMidiAutomationPoints(points);
if (normalizedPoints.length === 0) {
@@ -105,6 +112,10 @@ export function resolveMidiAutomationValueAtBeat(
return defaultValue;
}
if (interpolationMode === 'step') {
return previousPoint.value;
}
return interpolateBetweenPoints(previousPoint, point, beat);
}
@@ -121,9 +132,13 @@ export function bakeMidiAutomationPointsInWindow(
options: MidiAutomationBakeOptions
): BakedMidiAutomationPoint[] {
const normalizedPoints = normalizeMidiAutomationPoints(points);
const interpolationMode = options.interpolationMode ?? 'linear';
const anchorPoint = {
beat: windowStartBeat,
value: quantizeBakedValue(resolveMidiAutomationValueAtBeat(normalizedPoints, windowStartBeat, options.defaultValue)),
value: quantizeValue(
resolveMidiAutomationValueAtBeat(normalizedPoints, windowStartBeat, options.defaultValue, interpolationMode),
options
),
};
if (windowEndBeat <= windowStartBeat) {
@@ -137,7 +152,22 @@ export function bakeMidiAutomationPointsInWindow(
const firstPoint = normalizedPoints[0];
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);
@@ -184,10 +214,29 @@ export function bakeMidiAutomationPointsInWindow(
appendPoint(bakedPoints, {
beat,
value: quantizeBakedValue(interpolateBetweenPoints(startPoint, endPoint, beat)),
value: quantizeValue(interpolateBetweenPoints(startPoint, endPoint, beat), options),
});
}
}
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;
}
+6
View File
@@ -27,6 +27,8 @@ export const MIDI_PITCH_BEND_CENTER = 8192;
export const MIDI_PITCH_BEND_MAX = 16383;
export const MIDI_PITCH_BEND_MIN_SIGNED = -8192;
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) => {
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)))
);
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 => (
clampMidiPitchBendValue(value) - MIDI_PITCH_BEND_CENTER
);