From 5c2f2ca97b25ab0c8a57d6ec088b5a0150781270 Mon Sep 17 00:00:00 2001 From: "Michael \"Z\" Goddard" Date: Mon, 22 Oct 2018 15:59:26 -0400 Subject: [PATCH 1/3] reuse ArrayBufferStream parent internal objects The extracted children can refer to their parent typed array views and buffer to keep from needing to make memory copies that take a lot of time to create and memory to use. As well some time can be saved by using the same Uint8Array for reading Uint8 values and strings. --- src/ArrayBufferStream.js | 100 +++++++++++++++++++++++++++++++-------- 1 file changed, 80 insertions(+), 20 deletions(-) diff --git a/src/ArrayBufferStream.js b/src/ArrayBufferStream.js index e96387e..86d62f2 100644 --- a/src/ArrayBufferStream.js +++ b/src/ArrayBufferStream.js @@ -7,11 +7,53 @@ class ArrayBufferStream { * The available types to read include: * Uint8, Uint8String, Int16, Uint16, Int32, Uint32 * @param {ArrayBuffer} arrayBuffer - array to use as a stream + * @param {number} start - the start position in the raw buffer. position + * will be relative to the start value. + * @param {number} end - the end position in the raw buffer. length and + * bytes available will be relative to the end value. + * @param {ArrayBufferStream} parent - if passed reuses the parent's + * internal objects * @constructor */ - constructor (arrayBuffer) { + constructor ( + arrayBuffer, start = 0, end = arrayBuffer.byteLength, + { + _uint8View = new Uint8Array(arrayBuffer) + } = {} + ) { + /** + * Raw data buffer for stream to read. + * @type {ArrayBufferStream} + */ this.arrayBuffer = arrayBuffer; - this.position = 0; + + /** + * Start position in arrayBuffer. Read values are relative to the start + * in the arrayBuffer. + * @type {number} + */ + this.start = start; + + /** + * End position in arrayBuffer. Length and bytes available are relative + * to the start, end, and _position in the arrayBuffer; + * @type {number}; + */ + this.end = end; + + /** + * Cached Uint8Array view of the arrayBuffer. Heavily used for reading + * Uint8 values and Strings from the stream. + * @type {Uint8Array} + */ + this._uint8View = _uint8View; + + /** + * Raw position in the arrayBuffer relative to the beginning of the + * arrayBuffer. + * @type {number} + */ + this._position = start; } /** @@ -20,23 +62,40 @@ class ArrayBufferStream { * @return {ArrayBufferStream} the extracted stream */ extract (length) { - const slicedArrayBuffer = this.arrayBuffer.slice(this.position, this.position + length); - const newStream = new ArrayBufferStream(slicedArrayBuffer); - return newStream; + return new ArrayBufferStream(this.arrayBuffer, this._position, this._position + length, this); } /** * @return {number} the length of the stream in bytes */ getLength () { - return this.arrayBuffer.byteLength; + return this.end - this.start; } /** * @return {number} the number of bytes available after the current position in the stream */ getBytesAvailable () { - return (this.arrayBuffer.byteLength - this.position); + return this.end - this._position; + } + + /** + * Position relative to the start value in the arrayBuffer of this + * ArrayBufferStream. + * @type {number} + */ + get position () { + return this._position - this.start; + } + + /** + * Set the position to read from in the arrayBuffer. + * @type {number} + * @param {number} value - new value to set position to + */ + set position (value) { + this._position = value + this.start; + return value; } /** @@ -44,8 +103,8 @@ class ArrayBufferStream { * @return {number} the next 8 bit integer in the stream */ readUint8 () { - const val = new Uint8Array(this.arrayBuffer, this.position, 1)[0]; - this.position += 1; + const val = this._uint8View[this._position]; + this._position += 1; return val; } @@ -56,12 +115,13 @@ class ArrayBufferStream { * @return {string} a String made by concatenating the chars in the input */ readUint8String (length) { - const arr = new Uint8Array(this.arrayBuffer, this.position, length); - this.position += length; + const arr = this._uint8View; let str = ''; - for (let i = 0; i < arr.length; i++) { + const end = this._position + length; + for (let i = this._position; i < end; i++) { str += String.fromCharCode(arr[i]); } + this._position += length; return str; } @@ -70,8 +130,8 @@ class ArrayBufferStream { * @return {number} the next 16 bit integer in the stream */ readInt16 () { - const val = new Int16Array(this.arrayBuffer, this.position, 1)[0]; - this.position += 2; // one 16 bit int is 2 bytes + const val = new Int16Array(this.arrayBuffer, this._position, 1)[0]; + this._position += 2; // one 16 bit int is 2 bytes return val; } @@ -80,8 +140,8 @@ class ArrayBufferStream { * @return {number} the next unsigned 16 bit integer in the stream */ readUint16 () { - const val = new Uint16Array(this.arrayBuffer, this.position, 1)[0]; - this.position += 2; // one 16 bit int is 2 bytes + const val = new Uint16Array(this.arrayBuffer, this._position, 1)[0]; + this._position += 2; // one 16 bit int is 2 bytes return val; } @@ -90,8 +150,8 @@ class ArrayBufferStream { * @return {number} the next 32 bit integer in the stream */ readInt32 () { - const val = new Int32Array(this.arrayBuffer, this.position, 1)[0]; - this.position += 4; // one 32 bit int is 4 bytes + const val = new Int32Array(this.arrayBuffer, this._position, 1)[0]; + this._position += 4; // one 32 bit int is 4 bytes return val; } @@ -100,8 +160,8 @@ class ArrayBufferStream { * @return {number} the next unsigned 32 bit integer in the stream */ readUint32 () { - const val = new Uint32Array(this.arrayBuffer, this.position, 1)[0]; - this.position += 4; // one 32 bit int is 4 bytes + const val = new Uint32Array(this.arrayBuffer, this._position, 1)[0]; + this._position += 4; // one 32 bit int is 4 bytes return val; } } From c12bf02b33fb13e0a344c536c5b9c3e05ffdd0f3 Mon Sep 17 00:00:00 2001 From: "Michael \"Z\" Goddard" Date: Mon, 22 Oct 2018 16:01:41 -0400 Subject: [PATCH 2/3] write ADPCM samples directly to the Audio Buffer's channel data The number of samples in the ADPCM can be known once the data chunk is extracted and the block size is known. From there the audio buffer can be created and its channel data passed to the decompress method. A lot of time is saved by writing to the channel data directly instead of writing to one array, copying that to another array, and then finally copying to the channel data. A surprising amount of time is saved by using one getChannelData call instead of calling to store each sample. --- src/ADPCMSoundDecoder.js | 94 +++++++++++++++++++++++++++------------- 1 file changed, 63 insertions(+), 31 deletions(-) diff --git a/src/ADPCMSoundDecoder.js b/src/ADPCMSoundDecoder.js index b970d76..ac0e171 100644 --- a/src/ADPCMSoundDecoder.js +++ b/src/ADPCMSoundDecoder.js @@ -1,6 +1,28 @@ const ArrayBufferStream = require('./ArrayBufferStream'); const log = require('./log'); +/** + * Data used by the decompression algorithm + * @type {Array} + */ +const STEP_TABLE = [ + 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 +]; + +/** + * Data used by the decompression algorithm + * @type {Array} + */ +const INDEX_TABLE = [ + -1, -1, -1, -1, 2, 4, 6, 8, + -1, -1, -1, -1, 2, 4, 6, 8 +]; + /** * Decode wav audio files that have been compressed with the ADPCM format. * This is necessary because, while web browsers have native decoders for many audio @@ -16,19 +38,13 @@ class ADPCMSoundDecoder { constructor (audioContext) { this.audioContext = audioContext; } + /** * Data used by the decompression algorithm * @type {Array} */ static get STEP_TABLE () { - return [ - 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 - ]; + return STEP_TABLE; } /** @@ -36,10 +52,7 @@ class ADPCMSoundDecoder { * @type {Array} */ static get INDEX_TABLE () { - return [ - -1, -1, -1, -1, 2, 4, 6, 8, - -1, -1, -1, -1, 2, 4, 6, 8 - ]; + return INDEX_TABLE; } /** @@ -81,14 +94,11 @@ class ADPCMSoundDecoder { this.samplesPerBlock = formatChunk.readUint16(); this.adpcmBlockSize = ((this.samplesPerBlock - 1) / 2) + 4; // block size in bytes - const samples = this.imaDecompress(this.extractChunk('data', stream), this.adpcmBlockSize); + const compressedData = this.extractChunk('data', stream); + const sampleCount = this.numberOfSamples(compressedData, this.adpcmBlockSize); - const buffer = this.audioContext.createBuffer(1, samples.length, this.samplesPerSecond); - - // @todo optimize this? e.g. replace the divide by storing 1/32768 and multiply? - for (let i = 0; i < samples.length; i++) { - buffer.getChannelData(0)[i] = samples[i] / 32768; - } + const buffer = this.audioContext.createBuffer(1, sampleCount, this.samplesPerSecond); + this.imaDecompress(compressedData, this.adpcmBlockSize, buffer.getChannelData(0)); resolve(buffer); }); @@ -114,36 +124,60 @@ class ADPCMSoundDecoder { } } + /** + * Count the exact number of samples in the compressed data. + * @param {ArrayBufferStream} compressedData - the compressed data + * @param {number} blockSize - size of each block in the data in bytes + * @return {number} number of samples in the compressed data + */ + numberOfSamples (compressedData, blockSize) { + if (!compressedData) return 0; + + compressedData.position = 0; + + const available = compressedData.getBytesAvailable(); + const blocks = (available / blockSize) | 0; + // Number of samples in full blocks. + const fullBlocks = blocks * (2 * (blockSize - 4)) + 1; + // Number of samples in the last incomplete block. 0 if the last block + // is full. + const subBlock = Math.max((available % blockSize) - 4, 0) * 2; + // 1 if the last block is incomplete. 0 if it is complete. + const incompleteBlock = Math.min(available % blockSize, 1); + return fullBlocks + subBlock + incompleteBlock; + } + /** * Decompress sample data using the IMA ADPCM algorithm. * Note: Handles only one channel, 4-bits per sample. * @param {ArrayBufferStream} compressedData - a stream of compressed audio samples * @param {number} blockSize - the number of bytes in the stream - * @return {Int16Array} the uncompressed audio samples + * @param {Float32Array} out - the uncompressed audio samples */ - imaDecompress (compressedData, blockSize) { + imaDecompress (compressedData, blockSize, out) { let sample; let step; let code; let delta; let index = 0; let lastByte = -1; // -1 indicates that there is no saved lastByte - const out = []; // Bail and return no samples if we have no data - if (!compressedData) return out; + if (!compressedData) return; compressedData.position = 0; - // @todo Update this loop ported from Scratch 2.0 to use a condition or a for loop. - while (true) { // eslint-disable-line no-constant-condition + const size = out.length; + + let i = 0; + while (i < size) { if (((compressedData.position % blockSize) === 0) && (lastByte < 0)) { // read block header if (compressedData.getBytesAvailable() === 0) break; sample = compressedData.readInt16(); index = compressedData.readUint8(); compressedData.position++; // skip extra header byte if (index > 88) index = 88; - out.push(sample); + out[i++] = sample / 32768; } else { // read 4-bit code and compute delta from previous sample if (lastByte < 0) { @@ -154,25 +188,23 @@ class ADPCMSoundDecoder { code = (lastByte >> 4) & 0xF; lastByte = -1; } - step = ADPCMSoundDecoder.STEP_TABLE[index]; + step = STEP_TABLE[index]; delta = 0; if (code & 4) delta += step; if (code & 2) delta += step >> 1; if (code & 1) delta += step >> 2; delta += step >> 3; // compute next index - index += ADPCMSoundDecoder.INDEX_TABLE[code]; + index += INDEX_TABLE[code]; if (index > 88) index = 88; if (index < 0) index = 0; // compute and output sample sample += (code & 8) ? -delta : delta; if (sample > 32767) sample = 32767; if (sample < -32768) sample = -32768; - out.push(sample); + out[i++] = sample / 32768; } } - const samples = Int16Array.from(out); - return samples; } } From 80820c6ba6124ccbc7a6bba6599b68e5c74f8d67 Mon Sep 17 00:00:00 2001 From: "Michael \"Z\" Goddard" Date: Mon, 22 Oct 2018 16:55:58 -0400 Subject: [PATCH 3/3] precompute DELTA_TABLE and reorganize decompression loop There are 1424 possible deltas given 89 steps and 16 codes. We can quickly compute those and reuse them to save time. Knowing the exact size of the waveform we can re-author the decompression loop to take advantage of that. We can place the block header decompression first in the outer while loop and then place an inner loop with the 2 samples per block decompression unwrapped. The first sample reads from the stream and the second uses the other 4 bits. --- src/ADPCMSoundDecoder.js | 87 ++++++++++++++++++++++++++++------------ 1 file changed, 62 insertions(+), 25 deletions(-) diff --git a/src/ADPCMSoundDecoder.js b/src/ADPCMSoundDecoder.js index ac0e171..82ee9be 100644 --- a/src/ADPCMSoundDecoder.js +++ b/src/ADPCMSoundDecoder.js @@ -23,6 +23,36 @@ const INDEX_TABLE = [ -1, -1, -1, -1, 2, 4, 6, 8 ]; +let _deltaTable = null; + +/** + * Build a table of deltas from the 89 possible steps and 16 codes. + * @return {Array} computed delta values + */ +const deltaTable = function () { + if (_deltaTable === null) { + const NUM_STEPS = STEP_TABLE.length; + const NUM_INDICES = INDEX_TABLE.length; + _deltaTable = new Array(NUM_STEPS * NUM_INDICES).fill(0); + let i = 0; + + for (let index = 0; index < NUM_STEPS; index++) { + for (let code = 0; code < NUM_INDICES; code++) { + const step = STEP_TABLE[index]; + + let delta = 0; + if (code & 4) delta += step; + if (code & 2) delta += step >> 1; + if (code & 1) delta += step >> 2; + delta += step >> 3; + _deltaTable[i++] = (code & 8) ? -delta : delta; + } + } + } + + return _deltaTable; +}; + /** * Decode wav audio files that have been compressed with the ADPCM format. * This is necessary because, while web browsers have native decoders for many audio @@ -156,7 +186,6 @@ class ADPCMSoundDecoder { */ imaDecompress (compressedData, blockSize, out) { let sample; - let step; let code; let delta; let index = 0; @@ -168,40 +197,48 @@ class ADPCMSoundDecoder { compressedData.position = 0; const size = out.length; + const samplesAfterBlockHeader = (blockSize - 4) * 2; + + const DELTA_TABLE = deltaTable(); let i = 0; while (i < size) { - if (((compressedData.position % blockSize) === 0) && (lastByte < 0)) { // read block header - if (compressedData.getBytesAvailable() === 0) break; - sample = compressedData.readInt16(); - index = compressedData.readUint8(); - compressedData.position++; // skip extra header byte + // read block header + sample = compressedData.readInt16(); + index = compressedData.readUint8(); + compressedData.position++; // skip extra header byte + if (index > 88) index = 88; + out[i++] = sample / 32768; + + const blockLength = Math.min(samplesAfterBlockHeader, size - i); + const blockStart = i; + while (i - blockStart < blockLength) { + // read 4-bit code and compute delta from previous sample + lastByte = compressedData.readUint8(); + code = lastByte & 0xF; + delta = DELTA_TABLE[index * 16 + code]; + // compute next index + index += INDEX_TABLE[code]; if (index > 88) index = 88; + else if (index < 0) index = 0; + // compute and output sample + sample += delta; + if (sample > 32767) sample = 32767; + else if (sample < -32768) sample = -32768; out[i++] = sample / 32768; - } else { - // read 4-bit code and compute delta from previous sample - if (lastByte < 0) { - if (compressedData.getBytesAvailable() === 0) break; - lastByte = compressedData.readUint8(); - code = lastByte & 0xF; - } else { - code = (lastByte >> 4) & 0xF; - lastByte = -1; - } - step = STEP_TABLE[index]; - delta = 0; - if (code & 4) delta += step; - if (code & 2) delta += step >> 1; - if (code & 1) delta += step >> 2; - delta += step >> 3; + + // use 4-bit code from lastByte and compute delta from previous + // sample + code = (lastByte >> 4) & 0xF; + delta = DELTA_TABLE[index * 16 + code]; // compute next index index += INDEX_TABLE[code]; if (index > 88) index = 88; - if (index < 0) index = 0; + else if (index < 0) index = 0; // compute and output sample - sample += (code & 8) ? -delta : delta; + sample += delta; if (sample > 32767) sample = 32767; - if (sample < -32768) sample = -32768; + else if (sample < -32768) sample = -32768; out[i++] = sample / 32768; } }