WillyBilly06/LHDC-V5-Decoder
GitHub: WillyBilly06/LHDC-V5-Decoder
该项目是一个为 ESP32 A2DP 集成而从头编写的 LHDC V5 蓝牙音频软件解码器,实现了全浮点解码流水线与 Xtensa 架构实时优化。
Stars: 1 | Forks: 0
# LHDC V5 解码器
这是一个从头开始编写的 **LHDC V5** 蓝牙音频编解码器软件解码器,使用可移植的 C 语言编写,并经过调优,可作为 Bluedroid **A2DP sink** 的一部分在 **ESP32 (Xtensa LX6)** 上实时运行。它接收通过 A2DP 传输的原始 LHDC V5 数据帧流,并重建交错立体声 PCM。
## 目录
1. [特性](#features)
2. [硬件要求](#hardware-requirements)
3. [仓库结构](#repository-layout)
4. [快速开始](#quick-start)
5. [解码器 API 参考](#decoder-api-reference)
6. [数据类型](#data-types)
7. [内存模型](#memory-model)
8. [解码流水线工作原理](#how-the-decode-pipeline-works)
9. [集成至 ESP-IDF A2DP sink](#integrating-into-an-esp-idf-a2dp-sink)
10. [192 kHz / PSRAM 构建](#192-khz--psram-build)
11. [测试](#testing)
12. [验证结果](#verification-results)
13. [性能与 Xtensa 调优](#performance--xtensa-tuning)
14. [诊断](#diagnostics)
15. [故障排除](#troubleshooting)
16. [局限性](#limitations)
17. [许可证](#license)
## 特性
- **采样率:** 44.1 kHz, 48 kHz, 96 kHz, 192 kHz
- **位深:** 16 位和 24 位(24 位以 32 位对齐的容器输出)
- **目标比特率:** 256, 400, 500, 900 kbps,以及自动比特率 (Auto Bit Rate)
- **声道:** 立体声(直接 L/R)和单声道
- **帧时长:** 5 ms
- 全浮点流水线:FAC range decoder -> Rice -> mantissa bit-plane -> SNS synthesis -> 反量化 -> 快速 LD-IMDCT -> 加窗重叠相加 (windowed overlap-add)
- 针对 Xtensa LX6 进行了调优:热点路径位于 IRAM 中,全程使用单精度 FPU,range-coder 内部循环中没有整数除法,基于 FFT 的 IMDCT(无 O(N^2) 变换)
- 独立自包含:解码器数学运算不依赖 ESP-IDF,也可以在主机 PC 上构建
### 实时支持矩阵
| 采样率 | 16 位 | 24 位 | ESP32 上实时运行 | MDCT 大小 |
|-------------|:------:|:------:|--------------------|:---------:|
| 44.1 kHz | 是 | 是 | 是,单核 | 480 |
| 48 kHz | 是 | 是 | 是,单核 | 480 |
| 96 kHz | 是 | 是 | 是,单核 | 960 |
| 192 kHz | 是 | 是 | 需要 PSRAM + 双核(实验性) | 1920 |
所有五种比特率模式(256/400/500/900 kbps + Auto)在每个采样率下均能正确解码;在 44.1/48/96 kHz 下实时播放非常稳定。
## 硬件要求
- **ESP32**(经典款,双核 LX6),**ESP-IDF 5.x**。
- 对于 **192 kHz**:需要带有 **PSRAM** 的模块(例如 **ESP32-WROVER**、N8R8)。请参阅 [192 kHz / PSRAM 构建](#192-khz--psram-build)。
- 44.1 / 48 / 96 kHz 不需要 PSRAM。
- 解码器数学运算为可移植的 C 语言;它也可以在主机(Linux/Android)上通过 `-DLHDC_HOST_BUILD` 进行编译以用于测试。
## 仓库结构
```
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.)
```
解码所需的最低要求是 `decoder/` 中的所有内容。仅当将解码器接入 Bluedroid A2DP sink 时才需要 `a2dp_integration/`。
## 快速开始
使用裸解码器 API 解码原始 LHDC V5 帧流:
```
#include "lhdc_dec.h"
#include
/* 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);
```
真实的 A2DP 有效载荷通常包含多个连续的帧;在循环中调用 `lhdc_dec_decode_frame`,每次将输入指针前进 `consumed` 字节,直到处理完所有有效载荷。
## 解码器 API 参考
所有函数均在 `decoder/lhdc_dec.h` 中声明,并采用 C 链接。
### `size_t lhdc_dec_get_workspace_size(uint32_t sample_rate, uint8_t frame_duration)`
返回调用者在给定速率下必须为解码器工作空间分配的字节数。工作缓冲区的大小由采样率决定(受 MDCT 大小驱动),因此 48 kHz 所需的空间远小于 96/192 kHz。`frame_duration` 以毫秒为单位(使用 `5`)。
### `lhdc_decoder_t *lhdc_dec_init(void *workspace, const lhdc_dec_config_t *config)`
在调用者提供的 `workspace`(大小通过 `lhdc_dec_get_workspace_size` 确定)内初始化一个解码器实例。`config` 可以为 `NULL`,以便从第一个解码帧中自动检测格式。成功返回不透明的句柄,失败返回 `NULL`。该句柄完全存在于 `workspace` 内部;没有单独的释放函数 —— 只需释放工作空间即可。
### `lhdc_dec_ret_t lhdc_dec_decode_frame(...)`
```
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 */);
```
解码确切的一帧。成功返回 `LHDC_DEC_OK`。出错时返回一个 [返回码](#return-codes),并保持 `out_pcm` 不变。
### `void lhdc_dec_flush(lhdc_decoder_t *dec)`
清除重叠相加历史记录(在跳转/不连续时使用,以免下一帧涂抹了之前的内容)。
### `void lhdc_dec_reset(lhdc_decoder_t *dec)`
将解码器重置为其刚初始化的状态。
### `lhdc_dec_ret_t lhdc_dec_get_config(lhdc_decoder_t *dec, lhdc_dec_config_t *config)`
使用活动配置填充 `config`(在自动检测后非常有用)。
### `const char *lhdc_dec_strerror(lhdc_dec_ret_t ret)`
返回针对返回码的可读字符串。
## 数据类型
### 配置 — `lhdc_dec_config_t`
```
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;
```
### 帧信息 — `lhdc_dec_frame_info_t`
当 `info != NULL` 时,由 `lhdc_dec_decode_frame` 填充:`frame_index`、`encoded_frame_bytes`、`samples_per_channel`、`channels`、`sample_rate`、`bit_depth`、`frame_duration_ms`、`version`、`ext_func_flags`、`target_bitrate`。
### 返回码
| 代码 | 值 | 含义 |
|------|:-----:|---------|
| `LHDC_DEC_OK` | 0 | 成功 |
| `LHDC_DEC_ERROR` | -1 | 一般性失败 |
| `LHDC_DEC_INVALID_PARAM` | -2 | 参数错误 |
| `LHDC_DEC_INVALID_HANDLE` | -3 | 错误的/`NULL` 句柄 |
| `LHDC_DEC_NOT_INITIALIZED` | -4 | 未初始化即解码 |
| `LHDC_DEC_BUF_NOT_ENOUGH` | -5 | `out_pcm` 太小 |
| `LHDC_DEC_BITSTREAM_ERROR` | -6 | 畸形帧 |
| `LHDC_DEC_UNSUPPORTED_VERSION` | -7 | 未知的流版本 |
| `LHDC_DEC_UNSUPPORTED_SR` | -8 | 不支持的采样率 |
| `LHDC_DEC_UNSUPPORTED_FORMAT` | -9 | 不支持的格式 |
| `LHDC_DEC_NEED_MORE_DATA` | -10 | 不完整的帧 |
## 内存模型
- 解码器将 **所有** 状态(结构体 + 每个缓冲区)保存在您传递给 `lhdc_dec_init` 的单个 `workspace` 块中。在稳态解码路径中没有隐藏的堆分配。
- 工作空间大小由采样率驱动(如果在不同的采样率切换中重用一个解码器,则只能增大不能减小):
| 采样率 | MDCT | 大约工作空间 |
|------|:----:|:-----------------:|
| 44.1 / 48 kHz | 480 | ~10 KB |
| 96 kHz | 960 | ~18 KB |
| 192 kHz | 1920 | ~32.5 KB |
- 在 ESP32 上,请在 **内部** RAM (`MALLOC_CAP_INTERNAL`) 中分配工作空间。热点缓冲区需按样本读写;将它们放在 PSRAM 中会严重拖慢解码速度。
- 快速 IMDCT 的旋转因子表是单独分配的(按活动大小延迟分配),并且出于同样的原因也必须保留在内部 RAM 中。当您重新配置为不同的采样率时,它们会被自动释放。
## 解码流水线工作原理
一个 5 ms 的 LHDC V5 帧携带两个独立编码的声道。每个声道的解码过程如下:
1. **Header 伪随机解扰** — 每个声道的 8 字节 header 独立进行解扰。
2. **SNS 边信息** — 一个 4 位模式 + 每频带方向位重建每频带缩放因子 (adsq DPCM)。
3. **频谱数据** — 两个三元流(一个 LSB/标记平面和 Rice 商代码)采用带有滑动窗口自适应模型的 **FAC range coded** 进行编码,并共享一个字节流。
4. **Mantissa + 符号平面** — 一个因果位平面从 Rice 商中重建完整的系数幅度(每个系数的位移是预测的),外加每个非零系数的一个符号位。
5. **反量化** — 使用该帧的全局增益步长执行 `coeff * 2^gain_exponent`。
6. **SNS synthesis** — 撤销每频带的频谱整形。
7. **LD-IMDCT** — 快速反向 MDCT(大小 = 2 x 每声道样本数:480/960/1920),实现为带有前/后旋转因子和对称展开的长度为 N/4 的复数 FFT。
8. **加窗重叠相加** — 具有 50% TDAC 重叠的低重叠合成窗口。
然后,这两个声道被交错成立体声 PCM。
### IMDCT 内部原理
快速 IMDCT 将 N/4 点 FFT 分解为四步变换:一个基 2 阶段(对于 N = 480/960/1920 为 8/16/32 点),随后是一个实现为 3x5 Cooley-Tukey DFT 的 15 点阶段。在初始化时会运行一次性的自检,通过与直接余弦公式参考进行比较来验证每个快速路径;如果失败(例如,旋转因子表内存不足),解码器将回退到慢速参考 IMDCT,而不是产生错误的输出。请查看初始化日志行 `IMDCT- self-test: fast=ENABLED maxdiff=...` 以确认快速路径是否生效。
## 集成至 ESP-IDF A2DP sink
`a2dp_integration/` 层将解码器插入到 Bluedroid 的 vendor-codec sink 路径中。
1. **放置源码。** 将 `decoder/` 和 `a2dp_integration/` 添加到 Bluedroid 树(`components/bt/host/bluedroid/...`)中或作为 IDF 组件添加,并将它们加入构建。
2. **注册编解码器。** 使用 vendor ID / codec ID 和 `a2dp_vendor_lhdcv5_constants.h` 中的采样率位定义。在 sink 能力表 (`a2dp_vendor_lhdcv5.c`) 中宣告您支持的采样率。
3. **连接解码器回调** (`a2dp_vendor_lhdcv5_decoder.h`):
```cpp
bool a2dp_lhdcv5_decoder_init(decoded_data_callback_t cb); /* cb 接收 PCM */
void a2dp_lhdcv5_decoder_configure(const uint8_t *codec_info);/* 在 SET_CONFIG 时调用 */
ssize_t a2dp_lhdcv5_decoder_decode_packet_header(BT_HDR *p); /* 去除 RTP/分片 header */
bool a2dp_lhdcv5_decoder_decode_packet(BT_HDR *p, uint8_t *out, size_t out_len);
void a2dp_lhdcv5_decoder_cleanup(void);
```
`configure` 解析协商后的 CIE,确定/分配工作空间大小,并调用 `lhdc_dec_init`。`decode_packet` 重组数据碎片并调用 `lhdc_dec_decode_frame`,通过您的 `decoded_data_callback_t` 传输 PCM。
4. **输出容器。** 24 位以交错的 32 位小端序样本的形式交付;相应地馈送到您的 I2S 路径中。
解码在 Bluedroid 的 A2DP sink 任务上运行。将该任务与您的音频渲染任务绑定到 **不同的核心** 上,以免解码耗尽 I2S 写入程序的资源## 192 kHz / PSRAM 构建
192 kHz 每秒的工作量是 96 kHz 的两倍,并在普通的 ESP32 上触及了两个极限:
- **RAM:** 约 32.5 KB 的工作空间加上 IMDCT 表无法与蓝牙协议栈一起放入 WROOM 的内部 SRAM 中。解决方法是使用 PSRAM (WROVER):将 Bluedroid **host** 和大型环形缓冲区路由到 PSRAM,从而为解码器的热点缓冲区腾出内部 SRAM。
- **CPU:** 单核处理 192 kHz 已经处于极限,因此在单核上高比特率会超出实时处理能力。实现流畅 192 kHz 的途径是 **双核立体声拆分**(在不同的核心上解码 L 和 R 声道),这只有在 PSRAM 为第二个声道的缓冲区腾出了内部 SRAM 之后才变得可行。在此拆分实施之前,请将 192 kHz 视为实验性功能。
所使用的 PSRAM `sdkconfig` 设置(ESP32-WROVER,芯片版本 >= 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` 非常重要:在低于版本 3 的芯片上,PSRAM 缓存变通方案会强制将大块 libc 放入 IRAM 并导致其溢出。将最低版本设置为 3(在 v3.x 芯片上有效)可消除此变通方案并回收这些 IRAM。44.1/48/96 kHz 则完全不需要这些。
## 测试
`test/lhdc_roundtrip.c` 是主要的正确性测试工具。它使用真实的 LHDC 编码器对已知信号进行编码,使用此解码器进行解码,并写入两个 PCM 流以供比较。因为 LHDC 是有损的,所以测试验证的是频谱保真度(频率正确,THD 低,没有帧速率旁带),而不是位精确的 PCM。
它在运行时需要专有的 `liblhdcv5.so` 编码器(请自行提供;未包含在内)。使用 Android NDK 针对解码器源码进行构建:
```
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_roundtrip
```
在拥有 `liblhdcv5.so` 的设备上运行:
```
# 生成一个测试音作为 raw interleaved PCM,然后:
LHDC_SR=96000 LHDC_BPS=24 LHDC_CH=2 LHDC_BR=900 \
./lhdc_roundtrip ./liblhdcv5.so in.pcm out.pcm
```
环境变量开关:`LHDC_SR`(采样率)、`LHDC_BPS` (16/24)、`LHDC_CH` (1/2)、`LHDC_BR`(目标 kbps)。
使用 Python 脚本(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 tones
```
`test/fac_roundtrip.c` 是一个较小的测试工具,仅对 FAC range coder 进行往返测试。
## 验证结果
已通过手机编码器往返测试(真实的 `liblhdcv5.so`)和设备端播放进行验证:
- 在涵盖所有配置(包括 96 kHz 和 192 kHz)的主机往返测试中确认了 **位精确解码**(解码后的频谱在有损容差范围内与编码器输入匹配,无额外泄漏)。
- **THD(输入音 -> 输出音):** 48 kHz 50 Hz / 1 kHz = -70.1 / -80.2 dB;96 kHz 50 Hz / 1 kHz = -60.6 / -80.9 dB。
- **IMDCT 自检** (480 / 960 / 1920) 通过了对比余弦公式参考的测试。
- **设备端 (ESP32):** 44.1 / 48 / 96 kHz 在所有比特率(256 k -> 900 k + Auto)下均可干净、无卡顿地实时播放。
| 采样率 | 位深 | 256k | 400k | 500k | 900k | Auto | 结果 |
|-------------|:---------:|:----:|:----:|:----:|:----:|:----:|--------|
| 44.1 kHz | 16/24 | ok | ok | ok | ok | ok | 干净,实时 |
| 48 kHz | 16/24 | ok | ok | ok | ok | ok | 干净,实时 |
| 96 kHz | 24 | ok | ok | ok | ok | ok | 干净,实时 |
| 192 kHz | 24 | 解码成功 | 解码成功 | 解码成功 | 解码成功 | 解码成功 | PCM 正确;实时需 PSRAM + 双核 |
## 性能与 Xtensa 调优
在 ESP32 @ 240 MHz、96 kHz 采样率下,每个 5 ms 帧每个声道的解码时间约为 1.2 ms,具体开销大致分布为:熵解码约 38%,IMDCT 约占四分之一,其余的 mantissa/SNS/重叠相加工作占据剩余部分 —— 单核处理绰绰有余。
已应用的 Xtensa LX6 特定优化:
- 热点函数(`fac_decode`、Rice/mantissa、IMDCT、overlap-add、inverse-quant)放置在 IRAM 中,以避免在蓝牙中断下出现 flash-cache 停顿。
- 全程使用单精度 FPU;在每样本循环中没有 `double`,也没有超越函数调用(增益步长在每个声道仅使用一次 `exp2f`;SNS 增益为查表)。
- FAC range coder 使用恒等式 `floor(code/r) >= c <=> code >= r*c` 避免了逐符号整数除法(Xtensa 没有快速的硬件除法器)。
- IMDCT 是基于 FFT 的(无 O(N^2) 变换);15 点 DFT 阶段采用 3x5 Cooley-Tukey 分解,位宽计数使用 `NSAU` (count-leading-zeros) 指令。
- 旋转因子表位于 DRAM/IRAM(绝不在 flash 中),因为每帧会被读取数千次。
## 诊断
- `extern volatile int g_lhdc_trace;`(位于 `lhdc_dec.h` 中)— 设置为 `1` 可记录第一帧解码数据的前导部分值;解码器在一帧后将其清零。
- `lhdc_diag_config.h` 公开了可选的运行时开关(例如强制使用参考 IMDCT、SNS 方向),默认关闭,仅供启动阶段使用。
## 故障排除
- **`decode_packet null guard (dec=0x0)` / 无声音:** 工作空间分配失败 —— 对于(连续的)工作空间块而言,堆内存碎片过多。在 192 kHz 下,这意味着您需要 PSRAM,并且应该尽早/一次性分配工作空间。请参阅 [192 kHz / PSRAM 构建](#192-khz--psram-build)。
- **高比特率下音频减速 / “慢动作”:** CPU 资源饥饿 —— 解码器输出正确,但在单核上跟不上进度。这就是 192 kHz 需要双核的情况。
- **`IMDCT- self-test: fast=DISABLED`:** 无法为快速 IMDCT 表分配内存;解码器退回到慢速参考路径(结果正确但速度慢得多)。请释放内部 RAM。
- **切换编解码器时的咔哒声/爆音:** 刷新解码器 (`lhdc_dec_flush`) 并在重新配置时对输出进行斜坡处理,以免 IMDCT 重叠历史在不连续处发生涂抹。
## 局限性
- 192 kHz 是实验性的:输出正确但在单核上无法实时运行(见上文)。
- 帧时长为 5 ms(LHDC V5 的流时长);虽然存在 7.5/10 ms 的枚举,但实际活动路径采用的是 5 ms 帧结构。
- `lossless_enable` 予以保留。
## 许可证
在 **Apache License 2.0** 下发布(参见 `LICENSE`)。这适用于本仓库中的解码器实现。它 **不** 授予 LHDC 编解码器本身的任何权利,LHDC 归 Savitech 所有;为您的用例获取 LHDC 许可由您自行负责。
标签:ESP32, LHDC, 客户端加密, 嵌入式系统, 物联网, 蓝牙音频, 音频解码器