Files
KGStudio/src/util/midiAutomationUtil.ts
T

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;
}