feat: implemented track level automation
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user