import assert from 'node:assert/strict'; import test from 'node:test'; import { assignDeviceListIds } from '../src/lib/exporters/ableton/builders'; import abletonTransformer, { AblTrack, getDrumRackMidiNote } from '../src/lib/transformers/ableton'; import { EffectType, FaderParam, Group, GroupFaderParam, MidiPatternEvent, Note, Pad, PadCode, PatternEventKind, ProjectRawData, } from '../src/types/types'; function faderParams(): GroupFaderParam { return Object.fromEntries( (['a', 'b', 'c', 'd'] as Group[]).map((group) => [ group, Object.fromEntries(Array.from({ length: 12 }, (_, parameter) => [parameter, -1])), ]), ) as GroupFaderParam; } function pad(index: number, overrides: Partial = {}): Pad { return { pad: index + 1, name: `a${index}`, group: 'a', file: null, rawData: null, soundId: index + 1, volume: 100, attack: 0, release: 0, trimLeft: 0, trimRight: 46875, soundLength: 1, playMode: 'oneshot', pan: 0, pitch: 0, rootNote: 60, timeStretch: 'off', timeStretchBpm: 0, timeStretchBars: 0, inChokeGroup: false, midiChannel: 0, ...overrides, }; } function note(noteValue: number, position: number, velocity = 100): Note { return { note: noteValue, position, duration: 48, velocity, }; } function project(): ProjectRawData { const params = faderParams(); return { pads: { a: [ pad(0, { rootNote: 48, volume: 150, inChokeGroup: true }), pad(1, { playMode: 'key', inChokeGroup: true }), pad(2, { rootNote: 55, volume: 80, inChokeGroup: true }), ], b: [], c: [], d: [], }, scenes: [ { name: '01', patterns: [ { pad: 'a0', group: 'a', notes: [note(72, 0, 110)], bars: 1 }, { pad: 'a1', group: 'a', notes: [note(60, 0), note(64, 96)], bars: 1 }, ], patternEvents: { a: { group: 'a', patternNumber: 1, bars: 1, recordCount: 0, events: [], }, }, }, { name: '02', patterns: [ { pad: 'a0', group: 'a', notes: [note(72, 24, 90)], bars: 1 }, { pad: 'a1', group: 'a', notes: [note(67, 48)], bars: 1 }, ], patternEvents: { a: { group: 'a', patternNumber: 2, bars: 1, recordCount: 0, events: [], }, }, }, ], settings: { bpm: 120, scale: 0, rootNote: 0, groupFaderParams: params, faderAssignment: { a: FaderParam.LVL, b: FaderParam.LVL, c: FaderParam.LVL, d: FaderParam.LVL, }, rawData: new Uint8Array(), }, effects: { rawData: new Uint8Array(), effectType: EffectType.Off, param1: 0, param2: 0, }, sounds: [], projectFile: null as unknown as File, scenesSettings: { timeSignature: { numerator: 4, denominator: 4 } }, }; } function trackShape(tracks: AblTrack[]) { return tracks.map((track) => ({ name: track.name, drumRack: track.drumRack, padCode: track.padCode, children: track.tracks.map((child) => child.padCode), clips: track.lane?.clips.map((clip) => ({ sceneIndex: clip.sceneIndex, notes: clip.notes.map((item) => item.note), })), })); } test('assigns IDs to every device-list member, including nested rack devices', () => { const devices = [{ OriginalSimpler: {} }, { AutoFilter: {} }]; assignDeviceListIds(devices); assert.equal(devices[0].OriginalSimpler['@Id'], 0); assert.equal(devices[1].AutoFilter['@Id'], 1); }); test('automatically builds a per-group rack without flattening chromatic pads', () => { const result = abletonTransformer(project(), { includeArchivedSamples: true, exportAllPadsWithSamples: true, customSceneNames: { '01': 'Same', '02': 'Same' }, }); const rack = result.tracks.find((track) => track.drumRack); const melodic = result.tracks.find((track) => track.padCode === 'a1' && !track.drumRack); assert.ok(rack); assert.ok(melodic); assert.equal(rack.name, 'A Pads'); assert.deepEqual( rack.tracks.map((track) => track.padCode), ['a0', 'a2'], ); assert.deepEqual( rack.lane?.clips.map((clip) => [clip.sceneIndex, clip.sceneName, clip.notes[0].note]), [ [0, 'Same', getDrumRackMidiNote('a0')], [1, 'Same', getDrumRackMidiNote('a0')], ], ); assert.deepEqual( melodic.lane?.clips.map((clip) => clip.notes.map((item) => item.note)), [[60, 64], [67]], ); }); test('preserves rack playback pitch, unused-pad root note, AMP and choke membership', () => { const result = abletonTransformer(project(), { includeArchivedSamples: true, exportAllPadsWithSamples: true, }); const rack = result.tracks.find((track) => track.drumRack); const a0 = rack?.tracks.find((track) => track.padCode === 'a0'); const a2 = rack?.tracks.find((track) => track.padCode === 'a2'); const melodic = result.tracks.find((track) => track.padCode === 'a1' && !track.drumRack); assert.ok(a0); assert.ok(a2); assert.ok(melodic); assert.equal(a0.drumRackSendingNote, 72); assert.equal(a0.volume, 0.75); assert.equal(a0.inChokeGroup, true); assert.equal(a2.drumRackSendingNote, 55); assert.equal(a2.volume, 0.4); assert.equal(a2.inChokeGroup, true); assert.equal(melodic.inChokeGroup, true); }); test('uses one hybrid classification for Arrangement and Session exports', () => { const arrangement = abletonTransformer(project(), { includeArchivedSamples: true, exportAllPadsWithSamples: true, clips: false, }); const session = abletonTransformer(project(), { includeArchivedSamples: true, exportAllPadsWithSamples: true, clips: true, }); assert.deepEqual(trackShape(arrangement.tracks), trackShape(session.tracks)); }); test('classifies a pad from its distinct pitches across every exported scene', () => { const data = project(); data.scenes[0].patterns[1].notes = [note(60, 0)]; data.scenes[1].patterns[1].notes = [note(61, 0)]; const result = abletonTransformer(data, { includeArchivedSamples: true }); const rack = result.tracks.find((track) => track.drumRack); assert.deepEqual( rack?.tracks.map((track) => track.padCode), ['a0'], ); assert.ok(result.tracks.some((track) => track.padCode === 'a1' && !track.drumRack)); }); test('keeps pad-specific MIDI expression on an individual track', () => { const data = project(); const rawData = new Uint8Array([0, 0, PatternEventKind.Midi, 0xd0, 100, 0, 0, 0]); const event: MidiPatternEvent = { type: 'midi', kind: PatternEventKind.Midi, position: 0, source: 0, flags: 0, rawData, data: rawData.slice(3, 7), }; data.scenes[0].patternEvents.a?.events.push(event); const result = abletonTransformer(data, { includeArchivedSamples: true }); const a0 = result.tracks.find((track) => track.padCode === 'a0' && !track.drumRack); assert.ok(a0); assert.equal(a0.lane?.clips[0].midiEvents.length, 1); }); test('does not create selector-note racks when samples are excluded', () => { const result = abletonTransformer(project(), { includeArchivedSamples: false, exportAllPadsWithSamples: true, }); assert.equal( result.tracks.some((track) => track.drumRack), false, ); assert.deepEqual( result.tracks.map((track) => track.padCode), ['a0', 'a1', 'a2'] as PadCode[], ); assert.deepEqual( result.tracks[0].lane?.clips.map((clip) => clip.notes.map((item) => item.note)), [[72], [72]], ); });