import * as Sentry from '@sentry/react'; import omit from 'lodash/omit'; 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 & { padCode: PadCode; group: Group; sampleName: string; sampleChannels: number; sampleRate: number; sampleRootNote: number; samplePitch: number; bpm: number; drumRack: boolean; drumRackSendingNote?: number; lane?: AblLane; tracks: AblTrack[]; faderParams: { [K in FaderParam]: number }; timeSignature: TimeSignature; }; export type AblLane = { padCode: PadCode; sessionClips: AblClip[]; arrangementClips: AblClip[]; }; export type AblNote = Note; export type AblClip = { notes: AblNote[]; midiEvents: AblMidiEvent[]; bars: number; offset: number; duration: number; loopDuration: number; sceneIndex?: number; occurrenceIndex?: number; sceneName: string; timeSignature: TimeSignature; faderParams: { [K in FaderParam]: number }; groupPattern?: AblGroupPatternOccurrence; }; export type AblMidiEvent = Pick< MidiPatternEvent, 'position' | 'source' | 'flags' | 'data' | 'rawData' >; export type AblFaderAutomationPoint = Pick & { parameter: FaderParam; }; export type AblGroupPatternOccurrence = { group: Group; patternNumber: number; bars: number; 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: 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); const row = 3 - Math.floor(padNumber / 3); const col = padNumber % 3; 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) { 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) { const sceneBars = Math.max(1, ...getScenePatterns(scene, patternLookup).map((pattern) => pattern.bars)); return sceneBars * getQuarterNotesPerBar(scene.timeSignature); } function getTrackNoteValues(track: AblTrack) { const clips = [ ...(track.lane?.sessionClips || []), ...(track.lane?.arrangementClips || []), ]; return [...new Set(clips.flatMap((clip) => clip.notes.map((note) => note.note)))]; } function hasTrackMidiEvents(track: AblTrack) { return [ ...(track.lane?.sessionClips || []), ...(track.lane?.arrangementClips || []), ].some((clip) => clip.midiEvents.length > 0); } function fallbackPad(group: Group, source: number): Pad { return { group, volume: 100, attack: 0, release: 0, trimLeft: 0, trimRight: 0, pad: source + 1, 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, }; } 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(); const laneLookup = new Map(); const groupLaneLookup = new Map( 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 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, 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, sampleRootNote: pad.rootNote, samplePitch: sound?.meta?.['sound.pitch'] ?? 0, bpm: data.settings.bpm, drumRack: false, tracks: [], faderParams, timeSignature, }; trackLookup.set(padCode, track); return track; }; const ensureLane = (track: AblTrack) => { const existingLane = laneLookup.get(track.padCode); if (existingLane) { return existingLane; } const lane: AblLane = { padCode: track.padCode, sessionClips: [], arrangementClips: [], }; laneLookup.set(track.padCode, lane); track.lane = lane; return lane; }; 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, 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, 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, ); addPatternClips(pattern, occurrence, 'session'); }); }); if (exporterParams.keepUnusedPatterns !== false) { const referencedPatterns = new Set(); data.scenes.forEach((scene) => { PROJECT_GROUPS.forEach((group) => { const patternNumber = scene.patternNumbers[group]; if (patternNumber > 0) { referencedPatterns.add(getPatternKey(group, patternNumber)); } }); }); 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'); }); } 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, }); 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) { PROJECT_GROUPS.forEach((group) => { data.pads[group].forEach((pad, index) => { if (pad.soundId <= 0) { return; } const padCode = `${group}${index}` as PadCode; if (trackLookup.has(padCode)) { return; } const track = ensureTrack(padCode, group, data.scenesSettings.timeSignature); tracks.push(track); }); }); tracks = [...new Map(tracks.map((track) => [track.padCode, track])).values()].sort((a, b) => a.padCode.localeCompare(b.padCode, undefined, { numeric: true, sensitivity: 'base' }), ); } const createDrumRackTrack = (group: Group): AblTrack | null => { const groupTracks = tracks .filter((track) => track.group === group && track.sampleName) .filter((track) => getTrackNoteValues(track).length <= 1) .filter((track) => !hasTrackMidiEvents(track)) .map((track) => ({ ...track, drumRackSendingNote: getTrackNoteValues(track)[0] ?? track.rootNote, })); if (groupTracks.length === 0) { return null; } const mergeClips = (destination: 'session' | 'arrangement') => { const clips = new Map(); 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, sampleName: '', sampleChannels: 0, sampleRate: 0, sampleRootNote: 60, samplePitch: 0, bpm: data.settings.bpm, drumRack: true, drumRackSendingNote: undefined, soundId: 0, name: `${group.toUpperCase()} Pads`, volume: 1, attack: 0, release: 0, trimLeft: 0, trimRight: 0, pad: 0, lane: lane.sessionClips.length > 0 || lane.arrangementClips.length > 0 ? lane : undefined, playMode: 'oneshot', pan: 0, pitch: 0, rootNote: 60, timeStretch: 'off', timeStretchBpm: 0, timeStretchBars: 0, soundLength: 0, tracks: groupTracks, inChokeGroup: false, faderParams: data.settings.groupFaderParams[group], timeSignature: data.scenesSettings.timeSignature, }; return drumTrack; }; const drumRackTracks: AblTrack[] = []; if (exporterParams.includeArchivedSamples) { PROJECT_GROUPS.forEach((group) => { const drumTrack = createDrumRackTrack(group); if (!drumTrack) { return; } drumRackTracks.push(drumTrack); const rackPadCodes = new Set(drumTrack.tracks.map((track) => track.padCode)); tracks = tracks.filter((track) => !rackPadCodes.has(track.padCode)); }); } tracks.unshift(...drumRackTracks); const groupLanes = PROJECT_GROUPS.map((group) => groupLaneLookup.get(group)).filter( (lane): lane is AblGroupLane => Boolean(lane), ); const result: AblData = { tracks, scenes: ablScenes, groupLanes, arrangementSections, }; Sentry.setContext('abletonData', result); return result; } export default abletonTransformer;