Implement detailed fader automation logic for Ableton export
This commit is contained in:
277
src/lib/exporters/ableton/automation.ts
Normal file
277
src/lib/exporters/ableton/automation.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
import { FaderParam } from '../../../types/types';
|
||||
import { AblGroupPatternOccurrence, AblTrack } from '../../transformers/ableton';
|
||||
import { filterFreqFromNormalized, koEnvRangeToSeconds } from './utils';
|
||||
|
||||
export type AbletonAutomationTarget = {
|
||||
arrangement?: number;
|
||||
session?: number;
|
||||
};
|
||||
|
||||
export type AbletonAutomationTargets = Partial<
|
||||
Record<FaderParam, AbletonAutomationTarget[]>
|
||||
>;
|
||||
|
||||
type AutomationPoint = {
|
||||
time: number;
|
||||
value: number;
|
||||
};
|
||||
|
||||
const SENTINEL_TIME = -63072000;
|
||||
const BOUNDARY_EPSILON = 0.000001;
|
||||
|
||||
function defaultValue(parameter: FaderParam) {
|
||||
switch (parameter) {
|
||||
case FaderParam.LVL:
|
||||
case FaderParam.LPF:
|
||||
return 1;
|
||||
case FaderParam.PTC:
|
||||
case FaderParam.TIM:
|
||||
case FaderParam.PAN:
|
||||
case FaderParam.TUNE:
|
||||
return 0.5;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function convertFaderValue(
|
||||
parameter: FaderParam,
|
||||
value: number,
|
||||
track: AblTrack,
|
||||
groupMixer: boolean,
|
||||
session: boolean,
|
||||
) {
|
||||
switch (parameter) {
|
||||
case FaderParam.LVL:
|
||||
if (groupMixer || session) {
|
||||
return value;
|
||||
}
|
||||
return track.volume * value;
|
||||
case FaderParam.PTC:
|
||||
return (value - 0.5) * 10;
|
||||
case FaderParam.LPF:
|
||||
return filterFreqFromNormalized(value * 0.9);
|
||||
case FaderParam.HPF:
|
||||
return filterFreqFromNormalized(value * 0.7);
|
||||
case FaderParam.FX:
|
||||
return value;
|
||||
case FaderParam.ATK:
|
||||
return koEnvRangeToSeconds(value * 255, track.soundLength) * 1000;
|
||||
case FaderParam.REL:
|
||||
return koEnvRangeToSeconds(value * 255, track.soundLength) * 1000;
|
||||
case FaderParam.PAN:
|
||||
return (value - 0.5) * 2;
|
||||
case FaderParam.TUNE:
|
||||
return Math.max(-0.5, Math.min(0.5, value - 0.5)) * 100;
|
||||
case FaderParam.MOD:
|
||||
return value / 20;
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export function getFaderValueAt(
|
||||
occurrence: AblGroupPatternOccurrence | undefined,
|
||||
parameter: FaderParam,
|
||||
position: number,
|
||||
) {
|
||||
const staticValue = occurrence?.faderParams[parameter];
|
||||
let value = staticValue === undefined || staticValue === -1 ? defaultValue(parameter) : staticValue;
|
||||
const points = (occurrence?.faderAutomation || [])
|
||||
.filter((point) => point.parameter === parameter && point.position <= position)
|
||||
.sort((a, b) => a.position - b.position);
|
||||
|
||||
if (points.length > 0) {
|
||||
value = points[points.length - 1].value;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function getStaticValue(pattern: AblGroupPatternOccurrence, parameter: FaderParam) {
|
||||
const value = pattern.faderParams[parameter];
|
||||
return value === -1 ? defaultValue(parameter) : value;
|
||||
}
|
||||
|
||||
function getArrangementPoints(
|
||||
patterns: AblGroupPatternOccurrence[],
|
||||
parameter: FaderParam,
|
||||
track: AblTrack,
|
||||
groupMixer: boolean,
|
||||
) {
|
||||
const points: AutomationPoint[] = [];
|
||||
let hasAutomation = false;
|
||||
let previousValue = convertFaderValue(
|
||||
parameter,
|
||||
patterns[0] ? getStaticValue(patterns[0], parameter) : defaultValue(parameter),
|
||||
track,
|
||||
groupMixer,
|
||||
false,
|
||||
);
|
||||
|
||||
patterns.forEach((pattern) => {
|
||||
const beats =
|
||||
pattern.timeSignature.numerator * (4 / pattern.timeSignature.denominator);
|
||||
const patternTicks = pattern.bars * beats * 96;
|
||||
const sceneTicks = pattern.sceneBars * beats * 96;
|
||||
const startTime = pattern.offset * beats;
|
||||
const sourcePoints = pattern.faderAutomation
|
||||
.filter((point) => point.parameter === parameter)
|
||||
.sort((a, b) => a.position - b.position);
|
||||
const staticValue = convertFaderValue(
|
||||
parameter,
|
||||
getStaticValue(pattern, parameter),
|
||||
track,
|
||||
groupMixer,
|
||||
false,
|
||||
);
|
||||
|
||||
if (sourcePoints.length === 0) {
|
||||
if (hasAutomation) {
|
||||
points.push({ time: Math.max(0, startTime - BOUNDARY_EPSILON), value: previousValue });
|
||||
points.push({ time: startTime, value: staticValue });
|
||||
}
|
||||
previousValue = staticValue;
|
||||
return;
|
||||
}
|
||||
|
||||
hasAutomation = true;
|
||||
for (let loopOffset = 0; loopOffset < sceneTicks; loopOffset += patternTicks) {
|
||||
const loopStart = startTime + loopOffset / 96;
|
||||
points.push({ time: Math.max(0, loopStart - BOUNDARY_EPSILON), value: previousValue });
|
||||
points.push({ time: loopStart, value: staticValue });
|
||||
|
||||
sourcePoints.forEach((point) => {
|
||||
if (loopOffset + point.position >= sceneTicks) {
|
||||
return;
|
||||
}
|
||||
const value = convertFaderValue(parameter, point.value, track, groupMixer, false);
|
||||
points.push({ time: startTime + (loopOffset + point.position) / 96, value });
|
||||
previousValue = value;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (!hasAutomation) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [{ time: SENTINEL_TIME, value: points[0]?.value ?? previousValue }, ...points];
|
||||
}
|
||||
|
||||
function getClipPoints(
|
||||
pattern: AblGroupPatternOccurrence,
|
||||
parameter: FaderParam,
|
||||
track: AblTrack,
|
||||
groupMixer: boolean,
|
||||
) {
|
||||
const sourcePoints = pattern.faderAutomation
|
||||
.filter((point) => point.parameter === parameter)
|
||||
.sort((a, b) => a.position - b.position);
|
||||
if (sourcePoints.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const staticValue = convertFaderValue(
|
||||
parameter,
|
||||
getStaticValue(pattern, parameter),
|
||||
track,
|
||||
groupMixer,
|
||||
true,
|
||||
);
|
||||
return [
|
||||
{ time: SENTINEL_TIME, value: staticValue },
|
||||
{ time: 0, value: staticValue },
|
||||
...sourcePoints.map((point) => ({
|
||||
time: point.position / 96,
|
||||
value: convertFaderValue(parameter, point.value, track, groupMixer, true),
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
function floatEvents(points: AutomationPoint[]) {
|
||||
return points.map((point, index) => ({
|
||||
'@Id': index,
|
||||
'@Time': point.time,
|
||||
'@Value': point.value,
|
||||
}));
|
||||
}
|
||||
|
||||
function automationContent(points: AutomationPoint[]) {
|
||||
return {
|
||||
Events: { FloatEvent: floatEvents(points) },
|
||||
AutomationTransformViewState: {
|
||||
IsTransformPending: { '@Value': 'false' },
|
||||
TimeAndValueTransforms: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildArrangementEnvelopes(
|
||||
patterns: AblGroupPatternOccurrence[],
|
||||
targets: AbletonAutomationTargets,
|
||||
track: AblTrack,
|
||||
groupMixer: boolean,
|
||||
) {
|
||||
const envelopes: any[] = [];
|
||||
Object.entries(targets).forEach(([parameterString, targetList]) => {
|
||||
const parameter = Number(parameterString) as FaderParam;
|
||||
if (parameter === FaderParam.TIM || parameter === FaderParam.VEL) {
|
||||
return;
|
||||
}
|
||||
const points = getArrangementPoints(patterns, parameter, track, groupMixer);
|
||||
if (points.length === 0) {
|
||||
return;
|
||||
}
|
||||
targetList?.forEach((target) => {
|
||||
if (target.arrangement === undefined) {
|
||||
return;
|
||||
}
|
||||
envelopes.push({
|
||||
'@Id': envelopes.length,
|
||||
EnvelopeTarget: { PointeeId: { '@Value': target.arrangement } },
|
||||
Automation: automationContent(points),
|
||||
});
|
||||
});
|
||||
});
|
||||
return envelopes;
|
||||
}
|
||||
|
||||
export function buildClipEnvelopes(
|
||||
pattern: AblGroupPatternOccurrence | undefined,
|
||||
targets: AbletonAutomationTargets,
|
||||
track: AblTrack,
|
||||
groupMixer: boolean,
|
||||
) {
|
||||
if (!pattern) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const envelopes: any[] = [];
|
||||
Object.entries(targets).forEach(([parameterString, targetList]) => {
|
||||
const parameter = Number(parameterString) as FaderParam;
|
||||
if (parameter === FaderParam.TIM || parameter === FaderParam.VEL) {
|
||||
return;
|
||||
}
|
||||
const points = getClipPoints(pattern, parameter, track, groupMixer);
|
||||
if (points.length === 0) {
|
||||
return;
|
||||
}
|
||||
targetList?.forEach((target) => {
|
||||
if (target.session === undefined) {
|
||||
return;
|
||||
}
|
||||
envelopes.push({
|
||||
'@Id': envelopes.length,
|
||||
EnvelopeTarget: { PointeeId: { '@Value': target.session } },
|
||||
Automation: automationContent(points),
|
||||
LoopSlot: { Value: {} },
|
||||
ScrollerTimePreserver: {
|
||||
LeftTime: { '@Value': 0 },
|
||||
RightTime: { '@Value': 0 },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
return envelopes;
|
||||
}
|
||||
Reference in New Issue
Block a user