Update Ableton MIDI exporter and add comprehensive test suite

This commit is contained in:
2026-07-14 06:47:32 +02:00
parent 5233cae10b
commit 4bba670084
3 changed files with 207 additions and 8 deletions

View File

@@ -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) {

180
tests/ableton-midi.test.ts Normal file
View File

@@ -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]],
],
);
});

View File

@@ -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,