395 lines
11 KiB
TypeScript
395 lines
11 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;
|
|
delete data.groupPatterns[0].notes[1];
|
|
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],
|
|
);
|
|
|
|
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) =>
|
|
/<Envelope>[\s\S]*?<AttackTime>[\s\S]*?<Manual Value="0.1"\s*\/>/.test(simpler),
|
|
),
|
|
);
|
|
assert.ok(
|
|
simplerBlocks.every((simpler) =>
|
|
/<Envelope>[\s\S]*?<ReleaseTime>[\s\S]*?<Manual Value="50"\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/);
|
|
} 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 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]],
|
|
);
|
|
});
|