feat: implemented track level automation

This commit is contained in:
Xiaohan-Tian
2026-05-08 15:44:07 -07:00
parent 6e5d9466f4
commit 31e02b4149
24 changed files with 1931 additions and 36 deletions
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest';
import { KGTrackAutomationPoint } from '../core/track/KGTrackAutomationPoint';
import {
bakeTrackAutomationPointsInWindow,
normalizeTrackAutomationPoints,
resolveTrackAutomationValueAtBeat,
} from './trackAutomationUtil';
describe('trackAutomationUtil', () => {
it('dedupes same-beat points and keeps the latest value', () => {
const normalized = normalizeTrackAutomationPoints([
{ beat: 1, value: -6 },
{ beat: 1, value: -3 },
{ beat: 2, value: 1 },
], 'volume');
expect(normalized).toEqual([
{ beat: 1, value: -3 },
{ beat: 2, value: 1 },
]);
});
it('interpolates between points and falls back to the default before the first point', () => {
const points = [
new KGTrackAutomationPoint('point-1', 1, -6),
new KGTrackAutomationPoint('point-2', 3, 6),
];
expect(resolveTrackAutomationValueAtBeat(points, 'volume', 0.5, 0)).toBe(0);
expect(resolveTrackAutomationValueAtBeat(points, 'volume', 2, 0)).toBe(0);
});
it('bakes intermediate points for changing automation spans', () => {
const baked = bakeTrackAutomationPointsInWindow([
{ beat: 0, value: 0 },
{ beat: 2, value: 1 },
], 'pan', 0, 2, 250, 120);
expect(baked[0]).toEqual({ beat: 0, value: 0 });
expect(baked.some(point => point.beat > 0 && point.beat < 2)).toBe(true);
});
});
+195
View File
@@ -0,0 +1,195 @@
import { KGTrackAutomationPoint, type TrackAutomationType } from '../core/track/KGTrackAutomationPoint';
import { AUDIO_INTERFACE_CONSTANTS } from '../constants/coreConstants';
export interface TrackAutomationValuePoint {
beat: number;
value: number;
}
export interface BakedTrackAutomationPoint {
beat: number;
value: number;
}
const BEAT_EPSILON = 1e-9;
const DEFAULT_TRACK_AUTOMATION_VALUES: Record<TrackAutomationType, number> = {
volume: 0,
pan: 0,
};
export function getTrackAutomationDefaultValue(type: TrackAutomationType): number {
return DEFAULT_TRACK_AUTOMATION_VALUES[type];
}
export function clampTrackAutomationValue(type: TrackAutomationType, value: number): number {
if (type === 'volume') {
return Math.max(
AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB,
Math.min(AUDIO_INTERFACE_CONSTANTS.MAX_TRACK_VOLUME_DB, value)
);
}
return Math.max(-1, Math.min(1, value));
}
export function normalizeTrackAutomationPoints(
points: Array<TrackAutomationValuePoint | KGTrackAutomationPoint>,
type: TrackAutomationType
): TrackAutomationValuePoint[] {
const normalized = [...points]
.map(point => ({
beat: point instanceof KGTrackAutomationPoint ? point.getBeat() : point.beat,
value: point instanceof KGTrackAutomationPoint ? point.getValue() : point.value,
}))
.filter(point => Number.isFinite(point.beat) && Number.isFinite(point.value))
.map(point => ({
beat: point.beat,
value: clampTrackAutomationValue(type, point.value),
}))
.sort((a, b) => a.beat - b.beat);
const deduped: TrackAutomationValuePoint[] = [];
normalized.forEach(point => {
const lastPoint = deduped[deduped.length - 1];
if (lastPoint && Math.abs(lastPoint.beat - point.beat) <= BEAT_EPSILON) {
deduped[deduped.length - 1] = point;
return;
}
deduped.push(point);
});
return deduped;
}
export function instantiateTrackAutomationPoints(
type: TrackAutomationType,
points: Array<{ id: string; beat: number; value: number }>
): KGTrackAutomationPoint[] {
return normalizeTrackAutomationPoints(points, type).map(point => {
const matchingSource = [...points].reverse().find(source => Math.abs(source.beat - point.beat) <= BEAT_EPSILON);
return new KGTrackAutomationPoint(matchingSource?.id ?? '', point.beat, point.value);
});
}
export function resolveTrackAutomationValueAtBeat(
points: Array<TrackAutomationValuePoint | KGTrackAutomationPoint>,
type: TrackAutomationType,
beat: number,
defaultValue: number,
): number {
const normalizedPoints = normalizeTrackAutomationPoints(points, type);
if (normalizedPoints.length === 0) {
return defaultValue;
}
let previousPoint: TrackAutomationValuePoint | null = null;
for (const point of normalizedPoints) {
if (beat < point.beat) {
if (!previousPoint) {
return defaultValue;
}
const span = point.beat - previousPoint.beat;
if (Math.abs(span) <= BEAT_EPSILON) {
return point.value;
}
const ratio = (beat - previousPoint.beat) / span;
return previousPoint.value + ((point.value - previousPoint.value) * ratio);
}
previousPoint = point;
}
return previousPoint?.value ?? defaultValue;
}
export function bakeTrackAutomationPointsInWindow(
points: Array<TrackAutomationValuePoint | KGTrackAutomationPoint>,
type: TrackAutomationType,
windowStartBeat: number,
windowEndBeat: number,
maxIntervalMs: number,
bpm: number,
defaultValue: number = getTrackAutomationDefaultValue(type),
): BakedTrackAutomationPoint[] {
const normalizedPoints = normalizeTrackAutomationPoints(points, type);
const anchorPoint = {
beat: windowStartBeat,
value: resolveTrackAutomationValueAtBeat(normalizedPoints, type, windowStartBeat, defaultValue),
};
if (windowEndBeat <= windowStartBeat) {
return [anchorPoint];
}
const baked: BakedTrackAutomationPoint[] = [anchorPoint];
if (normalizedPoints.length === 0) {
return baked;
}
const maxIntervalBeats = !Number.isFinite(maxIntervalMs) || maxIntervalMs <= 0
? Number.POSITIVE_INFINITY
: (maxIntervalMs / 1000) * (bpm / 60);
const appendPoint = (point: BakedTrackAutomationPoint) => {
const lastPoint = baked[baked.length - 1];
if (!lastPoint) {
baked.push(point);
return;
}
if (Math.abs(lastPoint.beat - point.beat) <= BEAT_EPSILON) {
baked[baked.length - 1] = point;
return;
}
if (Math.abs(lastPoint.value - point.value) <= BEAT_EPSILON) {
return;
}
baked.push(point);
};
normalizedPoints.forEach(point => {
if (point.beat > windowStartBeat && point.beat < windowEndBeat) {
appendPoint(point);
}
});
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 (Math.abs(startPoint.value - endPoint.value) <= BEAT_EPSILON) {
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;
}
const ratio = (beat - startPoint.beat) / (endPoint.beat - startPoint.beat);
appendPoint({
beat,
value: clampTrackAutomationValue(type, startPoint.value + ((endPoint.value - startPoint.value) * ratio)),
});
}
}
return baked;
}