Skip to content

Instantly share code, notes, and snippets.

@jaames
Created June 18, 2022 14:44
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jaames/eed66aac4c9a47ba514f29fec604bd58 to your computer and use it in GitHub Desktop.
Save jaames/eed66aac4c9a47ba514f29fec604bd58 to your computer and use it in GitHub Desktop.
// run in node.js, version 16.0 or later
// assumes sound effects will be in a folder called 'pda', located next to the script (replace all instances of './pda' to change this)
const fs = require('fs');
function assert(condition, errMsg = 'Assert failed') {
if (!condition) {
console.trace(errMsg);
throw new Error(errMsg);
}
}
function readChars(data, ptr, size) {
let result = '';
if (size !== undefined) {
for (let i = 0; i < size; i++) {
const byte = data.getUint8(ptr + i);
if (byte === 0)
break;
result += String.fromCharCode(byte);
}
}
else {
let i = 0;
while(true) {
const byte = data.getUint8(ptr + i);
if (byte === 0)
break;
result += String.fromCharCode(byte);
i += 1;
}
}
return result;
}
function clamp(n, l, h) {
if (n < l)
return l;
if (n > h)
return h;
return n;
}
const ADPCM_INDEX_TABLE = new Int8Array([
-1, -1, -1, -1, 2, 4, 6, 8,
-1, -1, -1, -1, 2, 4, 6, 8
]);
const ADPCM_STEP_TABLE = new Int16Array([
7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 19, 21, 23, 25, 28, 31, 34, 37, 41, 45,
50, 55, 60, 66, 73, 80, 88, 97, 107, 118, 130, 143, 157, 173, 190, 209, 230,
253, 279, 307, 337, 371, 408, 449, 494, 544, 598, 658, 724, 796, 876, 963,
1060, 1166, 1282, 1411, 1552, 1707, 1878, 2066, 2272, 2499, 2749, 3024, 3327,
3660, 4026, 4428, 4871, 5358, 5894, 6484, 7132, 7845, 8630, 9493, 10442,
11487, 12635, 13899, 15289, 16818, 18500, 20350, 22385, 24623, 27086, 29794,
32767
]);
const PdAudioFormat = {
kFormat8bitMono: 0,
kFormat8bitStereo: 1,
kFormat16bitMono: 2,
kFormat16bitStereo: 3,
kFormat4bitMono: 4,
kFormat4bitStereo: 5
};
class PdAudioParser {
constructor(buffer) {
this.buffer = buffer;
this.bufferSize = buffer.byteLength;
// format is little endian
const le = true;
const data = new DataView(this.buffer, 0, 18);
this.ident = readChars(data, 0, 12);
assert(this.ident === 'Playdate AUD', `File ident ${ this.ident } not recognized`);
this.sampleRate = data.getUint8(12) | (data.getUint8(13) << 8) | (data.getUint8(14) << 16);
this.format = data.getUint8(15);
this.audioDataPtr = 16;
if (this.format === PdAudioFormat.kFormat4bitMono || this.format === PdAudioFormat.kFormat4bitStereo) {
this.audioDataPtr = 18;
this.blockSize = data.getUint16(16, le);
}
switch (this.format) {
case PdAudioFormat.kFormat4bitMono:
this.numChannels = 1;
this.bitDepth = 4;
this.read4BitAudioData();
break;
case PdAudioFormat.kFormat4bitStereo:
this.numChannels = 2;
this.bitDepth = 4;
this.read4BitAudioData();
break;
case PdAudioFormat.kFormat8bitMono:
this.numChannels = 1;
this.bitDepth = 8;
this.read8BitAudioData();
break;
case PdAudioFormat.kFormat8bitStereo:
this.numChannels = 2;
this.bitDepth = 8;
this.read8BitAudioData();
break;
case PdAudioFormat.kFormat16bitMono:
this.numChannels = 1;
this.bitDepth = 16;
this.read16BitAudioData();
break;
case PdAudioFormat.kFormat16bitStereo:
this.numChannels = 2;
this.bitDepth = 16;
this.read16BitAudioData();
break;
default:
throw new Error(`.pda audio format type ${ this.format } not recognized`);
}
}
read16BitAudioData() {
const src = new Int16Array(this.buffer, this.audioDataPtr);
const numChannels = this.numChannels;
const numSamples = src.length / numChannels;
const channelBuf = [
new Int16Array(numSamples),
new Int16Array(numSamples)
];
let srcPtr = 0;
let dstPtr = 0;
for (let i = 0; i < numSamples; i++) {
for (let ch = 0; ch < numChannels; ch++) {
channelBuf[ch][dstPtr] = src[srcPtr++];
}
dstPtr += 1;
}
this.pcm = channelBuf;
}
read8BitAudioData() {
const src = new Uint8Array(this.buffer, this.audioDataPtr);
const size = src.byteLength;
const numChannels = this.numChannels;
const numSamples = size / numChannels;
const channelBuf = [
new Int16Array(numSamples),
new Int16Array(numSamples)
];
let srcPtr = 0;
let dstPtr = 0;
for (let i = 0; i < numSamples; i++) {
for (let ch = 0; ch < numChannels; ch++) {
const sample = src[srcPtr++];
channelBuf[ch][dstPtr] = (sample - 0x80) << 8;
}
dstPtr += 1;
}
this.pcm = channelBuf;
}
read4BitAudioData() {
const audioPtr = this.audioDataPtr;
const src = new Uint8Array(this.buffer, audioPtr);
const size = src.byteLength;
const numChannels = this.numChannels;
const numSamples = (size * 2) / numChannels;
const blockSize = this.blockSize;
const blockHeaderSize = 4 * numChannels;
const adpcmSample = this.adpcmSample;
const channelBuf = [new Int16Array(numSamples), new Int16Array(numSamples)];
const channelPtr = [0, 0];
const channelCtx = [
{ predictor: 0, stepIndex: 0 },
{ predictor: 0, stepIndex: 0 }
];
let srcPtr = 0;
// loop through audio blocks
while (srcPtr < size) {
// start of every block contains the initial adpcm state and first sample for each channel
if (srcPtr % blockSize === 0) {
const h = new DataView(src.buffer, audioPtr + srcPtr, blockHeaderSize);
for (let ch = 0; ch < numChannels; ch++) {
const hptr = 4 * ch;
// TODO: just read this using bitwise ops without the dataview
const pred = h.getInt16(hptr, true);
const step = h.getUint8(hptr + 2);
const rsrv = h.getUint8(hptr + 3);
assert(rsrv === 0, 'Reserve byte in ADPCM block header should be zero');
channelCtx[ch].predictor = pred;
channelCtx[ch].stepIndex = step;
channelBuf[ch][channelPtr[ch]++] = pred;
}
srcPtr += blockHeaderSize;
}
// rest of the block contains audio samples
if (numChannels === 1) {
for (let i = blockHeaderSize; i < blockSize; i += 1) {
const byte = src[srcPtr++];
channelBuf[0][channelPtr[0]++] = adpcmSample(byte >> 4, channelCtx[0]);
channelBuf[0][channelPtr[0]++] = adpcmSample(byte & 0x0F, channelCtx[0]);
}
}
else if (numChannels === 2) {
for (let i = blockHeaderSize; i < blockSize; i += 1) {
const byte = src[srcPtr++];
// left channel uses high nibbles, right uses low nibbles
channelBuf[0][channelPtr[0]++] = adpcmSample(byte >> 4, channelCtx[0]);
channelBuf[1][channelPtr[1]++] = adpcmSample(byte & 0x0F, channelCtx[1]);
}
}
}
this.pcm = channelBuf;
}
adpcmSample(sample, ctx) {
const step = ADPCM_STEP_TABLE[ctx.stepIndex];
let pred = ctx.predictor;
let stepIndex = ctx.stepIndex;
let diff = step >> 3;
if (sample & 1)
diff += step >> 2;
if (sample & 2)
diff += step >> 1;
if (sample & 4)
diff += step;
if (sample & 8)
diff = -diff;
pred += diff;
stepIndex += ADPCM_INDEX_TABLE[sample];
ctx.predictor = clamp(pred, -32768, 32767);
ctx.stepIndex = clamp(stepIndex, 0, 88);
return ctx.predictor;
}
}
class BinaryWriter {
constructor() {
// sizes
this.pageSize = 2048 * 2;
this.allocSize = 0; // allocated size counting all pages
this.realSize = 0; // number of bytes actually used
// pages
this.pages = [];
this.numPages = 0;
// pointers
this.pageIdx = 0; // page to write to
this.pagePtr = 0; // position in page to write to
this.realPtr = 0; // position in file
this.newPage();
}
set pointer(ptr) {
this.setPointer(ptr);
}
get pointer() {
return this.realPtr;
}
newPage() {
this.pages[this.numPages] = new Uint8Array(this.pageSize);
this.numPages = this.pages.length;
this.allocSize = this.numPages * this.pageSize;
}
setPointer(ptr) {
// allocate enough pages to include pointer
while (ptr >= this.allocSize) {
this.newPage();
}
// increase real file size if the end is reached
if (ptr > this.realSize)
this.realSize = ptr;
// update ptrs
// TODO: this is going to get hit a lot, maybe optimise?
this.pageIdx = Math.floor(ptr / this.pageSize);
this.pagePtr = ptr % this.pageSize;
this.realPtr = ptr;
}
writeByte(value) {
this.pages[this.pageIdx][this.pagePtr] = value;
this.setPointer(this.realPtr + 1);
}
writeBytes(bytes, srcPtr = undefined, length = undefined) {
for (let l = length || bytes.length, i = srcPtr || 0; i < l; i++)
this.writeByte(bytes[i]);
}
writeChars(str) {
for (let i = 0; i < str.length; i++) {
this.writeByte(str.charCodeAt(i));
}
}
writeU8(value) {
this.writeByte(value & 0xFF);
}
writeU16(value) {
this.writeByte((value >>> 0) & 0xFF);
this.writeByte((value >>> 8) & 0xFF);
}
writeU32(value) {
this.writeByte((value >>> 0) & 0xFF);
this.writeByte((value >>> 8) & 0xFF);
this.writeByte((value >>> 16) & 0xFF);
this.writeByte((value >>> 24) & 0xFF);
}
getBytes() {
const bytes = new Uint8Array(this.realSize);
const numPages = this.numPages;
for (let i = 0; i < numPages; i++) {
const page = this.pages[i];
if (i === numPages - 1) // last page
bytes.set(page.slice(0, this.realSize % this.pageSize), i * this.pageSize);
else
bytes.set(page, i * this.pageSize);
}
return bytes;
}
getBuffer() {
const bytes = this.getBytes();
return bytes.buffer;
}
}
class WavEncoder {
constructor(sampleRate, channels=1, bitsPerSample=16) {
this.sampleRate = sampleRate;
this.channels = channels;
this.bitsPerSample = bitsPerSample;
// Write WAV file header
// Reference: http://www.topherlee.com/software/pcm-tut-wavformat.html
const header = new BinaryWriter();
// 'RIFF' indent
header.writeChars('RIFF');
// filesize (set later)
header.writeU32(0);
// 'WAVE' indent
header.writeChars('WAVE');
// 'fmt ' section header
header.writeChars('fmt ');
// fmt section length
header.writeU32(16);
// specify audio format is pcm (type 1)
header.writeU16(1);
// number of audio channels
header.writeU16(this.channels);
// audio sample rate
header.writeU32(this.sampleRate);
// byterate = (sampleRate * bitsPerSample * channelCount) / 8
header.writeU32((this.sampleRate * this.bitsPerSample * this.channels) / 8);
// blockalign = (bitsPerSample * channels) / 8
header.writeU16((this.bitsPerSample * this.channels) / 8);
// bits per sample
header.writeU16(this.bitsPerSample);
// 'data' section header
header.writeChars('data');
// data section length (set later)
header.writeU32(0);
this.header = header;
this.pcmData = undefined;
}
writeSamples(...channelBuffers) {
let header = this.header;
const numChannels = channelBuffers.length;
const numSamples = channelBuffers[0].length;
const dataSize = channelBuffers.reduce((size, channel) => size + channel.byteLength, 0);
// fill in filesize
header.setPointer(4);
header.writeU32(header.realSize + dataSize);
// fill in data section length
header.setPointer(40);
header.writeU32(dataSize);
// copy channel data into single interleaved buffer
const dst = new Int16Array(numChannels * numSamples);
let dstPtr = 0;
for (let i = 0; i < numSamples; i++) {
for (let ch = 0; ch < numChannels; ch++) {
dst[dstPtr++] = channelBuffers[ch][i];
}
}
this.pcmData = dst;
}
getArrayBuffer() {
assert(this.pcmData !== undefined);
const headerBytes = this.header.getBytes();
const pcmBytes = new Uint8Array(this.pcmData.buffer);
const resultBytes = new Uint8Array(this.header.realSize + this.pcmData.byteLength);
resultBytes.set(headerBytes);
resultBytes.set(pcmBytes, headerBytes.byteLength);
return resultBytes.buffer;
}
getBuffer() {
return Buffer.from(this.getArrayBuffer());
}
}
fs.readdir('./pda', (err, files) => {
files.forEach(filename => {
if (filename.endsWith('.pda')) {
const file = fs.readFileSync('./pda/' + filename);
const pda = new PdAudioParser(file.buffer);
const formatNames = ['8-bit mono', '8-bit stereo', '16-bit mono', '16-bit stereo', '4-bit mono', '4-bit stereo'];
console.log(`converting ${ filename } (sample rate: ${ pda.sampleRate }, format: ${ formatNames[pda.format] })`)
const wav = new WavEncoder(pda.sampleRate, pda.numChannels, 16);
wav.writeSamples(...pda.pcm);
const wavBuff = wav.getBuffer();
fs.writeFileSync('./pda/' + filename + '.wav', wavBuff);
}
});
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment