diff --git a/tests/ableton-automation.test.ts b/tests/ableton-automation.test.ts index 7b2e5a7..51891d1 100644 --- a/tests/ableton-automation.test.ts +++ b/tests/ableton-automation.test.ts @@ -49,6 +49,10 @@ test('uses original note velocity until a VEL point is reached', () => { assert.equal(getFaderValueAt(occurrence, FaderParam.VEL, 192), 0.5); }); +test('preserves each pad pitch offset while applying group PTC automation', () => { + assert.equal(convertFaderValue(FaderParam.PTC, 0.75, track, false, { valueOffset: 7 }), 9.5); +}); + test('tiles arrangement automation across the scene length', () => { const envelopes = buildArrangementEnvelopes( [occurrence], diff --git a/tests/ableton-hybrid.test.ts b/tests/ableton-hybrid.test.ts new file mode 100644 index 0000000..763b870 --- /dev/null +++ b/tests/ableton-hybrid.test.ts @@ -0,0 +1,232 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import abletonTransformer, { + AblTrack, + getDrumRackMidiNote, +} from '../src/lib/transformers/ableton'; +import { + EffectType, + FaderParam, + Group, + GroupFaderParam, + Note, + Pad, + PadCode, + 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('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('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]], + ); +});