Update Ableton MIDI exporter and add comprehensive test suite
This commit is contained in:
@@ -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
180
tests/ableton-midi.test.ts
Normal 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]],
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user