From 5233cae10bd0fe3e69d54fc4cd4506d46d9370e1 Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Tue, 14 Jul 2026 06:46:18 +0200 Subject: [PATCH] Add support for MIDI controller targets and clip envelopes in Ableton export --- src/lib/exporters/ableton/builders.ts | 24 +++++--- src/lib/exporters/ableton/midi.ts | 83 +++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 8 deletions(-) create mode 100644 src/lib/exporters/ableton/midi.ts diff --git a/src/lib/exporters/ableton/builders.ts b/src/lib/exporters/ableton/builders.ts index 41d3e3c..0c0d24e 100644 --- a/src/lib/exporters/ableton/builders.ts +++ b/src/lib/exporters/ableton/builders.ts @@ -29,6 +29,7 @@ import { filterEffectValues, reverbEffectValues, } from './effects'; +import { AbletonMidiControllerTargets, buildMidiClipEnvelopes } from './midi'; import { getSimplerPlaybackState } from './playback'; import { ALSDrumBranch } from './types/drumBranch'; import { ALSDrumRack } from './types/drumRack'; @@ -274,6 +275,7 @@ async function buildMidiClip( clipForLauncher: boolean = false, abletonVersion: AbletonVersion = '11', automationTargets: AbletonAutomationTargets = {}, + midiControllerTargets: AbletonMidiControllerTargets = {}, koTrack?: AblTrack, ): Promise { const midiClipTemplate = await loadTemplate('midiClip', abletonVersion); @@ -371,17 +373,21 @@ async function buildMidiClip( midiClip.Notes.NoteIdGenerator.NextId['@Value'] = _noteId; + const clipEnvelopes = + abletonVersion === '10' + ? buildMidiClipEnvelopes(koClip.midiEvents, midiControllerTargets) + : []; if (clipForLauncher && koTrack) { - const clipEnvelopes = buildClipEnvelopes( - koClip.groupPattern, - automationTargets, - koTrack, - false, + clipEnvelopes.push( + ...buildClipEnvelopes(koClip.groupPattern, automationTargets, koTrack, false), ); - midiClip.Envelopes.Envelopes = clipEnvelopes.length - ? ({ ClipEnvelope: clipEnvelopes } as any) - : {}; } + clipEnvelopes.forEach((envelope, index) => { + envelope['@Id'] = index; + }); + midiClip.Envelopes.Envelopes = clipEnvelopes.length + ? ({ ClipEnvelope: clipEnvelopes } as any) + : {}; return midiClip; } @@ -780,6 +786,7 @@ async function buildTrack( false, abletonVersion, automationTargets, + midiTrack.DeviceChain.MainSequencer.MidiControllers, koTrack, ); @@ -821,6 +828,7 @@ async function buildTrack( true, abletonVersion, automationTargets, + midiTrack.DeviceChain.MainSequencer.MidiControllers, koTrack, ); diff --git a/src/lib/exporters/ableton/midi.ts b/src/lib/exporters/ableton/midi.ts new file mode 100644 index 0000000..0be2abd --- /dev/null +++ b/src/lib/exporters/ableton/midi.ts @@ -0,0 +1,83 @@ +import type { AblMidiEvent } from '../../transformers/ableton'; + +export type AbletonMidiControllerTargets = Record; + +type AutomationPoint = { + time: number; + value: number; +}; + +const CHANNEL_PRESSURE_TARGET = 129; +const SENTINEL_TIME = -63072000; + +export function decodeChannelPressure(event: AblMidiEvent) { + if ( + event.data.length !== 4 || + event.data[0] !== 0xd0 || + event.data[1] > 127 || + event.data[2] !== 0xff || + event.data[3] !== 0 + ) { + return undefined; + } + + return event.data[1]; +} + +function floatEvents(points: AutomationPoint[]) { + return points.map((point, index) => ({ + '@Id': index, + '@Time': point.time, + '@Value': point.value, + })); +} + +export function buildMidiClipEnvelopes( + events: AblMidiEvent[], + controllerTargets: AbletonMidiControllerTargets, +) { + const target = controllerTargets[`ControllerTargets.${CHANNEL_PRESSURE_TARGET}`]?.['@Id']; + if (target === undefined) { + return []; + } + + const points: AutomationPoint[] = [{ time: SENTINEL_TIME, value: 0 }]; + let previousValue = 0; + let eventCount = 0; + + events.forEach((event) => { + const value = decodeChannelPressure(event); + if (value === undefined) { + return; + } + + const time = event.position / 96; + points.push({ time, value: previousValue }); + points.push({ time, value }); + previousValue = value; + eventCount++; + }); + + if (eventCount === 0) { + return []; + } + + return [ + { + '@Id': 0, + EnvelopeTarget: { PointeeId: { '@Value': target } }, + Automation: { + Events: { FloatEvent: floatEvents(points) }, + AutomationTransformViewState: { + IsTransformPending: { '@Value': 'false' }, + TimeAndValueTransforms: {}, + }, + }, + LoopSlot: { Value: {} }, + ScrollerTimePreserver: { + LeftTime: { '@Value': 0 }, + RightTime: { '@Value': 0 }, + }, + }, + ]; +}