Files
ep-133-project-export/src/lib/parsers.ts

730 lines
19 KiB
TypeScript
Raw Normal View History

2026-07-13 04:39:10 +02:00
import JSZip from 'jszip';
import {
Group,
2026-07-13 04:39:10 +02:00
GroupFaderParam,
GroupPattern,
2026-07-13 04:39:10 +02:00
Note,
Pad,
PadCode,
Pattern,
PatternEvent,
PatternEventKind,
2026-07-13 04:39:10 +02:00
ProjectSettings,
Scene,
SceneGroupPatternData,
2026-07-13 04:39:10 +02:00
ScenesSettings,
SongPosition,
2026-07-13 04:39:10 +02:00
Sound,
TimeSignature,
2026-07-13 04:39:10 +02:00
} from '../types/types';
import { GROUPS, NOTE_NAMES, PADS, SKU_EP40 } from './constants';
import { parseWavMetadata } from './exporters/utils';
import { getFileMetadata, getFileNodeByPath } from './midi/fs';
import { TESoundMetadata } from './midi/types';
import { TarFile } from './untar';
import { calculateSoundLength } from './utils';
type SceneRecord = {
number: number;
patternNumbers: Record<Group, number>;
timeSignature: TimeSignature;
2026-07-13 04:39:10 +02:00
};
export type ParsedPatternData = {
bars: number;
recordCount: number;
events: PatternEvent[];
notes: Record<number, Note[]>;
};
2026-07-13 04:39:10 +02:00
const defaultProjectSettings = {
bpm: 120,
scale: 0,
rootNote: 0,
groupFaderParams: {
a: { 0: -1, 1: -1, 2: -1, 3: -1, 4: -1, 5: -1, 6: -1, 7: -1, 8: -1, 9: -1, 10: -1, 11: -1 },
b: { 0: -1, 1: -1, 2: -1, 3: -1, 4: -1, 5: -1, 6: -1, 7: -1, 8: -1, 9: -1, 10: -1, 11: -1 },
c: { 0: -1, 1: -1, 2: -1, 3: -1, 4: -1, 5: -1, 6: -1, 7: -1, 8: -1, 9: -1, 10: -1, 11: -1 },
d: { 0: -1, 1: -1, 2: -1, 3: -1, 4: -1, 5: -1, 6: -1, 7: -1, 8: -1, 9: -1, 10: -1, 11: -1 },
},
faderAssignment: { a: 0, b: 0, c: 0, d: 0 },
};
const defaultScenesSettings = {
timeSignature: { numerator: 4, denominator: 4 },
};
const projectGroups: Group[] = ['a', 'b', 'c', 'd'];
2026-07-13 04:39:10 +02:00
export function noteNumberToName(noteNumber: number): string {
const noteIndex = noteNumber % 12;
const octave = Math.floor(noteNumber / 12) - 1;
return `${NOTE_NAMES[noteIndex]}${octave}`;
}
function timeStretch(data: number): Pad['timeStretch'] {
switch (data) {
case 1:
return 'bpm';
case 2:
return 'bars';
case 3:
return 'rev';
default:
return 'off';
}
}
function timeStretchBars(data: number) {
switch (data) {
case 0:
return 1;
case 1:
return 2;
case 2:
return 4;
case 255:
return 0.5;
case 254:
return 0.25;
default:
return 0;
}
}
function bytesToFloat32(bytes: Uint8Array): number {
const buffer = new ArrayBuffer(4);
const view = new DataView(buffer);
bytes.forEach((b, i) => {
view.setUint8(i, b);
});
return view.getFloat32(0, true);
}
function genPadFileName(group: string, pad: number) {
return `pads/${group}/p${String(pad).padStart(2, '0')}`;
}
function chunkArray(arr: Uint8Array, size: number, offset = 0) {
const result = [];
for (let i = offset; i < arr.length; i += size) {
result.push(arr.slice(i, i + size));
}
return result;
}
export async function collectSounds(files: TarFile[]) {
const soundIds = new Set<number>();
for (let g = 0; g < 4; g++) {
for (let i = 1; i <= 12; i++) {
const file = files.find((f) => f.name === genPadFileName(GROUPS[g].id, i));
if (file?.data) {
const soundId = (file.data[2] << 8) + file.data[1];
if (soundId > 0 && soundId < 1000) {
soundIds.add(soundId);
}
}
}
}
const selectedSounds: Sound[] = [];
for (const soundId of soundIds) {
const fileNode = await getFileNodeByPath(`/sounds/${String(soundId).padStart(3, '0')}.pcm`);
if (!fileNode) {
console.warn(`Sound file for sound ID ${soundId} not found`);
continue;
}
const meta = await getFileMetadata<TESoundMetadata>(fileNode.nodeId);
selectedSounds.push({
id: soundId,
fileNode,
meta,
});
}
return selectedSounds;
}
export function collectPads(files: TarFile[], sounds: Sound[]) {
const result: Record<string, Pad[]> = {};
for (const group of GROUPS) {
result[group.id] = [];
for (let i = 1; i <= 12; i++) {
const file = files.find((f) => f.name === genPadFileName(group.id, i));
if (file?.data) {
const soundId = (file.data[2] << 8) + file.data[1];
const sound = sounds.find((s) => s.id === soundId);
const pitch = file.data[17] <= 12 ? file.data[17] : -(256 - file.data[17]); // pitch from -12 to +12
const pitchDecimal = file.data[26];
const pan = (file.data[18] >= 240 ? -(256 - file.data[18]) : file.data[18]) / 16; // normalized pan
const trimLeft = (file.data[6] << 16) + (file.data[5] << 8) + file.data[4];
const trimRight = trimLeft + (file.data[10] << 16) + (file.data[9] << 8) + file.data[8];
result[group.id].push({
pad: i,
group: group.id,
name: `${group.name} ${PADS[i - 1]}`,
file,
rawData: file.data,
soundId,
pan,
volume: file.data[16],
attack: file.data[19],
release: file.data[20],
trimLeft,
trimRight,
playMode: file.data[23] === 0 ? 'oneshot' : file.data[23] === 1 ? 'key' : 'legato',
soundLength: sound ? calculateSoundLength(sound) : 0,
pitch: Math.max(-12, Math.min(12, parseFloat(`${pitch}.${pitchDecimal}`))),
rootNote: file.data[24],
timeStretch: timeStretch(file.data[21]),
timeStretchBpm: Number(bytesToFloat32(file.data.slice(12, 16)).toFixed(2)),
timeStretchBars: timeStretchBars(file.data[25]),
inChokeGroup: file.data[22] === 1,
midiChannel: file.data[3],
});
}
}
}
return result;
}
2026-07-13 04:42:56 +02:00
export function parsePatternEvents(data: Uint8Array, startOffset: number = 4): ParsedPatternData {
const recordCount = data.length >= 4 ? data[2] | (data[3] << 8) : 0;
const availableRecordCount = Math.max(0, Math.floor((data.length - startOffset) / 8));
const chunks = chunkArray(data, 8, startOffset).slice(
0,
Math.min(recordCount, availableRecordCount),
);
const events: PatternEvent[] = [];
2026-07-13 04:39:10 +02:00
const notes: Record<number, Note[]> = {};
chunks.forEach((chunk) => {
const kind = chunk[2] & 0x07;
const source = chunk[2] >> 3;
const position = chunk[0] | (chunk[1] << 8);
const flags = chunk[7];
const rawData = chunk.slice();
if (kind === PatternEventKind.Note) {
const duration = chunk[5] | (chunk[6] << 8);
const noteEvent: PatternEvent = {
type: 'note',
kind: PatternEventKind.Note,
position,
source,
flags,
rawData,
pad: source,
note: chunk[3],
velocity: chunk[4],
duration,
};
events.push(noteEvent);
if (!notes[source]) {
notes[source] = [];
}
notes[source].push({
note: noteEvent.note,
position: noteEvent.position,
duration: noteEvent.duration,
velocity: noteEvent.velocity,
});
return;
}
if (kind === PatternEventKind.Fader) {
const rawValue = chunk[5] | (chunk[6] << 8);
events.push({
type: 'fader',
kind: PatternEventKind.Fader,
position,
source,
flags,
rawData,
parameter: chunk[3],
reserved: chunk[4],
rawValue,
value: rawValue / 32767,
});
2026-07-13 04:39:10 +02:00
return;
}
const eventData = chunk.slice(3, 7);
if (kind === PatternEventKind.Midi) {
events.push({
type: 'midi',
kind: PatternEventKind.Midi,
position,
source,
flags,
rawData,
data: eventData,
});
return;
2026-07-13 04:39:10 +02:00
}
events.push({
type: 'unknown',
kind,
position,
source,
flags,
rawData,
data: eventData,
2026-07-13 04:39:10 +02:00
});
});
return {
bars: data[1],
recordCount,
events,
2026-07-13 04:39:10 +02:00
notes,
};
}
function getPatternKey(group: Group, patternNumber: number) {
return `${group}${patternNumber}`;
}
function normalizeTimeSignature(
numerator: number,
denominator: number,
fallback: TimeSignature,
): TimeSignature {
const validNumerator = Number.isInteger(numerator) && numerator > 0 && numerator <= 99;
const validDenominator =
Number.isInteger(denominator) &&
denominator > 0 &&
denominator <= 64 &&
(denominator & (denominator - 1)) === 0;
return {
numerator: validNumerator ? numerator : fallback.numerator,
denominator: validDenominator ? denominator : fallback.denominator,
};
}
function collectSceneRecords(data: Uint8Array): SceneRecord[] {
const headerTimeSignature = normalizeTimeSignature(
data[5],
data[6],
defaultScenesSettings.timeSignature,
);
const sceneRecords: SceneRecord[] = [];
for (let sceneIndex = 0; sceneIndex < 99; sceneIndex++) {
const offset = 7 + sceneIndex * 6;
if (offset + 6 > data.length) {
break;
}
const patternNumbers: Record<Group, number> = {
a: data[offset],
b: data[offset + 1],
c: data[offset + 2],
d: data[offset + 3],
};
if (!projectGroups.some((group) => patternNumbers[group] !== 0)) {
continue;
}
sceneRecords.push({
number: sceneIndex + 1,
patternNumbers,
timeSignature: normalizeTimeSignature(
data[offset + 4],
data[offset + 5],
headerTimeSignature,
),
});
}
return sceneRecords;
}
function collectGroupPatterns(files: TarFile[], sku: string): GroupPattern[] {
const groupPatterns = new Map<string, GroupPattern>();
for (const file of files) {
if (file.type !== 'file' || !file.data) {
continue;
}
const matches = file.name.match(/^patterns\/([abcd])(\d{1,2})$/);
if (!matches) {
continue;
}
const group = matches[1] as Group;
const patternNumber = Number(matches[2]);
if (patternNumber < 1 || patternNumber > 99) {
continue;
}
const parsedPattern = parsePatternEvents(file.data, sku === SKU_EP40 ? 6 : 4);
groupPatterns.set(getPatternKey(group, patternNumber), {
group,
patternNumber,
...parsedPattern,
});
}
return [...groupPatterns.values()].toSorted((a, b) => {
const numberDifference = a.patternNumber - b.patternNumber;
if (numberDifference !== 0) {
return numberDifference;
}
return projectGroups.indexOf(a.group) - projectGroups.indexOf(b.group);
});
}
function getPatternsForScene(
groupPatterns: Map<string, GroupPattern>,
patternNumbers: Record<Group, number>,
) {
2026-07-13 04:39:10 +02:00
const patterns: Pattern[] = [];
for (const group of projectGroups) {
const groupPattern = groupPatterns.get(getPatternKey(group, patternNumbers[group]));
2026-07-13 04:39:10 +02:00
if (groupPattern) {
const sources = new Set(Object.keys(groupPattern.notes).map(Number));
groupPattern.events.forEach((event) => {
if (event.type === 'midi' && event.source >= 0 && event.source < 12) {
sources.add(event.source);
}
});
[...sources].toSorted((a, b) => a - b).forEach((padNum) => {
2026-07-13 04:39:10 +02:00
patterns.push({
pad: `${group}${padNum}` as PadCode,
group,
notes: groupPattern.notes[padNum] || [],
bars: groupPattern.bars,
2026-07-13 04:39:10 +02:00
});
});
}
}
return patterns;
}
function getPatternEventsForScene(
groupPatterns: Map<string, GroupPattern>,
patternNumbers: Record<Group, number>,
) {
const patternEvents: Partial<Record<Group, SceneGroupPatternData>> = {};
for (const group of projectGroups) {
const patternNumber = patternNumbers[group];
const groupPattern = groupPatterns.get(getPatternKey(group, patternNumber));
if (groupPattern) {
patternEvents[group] = {
group,
patternNumber,
bars: groupPattern.bars,
recordCount: groupPattern.recordCount,
events: groupPattern.events,
};
}
}
return patternEvents;
}
function buildScene(
sceneRecord: SceneRecord,
groupPatterns: Map<string, GroupPattern>,
): Scene {
return {
number: sceneRecord.number,
name: String(sceneRecord.number).padStart(2, '0'),
patternNumbers: sceneRecord.patternNumbers,
timeSignature: sceneRecord.timeSignature,
patterns: getPatternsForScene(groupPatterns, sceneRecord.patternNumbers),
patternEvents: getPatternEventsForScene(groupPatterns, sceneRecord.patternNumbers),
};
}
2026-07-13 04:39:10 +02:00
function collectSongPositions(data: Uint8Array): SongPosition[] {
const songCountOffset = 612;
const firstSongPositionOffset = 613;
if (data.length <= songCountOffset) {
return [];
}
2026-07-13 04:39:10 +02:00
const availablePositions = Math.max(0, data.length - firstSongPositionOffset);
const songCount = Math.min(data[songCountOffset], 99, availablePositions);
if (songCount === 1 && data[firstSongPositionOffset] === 1) {
return [];
2026-07-13 04:39:10 +02:00
}
const songPositions: SongPosition[] = [];
2026-07-13 04:39:10 +02:00
for (let index = 0; index < songCount; index++) {
const sceneNumber = data[firstSongPositionOffset + index];
if (sceneNumber < 1 || sceneNumber > 99) {
2026-07-13 04:39:10 +02:00
continue;
}
songPositions.push({
position: index + 1,
sceneNumber,
});
}
return songPositions;
}
export function collectProjectStructure(files: TarFile[], sku: string) {
const groupPatterns = collectGroupPatterns(files, sku);
const groupPatternLookup = new Map(
groupPatterns.map((pattern) => [getPatternKey(pattern.group, pattern.patternNumber), pattern]),
);
const scenesFile = files.find((file) => file.name === 'scenes' && file.type === 'file');
let sceneRecords: SceneRecord[];
2026-07-13 04:39:10 +02:00
if (scenesFile?.data) {
sceneRecords = collectSceneRecords(scenesFile.data);
} else {
const patternNumbers = [...new Set(groupPatterns.map((pattern) => pattern.patternNumber))]
.toSorted((a, b) => a - b);
sceneRecords = patternNumbers.map((patternNumber) => ({
number: patternNumber,
patternNumbers: {
a: groupPatternLookup.has(getPatternKey('a', patternNumber)) ? patternNumber : 0,
b: groupPatternLookup.has(getPatternKey('b', patternNumber)) ? patternNumber : 0,
c: groupPatternLookup.has(getPatternKey('c', patternNumber)) ? patternNumber : 0,
d: groupPatternLookup.has(getPatternKey('d', patternNumber)) ? patternNumber : 0,
2026-07-13 04:39:10 +02:00
},
timeSignature: defaultScenesSettings.timeSignature,
}));
2026-07-13 04:39:10 +02:00
}
const scenes = sceneRecords.map((sceneRecord) => buildScene(sceneRecord, groupPatternLookup));
const scenesSettings = {
timeSignature: scenes[0]?.timeSignature ?? defaultScenesSettings.timeSignature,
};
2026-07-13 04:39:10 +02:00
return {
groupPatterns,
scenes,
songPositions: scenesFile?.data ? collectSongPositions(scenesFile.data) : [],
scenesSettings,
};
}
2026-07-13 04:39:10 +02:00
export function collectScenesAndPatterns(files: TarFile[], sku: string) {
return collectProjectStructure(files, sku).scenes;
2026-07-13 04:39:10 +02:00
}
export function collectSettings(files: TarFile[]): ProjectSettings {
const settings = files.find((f) => f.name === 'settings' && f.type === 'file');
if (!settings || !settings.data) {
console.warn('Could not find "settings" file');
return {
...defaultProjectSettings,
rawData: new Uint8Array(),
};
}
const faderParamsData: GroupFaderParam = {};
for (const groupNum of [0, 1, 2, 3]) {
const groupId = GROUPS[groupNum].id;
for (let paramNum = 0; paramNum <= 11; paramNum++) {
faderParamsData[groupId] = {
...faderParamsData[groupId],
[paramNum]: Number(
bytesToFloat32(
settings.data.slice(
24 + groupNum * 48 + paramNum * 4,
28 + groupNum * 48 + paramNum * 4,
),
).toFixed(2),
),
};
}
}
return {
bpm: Number(bytesToFloat32(settings.data.slice(4, 8)).toFixed(2)),
scale: settings.data?.[222] ?? 0,
rootNote: settings.data?.[223] ?? 0,
groupFaderParams: faderParamsData,
faderAssignment: {
a: settings.data[216],
b: settings.data[217],
c: settings.data[218],
d: settings.data[219],
},
rawData: settings.data,
};
}
export function collectEffects(files: TarFile[]) {
const fxFile = files.find((f) => f.name === 'fx_settings' && f.type === 'file');
if (!fxFile || !fxFile.data) {
console.warn('Could not find "fx_settings" file');
return {
rawData: new Uint8Array(),
effectType: 0,
param1: 0,
param2: 0,
};
}
const effectType = fxFile.data[4];
const param1 = bytesToFloat32(
fxFile.data.slice(12 + (effectType - 1) * 4, 16 + (effectType - 1) * 4),
);
const param2 = bytesToFloat32(
fxFile.data.slice(76 + (effectType - 1) * 4, 80 + (effectType - 1) * 4),
);
return {
rawData: fxFile.data,
effectType,
param1,
param2,
};
}
export function collectScenesSettings(files: TarFile[]): ScenesSettings {
const scenesSettingsFile = files.find((f) => f.name === 'scenes' && f.type === 'file');
if (!scenesSettingsFile || !scenesSettingsFile.data) {
console.warn('Could not find "scenes" file');
return defaultScenesSettings;
}
const sceneRecord = collectSceneRecords(scenesSettingsFile.data)[0];
if (!sceneRecord) {
return defaultScenesSettings;
}
2026-07-13 04:39:10 +02:00
return {
timeSignature: sceneRecord.timeSignature,
2026-07-13 04:39:10 +02:00
};
}
export async function loadSoundsFromBackup(
unzippedBackup: JSZip,
projectFiles: TarFile[],
): Promise<Sound[]> {
const soundIds = new Set<number>();
for (let group = 0; group < 4; group++) {
for (let pad = 1; pad <= 12; pad++) {
const file = projectFiles.find((f) => f.name === genPadFileName(GROUPS[group].id, pad));
if (file?.data) {
const soundId = (file.data[2] << 8) + file.data[1];
if (soundId > 0 && soundId < 1000) {
soundIds.add(soundId);
}
}
}
}
const selectedSounds: Sound[] = [];
for (const soundId of soundIds) {
const wavFile = Object.values(unzippedBackup.files).find((file) =>
file.name.startsWith(`/sounds/${String(soundId).padStart(3, '0')} `),
);
if (!wavFile) {
console.warn(`Sound file for sound ID ${soundId} not found in backup`);
continue;
}
const soundFile = unzippedBackup.file(wavFile.name);
if (!soundFile) {
console.warn(`Sound file for sound ID ${soundId} not found in backup`);
continue;
}
const soundData = await soundFile.async('uint8array');
const fileName = (soundFile.name.split('/').pop()?.split(' ').slice(1).join(' ') || '').replace(
'.wav',
'',
);
const fileNode = {
nodeId: soundId,
flags: 0,
fileSize: soundData.length,
fileName,
fileType: 'file' as const,
};
let wavMeta: ReturnType<typeof parseWavMetadata>;
try {
wavMeta = parseWavMetadata(soundData);
} catch (err) {
console.warn(`Failed to parse WAV metadata for sound ${soundId}:`, err);
wavMeta = {
channels: 1,
samplerate: 44100,
format: 's16' as const,
rootNote: 60,
teMeta: null,
};
}
const te = wavMeta.teMeta;
selectedSounds.push({
id: soundId,
fileNode,
meta: {
channels: wavMeta.channels,
samplerate: wavMeta.samplerate,
format: wavMeta.format,
crc: 0,
name: fileName,
'sound.loopstart': (te?.['sound.loopstart'] as number) ?? 0,
'sound.loopend': (te?.['sound.loopend'] as number) ?? 0,
'sound.amplitude': (te?.['sound.amplitude'] as number) ?? 1,
'sound.playmode':
(te?.['sound.playmode'] as TESoundMetadata['sound.playmode']) ?? 'oneshot',
'sound.pan': (te?.['sound.pan'] as number) ?? 0,
'sound.pitch': (te?.['sound.pitch'] as number) ?? 0,
'sound.rootnote': wavMeta.rootNote,
'time.mode': (te?.['time.mode'] as string) ?? '',
'sound.bpm': 0,
'envelope.attack': (te?.['envelope.attack'] as number) ?? 0,
'envelope.release': (te?.['envelope.release'] as number) ?? 0,
},
});
}
return selectedSounds;
}