feat: added pitch bend automation (linear interpolation)

This commit is contained in:
Xiaohan-Tian
2026-05-06 18:25:14 -07:00
parent fc1a044e98
commit 738628fc76
18 changed files with 963 additions and 76 deletions
+171
View File
@@ -0,0 +1,171 @@
export interface MidiAutomationPoint {
beat: number;
value: number;
}
export interface BakedMidiAutomationPoint {
beat: number;
value: number;
}
export interface MidiAutomationBakeOptions {
maxIntervalMs: number;
bpm: number;
defaultValue: number;
}
const BEAT_EPSILON = 1e-9;
function appendPoint(points: BakedMidiAutomationPoint[], nextPoint: BakedMidiAutomationPoint): void {
const lastPoint = points[points.length - 1];
if (!lastPoint) {
points.push(nextPoint);
return;
}
if (Math.abs(lastPoint.beat - nextPoint.beat) <= BEAT_EPSILON) {
points[points.length - 1] = nextPoint;
return;
}
if (lastPoint.value === nextPoint.value) {
return;
}
points.push(nextPoint);
}
function interpolateBetweenPoints(startPoint: MidiAutomationPoint, endPoint: MidiAutomationPoint, beat: number): number {
if (Math.abs(endPoint.beat - startPoint.beat) <= BEAT_EPSILON) {
return endPoint.value;
}
const progress = (beat - startPoint.beat) / (endPoint.beat - startPoint.beat);
return startPoint.value + ((endPoint.value - startPoint.value) * progress);
}
function getMaxIntervalBeats(options: MidiAutomationBakeOptions): number {
if (!Number.isFinite(options.maxIntervalMs) || options.maxIntervalMs <= 0) {
return Number.POSITIVE_INFINITY;
}
return (options.maxIntervalMs / 1000) * (options.bpm / 60);
}
export function normalizeMidiAutomationPoints(points: MidiAutomationPoint[]): MidiAutomationPoint[] {
const sortedPoints = [...points]
.filter(point => Number.isFinite(point.beat) && Number.isFinite(point.value))
.sort((a, b) => a.beat - b.beat);
const normalizedPoints: MidiAutomationPoint[] = [];
sortedPoints.forEach(point => {
const lastPoint = normalizedPoints[normalizedPoints.length - 1];
if (lastPoint && Math.abs(lastPoint.beat - point.beat) <= BEAT_EPSILON) {
normalizedPoints[normalizedPoints.length - 1] = point;
return;
}
normalizedPoints.push(point);
});
return normalizedPoints;
}
export function collectRegionMidiAutomationPoints(
regions: Array<{ startBeat: number; points: MidiAutomationPoint[] }>
): MidiAutomationPoint[] {
return normalizeMidiAutomationPoints(
regions.flatMap(region => region.points.map(point => ({
beat: region.startBeat + point.beat,
value: point.value,
})))
);
}
export function resolveMidiAutomationValueAtBeat(
points: MidiAutomationPoint[],
beat: number,
defaultValue: number
): number {
const normalizedPoints = normalizeMidiAutomationPoints(points);
if (normalizedPoints.length === 0) {
return defaultValue;
}
let previousPoint: MidiAutomationPoint | null = null;
for (const point of normalizedPoints) {
if (beat < point.beat) {
if (!previousPoint) {
return defaultValue;
}
return interpolateBetweenPoints(previousPoint, point, beat);
}
previousPoint = point;
}
return previousPoint?.value ?? defaultValue;
}
export function bakeMidiAutomationPointsInWindow(
points: MidiAutomationPoint[],
windowStartBeat: number,
windowEndBeat: number,
options: MidiAutomationBakeOptions
): BakedMidiAutomationPoint[] {
const normalizedPoints = normalizeMidiAutomationPoints(points);
const anchorPoint = {
beat: windowStartBeat,
value: resolveMidiAutomationValueAtBeat(normalizedPoints, windowStartBeat, options.defaultValue),
};
if (windowEndBeat <= windowStartBeat) {
return [anchorPoint];
}
const bakedPoints: BakedMidiAutomationPoint[] = [anchorPoint];
if (normalizedPoints.length === 0) {
return bakedPoints;
}
const firstPoint = normalizedPoints[0];
if (windowStartBeat < firstPoint.beat && firstPoint.beat < windowEndBeat) {
appendPoint(bakedPoints, { beat: firstPoint.beat, value: firstPoint.value });
}
const maxIntervalBeats = getMaxIntervalBeats(options);
for (let index = 0; index < normalizedPoints.length - 1; index += 1) {
const startPoint = normalizedPoints[index];
const endPoint = normalizedPoints[index + 1];
const overlapStartBeat = Math.max(windowStartBeat, startPoint.beat);
const overlapEndBeat = Math.min(windowEndBeat, endPoint.beat);
if (overlapEndBeat - overlapStartBeat <= BEAT_EPSILON) {
continue;
}
if (startPoint.value === endPoint.value) {
continue;
}
const segmentLengthBeats = overlapEndBeat - overlapStartBeat;
const segmentCount = Number.isFinite(maxIntervalBeats)
? Math.max(1, Math.ceil(segmentLengthBeats / maxIntervalBeats))
: 1;
for (let segmentIndex = 1; segmentIndex <= segmentCount; segmentIndex += 1) {
const beat = overlapStartBeat + ((segmentLengthBeats * segmentIndex) / segmentCount);
if (beat >= windowEndBeat - BEAT_EPSILON) {
continue;
}
appendPoint(bakedPoints, {
beat,
value: interpolateBetweenPoints(startPoint, endPoint, beat),
});
}
}
return bakedPoints;
}