import JSZip from 'jszip'; import { Group, GroupFaderParam, Note, Pad, PadCode, Pattern, PatternEvent, PatternEventKind, ProjectSettings, Scene, SceneGroupPatternData, 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; } >; export type ParsedPatternData = { bars: number; recordCount: number; events: PatternEvent[]; notes: Record; }; 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(); 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(fileNode.nodeId); selectedSounds.push({ id: soundId, fileNode, meta, }); } return selectedSounds; } export function collectPads(files: TarFile[], sounds: Sound[]) { const result: Record = {}; 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; } 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[] = []; const notes: Record = {}; 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, }); 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; } events.push({ type: 'unknown', kind, position, source, flags, rawData, data: eventData, }); }); return { bars: data[1], recordCount, events, 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]; const sources = new Set(Object.keys(groupData.notes).map(Number)); groupData.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) => { patterns.push({ pad: `${group}${padNum}` as PadCode, group, notes: groupData.notes[padNum] || [], bars: groupData.bars, }); }); } } return patterns; } function getPatternEventsForScene( scenesIntermediate: IntermediateScenes, scenePatternUsage: SceneMeta, ) { const patternEvents: Partial> = {}; for (const group of ['a', 'b', 'c', 'd'] as const) { const patternNumber = scenePatternUsage[group]; const groupData = scenesIntermediate[patternNumber]?.groups[group]; if (groupData) { patternEvents[group] = { group, patternNumber, bars: groupData.bars, recordCount: groupData.recordCount, events: groupData.events, }; } } return patternEvents; } export function collectScenesAndPatterns(files: TarFile[], sku: string) { const scenesIntermediateData: IntermediateScenes = {}; const scenes: Record = {}; const scenePatternUsage: Record = {}; // 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]: parsePatternEvents(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, patternEvents: getPatternEventsForScene( scenesIntermediateData, scenePatternUsage[sceneIndex], ), }; }); 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 { const soundIds = new Set(); 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; 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; }