| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
A from-scratch software decoder for the LHDC V5 Bluetooth audio codec, written in portable C and tuned to run in real time on the ESP32 (Xtensa LX6) as part of a Bluedroid A2DP sink. It takes the raw LHDC V5 frame stream delivered over A2DP and reconstructs interleaved stereo PCM.
Note on LHDC: LHDC is a proprietary high-resolution Bluetooth codec developed by Savitech. This repository is an independent decoder implementation for interoperability/research. It contains no Savitech/Qualcomm binaries. You are responsible for ensuring you have the rights to use LHDC in your jurisdiction and product. The round-trip test tool additionally requires the proprietary liblhdcv5.so encoder, which is not included here (see Testing).
| Sample rate | 16-bit | 24-bit | Real-time on ESP32 | MDCT size |
|---|---|---|---|---|
| 44.1 kHz | yes | yes | yes, single core | 480 |
| 48 kHz | yes | yes | yes, single core | 480 |
| 96 kHz | yes | yes | yes, single core | 960 |
| 192 kHz | yes | yes | requires PSRAM + dual-core (experimental) | 1920 |
All five bitrate modes (256/400/500/900 kbps + Auto) decode correctly at every sample rate; real-time playback is solid at 44.1/48/96 kHz.
CMakeLists.txt ESP-IDF component manifest — makes this repo a drop-in component idf_component.yml Component-manager metadata (version, description) decoder/ Core decoder (portable C; no ESP-IDF dependency in the math) lhdc_dec.c/.h Public API, top-level frame decode, per-channel pipeline, gain/level lhdc_dec_internal.h Decoder struct, size limits, workspace layout lhdc_entropy_dec.c/.h FAC range decoder + Rice quotient decode lhdc_sns_synth.c/.h SNS (spectral noise shaping) scalefactor synthesis lhdc_imdct.c/.h Fast inverse MDCT (FFT-based, sizes 480/960/1920) lhdc_tables.c/.h Band configs, synthesis windows, bitrate tables lhdc_bit_reader.c/.h 64-bit-cache MSB-first bit reader lhdc_diag_config.c/.h Optional runtime diagnostics (off by default) imdct_const_tables.inc a2dp_integration/ Bluedroid A2DP-sink glue (ESP-IDF) a2dp_vendor_lhdcv5.c/.h Codec capability / negotiation a2dp_vendor_lhdcv5_decoder.c/.h Frame reassembly + workspace mgmt + decode call a2dp_vendor_lhdcv5_constants.h Vendor/codec IDs, sampling-freq bits test/ Host verification tools (build on PC/Android, not ESP32) lhdc_roundtrip.c Encode with real encoder -> decode with this decoder -> compare PCM fac_roundtrip.c Standalone FAC range-coder round-trip test_roundtrip.py, roundtrip_pr.py PCM analysis (THD, sideband, correlation) docs/ Technical notes on specific fixes (windowing, channel selector, etc.)
The minimum you need to decode is everything in decoder/. a2dp_integration/ is only needed when wiring the decoder into a Bluedroid A2DP sink.
If you would rather have a tree that already works than integrate the decoder yourself, esp-idf-v6.1-codecs/ is a complete, self-contained snapshot of ESP-IDF v6.1 with this decoder (and an AAC-LC decoder) already wired into Bluedroid's A2DP sink path, plus the worked sink application under examples/bluetooth/bluedroid/classic_bt/.
Every submodule Espressif normally pulls in (BT controller blobs, PHY, WiFi, mbedTLS, NimBLE, …) is vendored as ordinary files, so there is no submodule step — a plain clone builds:
cd esp-idf-v6.1-codecs . ./export.sh # export.ps1 on Windows idf.py set-target esp32 idf.py build
See esp-idf-v6.1-codecs/README-FORK.md for exactly what differs from stock v6.1. Note this makes the repository large (~110 MB fetched); if you only want the decoder, decoder/ is self-contained and you can ignore that folder entirely, or use a partial clone:
git clone --filter=blob:none --sparse https://github.com/WillyBilly06/LHDC-V5-Decoder.git cd LHDC-V5-Decoder && git sparse-checkout set decoder a2dp_integration docs test
This repo is a self-contained ESP-IDF component. Drop it into your project's components/ directory — copy it, or add it as a git submodule:
cd your_project
git submodule add https://github.com/WillyBilly06/LHDC-V5-Decoder.git \
components/lhdcv5_decoderThen list it in the REQUIRES of whichever component calls the decoder — e.g. in main/CMakeLists.txt:
idf_component_register(SRCS "main.c"
INCLUDE_DIRS "."
REQUIRES lhdcv5_decoder)That's it — #include "lhdc_dec.h" and the decoder API is available (see Quick start). The component compiles only the portable decoder in decoder/, pulls in just log / esp_common / esp_timer, and builds for the classic dual-core ESP32 (and any variant with enough internal RAM). The a2dp_integration/ and test/ folders are shipped for reference but are not part of the component build.
Decoding audio from a phone (A2DP sink) is a further step: it requires patching Bluedroid's vendor-codec path with the files in a2dp_integration/. See Integrating into an ESP-IDF A2DP sink.
Decoding a raw LHDC V5 frame stream with the bare decoder API:
#include "lhdc_dec.h"
#include <stdlib.h>
/* 1. Size and allocate the workspace for the negotiated rate (internal RAM on ESP32). */
size_t ws_size = lhdc_dec_get_workspace_size(48000 /* Hz */, 5 /* ms */);
void *ws = malloc(ws_size);
/* 2. Configure and initialize. config may be NULL to auto-detect from the bitstream. */
lhdc_dec_config_t cfg = {
.sample_rate = LHDC_DEC_SR_48000,
.bit_depth = LHDC_DEC_BITDEPTH_24,
.frame_duration = LHDC_DEC_FRAME_5MS,
.channels = 2,
.max_frame_bytes = 1024,
.lossless_enable = 0,
};
lhdc_decoder_t *dec = lhdc_dec_init(ws, &cfg);
/* 3. Decode. out_pcm holds samples_per_channel * channels samples.
* samples_per_channel = mdct_size/2: 240 @48k, 480 @96k, 960 @192k.
* 24-bit output is written as 32-bit little-endian containers. */
int32_t pcm[960 * 2]; /* big enough for the 192k case */
size_t consumed = 0;
uint32_t generated = 0;
lhdc_dec_ret_t r = lhdc_dec_decode_frame(dec, frame, frame_len,
pcm, 960, &consumed, &generated, NULL);
if (r == LHDC_DEC_OK) {
/* `generated` samples per channel are now interleaved in `pcm`. */
}
/* 4. The workspace owns all decoder state; free it when done. */
free(ws);A real A2DP payload usually contains several frames back-to-back; call lhdc_dec_decode_frame in a loop, advancing your input pointer by consumed each time until the payload is exhausted.
All functions are declared in decoder/lhdc_dec.h and are C-linkage.
Returns the number of bytes the caller must allocate for the decoder workspace at the given rate. The work buffers are rate-sized (driven by the MDCT size), so 48 kHz needs far less than 96/192 kHz. frame_duration is in milliseconds (use 5).
Initializes a decoder instance inside the caller-provided workspace (sized via lhdc_dec_get_workspace_size). config may be NULL to auto-detect the format from the first decoded frame. Returns an opaque handle, or NULL on failure. The handle lives entirely inside workspace; there is no separate free function — release the workspace.
lhdc_dec_ret_t lhdc_dec_decode_frame(
lhdc_decoder_t *dec,
const uint8_t *in_data, /* encoded input */
size_t in_bytes, /* bytes available at in_data */
void *out_pcm, /* interleaved PCM out, native endian */
uint32_t out_samples,/* capacity of out_pcm in samples-per-channel */
size_t *consumed, /* [out] input bytes consumed by this frame */
uint32_t *generated, /* [out] samples-per-channel written */
lhdc_dec_frame_info_t *info /* [out] optional per-frame info, may be NULL */);Decodes exactly one frame. Returns LHDC_DEC_OK on success. On error it returns one of the return codes and leaves out_pcm untouched.
Clears the overlap-add history (use on a seek/discontinuity so the next frame doesn't smear the previous content).
Resets the decoder to its just-initialized state.
Fills config with the active configuration (useful after auto-detect).
Returns a human-readable string for a return code.
typedef struct {
lhdc_dec_sample_rate_t sample_rate; /* 44100/48000/96000/192000 */
lhdc_dec_bitdepth_t bit_depth; /* 16 or 24 (32 = container width) */
lhdc_dec_frame_duration_t frame_duration; /* 5 ms */
uint8_t channels; /* 1 or 2 */
uint32_t max_frame_bytes; /* largest encoded frame you'll feed */
uint8_t lossless_enable; /* reserved; 0 */
} lhdc_dec_config_t;Populated by lhdc_dec_decode_frame when info != NULL: frame_index, encoded_frame_bytes, samples_per_channel, channels, sample_rate, bit_depth, frame_duration_ms, version, ext_func_flags, target_bitrate.
| Code | Value | Meaning |
|---|---|---|
| LHDC_DEC_OK | 0 | success |
| LHDC_DEC_ERROR | -1 | generic failure |
| LHDC_DEC_INVALID_PARAM | -2 | bad argument |
| LHDC_DEC_INVALID_HANDLE | -3 | bad/NULL handle |
| LHDC_DEC_NOT_INITIALIZED | -4 | decode before init |
| LHDC_DEC_BUF_NOT_ENOUGH | -5 | out_pcm too small |
| LHDC_DEC_BITSTREAM_ERROR | -6 | malformed frame |
| LHDC_DEC_UNSUPPORTED_VERSION | -7 | unknown stream version |
| LHDC_DEC_UNSUPPORTED_SR | -8 | unsupported sample rate |
| LHDC_DEC_UNSUPPORTED_FORMAT | -9 | unsupported format |
| LHDC_DEC_NEED_MORE_DATA | -10 | partial frame |
The decoder keeps all state (struct + every per-frame buffer) inside the single workspace block you pass to lhdc_dec_init. There is no hidden heap allocation in the steady-state decode path.
Workspace size is rate-driven (grow-only if you reuse one decoder across rate switches):
| Rate | MDCT | Approx. workspace |
|---|---|---|
| 44.1 / 48 kHz | 480 | ~10 KB |
| 96 kHz | 960 | ~18 KB |
| 192 kHz | 1920 | ~32.5 KB |
On the ESP32, allocate the workspace in internal RAM (MALLOC_CAP_INTERNAL). The hot buffers are read/written per sample; placing them in PSRAM would slow decode badly.
The fast IMDCT twiddle tables are allocated separately (lazily, per active size) and must also stay in internal RAM for the same reason. They are freed automatically when you reconfigure to a different rate.
A 5 ms LHDC V5 frame carries two independently-coded channels. Each channel is decoded as:
The two channels are then interleaved to stereo PCM.
The fast IMDCT factors the N/4-point FFT as a four-step transform: a radix-2 stage (8/16/32-point for N = 480/960/1920) followed by a 15-point stage implemented as a 3x5 Cooley-Tukey DFT. A one-shot self-test at init validates each fast path against a direct cosine-formula reference; if it fails (e.g. out-of-memory for the twiddle tables) the decoder falls back to the slow reference IMDCT rather than producing wrong output. Watch the init log line IMDCT-<N> self-test: fast=ENABLED maxdiff=... to confirm the fast path.
The a2dp_integration/ layer plugs the decoder into Bluedroid's vendor-codec sink path.
bool a2dp_lhdcv5_decoder_init(decoded_data_callback_t cb); /* cb receives PCM */
void a2dp_lhdcv5_decoder_configure(const uint8_t *codec_info);/* on SET_CONFIG */
ssize_t a2dp_lhdcv5_decoder_decode_packet_header(BT_HDR *p); /* strip RTP/frag hdr */
bool a2dp_lhdcv5_decoder_decode_packet(BT_HDR *p, uint8_t *out, size_t out_len);
void a2dp_lhdcv5_decoder_cleanup(void);The decode runs on Bluedroid's A2DP sink task. Pin that task and your audio render task to different cores so decode doesn't starve the I2S writer.
Everything below was found while getting 192 kHz/24-bit to play cleanly on a classic ESP32. None of it is a decoder bug -- the decoder held over=0 frames per 1000 against a 5 ms budget throughout -- but each one produced audible stutter that looks exactly like a slow decoder, so they are worth knowing before you blame this library.
1. Never drop on a full output ring. If your PCM ring uses a zero timeout, a burst of arrivals destroys audio you will need 200 ms later, and you then underflow by approximately the amount discarded. Measured over 13.5 s at 192 kHz: 20,582,400 B accepted + 245,760 B dropped = 100.04% of realtime offered -- the source was essentially exact, delivery was merely bursty. Let the ring write wait (20 ms is ample; a 192 kHz render drains ~1.5 MB/s) so the burst backs up into the A2DP receive queue in encoded form, ~250 B per 5 ms frame instead of 7680 B of PCM -- about 30x cheaper per unit of buffered time.
2. Size the ring from measured free memory, not a constant. On the classic ESP32 the ring, this decoder's work buffers, the A2DP task stack and every BT media packet come from one small byte-addressable pool. Allocate the ring after decoder_configure and take free(MALLOC_CAP_INTERNAL|MALLOC_CAP_8BIT) - reserve; 192 kHz then automatically gets a smaller ring than 48 kHz with no rate table. Watch out for other codecs leaking into that pool -- a missing aac_decoder_deinit() elsewhere in our tree held ~27 KB for the whole session and silently halved the LHDC ring.
3. Track the source's clock, or accept a periodic gap. The phone and your board run off different crystals. Measured here: ~127 ppm, which drained a 44 KB ring in about 3-4 minutes, every time, forever -- a brief stutter then clean again. No buffer size fixes this; it only sets the interval. Use i2s_channel_tune_rate() (its stated purpose is "fine-tuning the mclk to match the speed of producer and consumer") driven by the ring fill level, following the control structure in ESP-IDF's i2s_usb example: act only on a sustained trend, step ~10 ppm, and disable the channel around the change.
4. Logging is not free. ESP_LOGx blocks the calling task until the bytes leave the UART. At 115200 one ~120-character line is ~10 ms -- two entire 5 ms frame budgets. A periodic three-line status print was ~31 ms and audibly stuttered playback on its own. Raise the console baud (note CONFIG_ESP_CONSOLE_UART_BAUDRATE is only settable with ESP_CONSOLE_UART_CUSTOM=y; with the default channel kconfig silently forces 115200 back -- verify in build/config/sdkconfig.h, not sdkconfig), and rate-limit anything in the decode path.
192 kHz is twice the per-second work of 96 kHz, and hits two limits on a stock ESP32:
PSRAM sdkconfig settings used (ESP32-WROVER, chip rev >= 3):
CONFIG_SPIRAM=y CONFIG_SPIRAM_MODE_QUAD=y CONFIG_SPIRAM_TYPE_AUTO=y CONFIG_SPIRAM_SPEED_40M=y CONFIG_SPIRAM_BOOT_INIT=y CONFIG_SPIRAM_USE_MALLOC=y CONFIG_BT_ALLOCATION_FROM_SPIRAM_FIRST=y # move Bluedroid host to PSRAM CONFIG_ESP32_REV_MIN_3=y # rev>=3: drops the PSRAM cache workaround (frees IRAM)
CONFIG_ESP32_REV_MIN_3 is important: on rev < 3 the PSRAM cache workaround forces a large block of libc into IRAM and overflows it. Setting the minimum revision to 3 (valid on a v3.x chip) removes the workaround and reclaims that IRAM. 44.1/48/96 kHz need none of this.
test/lhdc_roundtrip.c is the primary correctness harness. It encodes a known signal with the real LHDC encoder, decodes it with this decoder, and writes both PCM streams for comparison. Because LHDC is lossy, the test validates spectral fidelity (correct frequency, low THD, no frame-rate sidebands), not bit-exact PCM.
It requires the proprietary liblhdcv5.so encoder at runtime (provide your own; not included). Build it with the Android NDK against the decoder sources:
aarch64-linux-android21-clang test/lhdc_roundtrip.c \
decoder/lhdc_dec.c decoder/lhdc_entropy_dec.c decoder/lhdc_sns_synth.c \
decoder/lhdc_imdct.c decoder/lhdc_tables.c decoder/lhdc_bit_reader.c \
decoder/lhdc_diag_config.c \
-DLHDC_HOST_BUILD -Idecoder -ldl -lm -o lhdc_roundtripRun on a device that has liblhdcv5.so:
# generate a test tone as raw interleaved PCM, then:
LHDC_SR=96000 LHDC_BPS=24 LHDC_CH=2 LHDC_BR=900 \
./lhdc_roundtrip ./liblhdcv5.so in.pcm out.pcmEnvironment knobs: LHDC_SR (sample rate), LHDC_BPS (16/24), LHDC_CH (1/2), LHDC_BR (target kbps).
Analyze the result with the Python scripts (NumPy):
python test/test_roundtrip.py in.pcm out.pcm # THD+N, RMS/peak error, correlation
python test/roundtrip_pr.py in.pcm out.pcm # sideband energy around fixed tonestest/fac_roundtrip.c is a smaller harness that round-trips just the FAC range coder.
Validated by phone-encoder round-trip (real liblhdcv5.so) and on-device playback:
| Sample rate | Bit depth | 256k | 400k | 500k | 900k | Auto | Result |
|---|---|---|---|---|---|---|---|
| 44.1 kHz | 16/24 | ok | ok | ok | ok | ok | clean, real-time |
| 48 kHz | 16/24 | ok | ok | ok | ok | ok | clean, real-time |
| 96 kHz | 24 | ok | ok | ok | ok | ok | clean, real-time |
| 192 kHz | 24 | decodes | decodes | decodes | decodes | decodes | correct PCM; needs PSRAM + dual-core for real-time |
At 96 kHz on the ESP32 @ 240 MHz, decode is roughly 1.2 ms per channel per 5 ms frame, split approximately: entropy ~38%, IMDCT ~a quarter, and the remaining mantissa/SNS/overlap work the rest — comfortably within budget on one core.
Xtensa LX6-specific optimizations applied:
Per channel, per 5 ms frame, measured on-device with dense program material:
| stage | us | notes |
|---|---|---|
| entropy (FAC range coder) | ~380 | scales with bitrate, not sample rate |
| IMDCT (N=1920 fast path) | ~440 | four-step FFT, tables in DRAM |
| inverse quantize + SNS | ~150 | one exp2f, then table lookups |
| mantissa plane | ~98 | batched 4-byte bit-field reads |
| window + overlap-add | ~40 | structured window (was ~326 before) |
| header + SNS side info | ~22 |
Both channels run sequentially on one core, so a frame costs roughly twice these figures. Note the classic ESP32 has only ~82-135 KB of byte-addressable DRAM depending on the module, and it is the same pool the Bluetooth media allocator draws from -- see Memory model before sizing an output ring alongside this decoder at 192 kHz.
This decoder implementation is released under the Apache License 2.0. Note that this does not grant any rights to the LHDC codec itself, which is the property of Savitech; obtaining LHDC licensing for your use case is your responsibility.
| Back | FazBrowse Home | New Git URL |