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, 客户端加密, 嵌入式系统, 物联网, 蓝牙音频, 音频解码器