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

519 lines
14 KiB
TypeScript
Raw Normal View History

2026-07-13 04:39:10 +02:00
import JSZip from 'jszip';
import {
GroupFaderParam,
Note,
Pad,
PadCode,
Pattern,
ProjectSettings,
Scene,
ScenesSettings,
Sound,
} 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 SceneMeta = {
a: number;
b: number;
c: number;
d: number;
};
type IntermediateScenes = Record<
number,
{
name: string;
groups: Record<string, { bars: number; notes: Record<number, Note[]> }>;
}
>;
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 },
};
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;
}
function parsePatterns(data: Uint8Array, startOffset: number = 4) {
const chunks = chunkArray(data, 8, startOffset);
const notes: Record<number, Note[]> = {};
chunks.forEach((chunk) => {
// I discovered there could be some weird chunks with pad numbers not multiple of 8
// since I don't know what they are, just skip them
if (chunk[2] % 8 !== 0) {
return;
}
const pad = chunk[2] / 8;
if (!notes[pad]) {
notes[pad] = [];
}
notes[pad].push({
note: chunk[3],
position: (chunk[1] << 8) + chunk[0],
duration: (chunk[6] << 8) + chunk[5],
velocity: chunk[4],
});
});
return {
bars: data[1],
notes,
};
}
function getPatternsForScene(scenesIntermediate: IntermediateScenes, scenePatternUsage: SceneMeta) {
const patterns: Pattern[] = [];
for (const group of ['a', 'b', 'c', 'd'] as const) {
const scene = scenePatternUsage[group];
const lookupSceneData = scenesIntermediate[scene];
if (lookupSceneData?.groups[group]) {
const groupData = lookupSceneData.groups[group];
Object.entries(groupData.notes).forEach(([padNum, notes]) => {
patterns.push({
pad: `${group}${padNum}` as PadCode,
group,
notes,
bars: groupData.bars,
});
});
}
}
return patterns;
}
export function collectScenesAndPatterns(files: TarFile[], sku: string) {
const scenesIntermediateData: IntermediateScenes = {};
const scenes: Record<string, Scene> = {};
const scenePatternUsage: Record<string, SceneMeta> = {};
// build default scene metadata (in case "scenes" file is missing)
for (let i = 1; i <= 99; i++) {
scenePatternUsage[i] = {
a: i,
b: i,
c: i,
d: i,
};
}
// build group/pattern lookup table
const scenesFile = files.find((f) => f.name === 'scenes' && f.type === 'file');
if (scenesFile?.data) {
const chunks = chunkArray(scenesFile.data, 6, 7);
chunks.forEach((chunk, i) => {
if (i > 98) {
return;
}
scenePatternUsage[i + 1] = {
a: chunk[0],
b: chunk[1],
c: chunk[2],
d: chunk[3],
};
});
}
for (const file of files) {
if (!file.name.startsWith('patterns') || file.type !== 'file' || !file.data) {
continue;
}
const matches = file.name.match(/patterns\/([abcd])(\d+)/);
if (!matches) {
continue;
}
const [, group, scene] = matches;
const sceneIndex = Number(scene);
if (!scenesIntermediateData[sceneIndex]) {
scenesIntermediateData[sceneIndex] = {
name: String(scene).padStart(2, '0'),
groups: {},
};
}
// build intermediate scenes data
// will be used to resolve patterns later using sceneMetadata
scenesIntermediateData[sceneIndex] = {
...scenesIntermediateData[sceneIndex],
groups: {
...scenesIntermediateData[sceneIndex].groups,
[group]: parsePatterns(file.data, sku === SKU_EP40 ? 6 : 4), // EP-40 has 6-byte header
},
};
}
Object.entries(scenesIntermediateData).forEach(([sceneIndex, sceneData]) => {
if (!scenePatternUsage[sceneIndex]) {
return;
}
const patterns = getPatternsForScene(scenesIntermediateData, scenePatternUsage[sceneIndex]);
if (patterns.length === 0) {
return;
}
scenes[sceneIndex] = {
name: sceneData.name,
patterns,
};
});
return Object.values(scenes).sort((a, b) => a.name.localeCompare(b.name));
}
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 numerator = scenesSettingsFile.data[11];
const denominator = scenesSettingsFile.data[12];
return {
...defaultScenesSettings,
timeSignature: { numerator, denominator },
};
}
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;
}