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