diff --git a/src/lib/transformers/ableton.ts b/src/lib/transformers/ableton.ts index b4ca577..22c971a 100644 --- a/src/lib/transformers/ableton.ts +++ b/src/lib/transformers/ableton.ts @@ -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 & { padCode: PadCode; - group: string; + group: Group; sampleName: string; sampleChannels: number; sampleRate: number; @@ -39,7 +45,8 @@ export type AblTrack = Omit & { 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,246 +76,462 @@ export type AblFaderAutomationPoint = Pick { + 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) { - 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 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 { pads, scenes } = data; const customSceneNames = exporterParams.customSceneNames || {}; - const lanes: AblLane[] = []; - const groupLanes: AblGroupLane[] = []; + 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[] = []; - let tracks: AblTrack[] = []; - let offset = 0; + const arrangementSections: AblArrangementSection[] = []; - 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 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, }; - const groupPatterns: Record = {}; + trackLookup.set(padCode, track); + return track; + }; - Object.entries(scene.patternEvents).forEach(([group, pattern]) => { + 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 faderParams = data.settings.groupFaderParams[group]; - const groupPattern: AblGroupPatternOccurrence = { - group, - bars: pattern.bars, - sceneBars, + const occurrence = createOccurrence( + pattern, + sceneName, + scene.timeSignature, + 0, + duration, 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, - 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]; - 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 = { - ...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, - midiEvents, - bars: pattern.bars, - sceneBars, - sceneIndex, - sceneName: customSceneNames[scene.name] || scene.name, - timeSignature: data.scenesSettings.timeSignature, - faderParams, - groupPattern: groupPatterns[pad.group], - }); - - track.lane = lane; + addPatternClips(pattern, occurrence, 'session'); }); - - offset += sceneBars; - - ablScenes.push(ablScene); }); - tracks.sort((a, b) => + 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) { - 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(); + 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(); - - 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) { - drumRackTracks.push(drumTrack); - const rackPadCodes = new Set(drumTrack.tracks.map((track) => track.padCode)); - tracks = tracks.filter((track) => !rackPadCodes.has(track.padCode)); + 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;