From 540234708abb249ff6fd9b17de16854d609e4218 Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Tue, 14 Jul 2026 10:02:27 +0200 Subject: [PATCH] feat: Add comprehensive test suite for ableton song data structure --- tests/ableton-song.test.ts | 222 +++++++++++++++++++++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 tests/ableton-song.test.ts diff --git a/tests/ableton-song.test.ts b/tests/ableton-song.test.ts new file mode 100644 index 0000000..0590511 --- /dev/null +++ b/tests/ableton-song.test.ts @@ -0,0 +1,222 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import abletonTransformer from '../src/lib/transformers/ableton'; +import { + EffectType, + FaderParam, + Group, + GroupFaderParam, + GroupPattern, + Note, + Pad, + PatternEventKind, + ProjectRawData, + Scene, +} 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(group: Group, index: number): Pad { + return { + pad: index + 1, + name: `${group}${index}`, + group, + file: null, + rawData: null, + soundId: 0, + volume: 100, + attack: 0, + release: 0, + trimLeft: 0, + trimRight: 0, + soundLength: 0, + playMode: 'oneshot', + pan: 0, + pitch: 0, + rootNote: 60, + timeStretch: 'off', + timeStretchBpm: 0, + timeStretchBars: 0, + inChokeGroup: false, + midiChannel: 0, + }; +} + +function pattern( + group: Group, + patternNumber: number, + bars: number, + source: number, + noteValue: number, +): GroupPattern { + const note: Note = { note: noteValue, position: 0, duration: 48, velocity: 100 }; + return { + group, + patternNumber, + bars, + recordCount: 1, + events: [ + { + type: 'note', + kind: PatternEventKind.Note, + position: 0, + source, + flags: 0, + rawData: new Uint8Array(8), + pad: source, + note: noteValue, + velocity: 100, + duration: 48, + }, + ], + notes: { [source]: [note] }, + }; +} + +function scene( + number: number, + patternNumbers: Record, + numerator: number, + denominator: number, +): Scene { + return { + number, + name: String(number).padStart(2, '0'), + patternNumbers, + timeSignature: { numerator, denominator }, + patterns: [], + patternEvents: {}, + }; +} + +function project(): ProjectRawData { + const params = faderParams(); + return { + pads: { + a: [pad('a', 0), pad('a', 1)], + b: [pad('b', 0)], + c: [], + d: [], + }, + groupPatterns: [pattern('a', 1, 2, 0, 60), pattern('b', 2, 1, 0, 36), pattern('a', 3, 1, 1, 64)], + scenes: [ + scene(1, { a: 1, b: 2, c: 0, d: 0 }, 3, 4), + scene(2, { a: 1, b: 0, c: 0, d: 0 }, 2, 4), + ], + songPositions: [ + { position: 0, sceneNumber: 2 }, + { position: 1, sceneNumber: 1 }, + { position: 2, sceneNumber: 2 }, + ], + 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 } }, + }; +} + +test('combines genuine Session scenes, orphan patterns and the repeated Song Arrangement', () => { + const result = abletonTransformer(project(), { + allScenes: true, + includeArchivedSamples: false, + keepUnusedPatterns: true, + }); + + assert.deepEqual( + result.scenes.map((item) => [item.name, item.kind, item.affectedGroups]), + [ + ['01', 'scene', ['a', 'b', 'c', 'd']], + ['02', 'scene', ['a', 'b', 'c', 'd']], + ['A PATTERN 03', 'pattern', ['a']], + ], + ); + assert.deepEqual( + result.arrangementSections.map((section) => [ + section.sceneNumber, + section.offset, + section.duration, + `${section.timeSignature.numerator}/${section.timeSignature.denominator}`, + ]), + [ + [2, 0, 4, '2/4'], + [1, 4, 6, '3/4'], + [2, 10, 4, '2/4'], + ], + ); + + const a0 = result.tracks.find((track) => track.padCode === 'a0'); + const a1 = result.tracks.find((track) => track.padCode === 'a1'); + const b0 = result.tracks.find((track) => track.padCode === 'b0'); + + assert.deepEqual(a0?.lane?.sessionClips.map((clip) => clip.sceneIndex), [0, 1]); + assert.deepEqual( + a0?.lane?.arrangementClips.map((clip) => [clip.occurrenceIndex, clip.offset]), + [ + [0, 0], + [1, 4], + [2, 10], + ], + ); + assert.deepEqual(a1?.lane?.sessionClips.map((clip) => clip.sceneIndex), [2]); + assert.deepEqual(a1?.lane?.arrangementClips, []); + assert.deepEqual(b0?.lane?.sessionClips.map((clip) => clip.sceneIndex), [0]); + assert.deepEqual( + b0?.lane?.arrangementClips.map((clip) => [clip.occurrenceIndex, clip.offset]), + [[1, 4]], + ); +}); + +test('keeps Session material and leaves Arrangement empty when no Song is present', () => { + const data = project(); + data.songPositions = []; + const result = abletonTransformer(data, { + allScenes: false, + selectedScenes: ['02'], + includeArchivedSamples: false, + keepUnusedPatterns: false, + }); + + assert.deepEqual(result.scenes.map((item) => item.name), ['02']); + assert.deepEqual(result.arrangementSections, []); + assert.ok(result.tracks.every((track) => (track.lane?.arrangementClips.length || 0) === 0)); +}); + +test('keeps the complete Song Arrangement when Session scene selection is limited', () => { + const result = abletonTransformer(project(), { + allScenes: false, + selectedScenes: ['02'], + includeArchivedSamples: false, + keepUnusedPatterns: false, + }); + + assert.deepEqual(result.scenes.map((item) => item.name), ['02']); + assert.deepEqual( + result.arrangementSections.map((section) => section.sceneNumber), + [2, 1, 2], + ); +});