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

503 lines
15 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { gunzipSync } from 'node:zlib';
import { createServer } from 'vite';
import { assignDeviceListIds } from '../src/lib/exporters/ableton/builders';
import abletonTransformer, { AblTrack, getDrumRackMidiNote } from '../src/lib/transformers/ableton';
import {
EffectType,
FaderParam,
Group,
GroupFaderParam,
GroupPattern,
MidiPatternEvent,
Note,
Pad,
PadCode,
PatternEventKind,
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> = {}): 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();
const groupPatterns: GroupPattern[] = [
{
group: 'a',
patternNumber: 1,
bars: 1,
recordCount: 0,
events: [],
notes: {
0: [note(72, 0, 110)],
1: [note(60, 0), note(64, 96)],
},
},
{
group: 'a',
patternNumber: 2,
bars: 1,
recordCount: 0,
events: [],
notes: {
0: [note(72, 24, 90)],
1: [note(67, 48)],
},
},
];
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: [
{
number: 1,
name: '01',
patternNumbers: { a: 1, b: 0, c: 0, d: 0 },
timeSignature: { numerator: 4, denominator: 4 },
patterns: [
{ pad: 'a0', group: 'a', notes: groupPatterns[0].notes[0], bars: 1 },
{ pad: 'a1', group: 'a', notes: groupPatterns[0].notes[1], bars: 1 },
],
patternEvents: {
a: {
group: 'a',
patternNumber: 1,
bars: 1,
recordCount: 0,
events: [],
},
},
},
{
number: 2,
name: '02',
patternNumbers: { a: 2, b: 0, c: 0, d: 0 },
timeSignature: { numerator: 4, denominator: 4 },
patterns: [
{ pad: 'a0', group: 'a', notes: groupPatterns[1].notes[0], bars: 1 },
{ pad: 'a1', group: 'a', notes: groupPatterns[1].notes[1], bars: 1 },
],
patternEvents: {
a: {
group: 'a',
patternNumber: 2,
bars: 1,
recordCount: 0,
events: [],
},
},
},
],
groupPatterns,
songPositions: [
{ position: 0, sceneNumber: 1 },
{ position: 1, sceneNumber: 2 },
],
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),
sessionClips: track.lane?.sessionClips.map((clip) => ({
sceneIndex: clip.sceneIndex,
notes: clip.notes.map((item) => item.note),
})),
arrangementClips: track.lane?.arrangementClips.map((clip) => ({
occurrenceIndex: clip.occurrenceIndex,
notes: clip.notes.map((item) => item.note),
})),
}));
}
test('assigns IDs to every device-list member, including nested rack devices', () => {
const devices = [{ OriginalSimpler: {} }, { AutoFilter: {} }];
assignDeviceListIds(devices);
assert.equal(devices[0].OriginalSimpler['@Id'], 0);
assert.equal(devices[1].AutoFilter['@Id'], 1);
});
test('serializes nested Live 10 Simplers without artificial empty Session clips', async () => {
const server = await createServer({
server: { middlewareMode: true },
appType: 'custom',
mode: 'production',
define: { 'import.meta.env.DEV': 'false' },
});
try {
const { buildProject } = await server.ssrLoadModule('/src/lib/exporters/ableton/builders.ts');
const data = project();
data.pads.a.forEach((item) => {
item.attack = 200;
item.release = 20;
});
data.settings.groupFaderParams.a[FaderParam.ATK] = 0.66;
data.settings.groupFaderParams.a[FaderParam.REL] = 0.87;
data.settings.groupFaderParams.a[FaderParam.LPF] = 0.9996299743652344;
data.settings.groupFaderParams.a[FaderParam.VEL] = 0.5325199961662292;
delete data.groupPatterns[0].notes[1];
data.groupPatterns[0].notes[0] = [note(72, 0, 127), note(72, 96, 127), note(72, 192, 48)];
delete data.groupPatterns[1].notes[0];
data.groupPatterns[1].notes[1] = [note(67, 48), note(68, 96)];
data.songPositions = [];
const generated = await buildProject(data, {
abletonVersion: '10',
includeArchivedSamples: true,
clips: false,
groupTracks: true,
sendEffects: false,
allScenes: true,
});
const xml = gunzipSync(Buffer.from(generated)).toString('utf8');
const simplerTags = [...xml.matchAll(/<OriginalSimpler(?:\s[^>]*)?>/g)].map(
(match) => match[0],
);
const rackTags = [...xml.matchAll(/<DrumGroupDevice\b[\s\S]*?<\/DrumGroupDevice>/g)].map(
(match) => match[0],
);
const expectedAttack = (200 / 255) * 1000;
const expectedRelease = (20 / 255) * 1000;
assert.ok(simplerTags.length > 0);
assert.ok(simplerTags.every((tag) => /\sId="\d+"/.test(tag)));
const simplerBlocks = [...xml.matchAll(/<OriginalSimpler\b[\s\S]*?<\/OriginalSimpler>/g)].map(
(match) => match[0],
);
assert.ok(
simplerBlocks.every((simpler) => /<FadeInTime>[\s\S]*?<Manual Value="0"\s*\/>/.test(simpler)),
);
assert.ok(
simplerBlocks.every((simpler) =>
/<FadeOutTime>[\s\S]*?<Manual Value="5"\s*\/>/.test(simpler),
),
);
assert.ok(
simplerBlocks.every((simpler) =>
new RegExp(
`<Envelope>[\\s\\S]*?<AttackTime>[\\s\\S]*?<Manual Value="${expectedAttack}"\\s*\\/>`,
).test(simpler),
),
);
assert.ok(
simplerBlocks.every((simpler) =>
new RegExp(
`<Envelope>[\\s\\S]*?<ReleaseTime>[\\s\\S]*?<Manual Value="${expectedRelease}"\\s*\\/>`,
).test(simpler),
),
);
assert.ok(rackTags.length > 0);
assert.ok(rackTags.every((rack) => /<LockId Value="0"\s*\/>/.test(rack)));
assert.ok(rackTags.every((rack) => /<LockSeal Value="0"\s*\/>/.test(rack)));
assert.ok(
rackTags.every((rack) => [...rack.matchAll(/<IsSelected Value="true"\s*\/>/g)].length === 1),
);
assert.equal([...xml.matchAll(/<MidiClip(?:\s[^>]*)?>/g)].length, 2);
assert.doesNotMatch(xml, /<ReturnBranch\s/);
assert.doesNotMatch(xml, /<AudioBranchSendInfo\s/);
assert.doesNotMatch(xml, /Big Break Kit/);
assert.doesNotMatch(xml, /<AutoFilter\b/);
assert.doesNotMatch(xml, /<ClipEnvelope\b/);
assert.equal([...xml.matchAll(/<MidiNoteEvent\b[^>]*\sVelocity="68"/g)].length, 2);
assert.equal([...xml.matchAll(/<MidiNoteEvent\b[^>]*\sVelocity="26"/g)].length, 1);
} finally {
await server.close();
}
});
test('binds group device automation to each concrete pad and provisions MOD devices', async () => {
const server = await createServer({
server: { middlewareMode: true },
appType: 'custom',
mode: 'production',
define: { 'import.meta.env.DEV': 'false' },
});
try {
const { buildProject } = await server.ssrLoadModule('/src/lib/exporters/ableton/builders.ts');
const data = project();
data.pads.a[2].pitch = 7;
data.settings.groupFaderParams.a[FaderParam.PTC] = 0.5;
data.groupPatterns[0].events.push(
{
type: 'fader',
kind: PatternEventKind.Fader,
position: 0,
source: 0,
flags: 0,
rawData: new Uint8Array(8),
parameter: FaderParam.PTC,
reserved: 0,
rawValue: 24575,
value: 0.75,
},
{
type: 'fader',
kind: PatternEventKind.Fader,
position: 0,
source: 0,
flags: 0,
rawData: new Uint8Array(8),
parameter: FaderParam.MOD,
reserved: 0,
rawValue: 16384,
value: 0.5,
},
);
const generated = await buildProject(data, {
abletonVersion: '10',
includeArchivedSamples: true,
exportAllPadsWithSamples: true,
groupTracks: true,
sendEffects: false,
allScenes: true,
});
const xml = gunzipSync(Buffer.from(generated)).toString('utf8');
const simplerBlocks = [...xml.matchAll(/<OriginalSimpler\b[\s\S]*?<\/OriginalSimpler>/g)].map(
(match) => match[0],
);
assert.match(xml, /<FloatEvent\b[^>]*Value="2\.5"/);
assert.match(xml, /<FloatEvent\b[^>]*Value="9\.5"/);
assert.ok(
simplerBlocks.every((simpler) =>
/<Lfo>[\s\S]*?<IsOn>[\s\S]*?<Manual Value="true"\s*\/>/.test(simpler),
),
);
} finally {
await server.close();
}
});
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?.sessionClips.map((clip) => [clip.sceneIndex, clip.sceneName, clip.notes[0].note]),
[
[0, 'Same', getDrumRackMidiNote('a0')],
[1, 'Same', getDrumRackMidiNote('a0')],
],
);
assert.deepEqual(
melodic.lane?.sessionClips.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 the assigned pad record as the static pitch authority', () => {
const data = project();
data.pads.a[0].pitch = 3;
data.sounds = [
{
id: 1,
fileNode: {
nodeId: 1,
flags: 0,
fileSize: 0,
fileName: 'pitched slot',
fileType: 'file',
},
meta: {
name: 'pitched slot',
channels: 1,
samplerate: 46875,
format: 's16',
crc: 0,
'sound.pitch': 12,
},
} as ProjectRawData['sounds'][number],
];
const result = abletonTransformer(data, { includeArchivedSamples: true });
const rack = result.tracks.find((track) => track.drumRack);
const a0 = rack?.tracks.find((track) => track.padCode === 'a0');
assert.ok(a0);
assert.equal(a0.pitch, 3);
assert.equal('samplePitch' in a0, false);
});
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('classifies a pad from its distinct pitches across every exported scene', () => {
const data = project();
data.groupPatterns[0].notes[1] = [note(60, 0)];
data.groupPatterns[1].notes[1] = [note(61, 0)];
const result = abletonTransformer(data, { includeArchivedSamples: true });
const rack = result.tracks.find((track) => track.drumRack);
assert.deepEqual(
rack?.tracks.map((track) => track.padCode),
['a0'],
);
assert.ok(result.tracks.some((track) => track.padCode === 'a1' && !track.drumRack));
});
test('keeps pad-specific MIDI expression on an individual track', () => {
const data = project();
const rawData = new Uint8Array([0, 0, PatternEventKind.Midi, 0xd0, 100, 0, 0, 0]);
const event: MidiPatternEvent = {
type: 'midi',
kind: PatternEventKind.Midi,
position: 0,
source: 0,
flags: 0,
rawData,
data: rawData.slice(3, 7),
};
data.groupPatterns[0].events.push(event);
const result = abletonTransformer(data, { includeArchivedSamples: true });
const a0 = result.tracks.find((track) => track.padCode === 'a0' && !track.drumRack);
assert.ok(a0);
assert.equal(a0.lane?.sessionClips[0].midiEvents.length, 1);
});
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?.sessionClips.map((clip) => clip.notes.map((item) => item.note)),
[[72], [72]],
);
});