/** * Lossless codecs run in the browser, on int16 sample data. * * The baseline throughout is the uncompressed int16 stream: 2 bytes = 16 bits * per sample. A ratio is (baseline bytes) / (compressed bytes). */ import { zlibSync } from 'fflate' import { init as zstdInit, compress as zstdCompress } from '@bokuweb/zstd-wasm' import { ansEncode, ansDecode, encodedSize } from './ans' import { fitLpc, lpcResidual, lpcRestore, modelSize } from './lpc' // Bundled as an asset so the page needs no network at run time. The path is // relative rather than a bare specifier because the package's `exports` map // has no entry for the wasm file, so `@bokuweb/zstd-wasm/dist/web/zstd.wasm` // cannot be resolved. import zstdWasmUrl from '../../node_modules/@bokuweb/zstd-wasm/dist/web/zstd.wasm?url' export interface Codec { name: string /** Longer description, shown on hover. */ note: string /** Compressed size in bytes, including any table the decoder needs. */ size: (samples: Int16Array, bytes: Uint8Array) => number } /** * First differences, in int16 with wraparound — exactly invertible, and the * standard prefilter for signals whose neighbouring samples are related. */ function delta(samples: Int16Array): Int16Array { const d = new Int16Array(samples.length) d[0] = samples[0] for (let i = 1; i < samples.length; i++) { d[i] = ((samples[i] - samples[i - 1]) << 16) >> 16 } return d } function undelta(d: Int16Array): Int16Array { const s = new Int16Array(d.length) s[0] = d[0] for (let i = 1; i < d.length; i++) { s[i] = ((s[i - 1] + d[i]) << 16) >> 16 } return s } function asBytes(samples: Int16Array): Uint8Array { return new Uint8Array(samples.buffer, samples.byteOffset, samples.byteLength) } /** * Size of an ANS encoding of `coded`, after decoding it, running `inverse` to * undo whatever prefilter produced it, and checking the result against the * original samples — so a reported size always belongs to an encoding that * actually round-trips. `extraBytes` covers anything else the decoder needs, * such as predictor coefficients. */ function ansSize( samples: Int16Array, coded: Int16Array, inverse: (coded: Int16Array) => Int16Array, extraBytes = 0, ): number { const encoded = ansEncode(coded) const decoded = inverse(ansDecode(encoded)) if (decoded.length !== samples.length) throw new Error('ANS round-trip length mismatch') for (let i = 0; i < samples.length; i++) { if (decoded[i] !== samples[i]) throw new Error(`ANS round-trip mismatch at ${i}`) } return encodedSize(encoded) + extraBytes } /** Predictor order. Going past 32 buys well under a percent on this data. */ const LPC_ORDER = 32 /** Fit the predictor and take the residual, with the coefficients' cost. */ function lpcTransform(samples: Int16Array): { residual: Int16Array restore: (residual: Int16Array) => Int16Array extraBytes: number } { const model = fitLpc(samples, LPC_ORDER) if (!model) throw new Error('LPC fit failed') return { residual: lpcResidual(samples, model), restore: residual => lpcRestore(residual, model), extraBytes: modelSize(model), } } export const ZLIB: Codec = { name: 'zlib -9', note: 'DEFLATE at maximum level — the HDF5 gzip filter', size: (_s, bytes) => zlibSync(bytes, { level: 9 }).length, } export const ZSTD: Codec = { name: 'zstd -19', note: 'Zstandard at level 19 — the Blosc/Zarr default family', size: (_s, bytes) => zstdCompress(bytes, 19).length, } export const ANS: Codec = { name: 'ANS', note: 'rANS entropy coder over the sample histogram, ported from simple_ans; the size includes the symbol table', size: samples => ansSize(samples, samples, x => x), } export const DELTA_ZLIB: Codec = { name: 'delta + zlib -9', note: 'First differences, then DEFLATE', size: samples => zlibSync(asBytes(delta(samples)), { level: 9 }).length, } export const DELTA_ZSTD: Codec = { name: 'delta + zstd -19', note: 'First differences, then Zstandard 19', size: samples => zstdCompress(asBytes(delta(samples)), 19).length, } export const DELTA_ANS: Codec = { name: 'delta + ANS', note: 'First differences, then the rANS entropy coder', size: samples => ansSize(samples, delta(samples), undelta), } const LPC_NOTE = `Order-${LPC_ORDER} linear prediction with integer coefficients; the size includes the coefficients` export const LPC_ZLIB: Codec = { name: `LPC(${LPC_ORDER}) + zlib -9`, note: `${LPC_NOTE}, then DEFLATE`, size: samples => { const { residual, extraBytes } = lpcTransform(samples) return zlibSync(asBytes(residual), { level: 9 }).length + extraBytes }, } export const LPC_ZSTD: Codec = { name: `LPC(${LPC_ORDER}) + zstd -19`, note: `${LPC_NOTE}, then Zstandard 19`, size: samples => { const { residual, extraBytes } = lpcTransform(samples) return zstdCompress(asBytes(residual), 19).length + extraBytes }, } export const LPC_ANS: Codec = { name: `LPC(${LPC_ORDER}) + ANS`, note: `${LPC_NOTE}, then the rANS entropy coder`, size: samples => { const { residual, restore, extraBytes } = lpcTransform(samples) return ansSize(samples, residual, restore, extraBytes) }, } /** The general-purpose compressors, which know nothing about the data. */ export const GENERAL_CODECS: Codec[] = [ZLIB, ZSTD] let ready: Promise | null = null // zstd-wasm publishes the *node* build's types while the bundler resolves the // browser build (its `exports` map has a "browser" condition). Only the browser // build's `init` takes a wasm URL, so the signature has to be restated here. const initWithUrl = zstdInit as unknown as (path?: string) => Promise /** Load the zstd wasm module. Safe to call repeatedly. */ export function initCodecs(): Promise { if (!ready) { ready = initWithUrl(zstdWasmUrl).catch((err: unknown) => { ready = null throw err }) } return ready } export interface CodecResult { codec: string note: string bytes: number ratio: number bitsPerSample: number } /** Compress one int16 buffer with each of the given codecs. */ export function compressAll(buffer: Uint8Array, codecs: Codec[]): CodecResult[] { // The session's buffer may sit at an odd offset; align before viewing as int16. const aligned = buffer.byteOffset % 2 === 0 ? buffer : new Uint8Array(buffer) const samples = new Int16Array(aligned.buffer, aligned.byteOffset, aligned.byteLength / 2) return codecs.map(codec => { const bytes = codec.size(samples, aligned) return { codec: codec.name, note: codec.note, bytes, ratio: aligned.byteLength / bytes, bitsPerSample: (8 * bytes) / samples.length, } }) }