Files
ep-133-project-export/tests/ableton-midi.test.ts

215 lines
5.2 KiB
TypeScript

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 controllerTargets = {
'ControllerTargets.1': { '@Id': 45123, LockEnvelope: { '@Value': 0 } },
};
const envelopes = buildMidiClipEnvelopes(
[midiEvent(192, 2, 20), midiEvent(96, 2, 75), midiEvent(96, 2, 20)],
controllerTargets,
);
const envelope = envelopes[0];
const events = envelope.Automation.Events.FloatEvent;
assert.equal(envelope.EnvelopeTarget.PointeeId['@Value'], 45123);
assert.equal(controllerTargets['ControllerTargets.1'].LockEnvelope['@Value'], 1);
assert.deepEqual(
events.map((event: any) => [event['@Time'], event['@Value']]),
[
[-63072000, 0],
[1, 0],
[1, 75],
[1, 75],
[1, 20],
[2, 20],
],
);
assert.deepEqual(envelope.LoopSlot, { Value: {} });
});
test('retains an unchanged zero-pressure event at its original time', () => {
const envelopes = buildMidiClipEnvelopes([midiEvent(48, 2, 0)], {
'ControllerTargets.1': { '@Id': 45123 },
});
assert.deepEqual(
envelopes[0].Automation.Events.FloatEvent.map((event: any) => [
event['@Time'],
event['@Value'],
]),
[
[-63072000, 0],
[0.5, 0],
],
);
});
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 events = [source0, source1, source2].map((event) => ({
type: 'midi' as const,
kind: PatternEventKind.Midi,
...event,
}));
const project = {
pads: {
a: [pad('a', 0), pad('a', 1), pad('a', 2)],
b: [],
c: [],
d: [],
},
scenes: [
{
number: 1,
name: '01',
patternNumbers: { a: 1, b: 0, c: 0, d: 0 },
timeSignature: { numerator: 4, denominator: 4 },
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,
},
},
},
],
groupPatterns: [
{
group: 'a',
patternNumber: 1,
bars: 1,
recordCount: 3,
events,
notes: { 0: [], 1: [], 2: [] },
},
],
songPositions: [],
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?.sessionClips[0].notes.length,
track.lane?.sessionClips[0].midiEvents.map((event) => event.data[1]),
]),
[
['a0', 0, [30]],
['a1', 0, [60]],
['a2', 0, [90]],
],
);
});