Files
ep-133-project-export/src/lib/exporters/ableton/builders.ts

1038 lines
35 KiB
TypeScript

import { create } from 'xmlbuilder2';
import {
EffectType,
ExporterParams,
FaderParam,
ProjectRawData,
ProjectSettings,
} from '../../../types/types';
import { EFFECTS } from '../../constants';
import abletonTransformer, {
AblClip,
AblGroupLane,
AblScene,
AblTrack,
} from '../../transformers/ableton';
import { getQuarterNotesPerBar } from '../utils';
import { ALSDrumBranch } from './types/drumBranch';
import { ALSDrumRack } from './types/drumRack';
import { ALSChorus } from './types/effectChorus';
import { ALSCompressor } from './types/effectCompressor';
import { ALSDelay } from './types/effectDelay';
import { ALSDistortion } from './types/effectDistortion';
import { ALSFilter } from './types/effectFilter';
import { ALSReverb } from './types/effectReverb';
import { ALSGroupTrack } from './types/groupTrack';
import { ALSMidiClip, ALSMidiClipContent } from './types/midiClip';
import { ALSMidiTrack } from './types/midiTrack';
import { ALSProject } from './types/project';
import { ALSReturnTrack } from './types/returnTrack';
import { ALSScene, ALSSceneContent } from './types/scene';
import { ALSSimpler } from './types/simpler';
import { ALSTrackSendHolder } from './types/trackSendHolder';
import {
AbletonAutomationTargets,
buildArrangementEnvelopes,
buildClipEnvelopes,
getFaderValueAt,
} from './automation';
import {
filterFreqFromNormalized,
fixIds,
getId,
gzipString,
koEnvRangeToSeconds,
loadTemplate,
resetIds,
TIME_SIGNATURES,
toNativePath,
} from './utils';
let _localId = -1;
let _localGroupId = -1;
let _localTrackColor = -1;
let _localGroupTrackColor = -1;
const MIN_CLIP_LAUNCHER_SCENES = 8;
type AbletonVersion = '10' | '11';
function getAbletonVersion(exporterParams: ExporterParams): AbletonVersion {
return exporterParams.abletonVersion === '10' ? '10' : '11';
}
function setColorValue(node: any, value: number) {
const color = node.Color || node.ColorIndex;
color['@Value'] = value;
}
function getColorValue(node: any): number {
const color = node.Color || node.ColorIndex;
return Number(color['@Value']);
}
function setLive10SampleReference(fileRef: any, sampleName: string) {
const pathElements = ['Samples', 'Imported'].map((dir, id) => ({
'@Id': id,
'@Dir': dir,
}));
fileRef.HasRelativePath = { '@Value': 'true' };
fileRef.RelativePathType = { '@Value': 3 };
fileRef.RelativePath = { RelativePathElement: pathElements };
fileRef.Name = { '@Value': sampleName };
fileRef.Type = { '@Value': 2 };
fileRef.Data = {};
fileRef.RefersToFolder = { '@Value': 'false' };
fileRef.SearchHint = {
PathHint: { RelativePathElement: pathElements },
FileSize: { '@Value': 0 },
Crc: { '@Value': 0 },
MaxCrcSize: { '@Value': 16384 },
HasExtendedInfo: { '@Value': 'false' },
};
fileRef.LivePackName = { '@Value': '' };
fileRef.LivePackId = { '@Value': '' };
}
function targetFor(parameter: any): { arrangement?: number; session?: number } {
return {
arrangement:
parameter?.AutomationTarget?.['@Id'] === undefined
? undefined
: Number(parameter.AutomationTarget['@Id']),
session:
parameter?.ModulationTarget?.['@Id'] === undefined
? undefined
: Number(parameter.ModulationTarget['@Id']),
};
}
function findSimplers(node: any, result: any[] = []): any[] {
if (Array.isArray(node)) {
node.forEach((item) => {
findSimplers(item, result);
});
return result;
}
if (!node || typeof node !== 'object') {
return result;
}
if (node.OriginalSimpler) {
result.push(node.OriginalSimpler);
}
Object.values(node).forEach((value) => {
findSimplers(value, result);
});
return result;
}
function addTarget(
targets: AbletonAutomationTargets,
parameter: FaderParam,
target: { arrangement?: number; session?: number },
) {
if (target.arrangement === undefined && target.session === undefined) {
return;
}
targets[parameter] = [...(targets[parameter] || []), target];
}
function trackAutomationTargets(midiTrack: any, includeArrangementMixer: boolean) {
const targets: AbletonAutomationTargets = {};
const mixer = midiTrack.DeviceChain.Mixer;
const sequencer = midiTrack.DeviceChain.MainSequencer;
addTarget(targets, FaderParam.LVL, {
arrangement: includeArrangementMixer
? Number(mixer.Volume.AutomationTarget['@Id'])
: undefined,
session: Number(sequencer.VolumeModulationTarget['@Id']),
});
addTarget(targets, FaderParam.PAN, {
arrangement: includeArrangementMixer ? Number(mixer.Pan.AutomationTarget['@Id']) : undefined,
session: Number(mixer.Pan.ModulationTarget['@Id']),
});
const send = mixer.Sends?.TrackSendHolder?.Send;
if (send) {
const target = targetFor(send);
if (!includeArrangementMixer) {
target.arrangement = undefined;
}
addTarget(targets, FaderParam.FX, target);
}
findSimplers(midiTrack.DeviceChain.DeviceChain.Devices).forEach((simpler) => {
addTarget(targets, FaderParam.PTC, targetFor(simpler.Pitch.TransposeKey));
addTarget(targets, FaderParam.TUNE, targetFor(simpler.Pitch.TransposeFine));
addTarget(targets, FaderParam.LPF, targetFor(simpler.Filter.Slot.Value.SimplerFilter.Freq));
addTarget(targets, FaderParam.HPF, targetFor(simpler.Filter.Slot.Value.SimplerFilter.Freq));
addTarget(targets, FaderParam.ATK, targetFor(simpler.VolumeAndPan.Envelope.AttackTime));
addTarget(targets, FaderParam.REL, targetFor(simpler.VolumeAndPan.Envelope.ReleaseTime));
addTarget(targets, FaderParam.MOD, targetFor(simpler.Pitch.PitchLfoAmount));
});
return targets;
}
function groupAutomationTargets(groupTrack: any) {
const targets: AbletonAutomationTargets = {};
const mixer = groupTrack.DeviceChain.Mixer;
addTarget(targets, FaderParam.LVL, targetFor(mixer.Volume));
addTarget(targets, FaderParam.PAN, targetFor(mixer.Pan));
const send = mixer.Sends?.TrackSendHolder?.Send;
if (send) {
addTarget(targets, FaderParam.FX, targetFor(send));
}
return targets;
}
async function buildMidiClip(
koClip: AblClip,
clipIdx: number,
color: number,
clipForLauncher: boolean = false,
abletonVersion: AbletonVersion = '11',
automationTargets: AbletonAutomationTargets = {},
koTrack?: AblTrack,
): Promise<ALSMidiClipContent> {
const midiClipTemplate = await loadTemplate<ALSMidiClip>('midiClip', abletonVersion);
const midiClip = structuredClone(midiClipTemplate.MidiClip);
const beats = getQuarterNotesPerBar(
koClip.timeSignature.numerator,
koClip.timeSignature.denominator,
);
const time = koClip.offset * beats;
const start = time;
const end = time + koClip.sceneBars * beats;
midiClip['@Id'] = clipIdx;
midiClip['@Time'] = time;
midiClip.CurrentStart['@Value'] = start;
midiClip.CurrentEnd['@Value'] = end;
midiClip.Loop.LoopOn['@Value'] = 'true';
midiClip.Loop.LoopStart['@Value'] = 0;
midiClip.Loop.LoopEnd['@Value'] = koClip.bars * beats;
if (midiClip.Loop.OutMarker) {
midiClip.Loop.OutMarker['@Value'] = koClip.bars * beats;
}
midiClip.Loop.HiddenLoopStart['@Value'] = 0;
midiClip.Loop.HiddenLoopEnd['@Value'] = koClip.bars * beats;
setColorValue(midiClip, color);
midiClip.Name['@Value'] = koClip.sceneName;
midiClip.TimeSignature.TimeSignatures.RemoteableTimeSignature.Numerator['@Value'] =
koClip.timeSignature.numerator;
midiClip.TimeSignature.TimeSignatures.RemoteableTimeSignature.Denominator['@Value'] =
koClip.timeSignature.denominator;
if (clipForLauncher) {
midiClip['@Time'] = 0;
midiClip.CurrentStart['@Value'] = 0;
midiClip.CurrentEnd['@Value'] = koClip.bars * beats;
}
// ableton group notes
const grouppedNotes: Record<string, typeof koClip.notes> = koClip.notes.reduce(
(acc, note) => {
const group = note.note.toString();
acc[group] = acc[group] || [];
acc[group].push(note);
return acc;
},
{} as Record<string, typeof koClip.notes>,
);
midiClip.Notes.KeyTracks.KeyTrack = [];
let _noteId = 0;
Object.entries(grouppedNotes).forEach(([noteValueStr, notes], groupIndex) => {
const noteValue = parseInt(noteValueStr, 10);
midiClip.Notes.KeyTracks.KeyTrack.push({
'@Id': groupIndex,
Notes: {
MidiNoteEvent: notes.map((noteData, noteIndex) => {
let dur = noteData.duration / 96;
const nextNote = notes[noteIndex + 1];
// making sure same notes are not overlapping
if (nextNote && noteData.position / 96 + dur > nextNote.position / 96) {
dur = nextNote.position / 96 - noteData.position / 96;
}
const velocity = Math.max(
1,
Math.min(
127,
koClip.groupPattern
? getFaderValueAt(koClip.groupPattern, FaderParam.VEL, noteData.position) * 127
: noteData.velocity,
),
);
return {
'@Time': noteData.position / 96,
'@Duration': dur,
'@Velocity': velocity,
'@OffVelocity': 64,
'@NoteId': _noteId++,
'@IsEnabled': 'true',
};
}),
},
MidiKey: {
'@Value': noteValue,
},
});
});
midiClip.Notes.NoteIdGenerator.NextId['@Value'] = _noteId;
if (clipForLauncher && koTrack) {
const clipEnvelopes = buildClipEnvelopes(
koClip.groupPattern,
automationTargets,
koTrack,
false,
);
midiClip.Envelopes.Envelopes = clipEnvelopes.length
? ({ ClipEnvelope: clipEnvelopes } as any)
: {};
}
return midiClip;
}
async function buildSimplerDevice(koTrack: AblTrack, abletonVersion: AbletonVersion) {
const simplerTemplate = await loadTemplate<ALSSimpler>('simpler', abletonVersion);
const device = structuredClone(simplerTemplate.OriginalSimpler);
device.LastPresetRef.Value = {};
switch (koTrack.playMode) {
case 'legato':
device.Globals.PlaybackMode['@Value'] = 0;
device.Globals.NumVoices['@Value'] = 1;
device.Globals.RetriggerMode['@Value'] = 'false';
break;
case 'oneshot':
device.Globals.PlaybackMode['@Value'] = 1;
break;
case 'key':
device.Globals.PlaybackMode['@Value'] = 0;
break;
}
const sampleRef = device.Player.MultiSampleMap.SampleParts.MultiSamplePart.SampleRef;
if (abletonVersion === '10') {
setLive10SampleReference(sampleRef.FileRef, koTrack.sampleName);
sampleRef.SourceContext = {};
} else {
sampleRef.FileRef.RelativePath['@Value'] = toNativePath(
`Samples/Imported/${koTrack.sampleName}`,
);
}
if (sampleRef.LastModDate) {
sampleRef.LastModDate['@Value'] = 0;
}
if (sampleRef.DefaultDuration) {
sampleRef.DefaultDuration['@Value'] = Math.round(koTrack.soundLength * koTrack.sampleRate);
}
if (sampleRef.DefaultSampleRate) {
sampleRef.DefaultSampleRate['@Value'] = koTrack.sampleRate;
}
device.Player.MultiSampleMap.SampleParts.MultiSamplePart.Name['@Value'] = koTrack.name;
device.Player.MultiSampleMap.SampleParts.MultiSamplePart.RootKey['@Value'] = koTrack.rootNote;
device.Player.MultiSampleMap.SampleParts.MultiSamplePart.SampleStart['@Value'] = koTrack.trimLeft;
device.Player.MultiSampleMap.SampleParts.MultiSamplePart.SampleEnd['@Value'] = koTrack.trimRight;
device.Player.MultiSampleMap.SampleParts.MultiSamplePart.SampleWarpProperties.TimeSignature.TimeSignatures.RemoteableTimeSignature.Numerator[
'@Value'
] = koTrack.timeSignature.numerator;
device.Player.MultiSampleMap.SampleParts.MultiSamplePart.SampleWarpProperties.TimeSignature.TimeSignatures.RemoteableTimeSignature.Denominator[
'@Value'
] = koTrack.timeSignature.denominator;
// setting velocity scaling to 20% to match KO behavior
device.VolumeAndPan.VolumeVelScale.Manual['@Value'] = 0.2;
device.VolumeAndPan.Envelope.ReleaseTime.Manual['@Value'] =
koEnvRangeToSeconds(koTrack.release, koTrack.soundLength) * 1000;
device.VolumeAndPan.Envelope.AttackTime.Manual['@Value'] =
koEnvRangeToSeconds(koTrack.attack, koTrack.soundLength) * 1000;
device.VolumeAndPan.Panorama.Manual['@Value'] = koTrack.pan;
// overriding attack time if ATK fader param is defined
if (koTrack.faderParams[FaderParam.ATK] !== -1) {
device.VolumeAndPan.Envelope.AttackTime.Manual['@Value'] =
koEnvRangeToSeconds(koTrack.faderParams[FaderParam.ATK] * 255, koTrack.soundLength) * 1000;
}
// overriding release time if REL fader param is defined
if (koTrack.faderParams[FaderParam.REL] !== -1) {
device.VolumeAndPan.Envelope.ReleaseTime.Manual['@Value'] =
koEnvRangeToSeconds(koTrack.faderParams[FaderParam.REL] * 255, koTrack.soundLength) * 1000;
}
let pitch = koTrack.pitch || 0;
if (koTrack.samplePitch) {
pitch += koTrack.samplePitch;
}
// adding PITCH fader param if defined
if (koTrack.faderParams[FaderParam.PTC] !== -1) {
const normalized = (koTrack.faderParams[FaderParam.PTC] - 0.5) * 2; // from 0-1 to -1 to +1
pitch += Math.round(normalized * 5);
}
if (koTrack.faderParams[FaderParam.TUNE] !== -1) {
device.Pitch.TransposeFine.Manual['@Value'] =
Math.max(-0.5, Math.min(0.5, koTrack.faderParams[FaderParam.TUNE] - 0.5)) * 100;
}
device.Pitch.TransposeKey.Manual['@Value'] = pitch;
// adding vibrato from MOD fader param if defined
if (koTrack.faderParams[FaderParam.MOD] !== -1) {
device.Lfo.IsOn.Manual['@Value'] = 'true';
// KO's MOD wheel on 100% is roughly equal to 5% LFO pitch modulation
device.Pitch.PitchLfoAmount.Manual['@Value'] = koTrack.faderParams[FaderParam.MOD] / 20;
device.Lfo.Slot.Value.SimplerLfo.RateType.Manual['@Value'] = 1; // tempo synced 1/16
}
if (koTrack.faderParams[FaderParam.LPF] !== -1 && koTrack.faderParams[FaderParam.LPF] !== 1) {
device.Filter.IsOn.Manual['@Value'] = 'true';
device.Filter.Slot.Value.SimplerFilter.Type.Manual['@Value'] = 0; // Low Pass
device.Filter.Slot.Value.SimplerFilter.Slope.Manual['@Value'] = 'true'; // 24db/oct
device.Filter.Slot.Value.SimplerFilter.Freq.Manual['@Value'] = filterFreqFromNormalized(
koTrack.faderParams[FaderParam.LPF] * 0.9, // lowering max freq to match KO behavior
);
}
if (koTrack.faderParams[FaderParam.HPF] !== -1 && koTrack.faderParams[FaderParam.HPF] !== 0) {
device.Filter.IsOn.Manual['@Value'] = 'true';
device.Filter.Slot.Value.SimplerFilter.Type.Manual['@Value'] = 1; // High Pass
device.Filter.Slot.Value.SimplerFilter.Slope.Manual['@Value'] = 'false'; // 12db/oct
device.Filter.Slot.Value.SimplerFilter.Freq.Manual['@Value'] = filterFreqFromNormalized(
koTrack.faderParams[FaderParam.HPF] * 0.7, // lowering max freq to match KO behavior
);
}
// TODO: add bandpass filter
if (koTrack.timeStretch === 'bars') {
device.Player.MultiSampleMap.SampleParts.MultiSamplePart.SampleWarpProperties.IsWarped[
'@Value'
] = 'true';
device.Player.MultiSampleMap.SampleParts.MultiSamplePart.SampleWarpProperties.WarpMode[
'@Value'
] = 0; // warp in 'Beats' mode
device.Player.MultiSampleMap.SampleParts.MultiSamplePart.SampleWarpProperties.WarpMarkers.WarpMarker =
[
{
'@Id': 0,
'@SecTime': 0,
'@BeatTime': 0,
},
{
'@Id': 1,
'@SecTime': koTrack.soundLength,
'@BeatTime': koTrack.timeStretchBars * koTrack.timeSignature.numerator, // convert bars to beats
},
];
}
if (koTrack.timeStretch === 'bpm') {
// calculating beat length using koTrack.timeStretchBpm
const beatTime = koTrack.soundLength / (60 / koTrack.timeStretchBpm);
device.Player.MultiSampleMap.SampleParts.MultiSamplePart.SampleWarpProperties.IsWarped[
'@Value'
] = 'true';
device.Player.MultiSampleMap.SampleParts.MultiSamplePart.SampleWarpProperties.WarpMode[
'@Value'
] = 0; // warp in 'Beats' mode
device.Player.MultiSampleMap.SampleParts.MultiSamplePart.SampleWarpProperties.WarpMarkers.WarpMarker =
[
{
'@Id': 0,
'@SecTime': 0,
'@BeatTime': 0,
},
{
'@Id': 1,
'@SecTime': koTrack.soundLength,
'@BeatTime': beatTime,
},
];
}
if (koTrack.timeStretch === 'rev') {
device.Player.Reverse.Manual['@Value'] = 'true';
}
return {
OriginalSimpler: device,
};
}
async function buildDrumRackDevice(koTrack: AblTrack, abletonVersion: AbletonVersion) {
const [drumRackTemplate, drumBranchTemplate] = await Promise.all([
loadTemplate<ALSDrumRack>('drumRack', abletonVersion),
loadTemplate<ALSDrumBranch>('drumBranch', abletonVersion),
]);
const device = structuredClone(drumRackTemplate.DrumGroupDevice);
device.Branches.DrumBranch = [];
for (const [idx, subtrack] of koTrack.tracks.entries()) {
if (!subtrack.sampleName) {
continue;
}
const padNumber = parseInt(subtrack.padCode.slice(1), 10);
// EP133 is 3x4 (top→bottom), Ableton Drum Rack is 4x4 (bottom→top)
// flip vertically: row 3 → row 0, row 0 → row 3
const row = 3 - Math.floor(padNumber / 3);
const col = padNumber % 3;
const drumPad = row * 4 + col;
const drumBranch = structuredClone(drumBranchTemplate.DrumBranch);
const note = 92 - drumPad; // 92 is the number of slot for C1 in the drum rack and it goes down
drumBranch['@Id'] = idx;
drumBranch.Name.EffectiveName['@Value'] = subtrack.name;
drumBranch.Name.UserName['@Value'] = subtrack.name;
drumBranch.BranchInfo.ReceivingNote['@Value'] = note;
drumBranch.BranchInfo.SendingNote['@Value'] = 60; // not sure if this matters
drumBranch.BranchInfo.ChokeGroup['@Value'] = subtrack.inChokeGroup ? 1 : 0;
drumBranch.DeviceChain.MidiToAudioDeviceChain.Devices = await buildSimplerDevice(
subtrack,
abletonVersion,
);
device.Branches.DrumBranch.push(drumBranch);
}
return {
DrumGroupDevice: device,
};
}
async function buildTrack(
koTrack: AblTrack,
groupLane: AblGroupLane | undefined,
maxScenes: number,
trackGroupId: number,
exporterParams: ExporterParams,
): Promise<ALSMidiTrack> {
const abletonVersion = getAbletonVersion(exporterParams);
const midiTrackTemplate = await loadTemplate<ALSMidiTrack>('midiTrack', abletonVersion);
const midiTrack = structuredClone(midiTrackTemplate.MidiTrack);
midiTrack['@Id'] = _localId++;
midiTrack.Name.EffectiveName['@Value'] = koTrack.soundId
? `${String(koTrack.soundId).padStart(3, '0')} ${koTrack.name}`
: koTrack.name;
midiTrack.Name.UserName['@Value'] = midiTrack.Name.EffectiveName['@Value'];
setColorValue(midiTrack, 8 + _localTrackColor++);
midiTrack.DeviceChain.MainSequencer.ClipTimeable.ArrangerAutomation.Events.MidiClip = [];
midiTrack.TrackGroupId['@Value'] = trackGroupId;
midiTrack.DeviceChain.DeviceChain.Devices['#'] = [];
midiTrack.DeviceChain.Mixer.Sends = {} as any;
midiTrack.DeviceChain.Mixer.Volume.Manual['@Value'] = koTrack.volume;
if (!exporterParams.groupTracks && koTrack.faderParams[FaderParam.PAN] !== -1) {
midiTrack.DeviceChain.Mixer.Pan.Manual['@Value'] =
(koTrack.faderParams[FaderParam.PAN] - 0.5) * 2;
}
if (!exporterParams.groupTracks && koTrack.faderParams[FaderParam.LVL] !== -1) {
midiTrack.DeviceChain.Mixer.Volume.Manual['@Value'] =
koTrack.volume * koTrack.faderParams[FaderParam.LVL];
}
if (koTrack.drumRack && exporterParams.includeArchivedSamples) {
midiTrack.DeviceChain.DeviceChain.Devices['#'].push(
await buildDrumRackDevice(koTrack, abletonVersion),
);
}
if (!koTrack.drumRack && koTrack.sampleName && exporterParams.includeArchivedSamples) {
midiTrack.DeviceChain.DeviceChain.Devices['#'].push(
await buildSimplerDevice(koTrack, abletonVersion),
);
}
// make sure id's are sequential in device chain
midiTrack.DeviceChain.DeviceChain.Devices['#'].forEach((device, deviceIdx) => {
Object.values(device).forEach((devContent: any) => {
devContent['@Id'] = deviceIdx;
});
});
if (exporterParams.groupTracks) {
midiTrack.DeviceChain.AudioOutputRouting.Target['@Value'] = 'AudioOut/GroupTrack';
midiTrack.DeviceChain.AudioOutputRouting.UpperDisplayString['@Value'] = 'Group';
}
if (exporterParams.sendEffects && (!exporterParams.groupTracks || exporterParams.clips)) {
const trackSendHolderTemplate = await loadTemplate<ALSTrackSendHolder>(
'trackSendHolder',
abletonVersion,
);
const trackSendHolder = structuredClone(trackSendHolderTemplate.TrackSendHolder);
trackSendHolder['@Id'] = 0;
trackSendHolder.Send.Manual['@Value'] = koTrack.faderParams[FaderParam.FX];
midiTrack.DeviceChain.Mixer.Sends.TrackSendHolder = trackSendHolder;
}
fixIds(midiTrack);
const automationTargets = trackAutomationTargets(
midiTrack,
!exporterParams.groupTracks && !exporterParams.clips,
);
// make sure each tracks has the same empty slots for clips
// must be equeal to GroupTrackSlot if this track is in a group
// must be at least 8 slots to avoid Ableton crashes
midiTrack.DeviceChain.MainSequencer.ClipSlotList.ClipSlot = [];
midiTrack.DeviceChain.FreezeSequencer.ClipSlotList.ClipSlot = [];
for (let sc = 0; sc < Math.max(MIN_CLIP_LAUNCHER_SCENES, maxScenes); sc++) {
midiTrack.DeviceChain.MainSequencer.ClipSlotList.ClipSlot[sc] = {
'@Id': sc,
LomId: {
'@Value': 0,
},
ClipSlot: {
Value: {},
},
};
midiTrack.DeviceChain.FreezeSequencer.ClipSlotList.ClipSlot[sc] = {
'@Id': sc,
LomId: {
'@Value': 0,
},
ClipSlot: {
Value: {},
},
HasStop: {
'@Value': 'true',
},
NeedRefreeze: {
'@Value': 'true',
},
};
}
if (koTrack.lane) {
if (!exporterParams.clips) {
for (const [clipIdx, koClip] of koTrack.lane.clips.entries()) {
const midiClip = await buildMidiClip(
koClip,
clipIdx,
getColorValue(midiTrack),
false,
abletonVersion,
automationTargets,
koTrack,
);
midiTrack.DeviceChain.MainSequencer.ClipTimeable.ArrangerAutomation.Events.MidiClip.push(
midiClip,
);
}
} else {
const sessionClips = [...koTrack.lane.clips];
groupLane?.patterns.forEach((pattern) => {
const hasClip = sessionClips.some((clip) => clip.sceneIndex === pattern.sceneIndex);
const hasSupportedAutomation = pattern.faderAutomation.some(
(point) =>
point.parameter !== FaderParam.TIM &&
point.parameter !== FaderParam.VEL &&
automationTargets[point.parameter]?.some((target) => target.session !== undefined),
);
if (!hasClip && hasSupportedAutomation) {
sessionClips.push({
notes: [],
bars: pattern.bars,
offset: pattern.offset,
sceneBars: pattern.sceneBars,
sceneIndex: pattern.sceneIndex,
sceneName: pattern.sceneName,
timeSignature: pattern.timeSignature,
faderParams: pattern.faderParams,
groupPattern: pattern,
});
}
});
for (const koClip of sessionClips) {
const midiClip = await buildMidiClip(
koClip,
0,
getColorValue(midiTrack),
true,
abletonVersion,
automationTargets,
koTrack,
);
midiTrack.DeviceChain.MainSequencer.ClipSlotList.ClipSlot[koClip.sceneIndex] = {
...midiTrack.DeviceChain.MainSequencer.ClipSlotList.ClipSlot[koClip.sceneIndex],
ClipSlot: {
Value: {
MidiClip: midiClip,
},
},
};
}
}
}
if (!exporterParams.clips && groupLane) {
const envelopes = buildArrangementEnvelopes(
groupLane.patterns,
automationTargets,
koTrack,
false,
);
midiTrack.AutomationEnvelopes.Envelopes = envelopes.length
? ({ AutomationEnvelope: envelopes } as any)
: {};
}
return { MidiTrack: midiTrack };
}
async function buildGroupTrack(
koTrack: AblTrack,
groupLane: AblGroupLane | undefined,
exporterParams: ExporterParams,
maxScenes: number,
) {
const abletonVersion = getAbletonVersion(exporterParams);
const groupTrackTemplate = await loadTemplate<ALSGroupTrack>('groupTrack', abletonVersion);
const groupTrack = structuredClone(groupTrackTemplate.GroupTrack);
groupTrack['@Id'] = _localGroupId++;
groupTrack.Name.EffectiveName['@Value'] = koTrack.group.toLocaleUpperCase();
groupTrack.Name.UserName['@Value'] = koTrack.group.toLocaleUpperCase();
setColorValue(groupTrack, 5 + _localGroupTrackColor++);
groupTrack.Slots.GroupTrackSlot = [];
groupTrack.DeviceChain.Mixer.Sends = {} as any;
if (!exporterParams.clips) {
if (koTrack.faderParams[FaderParam.LVL] !== -1) {
groupTrack.DeviceChain.Mixer.Volume.Manual['@Value'] =
koTrack.faderParams[FaderParam.LVL];
}
if (koTrack.faderParams[FaderParam.PAN] !== -1) {
groupTrack.DeviceChain.Mixer.Pan.Manual['@Value'] =
(koTrack.faderParams[FaderParam.PAN] - 0.5) * 2;
}
} else {
groupTrack.DeviceChain.Mixer.Volume.Manual['@Value'] = 1;
groupTrack.DeviceChain.Mixer.Pan.Manual['@Value'] = 0;
}
// adding empty slots for clips (or Ableton will crash)
// must be at least 8 slots to avoid Ableton crashes
for (let sc = 0; sc < Math.max(MIN_CLIP_LAUNCHER_SCENES, maxScenes); sc++) {
groupTrack.Slots.GroupTrackSlot.push({
'@Id': sc,
LomId: {
'@Value': 0,
},
});
}
if (
exporterParams.sendEffects &&
!exporterParams.clips &&
koTrack.faderParams[FaderParam.FX] !== -1
) {
const trackSendHolderTemplate = await loadTemplate<ALSTrackSendHolder>(
'trackSendHolder',
abletonVersion,
);
const trackSendHolder = structuredClone(trackSendHolderTemplate.TrackSendHolder);
trackSendHolder['@Id'] = 0;
trackSendHolder.Send.Manual['@Value'] = koTrack.faderParams[FaderParam.FX];
groupTrack.DeviceChain.Mixer.Sends.TrackSendHolder = trackSendHolder;
}
fixIds(groupTrack);
if (!exporterParams.clips && groupLane) {
const envelopes = buildArrangementEnvelopes(
groupLane.patterns,
groupAutomationTargets(groupTrack),
koTrack,
true,
);
groupTrack.AutomationEnvelopes.Envelopes = envelopes.length
? ({ AutomationEnvelope: envelopes } as any)
: {};
}
return { GroupTrack: groupTrack };
}
async function buildScenes(
scenes: AblScene[],
settings: ProjectSettings,
maxScenes: number,
abletonVersion: AbletonVersion,
) {
const sceneTemplate = await loadTemplate<ALSScene>('scene', abletonVersion);
const result: ALSSceneContent[] = [];
const sceneCount = Math.max(MIN_CLIP_LAUNCHER_SCENES, maxScenes);
for (let index = 0; index < sceneCount; index++) {
const scene = scenes[index];
const sceneContent = structuredClone(sceneTemplate.Scene);
sceneContent['@Id'] = _localId++;
if (abletonVersion === '10') {
(sceneContent as any)['@Value'] = scene?.name || ` ${index + 1}`;
} else {
sceneContent.Name['@Value'] = scene?.name || '';
sceneContent.Tempo['@Value'] = settings.bpm;
}
result.push(sceneContent);
}
return result;
}
async function buildTracks(
tracks: AblTrack[],
groupLanes: AblGroupLane[],
maxScenes: number,
exporterParams: ExporterParams,
) {
const result = [];
let trackGroupId = -1;
let currentGroup = '';
// if the tracks are grouped, we need to create a group track for each group BEFORE the children midi tracks
// this took me around 3 hours to figure out
for (const koTrack of tracks.sort((a, b) => a.group.localeCompare(b.group))) {
const groupLane = groupLanes.find((lane) => lane.group === koTrack.group);
if (exporterParams.groupTracks && koTrack.group !== currentGroup[0]) {
currentGroup = koTrack.group;
const groupTrack = await buildGroupTrack(koTrack, groupLane, exporterParams, maxScenes);
trackGroupId = groupTrack.GroupTrack['@Id'];
result.push(groupTrack);
}
const midiTrack = await buildTrack(
koTrack,
groupLane,
maxScenes,
trackGroupId,
exporterParams,
);
result.push(midiTrack);
}
return result;
}
async function buildReturnTrack(
projectData: ProjectRawData,
trackId: number = 0,
abletonVersion: AbletonVersion = '11',
) {
const returnTrackTemplate = await loadTemplate<ALSReturnTrack>('returnTrack', abletonVersion);
const returnTrack = structuredClone(returnTrackTemplate.ReturnTrack);
returnTrack['@Id'] = trackId;
returnTrack.Name.EffectiveName['@Value'] = EFFECTS[projectData.effects.effectType] || '';
returnTrack.Name.UserName['@Value'] = EFFECTS[projectData.effects.effectType] || '';
setColorValue(returnTrack, 28);
returnTrack.DeviceChain.Mixer.Sends = {} as any;
returnTrack.DeviceChain.DeviceChain.Devices = {} as any;
if (projectData.effects.effectType === EffectType.Reverb) {
const effectReverbTemplate = await loadTemplate<ALSReverb>('effectReverb', abletonVersion);
const effectReverb = structuredClone(effectReverbTemplate.Reverb);
// these params are very rough approximation of the KO's reverb
// it's not exact, but close enough
effectReverb.PreDelay.Manual['@Value'] = 0.5;
effectReverb.RoomSize.Manual['@Value'] = 300;
effectReverb.ShelfHiFreq.Manual['@Value'] = 1000;
effectReverb.DecayTime.Manual['@Value'] = projectData.effects.param1 * 8000; // max 8 seconds which is kinda close to how it sounds in KO
effectReverb.ShelfHiGain.Manual['@Value'] = projectData.effects.param2; // param2 is "color" which roughly matches the high shelf gain cut amount
returnTrack.DeviceChain.DeviceChain.Devices = { Reverb: effectReverb };
}
if (projectData.effects.effectType === EffectType.Delay) {
const effectDelayTemplate = await loadTemplate<ALSDelay>('effectDelay', abletonVersion);
const effectDelay = structuredClone(effectDelayTemplate.Delay);
effectDelay.DelayLine_SyncL.Manual['@Value'] = 'false';
effectDelay.DelayLine_SyncR.Manual['@Value'] = 'false';
// don't even ask how I came up with this formula
effectDelay.DelayLine_TimeL.Manual['@Value'] =
0.017397 * (287.725 ** projectData.effects.param1 - 1);
effectDelay.DelayLine_TimeR.Manual['@Value'] =
0.017397 * (287.725 ** projectData.effects.param1 - 1);
effectDelay.Feedback.Manual['@Value'] = projectData.effects.param2;
returnTrack.DeviceChain.DeviceChain.Devices = { Delay: effectDelay };
}
if (projectData.effects.effectType === EffectType.Distortion) {
const effectDistortionTemplate = await loadTemplate<ALSDistortion>(
'effectDistortion',
abletonVersion,
);
const effectDistortion = structuredClone(effectDistortionTemplate.Overdrive);
effectDistortion.Drive.Manual['@Value'] = projectData.effects.param1 * 50;
effectDistortion.Tone.Manual['@Value'] = projectData.effects.param2 * 100;
returnTrack.DeviceChain.DeviceChain.Devices = { Overdrive: effectDistortion };
}
if (projectData.effects.effectType === EffectType.Chorus) {
const effectChorusTemplate = await loadTemplate<ALSChorus>('effectChorus', abletonVersion);
const effectChorus = structuredClone(
abletonVersion === '10'
? (effectChorusTemplate as any).Chorus
: effectChorusTemplate.Chorus2,
);
if (abletonVersion === '10') {
effectChorus.ModAmount.Manual['@Value'] = projectData.effects.param1 * 6.5;
} else {
effectChorus.Amount.Manual['@Value'] = projectData.effects.param1;
}
effectChorus.Feedback.Manual['@Value'] = projectData.effects.param2 * 0.8;
returnTrack.DeviceChain.DeviceChain.Devices = (
abletonVersion === '10' ? { Chorus: effectChorus } : { Chorus2: effectChorus }
) as any;
}
// putting a filter in return track is absolutely pointless, but hey, why not
if (projectData.effects.effectType === EffectType.Filter) {
const effectFilterTemplate = await loadTemplate<ALSFilter>('effectFilter', abletonVersion);
const effectFilter = structuredClone(effectFilterTemplate.AutoFilter);
effectFilter.Cutoff.Manual['@Value'] = 20 + projectData.effects.param1 * (135 - 20);
effectFilter.Resonance.Manual['@Value'] = projectData.effects.param2 * 1.25;
returnTrack.DeviceChain.DeviceChain.Devices = { AutoFilter: effectFilter };
}
if (projectData.effects.effectType === EffectType.Compressor) {
const effectCompressorTemplate = await loadTemplate<ALSCompressor>(
'effectCompressor',
abletonVersion,
);
const effectCompressor = structuredClone(effectCompressorTemplate.Compressor2);
// params are ignored for now, just putting a basic compressor
returnTrack.DeviceChain.DeviceChain.Devices = { Compressor2: effectCompressor };
}
return { ReturnTrack: returnTrack };
}
export async function buildProject(projectData: ProjectRawData, exporterParams: ExporterParams) {
resetIds();
const abletonVersion = getAbletonVersion(exporterParams);
const transformedData = abletonTransformer(projectData, exporterParams);
if (import.meta.env.DEV) {
console.log('projectData', projectData);
console.log('transformedData', transformedData);
}
// reset local ids
_localId = 1;
_localGroupId = 50;
_localTrackColor = 1;
_localGroupTrackColor = 1;
const projectTemplate = await loadTemplate<ALSProject>('project', abletonVersion);
const project = structuredClone(projectTemplate);
// setting up tempo
project.Ableton.LiveSet.MasterTrack.DeviceChain.Mixer.Tempo.Manual['@Value'] =
projectData.settings.bpm;
project.Ableton.LiveSet.MasterTrack.AutomationEnvelopes.Envelopes.AutomationEnvelope[1].Automation.Events.FloatEvent[
'@Value'
] = projectData.settings.bpm;
// setting up time signature
project.Ableton.LiveSet.MasterTrack.AutomationEnvelopes.Envelopes.AutomationEnvelope[0].Automation.Events.EnumEvent[
'@Value'
] =
TIME_SIGNATURES[
`${projectData.scenesSettings.timeSignature.numerator}/${projectData.scenesSettings.timeSignature.denominator}`
] || TIME_SIGNATURES['4/4'];
const tracks = await buildTracks(
transformedData.tracks,
transformedData.groupLanes,
transformedData.scenes.length,
exporterParams,
);
project.Ableton.LiveSet.Tracks = { '#': tracks } as any;
if (exporterParams.sendEffects) {
const returnTrack = await buildReturnTrack(
projectData,
project.Ableton.LiveSet.Tracks['#'].length + 1,
abletonVersion,
);
project.Ableton.LiveSet.Tracks['#'].push(returnTrack);
}
if (exporterParams.clips) {
const scenes = await buildScenes(
transformedData.scenes,
projectData.settings,
transformedData.scenes.length,
abletonVersion,
);
if (abletonVersion === '10') {
(project.Ableton.LiveSet as any).SceneNames.Scene = scenes;
} else {
project.Ableton.LiveSet.Scenes.Scene = scenes;
}
}
if (exporterParams.sendEffects) {
project.Ableton.LiveSet.SendsPre = {
SendPreBool: {
'@Id': 0,
'@Value': 'false',
},
};
} else {
project.Ableton.LiveSet.SendsPre = {} as any;
}
const fixedRoot = fixIds(project);
fixedRoot.Ableton.LiveSet.NextPointeeId['@Value'] = getId();
if (import.meta.env.DEV) {
console.log('ROOT', fixedRoot);
}
const newXml = create(fixedRoot).end({ prettyPrint: true, indent: '\t' });
const gzipped = gzipString(newXml);
return gzipped;
}