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 { AbletonAutomationTargets, buildArrangementEnvelopes, buildClipEnvelopes, getFaderValueAt, } from './automation'; import { chorusEffectValues, compressorEffectValues, delayEffectValues, distortionEffectValues, filterEffectValues, reverbEffectValues, } from './effects'; 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 { 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 simplerEnvelopeMilliseconds(value: number, soundLength: number, oneshot: boolean) { const milliseconds = koEnvRangeToSeconds(value, soundLength) * 1000; return oneshot ? Math.min(2000, milliseconds) : milliseconds; } 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.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, maxValue?: number, soundLength?: number) { const automationTarget = parameter?.AutomationTarget?.['@Id'] === undefined ? undefined : Number(parameter.AutomationTarget['@Id']); return { arrangement: automationTarget, session: automationTarget, maxValue, soundLength, }; } 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 findAutoFilters(node: any, result: any[] = []): any[] { if (Array.isArray(node)) { node.forEach((item) => { findAutoFilters(item, result); }); return result; } if (!node || typeof node !== 'object') { return result; } if (node.AutoFilter) { result.push(node.AutoFilter); } Object.values(node).forEach((value) => { findAutoFilters(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; addTarget(targets, FaderParam.LVL, { arrangement: includeArrangementMixer ? Number(mixer.Volume.AutomationTarget['@Id']) : undefined, session: Number(mixer.Volume.AutomationTarget['@Id']), }); addTarget(targets, FaderParam.PAN, { arrangement: includeArrangementMixer ? Number(mixer.Pan.AutomationTarget['@Id']) : undefined, session: Number(mixer.Pan.AutomationTarget['@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) => { const oneshot = Number(simpler.Globals.PlaybackMode['@Value']) === 1; const sampleRef = simpler.Player.MultiSampleMap.SampleParts.MultiSamplePart.SampleRef; const sampleRate = Number(sampleRef.DefaultSampleRate?.['@Value']); const sampleDuration = Number(sampleRef.DefaultDuration?.['@Value']); const soundLength = sampleRate > 0 ? sampleDuration / sampleRate : undefined; addTarget(targets, FaderParam.PTC, targetFor(simpler.Pitch.TransposeKey)); addTarget(targets, FaderParam.TUNE, targetFor(simpler.Pitch.TransposeFine)); addTarget( targets, FaderParam.ATK, targetFor( oneshot ? simpler.VolumeAndPan.OneShotEnvelope.FadeInTime : simpler.VolumeAndPan.Envelope.AttackTime, oneshot ? 2000 : undefined, soundLength, ), ); addTarget( targets, FaderParam.REL, targetFor( oneshot ? simpler.VolumeAndPan.OneShotEnvelope.FadeOutTime : simpler.VolumeAndPan.Envelope.ReleaseTime, oneshot ? 2000 : undefined, soundLength, ), ); addTarget(targets, FaderParam.MOD, targetFor(simpler.Pitch.PitchLfoAmount)); }); findAutoFilters(midiTrack.DeviceChain.DeviceChain.Devices).forEach((filter) => { const parameter = Number(filter.FilterType.Manual['@Value']) === 1 ? FaderParam.HPF : FaderParam.LPF; addTarget(targets, parameter, targetFor(filter.Cutoff)); }); 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 { const midiClipTemplate = await loadTemplate('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 = koClip.notes.reduce( (acc, note) => { const group = note.note.toString(); acc[group] = acc[group] || []; acc[group].push(note); return acc; }, {} as Record, ); 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 automatedVelocity = koClip.groupPattern ? getFaderValueAt(koClip.groupPattern, FaderParam.VEL, noteData.position) : undefined; const velocity = Math.max( 1, Math.min( 127, automatedVelocity === undefined ? noteData.velocity : automatedVelocity * 127, ), ); 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('simpler', abletonVersion); const device = structuredClone(simplerTemplate.OriginalSimpler); const oneshot = koTrack.playMode === 'oneshot'; const samplePart = device.Player.MultiSampleMap.SampleParts.MultiSamplePart; const loopModulators = device.Player.LoopModulators; const simplerFilter = device.Filter.Slot.Value.SimplerFilter; device.LastPresetRef.Value = {}; device.ShouldShowPresetName['@Value'] = 'false'; device.UserName['@Value'] = ''; loopModulators.SampleStart.Manual['@Value'] = 0; loopModulators.SampleLength.Manual['@Value'] = 1; loopModulators.LoopOn.Manual['@Value'] = 'false'; loopModulators.LoopLength.Manual['@Value'] = 1; loopModulators.LoopFade.Manual['@Value'] = 0; device.Filter.IsOn.Manual['@Value'] = 'false'; simplerFilter.Freq.Manual['@Value'] = 22000; simplerFilter.Res.Manual['@Value'] = 0; simplerFilter.Envelope.IsOn.Manual['@Value'] = 'false'; simplerFilter.Envelope.Amount.Manual['@Value'] = 0; simplerFilter.ModByLfo.Manual['@Value'] = 0; device.Lfo.IsOn.Manual['@Value'] = 'false'; device.Pitch.PitchLfoAmount.Manual['@Value'] = 0; device.VolumeAndPan.OneShotEnvelope.FadeInTime.Manual['@Value'] = 0; device.VolumeAndPan.OneShotEnvelope.FadeOutTime.Manual['@Value'] = 0; device.VolumeAndPan.OneShotEnvelope.SustainMode.Manual['@Value'] = 0; 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); } 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; } samplePart.Name['@Value'] = koTrack.name; samplePart.RootKey['@Value'] = koTrack.rootNote; samplePart.SampleStart['@Value'] = koTrack.trimLeft; samplePart.SampleEnd['@Value'] = koTrack.trimRight; samplePart.SustainLoop.Start['@Value'] = koTrack.trimLeft; samplePart.SustainLoop.End['@Value'] = koTrack.trimRight; samplePart.SustainLoop.Mode['@Value'] = 0; samplePart.SustainLoop.Crossfade['@Value'] = 0; samplePart.ReleaseLoop.Start['@Value'] = 0; samplePart.ReleaseLoop.End['@Value'] = 0; samplePart.SampleWarpProperties.TimeSignature.TimeSignatures.RemoteableTimeSignature.Numerator[ '@Value' ] = koTrack.timeSignature.numerator; samplePart.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; const attackTarget = oneshot ? device.VolumeAndPan.OneShotEnvelope.FadeInTime : device.VolumeAndPan.Envelope.AttackTime; const releaseTarget = oneshot ? device.VolumeAndPan.OneShotEnvelope.FadeOutTime : device.VolumeAndPan.Envelope.ReleaseTime; attackTarget.Manual['@Value'] = simplerEnvelopeMilliseconds( koTrack.attack, koTrack.soundLength, oneshot, ); releaseTarget.Manual['@Value'] = simplerEnvelopeMilliseconds( koTrack.release, koTrack.soundLength, oneshot, ); device.VolumeAndPan.Panorama.Manual['@Value'] = koTrack.pan; // overriding attack time if ATK fader param is defined if (koTrack.faderParams[FaderParam.ATK] !== -1) { attackTarget.Manual['@Value'] = simplerEnvelopeMilliseconds( koTrack.faderParams[FaderParam.ATK] * 255, koTrack.soundLength, oneshot, ); } // overriding release time if REL fader param is defined if (koTrack.faderParams[FaderParam.REL] !== -1) { releaseTarget.Manual['@Value'] = simplerEnvelopeMilliseconds( koTrack.faderParams[FaderParam.REL] * 255, koTrack.soundLength, oneshot, ); } 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'; if (!device.Lfo.Slot.Value.SimplerLfo && abletonVersion === '10') { const simplerLfoTemplate = await loadTemplate('simplerLfo', abletonVersion); device.Lfo.Slot.Value.SimplerLfo = structuredClone(simplerLfoTemplate.SimplerLfo); } // 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 } // 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 buildFaderFilterDevice( parameter: FaderParam.LPF | FaderParam.HPF, value: number, abletonVersion: AbletonVersion, ) { const filterTemplate = await loadTemplate('effectFilter', abletonVersion); const filter = structuredClone(filterTemplate.AutoFilter); const highPass = parameter === FaderParam.HPF; filter.UserName['@Value'] = highPass ? 'EP HPF' : 'EP LPF'; filter.FilterType.Manual['@Value'] = highPass ? 1 : 0; filter.LegacyFilterType.Manual['@Value'] = highPass ? 1 : 0; filter.Slope.Manual['@Value'] = highPass ? 'false' : 'true'; filter.Cutoff.Manual['@Value'] = highPass ? 20 + value * 0.7 * 115 : 20 + value * 0.9 * 115; return { AutoFilter: filter }; } async function buildDrumRackDevice(koTrack: AblTrack, abletonVersion: AbletonVersion) { const [drumRackTemplate, drumBranchTemplate] = await Promise.all([ loadTemplate('drumRack', abletonVersion), loadTemplate('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 { const abletonVersion = getAbletonVersion(exporterParams); const midiTrackTemplate = await loadTemplate('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), ); } if (exporterParams.includeArchivedSamples) { for (const parameter of [FaderParam.LPF, FaderParam.HPF] as const) { const staticValue = koTrack.faderParams[parameter]; const defaultValue = parameter === FaderParam.LPF ? 1 : 0; const hasAutomation = groupLane?.patterns.some((pattern) => pattern.faderAutomation.some((point) => point.parameter === parameter), ); if (hasAutomation || (staticValue !== -1 && staticValue !== defaultValue)) { midiTrack.DeviceChain.DeviceChain.Devices['#'].push( await buildFaderFilterDevice( parameter, staticValue === -1 ? defaultValue : staticValue, 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( '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('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( '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('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('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('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('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( '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('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('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( 'effectCompressor', abletonVersion, ); const effectCompressor = structuredClone(effectCompressorTemplate.Compressor2); // params are ignored for now, just putting a basic compressor returnTrack.DeviceChain.DeviceChain.Devices = { Compressor2: effectCompressor }; } Object.values(returnTrack.DeviceChain.DeviceChain.Devices).forEach((device: any) => { device['@Id'] = 0; }); 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('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; }