Skip to content

Instantly share code, notes, and snippets.

@mattdesl
Last active February 6, 2023 21:33
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mattdesl/8bab51bdb2c5e80668c33921420fefee to your computer and use it in GitHub Desktop.
Save mattdesl/8bab51bdb2c5e80668c33921420fefee to your computer and use it in GitHub Desktop.
The "Quite OK Audio" (QOA) Decoder for JavaScript [unstable / experimental / work-in-progress]
/**
* A decoder for The "Quite OK Audio" (QOA) format, a lossy audio compression
* that achieves relatively decent compression with fast decoding and not much complexity.
*
* Note that this has only been tested on QOA files generated by a specific commit (e8386f4)
* of QOA, see below:
* https://github.com/phoboslab/qoa/tree/e8386f41d435a864ce2890e9f56d964215b40301
*
* If you try to decode audio files that were encoded on a different version of QOA you might
* end up with jarring noise. This needs a companion encoder written in JS to be a little more stable.
*
* ** WARNING **
* When hacking or testing with this decoder you should not use headphones, or be very cautious
* of garbage audio data / noise to protect against hearing damage.
*
* Credits:
* - QOA format by @phoboslab
* - JavaScript port by @mattdesl
* - Thanks to @thi.ng for the BitStream decoder
*
* TODOs:
* - Optimizations: use a custom bit stream reader for optimization
* - Port the encoder
* - Continue updating toward latest QOA version
*/
/*
LICENSE - MIT
Copyright (c) 2023 Matt DesLauriers
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
import { BitInputStream } from "@thi.ng/bitstream";
const QOA_MAGIC = 0x716f6166; /* 'qoaf' */
const QOA_MIN_FILESIZE = 16;
const qoa_dequant_tab = [
[1, -1, 3, -3, 5, -5, 7, -7],
[5, -5, 18, -18, 32, -32, 49, -49],
[16, -16, 53, -53, 95, -95, 147, -147],
[34, -34, 113, -113, 203, -203, 315, -315],
[63, -63, 210, -210, 378, -378, 588, -588],
[104, -104, 345, -345, 621, -621, 966, -966],
[158, -158, 528, -528, 950, -950, 1477, -1477],
[228, -228, 760, -760, 1368, -1368, 2128, -2128],
[316, -316, 1053, -1053, 1895, -1895, 2947, -2947],
[422, -422, 1405, -1405, 2529, -2529, 3934, -3934],
[548, -548, 1828, -1828, 3290, -3290, 5117, -5117],
[696, -696, 2320, -2320, 4176, -4176, 6496, -6496],
[868, -868, 2893, -2893, 5207, -5207, 8099, -8099],
[1064, -1064, 3548, -3548, 6386, -6386, 9933, -9933],
[1286, -1286, 4288, -4288, 7718, -7718, 12005, -12005],
[1536, -1536, 5120, -5120, 9216, -9216, 14336, -14336],
];
const QOA_SLICE_LEN = 20;
const QOA_LMS_LEN = 4;
function decodeHeader(data, stream) {
if (data.byteLength < QOA_MIN_FILESIZE) {
throw new Error(`QOA file size must be >= ${QOA_MIN_FILESIZE}`);
}
const magic = stream.read(32);
if (magic !== QOA_MAGIC) {
throw new Error(`Not a QOA file; expected magic number 'qoaf'`);
}
// peek first frame to get audio file data
const header = {
samples: stream.read(32),
channels: stream.read(8),
sampleRate: stream.read(24),
};
// go back to end of header
stream.seek(64);
// return data
return header;
}
function LMS() {
const history = new Int16Array(4);
const weights = new Int16Array(4);
return { history, weights };
}
function qoa_clamp(v, min, max) {
return v < min ? min : v > max ? max : v;
}
function qoa_decode_frame(stream, audio, channelData, sampleOffset) {
const channels = stream.read(8);
const sampleRate = stream.read(24);
const samples = stream.read(16); // frame samples
const frameSize = stream.read(16);
const dataSize = Math.floor(frameSize - 8 - QOA_LMS_LEN * 4 * channels);
const numSlices = Math.floor(dataSize / 8);
const maxTotalSamples = numSlices * QOA_SLICE_LEN;
if (
channels != audio.channels ||
sampleRate != audio.sampleRate ||
samples * channels > maxTotalSamples
) {
throw new Error(`invalid frame header data`);
}
// decode LMS history and weights
const lmses = [];
for (let c = 0; c < channels; c++) {
const lms = LMS();
for (let i = 0; i < QOA_LMS_LEN; i++) {
let h = stream.read(16);
lms.history[i] = h;
}
for (let i = 0; i < QOA_LMS_LEN; i++) {
let w = stream.read(16);
lms.weights[i] = w;
}
lmses.push(lms);
}
for (
let sample_index = 0;
sample_index < samples;
sample_index += QOA_SLICE_LEN
) {
for (let c = 0; c < channels; c++) {
const scalefactor = stream.read(4);
const table = qoa_dequant_tab[scalefactor];
const slice_start = sample_index;
const slice_end = Math.min(sample_index + QOA_SLICE_LEN, samples);
const slice_count = slice_end - slice_start;
const lms = lmses[c];
const sampleData = channelData[c];
let idx = sampleOffset + slice_start;
const weights = lms.weights;
const history = lms.history;
let bitsRemaining = 60;
// note: this loop is a hot code path and could be optimized
for (let i = 0; i < slice_count; i++) {
const predicted = qoa_lms_predict(weights, history);
const quantized = stream.read(3);
const dequantized = table[quantized];
const reconstructed = qoa_clamp(predicted + dequantized, -32768, 32767);
const sample =
reconstructed < 0 ? reconstructed / 32768 : reconstructed / 32767;
sampleData[idx++] = sample;
qoa_lms_update_delta(weights, history, reconstructed, dequantized);
bitsRemaining -= 3;
}
// skip stream if needed
if (bitsRemaining > 0) {
stream.read(bitsRemaining);
}
}
}
return samples;
}
function qoa_lms_predict(W, H) {
return (W[0] * H[0] + W[1] * H[1] + W[2] * H[2] + W[3] * H[3]) >> 13;
}
function qoa_lms_update_delta(weights, history, sample, residual) {
let delta = residual >> 4;
weights[0] += history[0] < 0 ? -delta : delta;
weights[1] += history[1] < 0 ? -delta : delta;
weights[2] += history[2] < 0 ? -delta : delta;
weights[3] += history[3] < 0 ? -delta : delta;
history[0] = history[1];
history[1] = history[2];
history[2] = history[3];
history[3] = sample;
}
export default function decode(data) {
const stream = new BitInputStream(data);
const audio = decodeHeader(data, stream);
const channelData = [];
for (let c = 0; c < audio.channels; c++) {
const d = new Float32Array(audio.samples);
channelData.push(d);
}
let sampleIndex = 0;
let frameLen = 0;
do {
frameLen = qoa_decode_frame(stream, audio, channelData, sampleIndex);
sampleIndex += frameLen;
} while (frameLen && sampleIndex < audio.samples);
return {
...audio,
channelData,
};
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment