|
esp32-a2dp-xq 0.1.0
ESP32 A2DP source with SBC-XQ
|
ESP32 library that sends audio to Bluetooth headphones over A2DP with SBC-XQ: Dual Channel SBC at bitpool 38, 452 kbps at 44.1 kHz, with fallback to Joint Stereo bitpool 53 (328 kbps) for headphones that refuse it.
Русская версия · API reference · Changelog
Version 0.1.0 has not been run on hardware yet. The first hardware test will go into the changelog with the headphone models.
What is verified:
| esp32-a2dp-xq | ESP32-A2DP | |
|---|---|---|
| SBC-XQ, Dual Channel bitpool 38, 452 kbps | Yes, ESP-IDF 6.0+ | No |
| Standard SBC, Joint Stereo bitpool 53 | Yes | Yes |
| Input | 16-bit, or 32-bit with TPDF dither to 16 | 16-bit |
| Per-device XQ memory and blacklist | Yes | No |
| ESP32 as a Bluetooth speaker (A2DP sink) | No | Yes |
Arduino Stream and AudioTools integration | No | Yes |
| Tested on hardware | Not yet | Yes |
ESP32-A2DP is the library to take for a Bluetooth speaker or for a quick start with AudioTools. This library covers the source direction only and adds the higher SBC bitrate.
ESP-IDF 5.3 or newer:
PlatformIO (platformio.ini):
Arduino IDE with the ESP32 core 3.x: download esp32-a2dp-xq-0.1.0.zip from Releases and add it with Sketch > Include Library > Add .ZIP Library. The Arduino sine example takes 1.08 MB of the default 1.25 MB application partition; with more code next to it (esp32-audio-player, Wi-Fi) select Tools > Partition Scheme > "Huge APP".
The sdkconfig of an ESP-IDF project needs Bluetooth Classic with A2DP; the examples have it in sdkconfig.defaults:
SBC has two stereo modes that matter here. Joint Stereo shares one bitpool between both channels; the Bluedroid encoder in ESP-IDF targets 328 kbps, which gives bitpool 53. Dual Channel codes each channel with its own bitpool, and at bitpool 38 per channel the stream takes 452 kbps. Every A2DP sink has to decode Dual Channel (A2DP 1.3, section 4.3.2), and PipeWire offers this configuration as the sbc_xq codec.
After the link comes up the library waits up to 2 s for the headphones' SBC capabilities (ESP_A2D_REPORT_SNK_CODEC_CAPS_EVT). It asks for XQ when all of these hold:
allow_sbc_xq).The request goes out with esp_a2d_source_set_pref_mcc() as 44.1 kHz Dual Channel with bitpool 38..38. The Bluedroid encoder then raises its target bitrate in 5 kbps steps until bitpool 38 fits. If the headphones refuse, do not answer within 4 s, or the capabilities never arrive, the stream starts with Joint Stereo and the stack's bitpool range.
SBC-XQ needs ESP-IDF 6.0, where esp_a2d_source_set_pref_mcc() appeared. With older versions, including the Arduino core 3.x, the library logs "SBC-XQ needs ESP-IDF 6.0 or newer" and streams Joint Stereo. a2dp_xq_supported() tells which case the build is.
The result of XQ on each pair of headphones is kept in <state_dir>/a2dp_xq_devices.txt, one line per device:
An XQ stream that stays up for 30 s marks the device xq=ok. A link lost within 20 s after the XQ stream started counts as one failure, and two failures in a row mark it xq=fail. If the headphones accept the XQ configuration and then refuse to start the stream twice, the library marks xq=fail at once and reconnects with Joint Stereo. a2dp_xq_set_xq_allowed() lets the user switch XQ off or on for one device; switching it on clears an earlier failure. The file holds 16 devices and drops the one used least recently.
The built-in blacklist matches these names (case-insensitive, whole words): Soundcore 2, CMF Buds 2a, DC800, S305, PMK TWS, Phonak. I collected them from the PipeWire Bluetooth quirks list and from bug reports of other open-source players.
Without state_dir the memory stays in RAM and is lost at reboot.
The application writes stereo frames at 44.1 kHz with a2dp_xq_write_s16() or a2dp_xq_write_q31(). They go into a FIFO of 16384 frames that is filled up to 300 ms; its storage comes from PSRAM when the board has it. 32-bit input is reduced to 16 bits with TPDF dither, 16-bit input at full volume is copied unchanged. After an underrun the encoder waits for 100 ms of audio before it takes data again, and until then it sends silence.
a2dp_xq_set_volume_db() sends the volume to headphones with AVRCP absolute volume (0..127, 127 = 0 dB, 1 step = 60/127 dB), and the PCM path plays at 0 dB. For headphones without absolute volume, or until they acknowledge it, the library attenuates in its 16-bit conversion. Volume changes made on the headphones arrive in remote_cb as A2DP_XQ_REMOTE_VOLUME_ABS.
The headphone buttons (play, pause, stop, next, previous, volume up and down) arrive in remote_cb. A2DP_XQ_REMOTE_LINK_DOWN comes when audio is started but no headphones are connected and no reconnect is pending.
| Example | ESP-IDF | Arduino |
|---|---|---|
| 440 Hz sine to headphones | sine_to_headphones | SineToHeadphones |
| Negotiated codec, bitpool, bitrate and link statistics | codec_report | CodecReport |
| WAV file from the SD card | WavFileToHeadphones |
FLAC, MP3, Vorbis and gapless albums from an SD card to headphones are in the example bluetooth_headphones of esp32-audio-player, which uses this library for its Bluetooth output.
a2dp_xq_init() returns ESP_ERR_NOT_SUPPORTED.a2dp_xq_deinit() returns it.The pure-C parts build with GCC, Clang or zig cc in C17 mode:
On Windows without a C compiler, python -m pip install ziglang cmake ninja and add -G Ninja -DCMAKE_TOOLCHAIN_FILE=<absolute path>/cmake/zig-toolchain.cmake. CI runs the tests with AddressSanitizer and UndefinedBehaviorSanitizer on Linux.
Apache License 2.0, see [LICENSE](LICENSE). The library uses the Bluedroid stack of ESP-IDF (Apache-2.0) and no third-party code of its own.
The code comes from an ESP32 music player I am building; the player will be published after its own hardware tests. Most of the code was written by Claude (Anthropic) on my instructions; I set the tasks and checked the results with the tests and builds listed under Status.