Skip to content

Instantly share code, notes, and snippets.

@sgdc3
Last active May 11, 2024 16:24
Show Gist options
  • Save sgdc3/49de81f72fd3e03791def93b9c38e51c to your computer and use it in GitHub Desktop.
Save sgdc3/49de81f72fd3e03791def93b9c38e51c to your computer and use it in GitHub Desktop.
LittleBigPlanet Music Sequencer MIDI Dumper
/*
* LittleBigPlanet Music Sequencer MIDI Dumper
* NodeJS script that extracts music sequencer data from LBP levels in JSON format (jsoninator format)
*
* Author: @sgdc3
* Version: 0.2
* Latest Update: 07 May 2024
*
* HINT: You can obtain the JSON level data using https://github.com/ennuo/toolkit/tree/main/tools/jsoninator
* Output Format: ./out/{Level ID}/{Sequencer UID}/{Sequence Name}/{Sequence Name}-{Track Id}.mid
*
* Changelog:
* 0.1) initial release
* 0.2) extract sequencers inside nested resources, provide leveltojson and leveldownloader utility scripts
*
* TODO: 1 note per channel, implement volume automation
* TODO: pitch bending per note/channel
* TODO: timbre to modulation wheel automation
* TODO: migrate to typescript and validate JSON input
*/
/*
* Imports
*/
// System libraries
const fs = require('fs');
const path = require('path');
// Midi library
const Jzz = require('jzz');
const JzzMidi = require('jzz-midi-smf');
JzzMidi(Jzz);
/*
* Constants
*/
const MIDI_TICKS_PER_QUARTER_NOTE = 96; // 1/4 note = 96 ticks
const MIDI_TICKS_PER_16TH_NOTE = MIDI_TICKS_PER_QUARTER_NOTE / 4; // 1/16 note = 24 ticks
const MIDI_TICKS_PER_12TH_NOTE = MIDI_TICKS_PER_QUARTER_NOTE / 3; // 1/12 note = 32 ticks
// Min instrument unit = 32 steps = 105 px
const GRID_UNIT_SIZE = 52.5; // Grid size is min unit size / 2
const GRID_UNIT_STEPS = 16; // Steps per grid unit is min unit steps / 2
/**
* @typedef {Object} NoteComponent Info about the component of the note event
* @property {number} index The index of the component
* @property {number} step The note step (relative to the component)
*/
/**
* The note event types
*
* @readonly
* @enum {string}
*/
const NoteEventType = {
NOTE_ON: 'note_on',
NOTE_OFF: 'note_off',
NOTE_UPDATE: 'note_update'
}
/**
* @typedef {Object} NoteEvent A note event
* @property {number} step The absolute step number
* @property {NoteComponent} component The note parent component
* @property {number} note The note number
* @property {number} volume The volume of the event
* @property {number} timbre The timbre parameter of the event
* @property {boolean} triplet True if the step is a triplet
* @property {NoteEventType} type The type of the event
* @property {NoteEvent} [parent] The parent event (only specified in NOTE_OFF and NOTE_UPDATE events)
*/
/**
* @typedef {Object} Track An object holding the track data
* @property {number} id The track id
* @property {number} instrument The instrument of the track
* @property {NoteEvent[]} notes The note events inside the track
*/
/**
* @typedef {Object} Sequence An object holding the sequence data
* @property {string} name The sequence name
* @property {number} tempo The sequence tempo
* @property {number} swing The sequence swing amount
* @property {number} length The sequence length (in pixels)
* @property {Track[]} tracks The sequence tracks
*/
/*
* Functions
*/
/**
* Saves a sequence to disk into the output path
*
* @param {string} outputPath
* @param {Sequence} sequence - The sequence to save
*/
function saveSequence(outputPath, sequence) {
// Create sequence target path
const targetFolder = path.join(outputPath, sequence.name);
fs.mkdirSync(targetFolder, {recursive: true});
// Save the sequence .json data
const sequenceMeta = {
...sequence,
tracks: undefined
}
fs.writeFileSync(path.join(targetFolder, `${sequence.name}.json`), JSON.stringify(sequenceMeta, null, 2));
// Save tracks as midi files
for (const track of sequence.tracks) {
// Initialize midi file, type 0, N ticks per quarter note
const smf = new Jzz.MIDI.SMF(0, MIDI_TICKS_PER_QUARTER_NOTE);
// Create midi track
const trk = new Jzz.MIDI.SMF.MTrk();
// Push track to file
smf.push(trk);
// Push metadata to track
trk.add(0, Jzz.MIDI.smfSeqName(`${sequence.name} - Track: ${track.id} Inst: ${track.instrument}`))
.add(0, Jzz.MIDI.smfBPM(sequence.tempo));
// Push notes to track
for (const note of track.notes) {
let event;
// Handle events
if (note.type === NoteEventType.NOTE_ON) {
event = Jzz.MIDI.noteOn(0, note.note, note.volume);
} else if (note.type === NoteEventType.NOTE_UPDATE) {
// TODO: Handle note updates, store volume/timber/pitch changes
} else if (note.type === NoteEventType.NOTE_OFF) {
event = Jzz.MIDI.noteOff(0, note.parent.note);
}
if (!event) {
// No events to push into the track
continue;
}
// Handle triplets
if (note.triplet) {
trk.add(note.step * MIDI_TICKS_PER_12TH_NOTE, event);
} else {
trk.add(note.step * MIDI_TICKS_PER_16TH_NOTE, event);
}
}
// Mark end of track
trk.smfEndOfTrack();
// Write midi file
const targetFile = path.join(targetFolder, `${sequence.name}-${track.id}.mid`);
fs.writeFileSync(targetFile, smf.dump(), 'binary');
}
}
/**
* Extracts music from a sequencer thing
*
* @param {string} outputPath
* @param {Object} sequencer The sequencer thing
*/
function processSequencer(outputPath, sequencer) {
const PSequencer = sequencer.PSequencer;
const PMicrochip = sequencer.PMicrochip;
// Extract metadata
const sequence = {
name: `${PMicrochip.name || 'Unnamed'}`,
tempo: PSequencer.tempo,
swing: PSequencer.swing,
length: PMicrochip.circuitBoardSizeX,
tracks: [],
}
console.log(`> Extracting song ${sequence.name}`);
// Sort components, makes debugging easier
const components = PMicrochip.components.sort((a, b) => {
if (a.x === b.x) {
return a.y - b.y;
}
return a.x - b.x;
});
// Process components, extract note data and group them by track
let tracks = {};
for (const component of components) {
const PInstrument = component.thing.PInstrument;
if (!PInstrument) {
continue; // Skip non-instruments
}
// Since lbp doesn't group instruments into channels, treat instruments of the same type on same row as a channel
let track = tracks[component.y + "|" + PInstrument.instrument.value];
if (!track) {
// Track meta
track = {
y: component.y,
instrument: PInstrument.instrument.value,
notes: [], // Note data, populated later on
}
tracks[component.y + "|" + PInstrument.instrument.value] = track;
}
// Calculate current step and section index
const sectionIndex = Math.floor(component.x / GRID_UNIT_SIZE);
const sectionStep = sectionIndex * GRID_UNIT_STEPS;
// Process notes in the current component
let currentNote = null;
// We assume that LBP stores notes in the right order which is the only way to parse connected notes correctly
for (const note of PInstrument.notes) {
// Calculate current note step
const noteStep = sectionStep + note.x;
if (currentNote && !note.end) {
// If we are already tracking a note and the end flag isn't set handle this ad an inner note into a connected note chain,
// we treat this event as a note update
track.notes.push({
step: noteStep,
component: {
index: sectionIndex,
step: note.x,
},
note: note.y,
volume: note.volume,
timbre: note.timbre,
triplet: note.triplet,
type: NoteEventType.NOTE_UPDATE,
parent: currentNote,
});
} else if (!currentNote) {
// If we aren't tracking any exising note chain push a note on message and store the current note data into currentNote
const noteData = {
step: noteStep,
component: {
index: sectionIndex,
step: note.x,
},
note: note.y,
volume: note.volume,
timbre: note.timbre,
triplet: note.triplet,
type: NoteEventType.NOTE_ON,
};
currentNote = noteData;
track.notes.push(noteData);
}
if (note.end) {
// If the note end flag is set push a note off event with a reference to the original note, then unset currentNote
track.notes.push({
step: noteStep + 1,
component: {
index: sectionIndex,
step: note.x,
},
note: note.y,
volume: note.volume,
timbre: note.timbre,
triplet: note.triplet,
type: NoteEventType.NOTE_OFF,
parent: currentNote,
});
currentNote = null;
}
}
}
// Generate track ids
let trackId = 0;
tracks = Object.values(tracks).sort((a, b) => {
if (a.y === b.y) {
return a.instrument - b.instrument;
}
return a.y - b.y;
});
tracks = tracks.map(track => ({
id: trackId++,
instrument: track.instrument,
notes: track.notes,
}));
sequence.tracks = tracks;
// Save the resulting sequence data
saveSequence(outputPath, sequence);
}
/**
* Extracts sequences from a thing object
*
* @param {Object} thing
* @param {string} outputPath
*/
function visitThing(thing, outputPath) {
if (thing.parent) {
visitThing(thing.parent, outputPath);
}
if (thing.PSequencer?.musicSequencer) {
// Handle music sequencer
processSequencer(path.join(outputPath, `${thing.UID}`), thing);
}
if (thing.PSwitch?.outputs) {
// Handle switch outputs
for (const output of thing.PSwitch.outputs) {
for (const entry of output.targetList) {
if (!entry?.thing) {
// Ignore null entries
continue;
}
visitThing(entry.thing, outputPath);
}
}
}
if (thing.PMicrochip?.circuitBoardThing) {
// Handle microchip thing
visitThing(thing.PMicrochip.circuitBoardThing, outputPath);
}
if (thing.PMicrochip?.components) { // Includes sequencers
// Handle microchip components
for (const entry of thing.PMicrochip.components) {
if (!entry?.thing) {
// Ignore null entries
continue;
}
visitThing(entry.thing, outputPath);
}
}
}
/*
* Main
*/
// Check folders
const workDir = process.cwd();
const levelInputPath = path.join(workDir, 'json');
if (!fs.existsSync(levelInputPath)) {
throw new Error("Missing json directory!");
}
const outPath = path.join(workDir, 'out');
fs.mkdirSync(outPath, {recursive: true});
// Process json files
for (const levelJson of fs.readdirSync(levelInputPath)) {
const levelId = levelJson.split('.json')[0];
console.log(`Processing level ${levelId}`);
// Parse JSON
const jsonFilePath = path.join(levelInputPath, levelJson);
const data = JSON.parse(fs.readFileSync(jsonFilePath).toString());
// Iterate world things
const worldThings = data.resource.worldThing.PWorld.things;
for (const thing of worldThings) {
if (!thing) {
// Ignore null entries
continue;
}
visitThing(thing, path.join(outPath, levelId));
}
}
const fs = require('fs');
const axios = require('axios');
const path = require("node:path");
let levelHashes = process.argv.slice(2);
if (!levelHashes.length) {
throw new Error("No level hashes provided");
}
const workDir = process.cwd();
const outPath = path.join(workDir, 'levels');
fs.mkdirSync(outPath, {recursive: true});
(async () => {
for (let levelHash of levelHashes) {
levelHash = levelHash.toLowerCase();
console.log(`Fetching level ${levelHash}...`);
const response = await axios.get(
`https://archive.org/download/dry23r${levelHash.substring(0, 1)}/dry${levelHash.substring(0, 2)}.zip/${levelHash.substring(0, 2)}/${levelHash.substring(2, 4)}/${levelHash}`,
{
responseType: 'arraybuffer'
}
);
fs.writeFileSync(path.join(outPath, levelHash), response.data);
}
})();
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const workDir = process.cwd();
const toolsDir = path.join(workDir, 'tools');
if (!fs.existsSync(toolsDir)) {
throw "Missing tools directory!";
}
const jsoninatorToolPath = path.join(toolsDir, 'jsoninator-0.1.jar');
if (!fs.existsSync(jsoninatorToolPath)) {
throw "Missing jsoninator tool!";
}
const levelsDir = path.join(workDir, 'levels');
if (!fs.existsSync(levelsDir)) {
throw new Error("Missing levels directory!");
}
const jsonDir = path.join(workDir, 'json');
fs.mkdirSync(levelsDir, {recursive: true})
for (const levelFileName of fs.readdirSync(path.join(workDir, 'levels'))) {
console.log(`Extracting level from ${levelFileName}`);
const levelFile = path.join(levelsDir, levelFileName);
const targetFile = path.join(jsonDir, levelFileName + '.json')
execSync(`java -jar ${jsoninatorToolPath} ${levelFile} ${targetFile}`);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment