Initial mess.
This commit is contained in:
commit
605c44de79
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
ffmpeg.exe
|
||||||
|
ifstools.exe
|
||||||
|
.vscode/launch.json
|
||||||
|
package-lock.json
|
198
msadpcm.js
Normal file
198
msadpcm.js
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
const ADAPTATION_TABLE = [
|
||||||
|
230, 230, 230, 230, 307, 409, 512, 614,
|
||||||
|
768, 614, 512, 409, 307, 230, 230, 230,
|
||||||
|
];
|
||||||
|
|
||||||
|
function clamp(val, min, max) {
|
||||||
|
if(val < min) return min;
|
||||||
|
else if(val > max) return max;
|
||||||
|
else return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandNibble(nibble, state, channel) {
|
||||||
|
const signed = 8 <= nibble ? nibble - 16 : nibble;
|
||||||
|
|
||||||
|
let predictor = ((
|
||||||
|
state.sample1[channel] * state.coeff1[channel] +
|
||||||
|
state.sample2[channel] * state.coeff2[channel]
|
||||||
|
) >> 8) + (signed * state.delta[channel]);
|
||||||
|
|
||||||
|
predictor = clamp(predictor, -0x8000, 0x7fff);
|
||||||
|
|
||||||
|
state.sample2[channel] = state.sample1[channel];
|
||||||
|
state.sample1[channel] = predictor;
|
||||||
|
|
||||||
|
state.delta[channel] = Math.floor(ADAPTATION_TABLE[nibble] * state.delta[channel] / 256);
|
||||||
|
if(state.delta[channel] < 16) state.delta[channel] = 16;
|
||||||
|
|
||||||
|
return predictor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a block of MS-ADPCM data
|
||||||
|
* @param {Buffer} buf one block of MS-ADPCM data
|
||||||
|
* @param {number} channels number of channels (usually 1 or 2, never tested on upper values)
|
||||||
|
* @param {number[]} coefficient1 array of 7 UInt8 coefficient values
|
||||||
|
* usually, [ 256, 512, 0, 192, 240, 460, 392 ]
|
||||||
|
* @param {number[]} coefficient2 array of 7 UInt8 coefficient values
|
||||||
|
* usually, [ 0, -256, 0, 64, 0, -208, -232 ]
|
||||||
|
* @return {Buffer[]} array of decoded PCM buffer for each channels
|
||||||
|
*/
|
||||||
|
function decode(buf, channels, coefficient1, coefficient2) {
|
||||||
|
const state = {
|
||||||
|
coefficient: [ coefficient1, coefficient2 ],
|
||||||
|
coeff1: [],
|
||||||
|
coeff2: [],
|
||||||
|
delta: [],
|
||||||
|
sample1: [],
|
||||||
|
sample2: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
// Read MS-ADPCM header
|
||||||
|
for(let i = 0 ; i < channels ; i++) {
|
||||||
|
const predictor = clamp(buf.readUInt8(offset), 0, 6);
|
||||||
|
offset += 1;
|
||||||
|
|
||||||
|
state.coeff1[i] = state.coefficient[0][predictor];
|
||||||
|
state.coeff2[i] = state.coefficient[1][predictor];
|
||||||
|
}
|
||||||
|
|
||||||
|
for(let i = 0 ; i < channels ; i++) { state.delta.push(buf.readInt16LE(offset)); offset += 2; }
|
||||||
|
for(let i = 0 ; i < channels ; i++) { state.sample1.push(buf.readInt16LE(offset)); offset += 2; }
|
||||||
|
for(let i = 0 ; i < channels ; i++) { state.sample2.push(buf.readInt16LE(offset)); offset += 2; }
|
||||||
|
|
||||||
|
// Decode
|
||||||
|
const output = [];
|
||||||
|
|
||||||
|
for(let i = 0 ; i < channels ; i++)
|
||||||
|
output[i] = [ state.sample2[i], state.sample1[i] ];
|
||||||
|
|
||||||
|
let channel = 0;
|
||||||
|
while(offset < buf.length) {
|
||||||
|
const byte = buf.readUInt8(offset);
|
||||||
|
offset += 1;
|
||||||
|
|
||||||
|
output[channel].push(expandNibble(byte >> 4, state, channel));
|
||||||
|
channel = (channel + 1) % channels;
|
||||||
|
|
||||||
|
output[channel].push(expandNibble(byte & 0xf, state, channel));
|
||||||
|
channel = (channel + 1) % channels;
|
||||||
|
}
|
||||||
|
|
||||||
|
//Converting all sound to stereo since it'll be easier later on.
|
||||||
|
if (channels == 1) {
|
||||||
|
output.push(output[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readWav(buf) {
|
||||||
|
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
// 'RIFF'
|
||||||
|
const magic = buf.readUInt32BE(offset); offset += 4;
|
||||||
|
if(magic !== 0x52494646) {
|
||||||
|
console.log(magic);
|
||||||
|
throw "0x0000:0x0004 != 52:49:46:46";
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataSize = buf.readUInt32LE(offset); offset += 4;
|
||||||
|
|
||||||
|
// 'WAVE'
|
||||||
|
const format = buf.readUInt32BE(offset); offset += 4;
|
||||||
|
if(format !== 0x57415645) throw "0x0008:0x000B != 57:41:56:45";
|
||||||
|
|
||||||
|
let wavFormat, wavData;
|
||||||
|
|
||||||
|
while(offset < buf.length) {
|
||||||
|
const name = buf.readUInt32BE(offset); offset += 4;
|
||||||
|
const blockSize = buf.readUInt32LE(offset); offset += 4;
|
||||||
|
|
||||||
|
// 'fmt '
|
||||||
|
if(name === 0x666D7420) {
|
||||||
|
wavFormat = {
|
||||||
|
format: buf.readUInt16LE(offset + 0),
|
||||||
|
channels: buf.readUInt16LE(offset + 2),
|
||||||
|
sampleRate: buf.readUInt32LE(offset + 4),
|
||||||
|
byteRate: buf.readUInt32LE(offset + 8),
|
||||||
|
blockAlign: buf.readUInt16LE(offset + 12),
|
||||||
|
bitsPerSample: buf.readUInt16LE(offset + 14),
|
||||||
|
};
|
||||||
|
|
||||||
|
offset += 16;
|
||||||
|
|
||||||
|
if(wavFormat.format === 0x01) {
|
||||||
|
// console.log(`${filename} is PCM file`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
else if(wavFormat.format === 0x02) {
|
||||||
|
// console.log(`${filename} is MS-ADPCM file`);
|
||||||
|
|
||||||
|
const extraSize = buf.readUInt16LE(offset); offset += 2;
|
||||||
|
wavFormat.extraSize = extraSize;
|
||||||
|
wavFormat.extra = {
|
||||||
|
samplesPerBlock: buf.readUInt16LE(offset + 0),
|
||||||
|
coefficientCount: buf.readUInt16LE(offset + 2),
|
||||||
|
coefficient: [ [], [] ],
|
||||||
|
};
|
||||||
|
|
||||||
|
offset += 4;
|
||||||
|
|
||||||
|
for(let i = 0 ; i < wavFormat.extra.coefficientCount ; i++) {
|
||||||
|
wavFormat.extra.coefficient[0].push(buf.readInt16LE(offset + 0));
|
||||||
|
wavFormat.extra.coefficient[1].push(buf.readInt16LE(offset + 2));
|
||||||
|
offset += 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else throw `WAVE format ${wavFormat.format} is unknown`;
|
||||||
|
}
|
||||||
|
// 'data'
|
||||||
|
else if(name === 0x64617461) {
|
||||||
|
wavData = buf.slice(offset, offset + blockSize);
|
||||||
|
offset += blockSize;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
offset += blockSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(wavFormat && wavData) return { format: wavFormat, data: wavData };
|
||||||
|
else throw "'fmt ' or/and 'data' block not found";
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.decodeKeysoundOut = (buff, vol) => {
|
||||||
|
const adpcmData = readWav(buff);
|
||||||
|
const blockSize = adpcmData.format.blockAlign;
|
||||||
|
|
||||||
|
let totalBuff = Buffer.alloc(1);
|
||||||
|
const totalBlocks = adpcmData.data.length / blockSize;
|
||||||
|
let totalOffset = 0;
|
||||||
|
|
||||||
|
for(let i = 0 ; i < adpcmData.data.length ; i += blockSize) {
|
||||||
|
const adpcmBlock = adpcmData.data.slice(i, i + blockSize);
|
||||||
|
const decoded = decode(
|
||||||
|
adpcmBlock,
|
||||||
|
adpcmData.format.channels,
|
||||||
|
adpcmData.format.extra.coefficient[0],
|
||||||
|
adpcmData.format.extra.coefficient[1]
|
||||||
|
);
|
||||||
|
|
||||||
|
const pcmBlockSize = decoded[0].length * 2;
|
||||||
|
if (totalBuff.length == 1) {
|
||||||
|
totalBuff = Buffer.alloc(pcmBlockSize * totalBlocks * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
for(let s = 0 ; s < pcmBlockSize/2; s++) {
|
||||||
|
for(let c = 0 ; c < decoded.length ; c++) {
|
||||||
|
totalBuff.writeInt16LE(decoded[c][s], totalOffset);
|
||||||
|
totalOffset += 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {data: totalBuff, channels: adpcmData.format.channels, samplingRate: adpcmData.format.sampleRate, volume: vol};
|
||||||
|
}
|
100
popnchart.js
Normal file
100
popnchart.js
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
|
||||||
|
class PopnChart {
|
||||||
|
|
||||||
|
constructor(filename, offsetKeysounds=false) {
|
||||||
|
this.filename = filename;
|
||||||
|
this.data = fs.readFileSync(filename);
|
||||||
|
|
||||||
|
let newFormat = false;
|
||||||
|
if (this.data.readInt8(16) == 69) {
|
||||||
|
newFormat = true;
|
||||||
|
} else if (this.data.readInt8(12) == 69) {
|
||||||
|
newFormat = false;
|
||||||
|
} else {
|
||||||
|
throw "Chart format not supported.";
|
||||||
|
}
|
||||||
|
|
||||||
|
this.events = [];
|
||||||
|
|
||||||
|
let offset = 0;
|
||||||
|
while (offset < this.data.length) {
|
||||||
|
const eventOffset = this.data.readInt32LE(offset);
|
||||||
|
offset += 5;
|
||||||
|
const eventFlag = this.data.readInt8(offset);
|
||||||
|
offset += 1;
|
||||||
|
|
||||||
|
let eventParam = 0;
|
||||||
|
let eventValue = 0;
|
||||||
|
|
||||||
|
let joined = this.data.slice(offset, offset+2);
|
||||||
|
offset += 2;
|
||||||
|
if (eventFlag === 2 || eventFlag === 7) {
|
||||||
|
joined.swap16();
|
||||||
|
const hx = joined.toString("hex");
|
||||||
|
|
||||||
|
eventParam = parseInt(hx.slice(1, 4), 16);
|
||||||
|
eventValue = parseInt(hx.slice(0, 1), 16);
|
||||||
|
} else {
|
||||||
|
eventParam = joined.readUInt8(0);
|
||||||
|
eventValue = joined.readUInt8(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newFormat) {
|
||||||
|
const longNoteData = this.data.readInt32LE(offset);
|
||||||
|
offset += 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.events.push([eventOffset, eventFlag, eventParam, eventValue]);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bpm = 0;
|
||||||
|
this.bpmTransitions = [];
|
||||||
|
|
||||||
|
this.playEvents = [];
|
||||||
|
this.uniqueKeysounds = [];
|
||||||
|
|
||||||
|
this.notecount = 0;
|
||||||
|
|
||||||
|
const sampleColumns = [0, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||||
|
|
||||||
|
for (const event of this.events) {
|
||||||
|
let [offset, eventType, param, value] = event;
|
||||||
|
|
||||||
|
if (eventType == 7 || eventType == 2) {
|
||||||
|
if (this.uniqueKeysounds.indexOf(param) == -1) {
|
||||||
|
this.uniqueKeysounds.push(param);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (eventType) {
|
||||||
|
case 1:
|
||||||
|
if (sampleColumns[param] != 0) {
|
||||||
|
this.playEvents.push([offset, sampleColumns[param]]);
|
||||||
|
}
|
||||||
|
this.notecount += 1;
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
if (offsetKeysounds) {
|
||||||
|
param -= 1;
|
||||||
|
}
|
||||||
|
sampleColumns[value] = param;
|
||||||
|
break;
|
||||||
|
case 3:
|
||||||
|
this.playEvents.push([offset, 0]);
|
||||||
|
break;
|
||||||
|
case 4:
|
||||||
|
this.bpm = param;
|
||||||
|
this.bpmTransitions.push(param);
|
||||||
|
break;
|
||||||
|
case 7:
|
||||||
|
if (offsetKeysounds) {
|
||||||
|
param -= 1;
|
||||||
|
}
|
||||||
|
this.playEvents.push([offset, param]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = PopnChart;
|
121
popntowav.js
Normal file
121
popntowav.js
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
const MSADPCM = require("./msadpcm");
|
||||||
|
const Popnchart = require("./popnchart");
|
||||||
|
const Twodx = require("./twodx");
|
||||||
|
|
||||||
|
const child_process = require("child_process");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const SampleRate = require("node-libsamplerate");
|
||||||
|
const wav = require("wav");
|
||||||
|
|
||||||
|
if (process.argv.length < 3) {
|
||||||
|
console.log("Usage: node popntowav ifs_file");
|
||||||
|
process.exit();
|
||||||
|
}
|
||||||
|
|
||||||
|
let arg1 = process.argv[2];
|
||||||
|
let outputFilename = process.argv[3];
|
||||||
|
|
||||||
|
child_process.execSync(`ifstools ${arg1}`);
|
||||||
|
const ifsname = path.basename(arg1).slice(0, -4);
|
||||||
|
let twodxPath = `${ifsname}_ifs/${ifsname}.2dx`;
|
||||||
|
let chartPath = `${ifsname}_ifs/${ifsname}_op.bin`;
|
||||||
|
|
||||||
|
if (!fs.existsSync(chartPath)) {
|
||||||
|
chartPath = `${ifsname}_ifs/${ifsname}_hp.bin`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cleanUp = true;
|
||||||
|
|
||||||
|
let soundContainer = new Twodx(twodxPath);
|
||||||
|
let chart = new Popnchart(chartPath, !soundContainer.late_bg);
|
||||||
|
//The sound container is full of MSADPCM keysounds, so each one needs decoded.
|
||||||
|
let decodedKeysounds = soundContainer.keysounds.map((keysound) => MSADPCM.decodeKeysoundOut(keysound.data, keysound.unk2));
|
||||||
|
|
||||||
|
if (cleanUp) fs.rmdirSync(path.basename(arg1).slice(0, -4)+"_ifs", {recursive: true});
|
||||||
|
|
||||||
|
let highestSample = 0;
|
||||||
|
//Outputting stereo 44.1Khz regardless.
|
||||||
|
const channels = 2;
|
||||||
|
const samplingRate = 44100;
|
||||||
|
//Because Int32.
|
||||||
|
const bytes = 4;
|
||||||
|
let lowestVolume = 100;
|
||||||
|
|
||||||
|
for (var i = 0; i<decodedKeysounds.length; i++) {
|
||||||
|
let keysound = decodedKeysounds[i];
|
||||||
|
if (keysound.samplingRate != samplingRate) {
|
||||||
|
let options = {
|
||||||
|
type: 0,
|
||||||
|
channels: 2,
|
||||||
|
fromDepth: 16,
|
||||||
|
toDepth: 16,
|
||||||
|
fromRate: keysound.samplingRate,
|
||||||
|
toRate: samplingRate
|
||||||
|
}
|
||||||
|
const resample = new SampleRate(options);
|
||||||
|
|
||||||
|
resample.write(keysound.data);
|
||||||
|
keysound.data = Buffer.from(resample.read());
|
||||||
|
}
|
||||||
|
lowestVolume = keysound.volume < lowestVolume ? keysound.volume : lowestVolume;
|
||||||
|
decodedKeysounds[i] = keysound;
|
||||||
|
}
|
||||||
|
|
||||||
|
//Gotta find the proper endOfSong
|
||||||
|
//Trying to do this by getting the largest offset,
|
||||||
|
//and then adding its associated keysound length
|
||||||
|
//to get the true ending.
|
||||||
|
let buffSize = 0;
|
||||||
|
for (const event of chart.playEvents) {
|
||||||
|
const [offset, keysoundNo] = event;
|
||||||
|
let off = parseInt((offset*samplingRate)/1000)*channels*bytes;
|
||||||
|
const keysound = decodedKeysounds[keysoundNo];
|
||||||
|
if (keysound) {
|
||||||
|
if ((off + (keysound.data.length)*2) > buffSize) {
|
||||||
|
buffSize = off + (keysound.data.length*2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//Creating a buffer to store Int32s.
|
||||||
|
//This is overcompensating to deal with overflow from digital summing.
|
||||||
|
//Final Timestamp in milliseconds * sampling rate * 2 channels * 4 bytes.
|
||||||
|
const finalBuffer = Buffer.alloc(buffSize);
|
||||||
|
|
||||||
|
chart.playEvents.forEach((event) => {
|
||||||
|
const [offset, keysoundNo] = event;
|
||||||
|
//Grabbing the relevant offset for the buffer.
|
||||||
|
const convertedOffset = parseInt((offset*samplingRate)/1000)*channels*bytes;
|
||||||
|
const keysound = decodedKeysounds[keysoundNo];
|
||||||
|
|
||||||
|
if (keysound) {
|
||||||
|
const keysoundData = keysound.data;
|
||||||
|
for (var i = 0; i<keysoundData.length; i += 2) {
|
||||||
|
const keysoundBytes = keysoundData.readInt16LE(i);
|
||||||
|
const finalBytes = finalBuffer.readInt32LE(convertedOffset+(i*2));
|
||||||
|
let mixedBytes = keysoundBytes+finalBytes;
|
||||||
|
|
||||||
|
highestSample = Math.max(Math.abs(mixedBytes), highestSample);
|
||||||
|
finalBuffer.writeInt32LE(mixedBytes, convertedOffset+(i*2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
//We've got summed 16bit values, but they need normalising so we can hear them,
|
||||||
|
//from a 32bit buffer.
|
||||||
|
//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.
|
||||||
|
const normaliseFactor = parseInt(2147483647/highestSample);
|
||||||
|
for (var i = 0; i<finalBuffer.length; i += 4) {
|
||||||
|
const buffBytes = finalBuffer.readInt32LE(i) * normaliseFactor;
|
||||||
|
finalBuffer.writeInt32LE(buffBytes, i);
|
||||||
|
}
|
||||||
|
|
||||||
|
//The 2dx container names usually contain null bytes too.
|
||||||
|
let filename = soundContainer.name;
|
||||||
|
filename = filename.slice(0, filename.indexOf("\u0000"));
|
||||||
|
|
||||||
|
let writer = new wav.FileWriter("output\\"+outputFilename+".wav", {bitDepth: 32});
|
||||||
|
writer.write(finalBuffer);
|
64
twodx.js
Normal file
64
twodx.js
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
|
||||||
|
class Keysound {
|
||||||
|
constructor(data, offset, key_no) {
|
||||||
|
const header = data.toString("ascii", offset, offset+4);
|
||||||
|
offset += 4;
|
||||||
|
const header_lead = data.readUInt32LE(offset);
|
||||||
|
offset += 4;
|
||||||
|
if (header !== "2DX9" || header_lead != 24) {
|
||||||
|
throw "Invalid 2DX header.";
|
||||||
|
}
|
||||||
|
|
||||||
|
const size = data.readUInt32LE(offset);
|
||||||
|
offset += 6;
|
||||||
|
|
||||||
|
this.key_no = key_no;
|
||||||
|
this.is_bg = data.toString("hex", offset, offset+2) == "0000";
|
||||||
|
offset += 2;
|
||||||
|
//These values were for attenuation and loop point in SDVX 2dxs.
|
||||||
|
//I have no clue how to make use of these.
|
||||||
|
this.unk1 = data.readUInt16LE(offset);
|
||||||
|
offset += 2;
|
||||||
|
this.unk2 = data.readUInt16LE(offset);
|
||||||
|
offset += 6;
|
||||||
|
this.data = data.slice(offset, offset+size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Twodx {
|
||||||
|
constructor(path) {
|
||||||
|
this.path = path;
|
||||||
|
const data = fs.readFileSync(path);
|
||||||
|
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
this.name = data.toString("ascii", 0, 16);
|
||||||
|
offset += 16;
|
||||||
|
this.header_len = data.readUInt32LE(offset);
|
||||||
|
offset += 4;
|
||||||
|
this.file_count = data.readUInt32LE(offset);
|
||||||
|
offset += 52;
|
||||||
|
|
||||||
|
this.keysounds = [];
|
||||||
|
|
||||||
|
let trackOffsets = [...Array(this.file_count).keys()].map((_) => {
|
||||||
|
const ind = data.readUInt32LE(offset);
|
||||||
|
offset += 4;
|
||||||
|
return ind;
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i<trackOffsets.length; i++) {
|
||||||
|
const keysound = new Keysound(data, trackOffsets[i]);
|
||||||
|
if (keysound.is_bg) {
|
||||||
|
this.late_bg = i != 0;
|
||||||
|
this.keysounds.unshift(keysound);
|
||||||
|
} else {
|
||||||
|
this.keysounds.push(keysound);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = Twodx;
|
Loading…
Reference in New Issue
Block a user