Created
June 18, 2022 14:44
-
-
Save jaames/eed66aac4c9a47ba514f29fec604bd58 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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