2026-07-13 04:39:10 +02:00
|
|
|
import * as Sentry from '@sentry/react';
|
|
|
|
|
import omit from 'lodash/omit';
|
|
|
|
|
import {
|
|
|
|
|
ExporterParams,
|
2026-07-13 04:53:36 +02:00
|
|
|
FaderAutomationEvent,
|
2026-07-13 04:39:10 +02:00
|
|
|
FaderParam,
|
|
|
|
|
Note,
|
|
|
|
|
Pad,
|
|
|
|
|
PadCode,
|
|
|
|
|
ProjectRawData,
|
|
|
|
|
TimeSignature,
|
|
|
|
|
} from '../../types/types';
|
|
|
|
|
import { getSampleName } from '../exporters/utils';
|
|
|
|
|
import { findPad, findSoundByPad, findSoundIdByPad } from '../utils';
|
|
|
|
|
|
|
|
|
|
export type AblData = {
|
|
|
|
|
tracks: AblTrack[];
|
|
|
|
|
scenes: AblScene[];
|
2026-07-13 04:57:31 +02:00
|
|
|
groupLanes: AblGroupLane[];
|
2026-07-13 04:39:10 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type AblTrack = Omit<Pad, 'file' | 'rawData' | 'midiChannel'> & {
|
|
|
|
|
padCode: PadCode;
|
|
|
|
|
group: string;
|
|
|
|
|
sampleName: string;
|
|
|
|
|
sampleChannels: number;
|
|
|
|
|
sampleRate: number;
|
|
|
|
|
sampleRootNote: number;
|
|
|
|
|
samplePitch: number;
|
|
|
|
|
bpm: number;
|
|
|
|
|
drumRack: boolean;
|
|
|
|
|
lane?: AblLane;
|
|
|
|
|
tracks: AblTrack[];
|
|
|
|
|
faderParams: { [K in FaderParam]: number };
|
|
|
|
|
timeSignature: TimeSignature;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type AblLane = {
|
|
|
|
|
padCode: PadCode;
|
|
|
|
|
clips: AblClip[];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type AblNote = Note;
|
|
|
|
|
|
|
|
|
|
export type AblClip = {
|
|
|
|
|
notes: AblNote[];
|
|
|
|
|
bars: number;
|
|
|
|
|
offset: number;
|
|
|
|
|
sceneBars: number;
|
|
|
|
|
sceneIndex: number;
|
|
|
|
|
sceneName: string;
|
|
|
|
|
timeSignature: TimeSignature;
|
|
|
|
|
faderParams: { [K in FaderParam]: number };
|
2026-07-13 04:57:31 +02:00
|
|
|
groupPattern?: AblGroupPatternOccurrence;
|
2026-07-13 04:53:36 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type AblFaderAutomationPoint = Pick<FaderAutomationEvent, 'position' | 'value' | 'flags'> & {
|
|
|
|
|
parameter: FaderParam;
|
2026-07-13 04:39:10 +02:00
|
|
|
};
|
|
|
|
|
|
2026-07-13 04:57:31 +02:00
|
|
|
export type AblGroupPatternOccurrence = {
|
|
|
|
|
group: string;
|
|
|
|
|
bars: number;
|
|
|
|
|
sceneBars: number;
|
|
|
|
|
sceneIndex: number;
|
|
|
|
|
sceneName: string;
|
|
|
|
|
offset: number;
|
|
|
|
|
timeSignature: TimeSignature;
|
|
|
|
|
faderParams: { [K in FaderParam]: number };
|
|
|
|
|
faderAutomation: AblFaderAutomationPoint[];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type AblGroupLane = {
|
|
|
|
|
group: string;
|
|
|
|
|
patterns: AblGroupPatternOccurrence[];
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-13 04:39:10 +02:00
|
|
|
export type AblScene = {
|
|
|
|
|
name: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function abletonTransformer(data: ProjectRawData, exporterParams: ExporterParams) {
|
|
|
|
|
const { pads, scenes } = data;
|
|
|
|
|
const customSceneNames = exporterParams.customSceneNames || {};
|
|
|
|
|
const lanes: AblLane[] = [];
|
2026-07-13 04:57:31 +02:00
|
|
|
const groupLanes: AblGroupLane[] = [];
|
2026-07-13 04:39:10 +02:00
|
|
|
const ablScenes: AblScene[] = [];
|
|
|
|
|
let tracks: AblTrack[] = [];
|
|
|
|
|
let offset = 0;
|
|
|
|
|
|
|
|
|
|
scenes.forEach((scene, sceneIndex) => {
|
2026-07-13 04:53:36 +02:00
|
|
|
const sceneBars = Math.max(
|
|
|
|
|
1,
|
|
|
|
|
...scene.patterns.map((p) => p.bars),
|
|
|
|
|
...Object.values(scene.patternEvents).map((pattern) => pattern?.bars || 0),
|
|
|
|
|
);
|
2026-07-13 04:39:10 +02:00
|
|
|
const ablScene: AblScene = {
|
|
|
|
|
name: customSceneNames[scene.name] || scene.name,
|
|
|
|
|
};
|
2026-07-13 04:57:31 +02:00
|
|
|
const groupPatterns: Record<string, AblGroupPatternOccurrence> = {};
|
|
|
|
|
|
|
|
|
|
Object.entries(scene.patternEvents).forEach(([group, pattern]) => {
|
|
|
|
|
if (!pattern) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const faderParams = data.settings.groupFaderParams[group];
|
|
|
|
|
const groupPattern: AblGroupPatternOccurrence = {
|
|
|
|
|
group,
|
|
|
|
|
bars: pattern.bars,
|
|
|
|
|
sceneBars,
|
|
|
|
|
sceneIndex,
|
|
|
|
|
sceneName: customSceneNames[scene.name] || scene.name,
|
|
|
|
|
offset,
|
|
|
|
|
timeSignature: data.scenesSettings.timeSignature,
|
|
|
|
|
faderParams,
|
|
|
|
|
faderAutomation: pattern.events
|
|
|
|
|
.filter((event): event is FaderAutomationEvent => event.type === 'fader')
|
|
|
|
|
.filter((event) => event.parameter >= FaderParam.LVL && event.parameter <= FaderParam.MOD)
|
|
|
|
|
.map((event) => ({
|
|
|
|
|
position: event.position,
|
|
|
|
|
parameter: event.parameter as FaderParam,
|
|
|
|
|
value: event.value,
|
|
|
|
|
flags: event.flags,
|
|
|
|
|
})),
|
|
|
|
|
};
|
|
|
|
|
let groupLane = groupLanes.find((lane) => lane.group === group);
|
|
|
|
|
if (!groupLane) {
|
|
|
|
|
groupLane = { group, patterns: [] };
|
|
|
|
|
groupLanes.push(groupLane);
|
|
|
|
|
}
|
|
|
|
|
groupLane.patterns.push(groupPattern);
|
|
|
|
|
groupPatterns[group] = groupPattern;
|
|
|
|
|
});
|
2026-07-13 04:39:10 +02:00
|
|
|
|
|
|
|
|
scene.patterns.forEach((pattern) => {
|
|
|
|
|
let track = tracks.find((c) => c.padCode === pattern.pad);
|
|
|
|
|
let pad = findPad(pattern.pad, pads);
|
|
|
|
|
|
|
|
|
|
if (!pad) {
|
|
|
|
|
console.warn('Could not find pad data for', pattern.pad);
|
|
|
|
|
|
|
|
|
|
pad = {
|
|
|
|
|
group: pattern.group,
|
|
|
|
|
volume: 100,
|
|
|
|
|
attack: 0,
|
|
|
|
|
release: 0,
|
|
|
|
|
trimLeft: 0,
|
|
|
|
|
trimRight: 0,
|
|
|
|
|
pad: 0,
|
|
|
|
|
playMode: 'oneshot',
|
|
|
|
|
pan: 0,
|
|
|
|
|
pitch: 0,
|
|
|
|
|
rootNote: 60,
|
|
|
|
|
timeStretch: 'off',
|
|
|
|
|
timeStretchBpm: 0,
|
|
|
|
|
timeStretchBars: 0,
|
|
|
|
|
soundId: 0,
|
|
|
|
|
name: '',
|
|
|
|
|
file: null,
|
|
|
|
|
rawData: null,
|
|
|
|
|
soundLength: 0,
|
|
|
|
|
inChokeGroup: false,
|
|
|
|
|
midiChannel: 0,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const faderParams = data.settings.groupFaderParams[pad.group];
|
|
|
|
|
|
|
|
|
|
if (!track) {
|
|
|
|
|
const soundId = findSoundIdByPad(pattern.pad, pads) || 0;
|
|
|
|
|
const sound = findSoundByPad(pattern.pad, pads, data.sounds);
|
|
|
|
|
track = {
|
|
|
|
|
...omit(pad, ['file', 'rawData']),
|
|
|
|
|
soundId,
|
|
|
|
|
padCode: pattern.pad,
|
|
|
|
|
name: sound?.meta?.name || pattern.pad,
|
|
|
|
|
volume: pad.volume / 200, // converting from 0-200 to 0.0-1.0
|
|
|
|
|
sampleName: getSampleName(sound?.meta?.name, soundId),
|
|
|
|
|
sampleChannels: sound?.meta?.channels || 0,
|
|
|
|
|
sampleRate: sound?.meta?.samplerate || 0,
|
|
|
|
|
sampleRootNote: pad.rootNote,
|
|
|
|
|
samplePitch: sound?.meta?.['sound.pitch'] ?? 0,
|
|
|
|
|
bpm: data.settings.bpm,
|
|
|
|
|
drumRack: false,
|
|
|
|
|
tracks: [],
|
|
|
|
|
faderParams,
|
|
|
|
|
timeSignature: data.scenesSettings.timeSignature,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
tracks.push(track);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let lane = lanes.find((c) => c.padCode === pattern.pad);
|
|
|
|
|
if (!lane) {
|
|
|
|
|
lane = {
|
|
|
|
|
padCode: pattern.pad,
|
|
|
|
|
clips: [],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
lanes.push(lane);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
lane.clips.push({
|
|
|
|
|
offset,
|
|
|
|
|
notes: pattern.notes,
|
|
|
|
|
bars: pattern.bars,
|
|
|
|
|
sceneBars,
|
|
|
|
|
sceneIndex,
|
|
|
|
|
sceneName: customSceneNames[scene.name] || scene.name,
|
|
|
|
|
timeSignature: data.scenesSettings.timeSignature,
|
|
|
|
|
faderParams,
|
2026-07-13 04:57:31 +02:00
|
|
|
groupPattern: groupPatterns[pad.group],
|
2026-07-13 04:39:10 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
track.lane = lane;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
offset += sceneBars;
|
|
|
|
|
|
|
|
|
|
ablScenes.push(ablScene);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
tracks.sort((a, b) =>
|
|
|
|
|
a.padCode.localeCompare(b.padCode, undefined, { numeric: true, sensitivity: 'base' }),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (exporterParams.exportAllPadsWithSamples) {
|
|
|
|
|
for (const group in pads) {
|
|
|
|
|
pads[group].forEach((pad, index) => {
|
|
|
|
|
if (pad.soundId <= 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const padCode = `${group}${index}` as PadCode;
|
|
|
|
|
if (tracks.some((t) => t.padCode === padCode)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const sound = data.sounds.find((s) => s.id === pad.soundId);
|
|
|
|
|
const faderParams = data.settings.groupFaderParams[pad.group];
|
|
|
|
|
|
|
|
|
|
tracks.push({
|
|
|
|
|
...omit(pad, ['file', 'rawData']),
|
|
|
|
|
soundId: pad.soundId,
|
|
|
|
|
padCode,
|
|
|
|
|
name: sound?.meta?.name || padCode,
|
|
|
|
|
volume: pad.volume / 200,
|
|
|
|
|
sampleName: getSampleName(sound?.meta?.name, pad.soundId),
|
|
|
|
|
sampleChannels: sound?.meta?.channels || 0,
|
|
|
|
|
sampleRate: sound?.meta?.samplerate || 0,
|
|
|
|
|
sampleRootNote: pad.rootNote,
|
|
|
|
|
samplePitch: sound?.meta?.['sound.pitch'] ?? 0,
|
|
|
|
|
bpm: data.settings.bpm,
|
|
|
|
|
drumRack: false,
|
|
|
|
|
tracks: [],
|
|
|
|
|
faderParams,
|
|
|
|
|
timeSignature: data.scenesSettings.timeSignature,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
tracks.sort((a, b) =>
|
|
|
|
|
a.padCode.localeCompare(b.padCode, undefined, { numeric: true, sensitivity: 'base' }),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Helper function to create a drum rack track for a specific group
|
|
|
|
|
const createDrumRackTrack = (group: 'a' | 'b' | 'c' | 'd'): AblTrack | null => {
|
|
|
|
|
const groupTracksForDrumRack = tracks.filter((t) => t.group === group);
|
|
|
|
|
if (groupTracksForDrumRack.length === 0) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const drumTrack: AblTrack = {
|
|
|
|
|
padCode: `${group}0` as PadCode,
|
|
|
|
|
group,
|
|
|
|
|
sampleName: '',
|
|
|
|
|
sampleChannels: 0,
|
|
|
|
|
sampleRate: 0,
|
|
|
|
|
sampleRootNote: 60,
|
|
|
|
|
samplePitch: 0,
|
|
|
|
|
bpm: data.settings.bpm,
|
|
|
|
|
drumRack: true,
|
|
|
|
|
soundId: 0,
|
|
|
|
|
name: `Drums ${group.toUpperCase()}`,
|
|
|
|
|
volume: 1, // 1 means 0dB
|
|
|
|
|
attack: 0,
|
|
|
|
|
release: 0,
|
|
|
|
|
trimLeft: 0,
|
|
|
|
|
trimRight: 0,
|
|
|
|
|
pad: 0,
|
|
|
|
|
lane: undefined,
|
|
|
|
|
playMode: 'oneshot',
|
|
|
|
|
pan: 0,
|
|
|
|
|
pitch: 0,
|
|
|
|
|
rootNote: 60,
|
|
|
|
|
timeStretch: 'off',
|
|
|
|
|
timeStretchBpm: 0,
|
|
|
|
|
timeStretchBars: 0,
|
|
|
|
|
soundLength: 0,
|
|
|
|
|
tracks: groupTracksForDrumRack,
|
|
|
|
|
inChokeGroup: false,
|
|
|
|
|
faderParams: data.settings.groupFaderParams[group],
|
|
|
|
|
timeSignature: data.scenesSettings.timeSignature,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// We need to merge notes from all tracks in the group into one track
|
|
|
|
|
const newClips: Record<string, AblClip> = {};
|
|
|
|
|
|
|
|
|
|
drumTrack.tracks
|
|
|
|
|
.toSorted((a, b) =>
|
|
|
|
|
a.padCode.localeCompare(b.padCode, undefined, { numeric: true, sensitivity: 'base' }),
|
|
|
|
|
)
|
|
|
|
|
.forEach((track) => {
|
|
|
|
|
const padNumber = parseInt(track.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 = 36 + row * 4 + col; // remaping notes starting from C1 (36)
|
|
|
|
|
|
|
|
|
|
track.lane?.clips.forEach((clip) => {
|
|
|
|
|
if (!newClips[clip.sceneName]) {
|
|
|
|
|
newClips[clip.sceneName] = structuredClone(clip);
|
|
|
|
|
newClips[clip.sceneName].notes = [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
newClips[clip.sceneName].notes = [
|
|
|
|
|
...newClips[clip.sceneName].notes,
|
|
|
|
|
...clip.notes.map((n) => ({
|
|
|
|
|
...n,
|
|
|
|
|
note: drumPad,
|
|
|
|
|
})),
|
|
|
|
|
];
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
drumTrack.lane = {
|
|
|
|
|
padCode: `${group}0` as PadCode,
|
|
|
|
|
clips: Object.values(newClips),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return drumTrack;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Process drum racks for each group that has the option enabled
|
|
|
|
|
const drumRackTracks: AblTrack[] = [];
|
|
|
|
|
const groupsToProcess: Array<{ group: 'a' | 'b' | 'c' | 'd'; enabled: boolean }> = [
|
|
|
|
|
{ group: 'a', enabled: exporterParams.drumRackGroupA || false },
|
|
|
|
|
{ group: 'b', enabled: exporterParams.drumRackGroupB || false },
|
|
|
|
|
{ group: 'c', enabled: exporterParams.drumRackGroupC || false },
|
|
|
|
|
{ group: 'd', enabled: exporterParams.drumRackGroupD || false },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
for (const { group, enabled } of groupsToProcess) {
|
|
|
|
|
if (enabled) {
|
|
|
|
|
const drumTrack = createDrumRackTrack(group);
|
|
|
|
|
if (drumTrack) {
|
|
|
|
|
drumRackTracks.push(drumTrack);
|
|
|
|
|
// Remove tracks from this group from the main tracks array
|
|
|
|
|
tracks = tracks.filter((t) => t.group !== group);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Insert drum rack tracks at the beginning, maintaining group order (A, B, C, D)
|
|
|
|
|
tracks.unshift(...drumRackTracks);
|
|
|
|
|
|
|
|
|
|
Sentry.setContext(`abletonData`, {
|
|
|
|
|
tracks,
|
|
|
|
|
scenes: ablScenes,
|
2026-07-13 04:57:31 +02:00
|
|
|
groupLanes,
|
2026-07-13 04:39:10 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
tracks,
|
|
|
|
|
scenes: ablScenes,
|
2026-07-13 04:57:31 +02:00
|
|
|
groupLanes,
|
2026-07-13 04:39:10 +02:00
|
|
|
} as AblData;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default abletonTransformer;
|