Tidied up and added some comments.
This commit is contained in:
parent
605c44de79
commit
f101de695b
@ -1,3 +1,8 @@
|
|||||||
|
//This is adapted from https://github.com/Snack-X/node-ms-adpcm
|
||||||
|
//I tried to find a fast decoder for MSADPCM in nodejs and came up short.
|
||||||
|
//Maybe I didn't look hard enough.
|
||||||
|
//With some work, this did the job well for me.
|
||||||
|
|
||||||
const ADAPTATION_TABLE = [
|
const ADAPTATION_TABLE = [
|
||||||
230, 230, 230, 230, 307, 409, 512, 614,
|
230, 230, 230, 230, 307, 409, 512, 614,
|
||||||
768, 614, 512, 409, 307, 230, 230, 230,
|
768, 614, 512, 409, 307, 230, 230, 230,
|
||||||
|
24
package.json
Normal file
24
package.json
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "popntowav",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Tool for rendering pop'n music IFS/chart files to 16-bit PCM wav.",
|
||||||
|
"main": "popntowav.js",
|
||||||
|
"dependencies": {
|
||||||
|
"node-libsamplerate": "^1.0.0",
|
||||||
|
"wav": "^1.0.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {},
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/Gi-z/popntowav.git"
|
||||||
|
},
|
||||||
|
"author": "Giz",
|
||||||
|
"license": "ISC",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/Gi-z/popntowav/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/Gi-z/popntowav#readme"
|
||||||
|
}
|
40
popnchart.js
40
popnchart.js
@ -2,21 +2,22 @@ const fs = require("fs");
|
|||||||
|
|
||||||
class PopnChart {
|
class PopnChart {
|
||||||
|
|
||||||
|
//offsetKeysounds indicates that any keysound index references
|
||||||
|
//within the chart may need to be decremented by one to account
|
||||||
|
//for the bgtrack not being at the start of the 2dx container.
|
||||||
|
|
||||||
constructor(filename, offsetKeysounds=false) {
|
constructor(filename, offsetKeysounds=false) {
|
||||||
this.filename = filename;
|
this.filename = filename;
|
||||||
this.data = fs.readFileSync(filename);
|
this.data = fs.readFileSync(filename);
|
||||||
|
|
||||||
let newFormat = false;
|
//Check if the chart is newstyle or old>
|
||||||
if (this.data.readInt8(16) == 69) {
|
//Needed to know event lengths.
|
||||||
newFormat = true;
|
let newFormat = this.checkFormat();
|
||||||
} else if (this.data.readInt8(12) == 69) {
|
|
||||||
newFormat = false;
|
|
||||||
} else {
|
|
||||||
throw "Chart format not supported.";
|
|
||||||
}
|
|
||||||
|
|
||||||
this.events = [];
|
this.events = [];
|
||||||
|
|
||||||
|
//This loop reads through the entire file,
|
||||||
|
//rather than ending on an endofsong event.
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
while (offset < this.data.length) {
|
while (offset < this.data.length) {
|
||||||
const eventOffset = this.data.readInt32LE(offset);
|
const eventOffset = this.data.readInt32LE(offset);
|
||||||
@ -27,9 +28,15 @@ class PopnChart {
|
|||||||
let eventParam = 0;
|
let eventParam = 0;
|
||||||
let eventValue = 0;
|
let eventValue = 0;
|
||||||
|
|
||||||
|
//In regular events, param and value are 1 byte.
|
||||||
|
//However on keysound events, the first 4 bits
|
||||||
|
//are used for the param, while the proceeding
|
||||||
|
//12 bits are used for the value.
|
||||||
let joined = this.data.slice(offset, offset+2);
|
let joined = this.data.slice(offset, offset+2);
|
||||||
offset += 2;
|
offset += 2;
|
||||||
if (eventFlag === 2 || eventFlag === 7) {
|
if (eventFlag === 2 || eventFlag === 7) {
|
||||||
|
//Endianness needs flipped.
|
||||||
|
//This is a terrible way of doing this, I think.
|
||||||
joined.swap16();
|
joined.swap16();
|
||||||
const hx = joined.toString("hex");
|
const hx = joined.toString("hex");
|
||||||
|
|
||||||
@ -40,6 +47,7 @@ class PopnChart {
|
|||||||
eventValue = joined.readUInt8(1);
|
eventValue = joined.readUInt8(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//Long note data isn't needed for GSTs, however it's here.
|
||||||
if (newFormat) {
|
if (newFormat) {
|
||||||
const longNoteData = this.data.readInt32LE(offset);
|
const longNoteData = this.data.readInt32LE(offset);
|
||||||
offset += 4;
|
offset += 4;
|
||||||
@ -69,25 +77,31 @@ class PopnChart {
|
|||||||
|
|
||||||
switch (eventType) {
|
switch (eventType) {
|
||||||
case 1:
|
case 1:
|
||||||
|
//Playable note event.
|
||||||
|
//This if is overzealous, just trying to stop BG tracks from being played twice.
|
||||||
if (sampleColumns[param] != 0) {
|
if (sampleColumns[param] != 0) {
|
||||||
this.playEvents.push([offset, sampleColumns[param]]);
|
this.playEvents.push([offset, sampleColumns[param]]);
|
||||||
}
|
}
|
||||||
this.notecount += 1;
|
this.notecount += 1;
|
||||||
break;
|
break;
|
||||||
case 2:
|
case 2:
|
||||||
|
//Sample change event.
|
||||||
if (offsetKeysounds) {
|
if (offsetKeysounds) {
|
||||||
param -= 1;
|
param -= 1;
|
||||||
}
|
}
|
||||||
sampleColumns[value] = param;
|
sampleColumns[value] = param;
|
||||||
break;
|
break;
|
||||||
case 3:
|
case 3:
|
||||||
|
//BG track start event.
|
||||||
this.playEvents.push([offset, 0]);
|
this.playEvents.push([offset, 0]);
|
||||||
break;
|
break;
|
||||||
case 4:
|
case 4:
|
||||||
|
//BPM change event.
|
||||||
this.bpm = param;
|
this.bpm = param;
|
||||||
this.bpmTransitions.push(param);
|
this.bpmTransitions.push(param);
|
||||||
break;
|
break;
|
||||||
case 7:
|
case 7:
|
||||||
|
//BG sample event.
|
||||||
if (offsetKeysounds) {
|
if (offsetKeysounds) {
|
||||||
param -= 1;
|
param -= 1;
|
||||||
}
|
}
|
||||||
@ -95,6 +109,16 @@ class PopnChart {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
checkFormat() {
|
||||||
|
if (this.data.readInt8(16) == 69) {
|
||||||
|
return true;
|
||||||
|
} else if (this.data.readInt8(12) == 69) {
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
throw "Chart format not supported.";
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = PopnChart;
|
module.exports = PopnChart;
|
18
popntowav.js
18
popntowav.js
@ -40,8 +40,11 @@ const channels = 2;
|
|||||||
const samplingRate = 44100;
|
const samplingRate = 44100;
|
||||||
//Because Int32.
|
//Because Int32.
|
||||||
const bytes = 4;
|
const bytes = 4;
|
||||||
let lowestVolume = 100;
|
|
||||||
|
|
||||||
|
//After loading in all the keysounds, we need to find ones that
|
||||||
|
//aren't 44.1KHz, since they'll mess everything up.
|
||||||
|
//Best resampling option I could find was node-libsamplerate.
|
||||||
|
//I'm sure other people have better suggestions.
|
||||||
for (var i = 0; i<decodedKeysounds.length; i++) {
|
for (var i = 0; i<decodedKeysounds.length; i++) {
|
||||||
let keysound = decodedKeysounds[i];
|
let keysound = decodedKeysounds[i];
|
||||||
if (keysound.samplingRate != samplingRate) {
|
if (keysound.samplingRate != samplingRate) {
|
||||||
@ -58,7 +61,6 @@ for (var i = 0; i<decodedKeysounds.length; i++) {
|
|||||||
resample.write(keysound.data);
|
resample.write(keysound.data);
|
||||||
keysound.data = Buffer.from(resample.read());
|
keysound.data = Buffer.from(resample.read());
|
||||||
}
|
}
|
||||||
lowestVolume = keysound.volume < lowestVolume ? keysound.volume : lowestVolume;
|
|
||||||
decodedKeysounds[i] = keysound;
|
decodedKeysounds[i] = keysound;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -82,8 +84,7 @@ for (const event of chart.playEvents) {
|
|||||||
//This is overcompensating to deal with overflow from digital summing.
|
//This is overcompensating to deal with overflow from digital summing.
|
||||||
//Final Timestamp in milliseconds * sampling rate * 2 channels * 4 bytes.
|
//Final Timestamp in milliseconds * sampling rate * 2 channels * 4 bytes.
|
||||||
const finalBuffer = Buffer.alloc(buffSize);
|
const finalBuffer = Buffer.alloc(buffSize);
|
||||||
|
for (const event of chart.playEvents) {
|
||||||
chart.playEvents.forEach((event) => {
|
|
||||||
const [offset, keysoundNo] = event;
|
const [offset, keysoundNo] = event;
|
||||||
//Grabbing the relevant offset for the buffer.
|
//Grabbing the relevant offset for the buffer.
|
||||||
const convertedOffset = parseInt((offset*samplingRate)/1000)*channels*bytes;
|
const convertedOffset = parseInt((offset*samplingRate)/1000)*channels*bytes;
|
||||||
@ -100,12 +101,12 @@ chart.playEvents.forEach((event) => {
|
|||||||
finalBuffer.writeInt32LE(mixedBytes, convertedOffset+(i*2));
|
finalBuffer.writeInt32LE(mixedBytes, convertedOffset+(i*2));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
//We've got summed 16bit values, but they need normalising so we can hear them,
|
//We've got summed 16bit values, which means they won't fit into a 16bit buffer.
|
||||||
//from a 32bit buffer.
|
//We also can't just shove them into a 32bit buffer, since they're 16bit scale.
|
||||||
|
//Instead, we'll have to normalise them first using the peak observed volume.
|
||||||
//2147483647 is just so I don't have to import a MAX_INT32 module.
|
//2147483647 is just so I don't have to import a MAX_INT32 module.
|
||||||
//We're normaslising against the highest volume seen.
|
|
||||||
//After normalising, these values will be scaled correctly from 16bit to 32bit.
|
//After normalising, these values will be scaled correctly from 16bit to 32bit.
|
||||||
const normaliseFactor = parseInt(2147483647/highestSample);
|
const normaliseFactor = parseInt(2147483647/highestSample);
|
||||||
for (var i = 0; i<finalBuffer.length; i += 4) {
|
for (var i = 0; i<finalBuffer.length; i += 4) {
|
||||||
@ -117,5 +118,6 @@ for (var i = 0; i<finalBuffer.length; i += 4) {
|
|||||||
let filename = soundContainer.name;
|
let filename = soundContainer.name;
|
||||||
filename = filename.slice(0, filename.indexOf("\u0000"));
|
filename = filename.slice(0, filename.indexOf("\u0000"));
|
||||||
|
|
||||||
|
//I could manually generate a wav header, but I don't because I'm lazy.
|
||||||
let writer = new wav.FileWriter("output\\"+outputFilename+".wav", {bitDepth: 32});
|
let writer = new wav.FileWriter("output\\"+outputFilename+".wav", {bitDepth: 32});
|
||||||
writer.write(finalBuffer);
|
writer.write(finalBuffer);
|
58
twodx.js
58
twodx.js
@ -1,6 +1,9 @@
|
|||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
|
|
||||||
class Keysound {
|
//This is adapted from https://github.com/mon/SDVX-Song-Extractor/blob/master/bm2dx.py.
|
||||||
|
//I'm personally not very good at data exploration.
|
||||||
|
|
||||||
|
class Sample {
|
||||||
constructor(data, offset, key_no) {
|
constructor(data, offset, key_no) {
|
||||||
const header = data.toString("ascii", offset, offset+4);
|
const header = data.toString("ascii", offset, offset+4);
|
||||||
offset += 4;
|
offset += 4;
|
||||||
@ -13,50 +16,71 @@ class Keysound {
|
|||||||
const size = data.readUInt32LE(offset);
|
const size = data.readUInt32LE(offset);
|
||||||
offset += 6;
|
offset += 6;
|
||||||
|
|
||||||
|
//Previously the keysound number was stored here.
|
||||||
|
//In popn files this doesn't appear to be the case.
|
||||||
|
//So we assume keysounds are sequential in these files.
|
||||||
this.key_no = key_no;
|
this.key_no = key_no;
|
||||||
|
|
||||||
|
//These two bytes are set to 0000 on keysounds which are
|
||||||
|
//used as background tracks. This keysound needs to be
|
||||||
|
//identified as it should be at the start of the container.
|
||||||
this.is_bg = data.toString("hex", offset, offset+2) == "0000";
|
this.is_bg = data.toString("hex", offset, offset+2) == "0000";
|
||||||
offset += 2;
|
offset += 2;
|
||||||
|
|
||||||
//These values were for attenuation and loop point in SDVX 2dxs.
|
//These values were for attenuation and loop point in SDVX 2dxs.
|
||||||
//I have no clue how to make use of these.
|
//I have no clue how to make use of these.
|
||||||
this.unk1 = data.readUInt16LE(offset);
|
this.unk1 = data.readUInt16LE(offset);
|
||||||
offset += 2;
|
offset += 2;
|
||||||
this.unk2 = data.readUInt16LE(offset);
|
this.unk2 = data.readUInt16LE(offset);
|
||||||
offset += 6;
|
offset += 6;
|
||||||
|
|
||||||
|
|
||||||
this.data = data.slice(offset, offset+size);
|
this.data = data.slice(offset, offset+size);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class Twodx {
|
class Twodx {
|
||||||
|
|
||||||
constructor(path) {
|
constructor(path) {
|
||||||
this.path = path;
|
this.path = path;
|
||||||
const data = fs.readFileSync(path);
|
this.data = fs.readFileSync(path);
|
||||||
|
|
||||||
let offset = 0;
|
this.offset = 0;
|
||||||
|
|
||||||
this.name = data.toString("ascii", 0, 16);
|
this.name = this.data.toString("ascii", 0, 16);
|
||||||
offset += 16;
|
this.offset += 16;
|
||||||
this.header_len = data.readUInt32LE(offset);
|
this.header_len = this.data.readUInt32LE(this.offset);
|
||||||
offset += 4;
|
this.offset += 4;
|
||||||
this.file_count = data.readUInt32LE(offset);
|
this.file_count = this.data.readUInt32LE(this.offset);
|
||||||
offset += 52;
|
this.offset += 52;
|
||||||
|
|
||||||
this.keysounds = [];
|
const offsets = this.generateOffsets();
|
||||||
|
this.keysounds = this.generateSamples(offsets);
|
||||||
|
}
|
||||||
|
|
||||||
let trackOffsets = [...Array(this.file_count).keys()].map((_) => {
|
generateOffsets() {
|
||||||
const ind = data.readUInt32LE(offset);
|
return [...Array(this.file_count).keys()].map((_) => {
|
||||||
offset += 4;
|
const ind = this.data.readUInt32LE(this.offset);
|
||||||
|
this.offset += 4;
|
||||||
return ind;
|
return ind;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
for (let i = 0; i<trackOffsets.length; i++) {
|
generateSamples(offsets) {
|
||||||
const keysound = new Keysound(data, trackOffsets[i]);
|
const keysounds = [];
|
||||||
|
for (let i = 0; i<offsets.length; i++) {
|
||||||
|
const keysound = new Sample(this.data, offsets[i]);
|
||||||
if (keysound.is_bg) {
|
if (keysound.is_bg) {
|
||||||
|
//BG tracks are placed at the start of the list
|
||||||
|
//as this makes it easier to deal with keysound
|
||||||
|
//indices in chart files.
|
||||||
this.late_bg = i != 0;
|
this.late_bg = i != 0;
|
||||||
this.keysounds.unshift(keysound);
|
keysounds.unshift(keysound);
|
||||||
} else {
|
} else {
|
||||||
this.keysounds.push(keysound);
|
keysounds.push(keysound);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return keysounds;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user