diff --git a/src/lib/exporters/ableton/automation.ts b/src/lib/exporters/ableton/automation.ts new file mode 100644 index 0000000..8b50ed4 --- /dev/null +++ b/src/lib/exporters/ableton/automation.ts @@ -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 +>; + +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; +}