An Open-Source Lightweight ASR Codec
Ellison Murray · Alexander Chen · Anya Knudsen · Adam Jean · Morriel Kasher · Predrag Spasojevic
2.90% WER · 25 kbps · 8 bytes RAM · zero floating-point ops
Speech recognition models are huge. Earbuds and smart glasses have kilobytes of memory and no math coprocessor. So the audio gets sent to a server instead — a small encoder on the device, the heavy ASR model in the cloud.
But sending raw audio drains the battery and the bandwidth. The audio has to be compressed first, and that compression itself has to be almost free.
And the thing we are not optimizing for: how good it sounds. Codecs like MP3 and Opus are tuned so audio sounds good to a human ear. No human ever hears our audio — only a machine does. That frees us to throw away completely different things.
Speech models don't read the raw waveform. They convert it into a mel-spectrogram — a picture of sound where time runs left to right, pitch runs bottom to top, and brightness is energy — and run image-style convolutions over it. "Mel" just means the pitch axis is spaced the way human hearing is: squeezed at high frequencies.
The rounding error is not random. It follows the signal, and that's exactly what puts fake stripes in the spectrogram.
Both sides generate v from the same 32-bit shift register, seeded from the file header, so it never has to be transmitted.
Why bother? Randomizing the rounding breaks the link between the signal and the error. The error stops being structured and becomes ordinary background hiss, which the recognition model handles far better than fake speech structure. Without dither, quantization error shows up as harmonic spikes an ASR model can mistake for real speech content. With subtractive dither, that same error becomes a flat noise floor.
Speech spends most of its time near zero and rarely gets loud, so with only 8 levels, the loud codes sit unused. Instead of guessing a fix, a curve was trained to reshape the signal before rounding so every level gets used, with the objective of preserving as much information about the original signal as possible.
That learned curve rediscovered µ-law companding — the same curve telephone networks have used for decades. A nice sanity check that the training was doing something real.
α = 0 → no dither α = 1 → full dither in between → partial
Turning α up cleans up the fake structure, but it costs information and it costs bitrate — dithered samples get harder to compress. Sweeping α and picking the best spot per bit depth showed dithering helps most at 2 bits (a 23% cut in errors) and actually hurts at 1 bit.
.glx, with its own header and CRC-32 error check.Pipeline
Encoder: PCM 48 kHz -> anti-alias FIR -> decimate /3 -> compress (mu-law LUT)
-> headroom prescale -> add dither -> quantize -> first-order residual
-> static Huffman -> bitstream -> CRC-32 -> header + payload
Decoder: header -> verify CRC-32 -> Huffman decode -> reconstruct code
-> dequantize -> subtract dither -> PCM 16 kHz
Build & run
make # build glx_encode and glx_decode
make tables # generate look-up tables such as compression_lut.h, resample_taps.h, huffman_lut.h
make clean
./glx_encode in.pcm bits alpha_idx seed out.glx [in_rate]
./glx_decode in.glx out.pcm
| Codec | Encoder work (MIPS/s audio) | Bitrate (kbps) |
|---|---|---|
| G.711 | 0.345 | 64.0 |
| GLX | 2.25 | 23.7 |
| G.726 | 9.66 | 24.0 |
| MP3 | 16.9 | 24.0 |
| SILK | 24.2 | 23.4 |
The honest trade: at the same bitrate, GLX's accuracy is a bit worse than MP3 or SILK. On a chip with no floating-point unit and a battery to protect, that's judged the right side of the trade. Going from test-clean to test-other conditions costs GLX 2.88× WER on average versus 2.14× for the baselines — low-resolution scalar quantization is more sensitive to noise.
Next: train the dithering directly into the recognition model itself.
8 bytes of RAM total for the runtime state; ROM is dominated by the lookup tables.
| Stage | ROM (bytes) | RAM (bytes) |
|---|---|---|
| CRC (header check) | 248 | 0 |
| Resampling | 450 | 80 |
| Compression | 370 | 0 |
| Dithering | 244 | 4 |
| Quantization | 90 | 2 |
| Coding | 385 | 36 |
| Total | 1171 | — |
V ~ f_V(v) = α · Π_αΔ(v) + (1 − α) · δ(v), α ∈ [0, 1]
Evaluated on 2,620 held-out utterances from LibriSpeech test-clean and test-other, with 95% bootstrap confidence intervals.
18-byte, packed, little-endian header, followed by the Huffman payload:
| Offset | Size | Field | Notes |
|---|---|---|---|
| 0 | 4 | magic | "GLX\0" |
| 4 | 4 | numSamples | count of 16 kHz samples, post-decimation |
| 8 | 1 | bits | 1..3 |
| 9 | 1 | alphaIdx | index into GLX_ALPHA_Q16_TABLE |
| 10 | 4 | seed | dither xorshift32 seed (nonzero) |
| 14 | 4 | crc32 | CRC-32 over numSamples..seed + payload |
The header carries only what's needed to reproduce a decode — never the tables themselves. The compression LUT, resampler taps, and Huffman code tables are compile-time constants baked into both binaries; a decoder built from a different table set will not interoperate. CRC-32 is reflected, poly 0xEDB88320, init/xorout 0xFFFFFFFF. glx_decode refuses to decode a file whose CRC doesn't match rather than emitting garbage.
Average codeword length at 16 kHz, computed from the residual PMFs:
| bits | α = 0.0 | α = 0.5 | α = 1.0 | raw PCM at this depth |
|---|---|---|---|---|
| 1 | 17.1 kbps | 20.9 kbps | 23.5 kbps | 16 kbps |
| 2 | 17.7 kbps | 22.4 kbps | 26.5 kbps | 32 kbps |
| 3 | 17.9 kbps | 21.8 kbps | 26.1 kbps | 48 kbps |
Dither is not free — going from α=0 to α=1 costs roughly 40–50% more bits at every depth, because dither spreads the residual PMF and flattens the entropy the Huffman coder is exploiting. At bits=1 the Huffman stage is actually a net loss versus raw PCM; the 2- and 3-bit configurations are where entropy coding pays for itself.
in_rate produces a valid-looking file containing garbage.glx_huffman_decode linear-scans the symbol table per bit — fine on a host, the obvious hot spot if decode throughput ever matters on-device.github.com/chendude404/GLX (private)