
Turning Limitations into Opportunities: Receiving High-Fidelity Music over Bluetooth and USB
In our previous post about BTstack on the Ezurio Vela IF820 development kit, we explored how to fully port the stack to this platform. As an added bonus, we repurposed the onboard RP2040 MCU to run the stack and to eliminate the need for extra hardware. In the end, a standard Bluetooth Controller breakout board was transformed into a compact, standalone BTstack development platform connected via USB. While this is quite nice, the Vela IF820 development board does not include an onboard audio codec or DAC, which means there’s no straightforward way to play back music. Instead of seeing that as a limitation for our project, we turned this into an opportunity to learn something new.
In this post, we explore how to route audio over USB using BTstack and TinyUSB on the RP2040. In doing so, we turn the Vela IF820 development board into a platform for experimenting with Bluetooth audio over USB.
And of course, we won’t stop there. Once the audio playback is running smoothly over USB at 48 kHz on both macOS and Windows, we’ll put it to the test by running the A2DP Sink Demo on the Vela IF820. The result? You can stream high-fidelity music from your phone straight into any device that supports USB audio input — all powered by BTstack.
TinyUSB on RP2040
The original role of the Raspberry Pi Pico RP2040 on Ezurio’s development kit is to handle USB-to-UART conversion and provide an SWD programmer for the Vela module. In our RP2040 port, we use the official pico-sdk, which by default can route stdio over USB CDC and also offers a vendor-specific Reset interface to reboot the RP2040 into recovery mode.
The Reset interface is quite convenient as it allows the RP2040 to be re-flashed without an external SWD programmer or the need to hold the BOOTSEL button while pressing RESET. That’s why we want to preserve both, USB CDC and the Reset interface, while also adding the USB Audio interface.
Keeping USB CDC and the Reset Interface
A USB device’s functionality is defined by a set of USB descriptors. In pico-sdk v2.2.0, the set of descriptors are provided by src/rp2_common/pico_stdio_usb/stdio_usb_descriptors.c
The current descriptor looks like this:
static const uint8_t usbd_desc_cfg[USBD_DESC_LEN] = {
TUD_CONFIG_DESCRIPTOR(1, USBD_ITF_MAX, USBD_STR_0, USBD_DESC_LEN,
USBD_CONFIGURATION_DESCRIPTOR_ATTRIBUTE, USBD_MAX_POWER_MA),
TUD_CDC_DESCRIPTOR(USBD_ITF_CDC, USBD_STR_CDC, USBD_CDC_EP_CMD,
USBD_CDC_CMD_MAX_SIZE, USBD_CDC_EP_OUT, USBD_CDC_EP_IN, USBD_CDC_IN_OUT_MAX_SIZE),
TUD_RPI_RESET_DESCRIPTOR(USBD_ITF_RPI_RESET, USBD_STR_RPI_RESET)
};
As we need to extend it with the audio functionality, we copied the usbd_desc_cfg
descriptor into our code to provide it ourselves.
To activate the Tiny USB device functionality, we only had to add tinyusb_device
to the list of used libraries in the CMake file. Unfortunately, this disabled the default support USB CDC and the Reset interface.
So, we kept playing with various CMake options until USB CDC as well as the Reset interface worked as before. We settled onto the following defines:
# Re-use pico-sdk implementation for USB CDC and Reset via vendor interface
PICO_STDIO_USB=1
PICO_STDIO_USB_ENABLE_IRQ_BACKGROUND_TASK=1
PICO_STDIO_USB_ENABLE_TINYUSB_INIT=1
PICO_STDIO_USB_ENABLE_RESET_VIA_VENDOR_INTERFACE=1
PICO_STDIO_USB_RESET_INTERFACE_SUPPORT_RESET_TO_BOOTSEL=1
PICO_STDIO_USB_RESET_INTERFACE_SUPPORT_MS_OS_20_DESCRIPTOR=1
PICO_STDIO_USB_RESET_BOOTSEL_INTERFACE_DISABLE_MASK=0
There’s a pull request that will allow to re-use the Reset interface by third parties in the future.
USB Audio with TinyUSB
Next, we needed to add the audio functionality. For this we extended our usbd_desc_cfg
descriptor with ‘just’ one another macro TUD_AUDIO_HEADSET_STEREO_DESCRIPTOR
:
static const uint8_t usbd_desc_cfg[USBD_DESC_LEN] = {
TUD_CONFIG_DESCRIPTOR(1, ITF_NUM_TOTAL, USBD_STR_0, USBD_DESC_LEN,
USBD_CONFIGURATION_DESCRIPTOR_ATTRIBUTE, USBD_MAX_POWER_MA),
TUD_CDC_DESCRIPTOR(ITF_NUM_CDC_CONTROL, USBD_STR_CDC, USBD_CDC_EP_CMD,
USBD_CDC_CMD_MAX_SIZE, USBD_CDC_EP_OUT, USBD_CDC_EP_IN, USBD_CDC_IN_OUT_MAX_SIZE),
TUD_RPI_RESET_DESCRIPTOR(ITF_NUM_RPI_RESET, USBD_STR_RPI_RESET),
TUD_AUDIO_HEADSET_STEREO_DESCRIPTOR(USBD_STR_AUDIO, USBD_STR_AUDIO_SPEAKER, USBD_STR_AUDIO_MIC,
USBD_AUDIO_EP_OUT, USBD_AUDIO_EP_IN | 0x80, USBD_AUDIO_EP_INT | 0x80)
};
In general, a USB audio interface can offer different audio resolutions (e.g. 16-bit vs. 24-bit signed integer), sample rates, as well as the number of channels. However, it’s up to to the USB Host to select a configuration of these at runtime. In parallel, the BTstack audio interface allows the application itself to configure the audio output, including the number of channels and the sample rate.
USB audio data is sent over isochronous endpoints with a fixed period of 1 ms. The common sample rates are 44.1 and 48 kHz. At 48 kHz sample rate, this requires to send 48 samples every millisecond. With 16-bit resolution and 2 channels, this amounts to 192 byte blocks. With 44.1 kHz we would have to send 176.4 bytes every 1 ms, which is a bit tedious to handle, as we can only send complete 8-bit bytes :).
To simplify development, we limited the USB Audio interface to a fixed configuration: 2 channels in / 2 channels out at 48 kHz, using 16-bit audio samples.
Adding a bi-directional audio interface
Adding TUD_AUDIO_HEADSET_STEREO_DESCRIPTOR
macro to the usbd_desc_cfg
descriptor may sound simple, but the TUD_AUDIO_HEADSET_STEREO_DESCRIPTOR
is actually a macro that expands into another macro, which in turn invokes 22 additional macros.
#define TUD_AUDIO_HEADSET_STEREO_DESCRIPTOR(_stridx, _stridx_speaker, _stridx_mic, _epout, _epin, _epint)
/* Standard Interface Association Descriptor (IAD) */
TUD_AUDIO_DESC_IAD(/*_firstitf*/ ITF_NUM_AUDIO_CONTROL, /*_nitfs*/ ITF_NUM_AUDIO_INTERFACES, /*_stridx*/ 0x00),
/* Standard AC Interface Descriptor(4.7.1) */
TUD_AUDIO_DESC_STD_AC(/*_itfnum*/ ITF_NUM_AUDIO_CONTROL, /*_nEPs*/ 0x01, /*_stridx*/ _stridx),
... 44 more lines ...
So, our bi-directional audio interface is actually quite complex as it consists of 22 individual USB descriptors. In total, including the CDC and the Reset interface, the final USB descriptor has a total length of 311 bytes. After double checking the descriptors multiple times, we eventually got audio to work on MacOS. Just to double-check, we’ve also tested the audio playback on Windows 11, which immediately failed with the error “USB Descriptor invalid". Debugging this was rather tedious, but we would like to point out that ChatGPT was able to spot the main error right away after pasting the output of the useful Linux lsusb
tool.
Bringing it all together
With audio playback running smoothly over USB at 48 kHz on both macOS and Windows, we set out to take the next step — running the A2DP Sink Demo on the Vela IF820 board. This would allow streaming music from any phone straight into any device that supports USB audio input — no extra hardware required.
Of course, there was a missing detail here. The Bluetooth A2DP Specification mandates support for both 44.1 kHz and 48 kHz in the A2DP Sink role. Android naturally prefers 48 kHz, while iOS opts for 44.1 kHz. We initially tried announcing support for 48 kHz only via AVDTP, but iOS 18 stuck firmly to the Bluetooth spec, which guarantees 44.1 kHz being available, and chose 44.1 kHz regardless of our “48 kHz only" announcement.
In order to handle 44.1 kHz, we turned to our linear resampler, which upsamples a given sample rate to 48 kHz right inside the USB audio driver — this works well enough.
While dipping our toes into USB with TinyUSB, we’ve discovered various limitations in the RP2040 port. Due to unknown reasons, we started with TinyUSB v0.17 instead of v0.18, which should have come with pico-sdk 2.2.0. as a git submodule. In v0.17, the buffers for USB endpoints are provided by a very simple allocator, which only frees them when all endpoints are closed. This caused an assert to fail when opening the USB Microphone the 7th time on the PC. Once we understood the cause, fixing it was straightforward — but while preparing a pull request, we realized that we were using an outdated version. In v0.18, the logic was replaced and a different assert already failed when opening the USB Microphone the second time. Being a bit smarter this time, we’ve also tested the latest version on the main branch and alas – the logic for the buffers had been implemented properly and there’s no need for fixes.
We haven’t yet implemented the USB Speaker direction — which would enable USB audio input on the RP2040 for demos like A2DP Source — but that’s something any laptop can do already.
The A2DP Sink Demo, on the other hand, lets you receive music over Bluetooth at 48 kHz and play it back through your local speakers, transforming the Vela IF820 development board into a compact, high-fidelity Bluetooth-to-USB bridge. Isn’t that cool?