Refactor Ablton data structure to support arrangement sections and modern clip handling

This commit is contained in:
2026-07-14 09:37:36 +02:00
parent 58d90a4740
commit 496d3ddf21

View File

@@ -4,25 +4,31 @@ import {
ExporterParams,
FaderAutomationEvent,
FaderParam,
Group,
GroupPattern,
MidiPatternEvent,
Note,
Pad,
PadCode,
ProjectRawData,
Scene,
TimeSignature,
} from '../../types/types';
import { getSampleName } from '../exporters/utils';
import { findPad, findSoundByPad, findSoundIdByPad } from '../utils';
const PROJECT_GROUPS: Group[] = ['a', 'b', 'c', 'd'];
export type AblData = {
tracks: AblTrack[];
scenes: AblScene[];
groupLanes: AblGroupLane[];
arrangementSections: AblArrangementSection[];
};
export type AblTrack = Omit<Pad, 'file' | 'rawData' | 'midiChannel'> & {
padCode: PadCode;
group: string;
group: Group;
sampleName: string;
sampleChannels: number;
sampleRate: number;
@@ -39,7 +45,8 @@ export type AblTrack = Omit<Pad, 'file' | 'rawData' | 'midiChannel'> & {
export type AblLane = {
padCode: PadCode;
clips: AblClip[];
sessionClips: AblClip[];
arrangementClips: AblClip[];
};
export type AblNote = Note;
@@ -49,8 +56,10 @@ export type AblClip = {
midiEvents: AblMidiEvent[];
bars: number;
offset: number;
sceneBars: number;
sceneIndex: number;
duration: number;
loopDuration: number;
sceneIndex?: number;
occurrenceIndex?: number;
sceneName: string;
timeSignature: TimeSignature;
faderParams: { [K in FaderParam]: number };
@@ -67,110 +76,101 @@ export type AblFaderAutomationPoint = Pick<FaderAutomationEvent, 'position' | 'v
};
export type AblGroupPatternOccurrence = {
group: string;
group: Group;
patternNumber: number;
bars: number;
sceneBars: number;
sceneIndex: number;
sceneName: string;
offset: number;
duration: number;
loopDuration: number;
sceneIndex?: number;
occurrenceIndex?: number;
sceneName: string;
timeSignature: TimeSignature;
faderParams: { [K in FaderParam]: number };
faderAutomation: AblFaderAutomationPoint[];
};
export type AblGroupLane = {
group: string;
patterns: AblGroupPatternOccurrence[];
group: Group;
sessionPatterns: AblGroupPatternOccurrence[];
arrangementPatterns: AblGroupPatternOccurrence[];
};
export type AblScene = {
name: string;
kind: 'scene' | 'pattern';
affectedGroups: Group[];
timeSignature?: TimeSignature;
};
export type AblArrangementSection = {
sceneNumber: number;
sceneName: string;
offset: number;
duration: number;
timeSignature: TimeSignature;
};
export function getDrumRackMidiNote(padCode: PadCode) {
const padNumber = parseInt(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;
return 36 + row * 4 + col; // remaping notes starting from C1 (36)
return 36 + row * 4 + col;
}
function getQuarterNotesPerBar(timeSignature: TimeSignature) {
return timeSignature.numerator * (4 / timeSignature.denominator);
}
function getPatternKey(group: Group, patternNumber: number) {
return `${group}${patternNumber}`;
}
function getPatternSources(pattern: GroupPattern) {
const sources = new Set(Object.keys(pattern.notes).map(Number));
pattern.events.forEach((event) => {
if (event.type === 'midi' && event.source >= 0 && event.source < 12) {
sources.add(event.source);
}
});
return [...sources].toSorted((a, b) => a - b);
}
function getScenePatterns(scene: Scene, patternLookup: Map<string, GroupPattern>) {
return PROJECT_GROUPS.map((group) =>
patternLookup.get(getPatternKey(group, scene.patternNumbers[group])),
).filter((pattern): pattern is GroupPattern => Boolean(pattern));
}
function getSceneDuration(scene: Scene, patternLookup: Map<string, GroupPattern>) {
const sceneBars = Math.max(1, ...getScenePatterns(scene, patternLookup).map((pattern) => pattern.bars));
return sceneBars * getQuarterNotesPerBar(scene.timeSignature);
}
function getTrackNoteValues(track: AblTrack) {
return [
...new Set(track.lane?.clips.flatMap((clip) => clip.notes.map((note) => note.note)) || []),
const clips = [
...(track.lane?.sessionClips || []),
...(track.lane?.arrangementClips || []),
];
return [...new Set(clips.flatMap((clip) => clip.notes.map((note) => note.note)))];
}
function abletonTransformer(data: ProjectRawData, exporterParams: ExporterParams) {
const { pads, scenes } = data;
const customSceneNames = exporterParams.customSceneNames || {};
const lanes: AblLane[] = [];
const groupLanes: AblGroupLane[] = [];
const ablScenes: AblScene[] = [];
let tracks: AblTrack[] = [];
let offset = 0;
function hasTrackMidiEvents(track: AblTrack) {
return [
...(track.lane?.sessionClips || []),
...(track.lane?.arrangementClips || []),
].some((clip) => clip.midiEvents.length > 0);
}
scenes.forEach((scene, sceneIndex) => {
const sceneBars = Math.max(
1,
...scene.patterns.map((p) => p.bars),
...Object.values(scene.patternEvents).map((pattern) => pattern?.bars || 0),
);
const ablScene: AblScene = {
name: customSceneNames[scene.name] || scene.name,
};
const groupPatterns: Record<string, AblGroupPatternOccurrence> = {};
Object.entries(scene.patternEvents).forEach(([group, pattern]) => {
if (!pattern) {
return;
}
const faderParams = data.settings.groupFaderParams[group];
const groupPattern: AblGroupPatternOccurrence = {
function fallbackPad(group: Group, source: number): Pad {
return {
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;
});
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,
pad: source + 1,
playMode: 'oneshot',
pan: 0,
pitch: 0,
@@ -186,23 +186,47 @@ function abletonTransformer(data: ProjectRawData, exporterParams: ExporterParams
inChokeGroup: false,
midiChannel: 0,
};
}
function abletonTransformer(data: ProjectRawData, exporterParams: ExporterParams) {
const customSceneNames = exporterParams.customSceneNames || {};
const patternLookup = new Map(
data.groupPatterns.map((pattern) => [
getPatternKey(pattern.group, pattern.patternNumber),
pattern,
]),
);
const trackLookup = new Map<PadCode, AblTrack>();
const laneLookup = new Map<PadCode, AblLane>();
const groupLaneLookup = new Map<Group, AblGroupLane>(
PROJECT_GROUPS.map((group) => [
group,
{ group, sessionPatterns: [], arrangementPatterns: [] },
]),
);
const ablScenes: AblScene[] = [];
const arrangementSections: AblArrangementSection[] = [];
const getSceneName = (scene: Scene) => customSceneNames[scene.name] || scene.name;
const ensureTrack = (padCode: PadCode, group: Group, timeSignature: TimeSignature) => {
const existingTrack = trackLookup.get(padCode);
if (existingTrack) {
return existingTrack;
}
const faderParams = data.settings.groupFaderParams[pad.group];
const source = Number(pattern.pad.slice(1));
const midiEvents = (scene.patternEvents[pattern.group]?.events || []).filter(
(event): event is MidiPatternEvent => event.type === 'midi' && event.source === source,
);
if (!track) {
const soundId = findSoundIdByPad(pattern.pad, pads) || 0;
const sound = findSoundByPad(pattern.pad, pads, data.sounds);
track = {
const source = Number(padCode.slice(1));
const pad = findPad(padCode, data.pads) || fallbackPad(group, source);
const soundId = findSoundIdByPad(padCode, data.pads) || 0;
const sound = findSoundByPad(padCode, data.pads, data.sounds);
const faderParams = data.settings.groupFaderParams[group];
const track: AblTrack = {
...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
padCode,
group,
name: sound?.meta?.name || padCode,
volume: pad.volume / 200,
sampleName: getSampleName(sound?.meta?.name, soundId),
sampleChannels: sound?.meta?.channels || 0,
sampleRate: sound?.meta?.samplerate || 0,
@@ -212,101 +236,302 @@ function abletonTransformer(data: ProjectRawData, exporterParams: ExporterParams
drumRack: false,
tracks: [],
faderParams,
timeSignature: data.scenesSettings.timeSignature,
timeSignature,
};
trackLookup.set(padCode, track);
return track;
};
tracks.push(track);
const ensureLane = (track: AblTrack) => {
const existingLane = laneLookup.get(track.padCode);
if (existingLane) {
return existingLane;
}
let lane = lanes.find((c) => c.padCode === pattern.pad);
if (!lane) {
lane = {
padCode: pattern.pad,
clips: [],
const lane: AblLane = {
padCode: track.padCode,
sessionClips: [],
arrangementClips: [],
};
laneLookup.set(track.padCode, lane);
track.lane = lane;
return lane;
};
lanes.push(lane);
}
lane.clips.push({
const createOccurrence = (
pattern: GroupPattern,
sceneName: string,
timeSignature: TimeSignature,
offset: number,
duration: number,
sceneIndex?: number,
occurrenceIndex?: number,
): AblGroupPatternOccurrence => {
const occurrence: AblGroupPatternOccurrence = {
group: pattern.group,
patternNumber: pattern.patternNumber,
bars: pattern.bars,
offset,
notes: pattern.notes,
duration,
loopDuration: pattern.bars * getQuarterNotesPerBar(timeSignature),
sceneIndex,
occurrenceIndex,
sceneName,
timeSignature,
faderParams: data.settings.groupFaderParams[pattern.group],
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,
})),
};
const groupLane = groupLaneLookup.get(pattern.group);
if (groupLane) {
if (sceneIndex !== undefined) {
groupLane.sessionPatterns.push(occurrence);
}
if (occurrenceIndex !== undefined) {
groupLane.arrangementPatterns.push(occurrence);
}
}
return occurrence;
};
const addPatternClips = (
pattern: GroupPattern,
occurrence: AblGroupPatternOccurrence,
destination: 'session' | 'arrangement',
) => {
getPatternSources(pattern).forEach((source) => {
const padCode = `${pattern.group}${source}` as PadCode;
const track = ensureTrack(padCode, pattern.group, occurrence.timeSignature);
const lane = ensureLane(track);
const midiEvents = pattern.events.filter(
(event): event is MidiPatternEvent => event.type === 'midi' && event.source === source,
);
const clip: AblClip = {
notes: pattern.notes[source] || [],
midiEvents,
bars: pattern.bars,
sceneBars,
offset: occurrence.offset,
duration: occurrence.duration,
loopDuration: occurrence.loopDuration,
sceneIndex: occurrence.sceneIndex,
occurrenceIndex: occurrence.occurrenceIndex,
sceneName: occurrence.sceneName,
timeSignature: occurrence.timeSignature,
faderParams: occurrence.faderParams,
groupPattern: occurrence,
};
if (destination === 'session') {
lane.sessionClips.push(clip);
} else {
lane.arrangementClips.push(clip);
}
});
};
const selectedSceneNames = new Set(exporterParams.selectedScenes || []);
const sessionScenes = data.scenes.filter(
(scene) => exporterParams.allScenes !== false || selectedSceneNames.has(scene.name),
);
sessionScenes.forEach((scene) => {
const sceneIndex = ablScenes.length;
const sceneName = getSceneName(scene);
const duration = getSceneDuration(scene, patternLookup);
ablScenes.push({
name: sceneName,
kind: 'scene',
affectedGroups: [...PROJECT_GROUPS],
timeSignature: scene.timeSignature,
});
PROJECT_GROUPS.forEach((group) => {
const pattern = patternLookup.get(getPatternKey(group, scene.patternNumbers[group]));
if (!pattern) {
return;
}
const occurrence = createOccurrence(
pattern,
sceneName,
scene.timeSignature,
0,
duration,
sceneIndex,
sceneName: customSceneNames[scene.name] || scene.name,
timeSignature: data.scenesSettings.timeSignature,
faderParams,
groupPattern: groupPatterns[pad.group],
);
addPatternClips(pattern, occurrence, 'session');
});
});
track.lane = lane;
if (exporterParams.keepUnusedPatterns !== false) {
const referencedPatterns = new Set<string>();
data.scenes.forEach((scene) => {
PROJECT_GROUPS.forEach((group) => {
const patternNumber = scene.patternNumbers[group];
if (patternNumber > 0) {
referencedPatterns.add(getPatternKey(group, patternNumber));
}
});
});
offset += sceneBars;
data.groupPatterns
.filter(
(pattern) =>
pattern.events.length > 0 &&
!referencedPatterns.has(getPatternKey(pattern.group, pattern.patternNumber)),
)
.toSorted((a, b) => {
const groupDifference = PROJECT_GROUPS.indexOf(a.group) - PROJECT_GROUPS.indexOf(b.group);
return groupDifference || a.patternNumber - b.patternNumber;
})
.forEach((pattern) => {
const sceneIndex = ablScenes.length;
const sceneName = `${pattern.group.toUpperCase()} PATTERN ${String(pattern.patternNumber).padStart(2, '0')}`;
const timeSignature = data.scenesSettings.timeSignature;
const duration = Math.max(1, pattern.bars) * getQuarterNotesPerBar(timeSignature);
ablScenes.push({
name: sceneName,
kind: 'pattern',
affectedGroups: [pattern.group],
});
const occurrence = createOccurrence(
pattern,
sceneName,
timeSignature,
0,
duration,
sceneIndex,
);
addPatternClips(pattern, occurrence, 'session');
});
}
ablScenes.push(ablScene);
let arrangementOffset = 0;
data.songPositions.forEach((songPosition, occurrenceIndex) => {
const scene = data.scenes.find((candidate) => candidate.number === songPosition.sceneNumber);
if (!scene) {
console.warn(`Could not find scene ${songPosition.sceneNumber} from Song position`);
return;
}
const sceneName = getSceneName(scene);
const duration = getSceneDuration(scene, patternLookup);
arrangementSections.push({
sceneNumber: scene.number,
sceneName,
offset: arrangementOffset,
duration,
timeSignature: scene.timeSignature,
});
tracks.sort((a, b) =>
PROJECT_GROUPS.forEach((group) => {
const pattern = patternLookup.get(getPatternKey(group, scene.patternNumbers[group]));
if (!pattern) {
return;
}
const occurrence = createOccurrence(
pattern,
sceneName,
scene.timeSignature,
arrangementOffset,
duration,
undefined,
occurrenceIndex,
);
addPatternClips(pattern, occurrence, 'arrangement');
});
arrangementOffset += duration;
});
let tracks = [...trackLookup.values()].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) => {
PROJECT_GROUPS.forEach((group) => {
data.pads[group].forEach((pad, index) => {
if (pad.soundId <= 0) {
return;
}
const padCode = `${group}${index}` as PadCode;
if (tracks.some((t) => t.padCode === padCode)) {
if (trackLookup.has(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,
const track = ensureTrack(padCode, group, data.scenesSettings.timeSignature);
tracks.push(track);
});
});
}
tracks.sort((a, b) =>
tracks = [...new Map(tracks.map((track) => [track.padCode, track])).values()].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
const createDrumRackTrack = (group: Group): AblTrack | null => {
const groupTracks = tracks
.filter((track) => track.group === group && track.sampleName)
.filter((track) => getTrackNoteValues(track).length <= 1)
.filter((track) => !track.lane?.clips.some((clip) => clip.midiEvents.length > 0))
.filter((track) => !hasTrackMidiEvents(track))
.map((track) => ({
...track,
drumRackSendingNote: getTrackNoteValues(track)[0] ?? track.rootNote,
}));
if (groupTracksForDrumRack.length === 0) {
if (groupTracks.length === 0) {
return null;
}
const mergeClips = (destination: 'session' | 'arrangement') => {
const clips = new Map<number, AblClip>();
groupTracks
.toSorted((a, b) =>
a.padCode.localeCompare(b.padCode, undefined, { numeric: true, sensitivity: 'base' }),
)
.forEach((track) => {
const drumPad = getDrumRackMidiNote(track.padCode);
const sourceClips =
destination === 'session'
? track.lane?.sessionClips || []
: track.lane?.arrangementClips || [];
sourceClips.forEach((clip) => {
const key = destination === 'session' ? clip.sceneIndex : clip.occurrenceIndex;
if (key === undefined) {
return;
}
if (!clips.has(key)) {
const mergedClip = structuredClone(clip);
mergedClip.notes = [];
mergedClip.midiEvents = [];
clips.set(key, mergedClip);
}
const mergedClip = clips.get(key);
if (!mergedClip) {
return;
}
mergedClip.notes.push(
...clip.notes.map((note) => ({
...note,
note: drumPad,
})),
);
mergedClip.midiEvents.push(...clip.midiEvents);
});
});
return [...clips.values()].sort((a, b) => {
const aIndex = destination === 'session' ? a.sceneIndex : a.occurrenceIndex;
const bIndex = destination === 'session' ? b.sceneIndex : b.occurrenceIndex;
return (aIndex ?? 0) - (bIndex ?? 0);
});
};
const lane: AblLane = {
padCode: `${group}0` as PadCode,
sessionClips: mergeClips('session'),
arrangementClips: mergeClips('arrangement'),
};
const drumTrack: AblTrack = {
padCode: `${group}0` as PadCode,
group,
@@ -320,13 +545,14 @@ function abletonTransformer(data: ProjectRawData, exporterParams: ExporterParams
drumRackSendingNote: undefined,
soundId: 0,
name: `${group.toUpperCase()} Pads`,
volume: 1, // 1 means 0dB
volume: 1,
attack: 0,
release: 0,
trimLeft: 0,
trimRight: 0,
pad: 0,
lane: undefined,
lane:
lane.sessionClips.length > 0 || lane.arrangementClips.length > 0 ? lane : undefined,
playMode: 'oneshot',
pan: 0,
pitch: 0,
@@ -335,81 +561,39 @@ function abletonTransformer(data: ProjectRawData, exporterParams: ExporterParams
timeStretchBpm: 0,
timeStretchBars: 0,
soundLength: 0,
tracks: groupTracksForDrumRack,
tracks: groupTracks,
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 = new Map<number, AblClip>();
drumTrack.tracks
.toSorted((a, b) =>
a.padCode.localeCompare(b.padCode, undefined, { numeric: true, sensitivity: 'base' }),
)
.forEach((track) => {
const drumPad = getDrumRackMidiNote(track.padCode);
track.lane?.clips.forEach((clip) => {
if (!newClips.has(clip.sceneIndex)) {
const newClip = structuredClone(clip);
newClip.notes = [];
newClip.midiEvents = [];
newClips.set(clip.sceneIndex, newClip);
}
const newClip = newClips.get(clip.sceneIndex);
if (!newClip) {
return;
}
newClip.notes = [
...newClip.notes,
...clip.notes.map((n) => ({
...n,
note: drumPad,
})),
];
newClip.midiEvents = [...newClip.midiEvents, ...clip.midiEvents];
});
});
drumTrack.lane = {
padCode: `${group}0` as PadCode,
clips: [...newClips.values()].sort((a, b) => a.sceneIndex - b.sceneIndex),
};
return drumTrack;
};
// Process drum racks for each group that has the option enabled
const drumRackTracks: AblTrack[] = [];
if (exporterParams.includeArchivedSamples) {
for (const group of ['a', 'b', 'c', 'd'] as const) {
PROJECT_GROUPS.forEach((group) => {
const drumTrack = createDrumRackTrack(group);
if (drumTrack) {
if (!drumTrack) {
return;
}
drumRackTracks.push(drumTrack);
const rackPadCodes = new Set(drumTrack.tracks.map((track) => track.padCode));
tracks = tracks.filter((track) => !rackPadCodes.has(track.padCode));
});
}
}
}
// Insert drum rack tracks at the beginning, maintaining group order (A, B, C, D)
tracks.unshift(...drumRackTracks);
Sentry.setContext(`abletonData`, {
const groupLanes = PROJECT_GROUPS.map((group) => groupLaneLookup.get(group)).filter(
(lane): lane is AblGroupLane => Boolean(lane),
);
const result: AblData = {
tracks,
scenes: ablScenes,
groupLanes,
});
return {
tracks,
scenes: ablScenes,
groupLanes,
} as AblData;
arrangementSections,
};
Sentry.setContext('abletonData', result);
return result;
}
export default abletonTransformer;