243 lines
7.4 KiB
TypeScript
243 lines
7.4 KiB
TypeScript
import { clampMidiPitchBendValue } from './midiUtil';
|
|
|
|
export interface MidiAutomationPoint {
|
|
beat: number;
|
|
value: number;
|
|
}
|
|
|
|
export interface BakedMidiAutomationPoint {
|
|
beat: number;
|
|
value: number;
|
|
}
|
|
|
|
export interface MidiAutomationBakeOptions {
|
|
maxIntervalMs: number;
|
|
bpm: number;
|
|
defaultValue: number;
|
|
interpolationMode?: 'linear' | 'step';
|
|
quantizeValue?: (value: number) => number;
|
|
}
|
|
|
|
const BEAT_EPSILON = 1e-9;
|
|
|
|
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) {
|
|
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,
|
|
interpolationMode: 'linear' | 'step' = 'linear'
|
|
): 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;
|
|
}
|
|
|
|
if (interpolationMode === 'step') {
|
|
return previousPoint.value;
|
|
}
|
|
|
|
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 interpolationMode = options.interpolationMode ?? 'linear';
|
|
const anchorPoint = {
|
|
beat: windowStartBeat,
|
|
value: quantizeValue(
|
|
resolveMidiAutomationValueAtBeat(normalizedPoints, windowStartBeat, options.defaultValue, interpolationMode),
|
|
options
|
|
),
|
|
};
|
|
|
|
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: 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);
|
|
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;
|
|
}
|
|
|
|
// MIDI pitch bend playback here is event-based, not continuous on its own.
|
|
// The last emitted value is held until a later baked event changes it.
|
|
//
|
|
// That means a flat authored span such as:
|
|
// beat 1 -> value 0
|
|
// beat 2 -> value 0
|
|
// beat 3 -> value 100
|
|
// should *not* generate any intermediate events between beats 1 and 2.
|
|
// The value from beat 1 is simply held through beat 2, and the bend only
|
|
// begins once we emit the first baked point from the changing 2 -> 3 segment.
|
|
//
|
|
// In practice that first changing baked point may land slightly after beat 2
|
|
// depending on the bake interval (for example 10 ms), but it still does not
|
|
// cause an earlier gradual bend from beat 1. Skipping flat segments preserves
|
|
// the intended "hold, then change" behavior while also avoiding redundant
|
|
// scheduled pitch-bend events.
|
|
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: 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;
|
|
}
|