From 4bba670084f08104468e52d2576e201a7e57105e Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Tue, 14 Jul 2026 06:47:32 +0200 Subject: [PATCH] Update Ableton MIDI exporter and add comprehensive test suite --- src/lib/exporters/ableton/midi.ts | 8 +- tests/ableton-midi.test.ts | 180 ++++++++++++++++++++++++++++++ tests/pattern-events.test.ts | 27 ++++- 3 files changed, 207 insertions(+), 8 deletions(-) create mode 100644 tests/ableton-midi.test.ts diff --git a/src/lib/exporters/ableton/midi.ts b/src/lib/exporters/ableton/midi.ts index 0be2abd..414aff4 100644 --- a/src/lib/exporters/ableton/midi.ts +++ b/src/lib/exporters/ableton/midi.ts @@ -45,17 +45,21 @@ export function buildMidiClipEnvelopes( let previousValue = 0; let eventCount = 0; - events.forEach((event) => { + events.toSorted((a, b) => a.position - b.position).forEach((event) => { const value = decodeChannelPressure(event); if (value === undefined) { return; } + eventCount++; + if (value === previousValue) { + return; + } + const time = event.position / 96; points.push({ time, value: previousValue }); points.push({ time, value }); previousValue = value; - eventCount++; }); if (eventCount === 0) { diff --git a/tests/ableton-midi.test.ts b/tests/ableton-midi.test.ts new file mode 100644 index 0000000..88b206e --- /dev/null +++ b/tests/ableton-midi.test.ts @@ -0,0 +1,180 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + buildMidiClipEnvelopes, + decodeChannelPressure, +} from '../src/lib/exporters/ableton/midi'; +import abletonTransformer, { AblMidiEvent } from '../src/lib/transformers/ableton'; +import { + EffectType, + FaderParam, + Group, + GroupFaderParam, + Pad, + PatternEventKind, + ProjectRawData, +} from '../src/types/types'; + +function midiEvent(position: number, source: number, value: number): AblMidiEvent { + const rawData = new Uint8Array([ + position & 0xff, + position >> 8, + (source << 3) | PatternEventKind.Midi, + 0xd0, + value, + 0xff, + 0, + 0x3d, + ]); + return { + position, + source, + flags: rawData[7], + rawData, + data: rawData.slice(3, 7), + }; +} + +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, + }; +} + +test('decodes only the captured EP Channel Pressure record shape', () => { + assert.equal(decodeChannelPressure(midiEvent(39, 2, 98)), 98); + assert.equal( + decodeChannelPressure({ ...midiEvent(39, 2, 98), data: new Uint8Array([0xd0, 98, 3, 4]) }), + undefined, + ); + assert.equal( + decodeChannelPressure({ ...midiEvent(39, 2, 98), data: new Uint8Array([0xb0, 1, 98, 0]) }), + undefined, + ); +}); + +test('builds native Live 10 Channel Pressure steps with raw values', () => { + const envelopes = buildMidiClipEnvelopes( + [midiEvent(192, 2, 20), midiEvent(96, 2, 75), midiEvent(96, 2, 20)], + { 'ControllerTargets.129': { '@Id': 45123 } }, + ); + const envelope = envelopes[0]; + const events = envelope.Automation.Events.FloatEvent; + + assert.equal(envelope.EnvelopeTarget.PointeeId['@Value'], 45123); + assert.deepEqual( + events.map((event: any) => [event['@Time'], event['@Value']]), + [ + [-63072000, 0], + [1, 0], + [1, 75], + [1, 75], + [1, 20], + ], + ); + assert.deepEqual(envelope.LoopSlot, { Value: {} }); +}); + +test('routes each source curve only to its matching pad clip, including MIDI-only pads', () => { + const source0 = midiEvent(24, 0, 30); + const source1 = midiEvent(48, 1, 60); + const source2 = midiEvent(72, 2, 90); + const params = faderParams(); + const project = { + pads: { + a: [pad('a', 0), pad('a', 1), pad('a', 2)], + b: [], + c: [], + d: [], + }, + scenes: [ + { + name: '01', + patterns: [ + { pad: 'a0', group: 'a', notes: [], bars: 1 }, + { pad: 'a1', group: 'a', notes: [], bars: 1 }, + { pad: 'a2', group: 'a', notes: [], bars: 1 }, + ], + patternEvents: { + a: { + group: 'a', + patternNumber: 1, + bars: 1, + recordCount: 3, + events: [source0, source1, source2].map((event) => ({ + type: 'midi' as const, + kind: PatternEventKind.Midi, + ...event, + })), + }, + }, + }, + ], + 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 } }, + } satisfies ProjectRawData; + + const result = abletonTransformer(project, {}); + + assert.deepEqual( + result.tracks.map((track) => [ + track.padCode, + track.lane?.clips[0].notes.length, + track.lane?.clips[0].midiEvents.map((event) => event.data[1]), + ]), + [ + ['a0', 0, [30]], + ['a1', 0, [60]], + ['a2', 0, [90]], + ], + ); +}); diff --git a/tests/pattern-events.test.ts b/tests/pattern-events.test.ts index db0f343..bf9bc4e 100644 --- a/tests/pattern-events.test.ts +++ b/tests/pattern-events.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { parsePatternEvents } from '../src/lib/parsers'; +import { collectScenesAndPatterns, parsePatternEvents } from '../src/lib/parsers'; +import { TarFile } from '../src/lib/untar'; function buildPattern(records: number[][], bars: number = 4, declaredCount = records.length) { const data = new Uint8Array(4 + records.length * 8); @@ -56,18 +57,18 @@ test('parses and normalizes fader records', () => { }); test('preserves MIDI and unknown records', () => { - const midi = [1, 0, (4 << 3) | 2, 0xd0, 75, 3, 4, 5]; + const midi = [0x27, 0, (2 << 3) | 2, 0xd0, 0x62, 0xff, 0, 0x3d]; const unknown = [2, 0, (5 << 3) | 6, 10, 11, 12, 13, 14]; const result = parsePatternEvents(buildPattern([midi, unknown])); assert.deepEqual(result.events[0], { type: 'midi', kind: 2, - position: 1, - source: 4, - flags: 5, + position: 0x27, + source: 2, + flags: 0x3d, rawData: new Uint8Array(midi), - data: new Uint8Array([0xd0, 75, 3, 4]), + data: new Uint8Array([0xd0, 0x62, 0xff, 0]), }); assert.deepEqual(result.events[1], { type: 'unknown', @@ -80,6 +81,20 @@ test('preserves MIDI and unknown records', () => { }); }); +test('retains MIDI-only sources as empty-note scene patterns', () => { + const patternData = buildPattern([[0x27, 0, (2 << 3) | 2, 0xd0, 0x62, 0xff, 0, 0x3d]]); + const scenes = collectScenesAndPatterns( + [new TarFile('patterns/a01', patternData.length, patternData)], + 'TE032AS001', + ); + + assert.equal(scenes.length, 1); + assert.equal(scenes[0].patterns.length, 1); + assert.equal(scenes[0].patterns[0].pad, 'a2'); + assert.deepEqual(scenes[0].patterns[0].notes, []); + assert.equal(scenes[0].patternEvents.a?.events.length, 1); +}); + test('uses the uint16 header count and ignores undeclared or partial records', () => { const records = Array.from({ length: 300 }, (_, index) => [ index & 0xff,