Binary File Decoding and Encoding in JavaScript
In this article, I will take the wav file format as an example to see how to decode and encode binary files in JavaScript.
Decoding
Before go to the code, let's see the wav file format first. I found a image which shows the file format clearly.
A few points needed to be noted:
- to know what parts the file is devided
- to know what fields each part contains
- to know the size and meaning of each field
- to know the file size endianess
Now let's see how to decode a wav file step by step.
We can decode the binary file manually, but the I recommend to use the arcsecond package to do this.
Let's install it first.
npm i arcsecond arcsecond-binary
And import all the packages we need.
const as = require("arcsecond");
const asb = require("arcsecond-binary");
const fs = require("fs");
const path = require("path");
Next, we need to read the binary file into ArrayBuffer.
const file = fs.readFileSync(path.join(__dirname, "test.wav"));
console.log(file);
// a node Buffer
// <Buffer 52 49 46 46 14 60 28 00 57 41 56 45 66 6d 74 20 10 00 00 00 01 00 01 00 44 ac 00 00 88 58 01 00 02 00 10 00 64 61 74 61 f0 5f 28 00 00 00 6a 06 ce 0c ... 2645994 more bytes>
console.log(file.buffer);
// ArrayBuffer {
// [Uint8Contents]: <52 49 46 46 14 60 28 00 57 41 56 45 66 6d 74 20 10 00 00 00 01 00 01 00 44 ac 00 00 88 58 01 00 02 00 10 00 64 61 74 61 f0 5f 28 00 00 00 6a 06 ce 0c 27 13 65 19 97 1f 99 25 86 2b 38 31 c4 36 10 3c 28 41 f6 45 87 4a c3 4e b8 52 55 56 9e 59 8a 5c 1c 5f 4c 61 1a 63 8a 64 88 65 32 66 61 66 3d 66 a2 65 ... 2645944 more bytes>,
// byteLength: 2646044
// }
You can create a wav file for testing using Audacity.
Then let's see the first decoding part, the RIFF chunk.
const riffChunk = as.coroutine(function* () {
const chunkID = yield as.str("RIFF");
const chunkSize = yield asb.u32LE;
if (chunkSize !== file.length - 8) {
yield as.fail(`Invalid file size: ${file.length}. Expected ${chunkSize}`);
}
const format = yield as.str("WAVE");
return { chunkID, chunkSize, format };
});
As you can see, we use the coroutine api here to define a parser. Then yield all the fields. We can check if the field value is valid, if it is not, then we use the fail function to throw an error.
Then let's see the fmt sub-chunk part.
const fmtSubChunk = as.coroutine(function* () {
const subChunk1ID = yield as.str("fmt ");
const subChunk1Size = yield asb.u32LE;
const audioFormat = yield asb.u16LE;
const numChannels = yield asb.u16LE;
const sampleRate = yield asb.u32LE;
const byteRate = yield asb.u32LE;
const blockAlign = yield asb.u16LE;
const bitsPerSample = yield asb.u16LE;
const expectedByteRate = sampleRate * numChannels * bitsPerSample / 8;
if (byteRate !== expectedByteRate) {
yield as.fail(`Invalid byte rate: ${byteRate}, expected ${expectedByteRate}`);
}
const expectedBlockAlign = numChannels * bitsPerSample / 8;
if (blockAlign !== expectedBlockAlign) {
yield as.fail(`Invalid block align: ${blockAlign}, expected ${expectedBlockAlign}`);
}
const fmtData = {
subChunk1ID,
subChunk1Size,
audioFormat,
numChannels,
sampleRate,
byteRate,
blockAlign,
bitsPerSample,
};
yield as.setData(fmtData);
return fmtData;
});
The process is pretty the same. But you may notice the setData function here, which is used to save a state. So the next parser could use the getData function a get this state.
Then is the data sub-chunk part.
const dataSubChunk = as.coroutine(function* () {
const subChunk2ID = yield as.str("data");
const subChunk2Size = yield asb.u32LE;
const fmtData = yield as.getData;
const samples = subChunk2Size / fmtData.numChannels / (fmtData.bitsPerSample / 8);
const channelData = Array.from({ length: fmtData.numChannels }, () => []);
let sampleParser;
if (fmtData.bitsPerSample === 8) {
sampleParser = asb.s8;
} else if (fmtData.bitsPerSample === 16) {
sampleParser = asb.s16LE;
} else if (fmtData.bitsPerSample === 32) {
sampleParser = asb.s32LE;
} else {
yield as.fail(`Unsupported bits per sample: ${fmtData.bitsPerSample}`);
}
// iterate all samples
for (let sampleIndex = 0; sampleIndex < samples; sampleIndex++) {
// iterate all channels
for (let i = 0; i < fmtData.numChannels; i++) {
const sampleValue = yield sampleParser;
channelData[i].push(sampleValue);
}
}
return {
subChunk2ID, subChunk2Size, channelData
};
});
As you can see, we use the getData function to get the state saved from the last parser. Then the next thing needed to be noted is that we confirm the audio sample size by the bitsPerSample field and yield the sound sample value one by one.
Then we combine the above three parser into one.
const parser = as.coroutine(function* () {
const riffChunkPart = yield riffChunk;
const fmtSubChunkPart = yield fmtSubChunk;
const dataSubChunkPart = yield dataSubChunk;
yield as.endOfInput;
return { riffChunkPart, fmtSubChunkPart, dataSubChunkPart };
});
Note that we use the endOfInput to ensure there is no data left.
And lastly, we run the parser and get all the decoding result.
const output = parser.run(file.buffer);
if (output.isError) {
throw new Error(output.error);
}
console.log(output.result);
// {
// riffChunkPart: { chunkID: 'RIFF', chunkSize: 2646036, format: 'WAVE' },
// fmtSubChunkPart: {
// subChunk1ID: 'fmt ',
// subChunk1Size: 16,
// audioFormat: 1,
// numChannels: 1,
// sampleRate: 44100,
// byteRate: 88200,
// blockAlign: 2,
// bitsPerSample: 16
// },
// dataSubChunkPart: {
// subChunk2ID: 'data',
// subChunk2Size: 2646000,
// channelData: [[Array]]
// }
// };
Encoding
For encoding, the same author create a package construct-js is good to use.
npm i construct-js
Then we import all necessary packages.
const cj = require("construct-js");
const fs = require("fs");
const path = require("path");
Then we define some parameters.
const sampleRate = 44100;
const numChannels = 1;
const bitsPerSample = 16;
Now comes the first encoding part.
const riffChunkStruct = cj.Struct("riffChunck")
.field("chunkID", cj.RawString("RIFF"))
.field("chunkSize", cj.U32(0))
.field("format", cj.RawString("WAVE"));
As you can see, we use the Struct function to define a bianry part, and use the field function to defines fields in the binary part. One thing to be noted that the chunkSize we set it to 0, because we don't know it for now. We can set it to the right value when we actually know it.
Then comes the second encoding part.
const fmtSubChunkStruct = cj.Struct("fmtSubChunk")
.field("subChunk1ID", cj.RawString("fmt "))
.field("subChunk1Size", cj.U32(0))
.field("audioFormat", cj.U16(1))
.field("numChannels", cj.U16(numChannels))
.field("sampleRate", cj.U32(sampleRate))
.field("byteRate", cj.U32(sampleRate * numChannels * bitsPerSample / 8))
.field("blockAlign", cj.U16(numChannels * bitsPerSample / 8))
.field("bitsPerSample", cj.U16(bitsPerSample));
const fmtSubChunkSize = fmtSubChunkStruct.computeBufferSize();
fmtSubChunkStruct.get("subChunk1Size").set(fmtSubChunkSize - 8);
As you can see, we set the subChunk1Size field after the struct is created.
Then the third part.
const dataSubChunkStruct = cj.Struct("dataSubChunkStruct")
.field("subChunk2ID", cj.RawString("data"))
.field("subChunk2Size", cj.U32(0))
.field("data", cj.I16s([0]));
// generate random sound
const soundData = [];
let isUp = true;
for (let i = 0; i < sampleRate; i++) {
if (i % 100 === 0) {
isUp = !isUp;
}
const sampleValue = isUp ? 16383 : -16383;
soundData[i] = sampleValue;
}
dataSubChunkStruct.get("data").set(soundData);
dataSubChunkStruct.get("subChunk2Size").set(soundData.length * bitsPerSample / 8);
The process is the same, we define necessary fields first, set real values later.
Now we can compute the field chunkSize.
riffChunkStruct
.get("chunkSize")
.set(riffChunkStruct.computeBufferSize() + fmtSubChunkSize + dataSubChunkStruct.computeBufferSize() - 8);
Lastly, we combines the above structs into one and save it into disk.
const fileStruct = cj.Struct("waveFile")
.field("riffChunkStruct", riffChunkStruct)
.field("fmtSubChunkStruct", fmtSubChunkStruct)
.field("dataSubChunkStruct", dataSubChunkStruct);
fs.writeFileSync(path.join(__dirname, "./new.wav"), fileStruct.toUint8Array());
At the very last, these decoding and encoding process I mostly learned from this youtube video. It explains all the process nicely, I recommend you to watch it if you're interested.