feat: added pitch bend automation (linear interpolation)
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
bakeMidiAutomationPointsInWindow,
|
||||
collectRegionMidiAutomationPoints,
|
||||
normalizeMidiAutomationPoints,
|
||||
resolveMidiAutomationValueAtBeat,
|
||||
type MidiAutomationPoint,
|
||||
} from './midiAutomationUtil';
|
||||
|
||||
const defaultOptions = {
|
||||
maxIntervalMs: 10,
|
||||
bpm: 120,
|
||||
defaultValue: 8192,
|
||||
};
|
||||
|
||||
describe('midiAutomationUtil', () => {
|
||||
it('normalizes points and keeps the last duplicate beat', () => {
|
||||
const points: MidiAutomationPoint[] = [
|
||||
{ beat: 2, value: 1000 },
|
||||
{ beat: 1, value: 2000 },
|
||||
{ beat: 2, value: 3000 },
|
||||
];
|
||||
|
||||
expect(normalizeMidiAutomationPoints(points)).toEqual([
|
||||
{ beat: 1, value: 2000 },
|
||||
{ beat: 2, value: 3000 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('holds the default value before the first point', () => {
|
||||
const points = [{ beat: 4, value: 0 }];
|
||||
|
||||
expect(resolveMidiAutomationValueAtBeat(points, 2, 8192)).toBe(8192);
|
||||
});
|
||||
|
||||
it('interpolates linearly between two points', () => {
|
||||
const points = [
|
||||
{ beat: 0, value: 8192 },
|
||||
{ beat: 4, value: 0 },
|
||||
];
|
||||
|
||||
expect(resolveMidiAutomationValueAtBeat(points, 2, 8192)).toBe(4096);
|
||||
});
|
||||
|
||||
it('holds the last value after the final point', () => {
|
||||
const points = [{ beat: 1, value: 2048 }];
|
||||
|
||||
expect(resolveMidiAutomationValueAtBeat(points, 8, 8192)).toBe(2048);
|
||||
});
|
||||
|
||||
it('collects region-local points into absolute beats', () => {
|
||||
const collected = collectRegionMidiAutomationPoints([
|
||||
{ startBeat: 4, points: [{ beat: 0.5, value: 7000 }] },
|
||||
{ startBeat: 1, points: [{ beat: 0.25, value: 6000 }] },
|
||||
]);
|
||||
|
||||
expect(collected).toEqual([
|
||||
{ beat: 1.25, value: 6000 },
|
||||
{ beat: 4.5, value: 7000 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('bakes dense points according to the requested ms interval', () => {
|
||||
const points = [
|
||||
{ beat: 0, value: 8192 },
|
||||
{ beat: 1, value: 0 },
|
||||
];
|
||||
|
||||
expect(bakeMidiAutomationPointsInWindow(points, 0, 1, { ...defaultOptions, maxIntervalMs: 20 })).toHaveLength(25);
|
||||
expect(bakeMidiAutomationPointsInWindow(points, 0, 1, { ...defaultOptions, maxIntervalMs: 10 })).toHaveLength(50);
|
||||
expect(bakeMidiAutomationPointsInWindow(points, 0, 1, { ...defaultOptions, maxIntervalMs: 5 })).toHaveLength(100);
|
||||
});
|
||||
|
||||
it('treats flat segments as holds without adding interior baked points', () => {
|
||||
const points = [
|
||||
{ beat: 0, value: 4096 },
|
||||
{ beat: 4, value: 4096 },
|
||||
];
|
||||
|
||||
expect(bakeMidiAutomationPointsInWindow(points, 0, 4, { ...defaultOptions, maxIntervalMs: 10 })).toEqual([
|
||||
{ beat: 0, value: 4096 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('collapses consecutive baked points with the same value', () => {
|
||||
const points = [
|
||||
{ beat: 0.5, value: 8192 },
|
||||
{ beat: 1, value: 4096 },
|
||||
{ beat: 2, value: 4096 },
|
||||
{ beat: 3, value: 0 },
|
||||
];
|
||||
|
||||
expect(bakeMidiAutomationPointsInWindow(points, 0, 4, { ...defaultOptions, maxIntervalMs: 500 })).toEqual([
|
||||
{ beat: 0, value: 8192 },
|
||||
{ beat: 1, value: 4096 },
|
||||
{ beat: 3, value: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps later changing segments after skipping a flat segment', () => {
|
||||
const points = [
|
||||
{ beat: 0, value: 4096 },
|
||||
{ beat: 2, value: 4096 },
|
||||
{ beat: 4, value: 0 },
|
||||
];
|
||||
|
||||
const baked = bakeMidiAutomationPointsInWindow(points, 0, 4, { ...defaultOptions, maxIntervalMs: 500 });
|
||||
|
||||
expect(baked).toEqual([
|
||||
{ beat: 0, value: 4096 },
|
||||
{ beat: 3, value: 2048 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds a window anchor and preserves the correct loop boundary value', () => {
|
||||
const points = [
|
||||
{ beat: 2, value: 0 },
|
||||
{ beat: 6, value: 8192 },
|
||||
];
|
||||
|
||||
const baked = bakeMidiAutomationPointsInWindow(points, 4, 8, { ...defaultOptions, maxIntervalMs: 500 });
|
||||
|
||||
expect(baked[0]).toEqual({ beat: 4, value: 4096 });
|
||||
expect(baked.some(point => point.beat === 5 && point.value === 6144)).toBe(true);
|
||||
expect(baked.some(point => point.beat === 6 && point.value === 8192)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user