Repository navigation
Communication USB Audio Class 2.0
This library provides native USB Audio Class 2.0 (UAC2) support: Your ESP32-S3, RP2040, or Arduino UNO R4 board can show up as a standard USB microphone, speaker, or both — no drivers needed on the host side. Linux, macOS, and Windows 10+ recognize it out of the box.
The opposite role is supported as well: with USBAudioHostStream the microcontroller acts as USB host, so you can plug a USB microphone, speaker or headset (UAC1 or UAC2) directly into the board and use it with the same API — see USB Audio Host below.
With USBAudioStream the microcontroller is a USB Audio Class 2.0 device: it shows up on a PC as a standard USB microphone, speaker, or both.
Most Arduino audio projects use I2S to talk to an external DAC or ADC. That works well, but it requires extra hardware and doesn't help when you want to stream audio directly between a microphone and a computer, or use your board as a USB sound card.
With UAC2, the microcontroller is the sound card. Plug it in, and tools like arecord, aplay, Audacity, or any DAW can send and receive audio without any custom software on the host.
- TX mode (device → host): The board appears as a USB microphone. Feed it audio from any source — I2S, a sine generator, an MP3 decoder — and it streams to the host as a standard capture device.
-
RX mode (host → device): The board appears as a USB speaker. The host plays audio, and your sketch reads it with
readBytes()to forward to a DAC, process, or analyze. - RXTX mode: Both directions at once. The board appears as a composite capture + playback device.
-
Volume and mute control: The host can adjust per-channel volume and mute through the standard mixer interface (e.g.
amixer, PulseAudio, Windows volume mixer). - Sample rate flexibility: 16-bit, 24-bit, and 32-bit PCM at any standard rate. An optional multi-rate mode advertises a configurable list of discrete rates (8 kHz – 48 kHz by default) so the host can switch rates via the standard clock-source control.
- Composite USB: Works alongside USB CDC (serial), so you can debug over the same USB cable.
-
Cross-platform: Tested on Linux (
snd-usb-audio), and designed to work with macOS CoreAudio and Windows UAC2 drivers.
- ESP32-S2 / ESP32-S3 — via the native USB OTG peripheral (DWC2 controller)
- Adafruit TinyUSB stack — all boards supporting the Adafruit TinyUSB stack (e.g. Raspberry Pi Pico), including a FreeRTOS-aware variant that's selected automatically when FreeRTOS is active in your sketch (see FreeRTOS Task below)
-
Zephyr-based boards — via Zephyr's native
usbd_uac2driver (nRF5340, STM32, etc.) - Arduino UNO R4 / Nano R4 (Renesas RA4M1) — via the core's own TinyUSB integration; both directions hardware-verified working, but requires a not-yet-released core and swapping in a newer TinyUSB (see Arduino UNO R4 (Renesas) below)
A minimal example that streams a sine wave to the host:
#include "AudioTools.h"
#include "AudioTools/Communication/USB/USBAudioStream.h"
AudioInfo info(44100, 2, 16);
SineGenerator<int16_t> sineWave;
GeneratedSoundStream<int16_t> sound(sineWave);
USBAudioStream out;
StreamCopy copier(out, sound);
void setup() {
// Required on cores without built-in TinyUSB support (e.g. mbed RP2040)
if (!TinyUSBDevice.isInitialized()) {
TinyUSBDevice.begin(0);
}
sineWave.begin(info, N_B4);
out.addNotifyAudioChange(sound);
auto config = out.defaultConfig(TX_MODE);
config.copyFrom(info);
out.begin(config);
// Re-enumerate so the host picks up the new audio interface
if (TinyUSBDevice.mounted()) {
TinyUSBDevice.detach();
delay(10);
TinyUSBDevice.attach();
}
}
void loop() {
copier.copy();
}On the host, the device appears immediately:
arecord -D hw:Audio -f S16_LE -r 44100 -c 2 -d 5 recording.wavThe board receives audio from the host:
#include "AudioTools.h"
#include "AudioTools/Communication/USB/USBAudioStream.h"
AudioInfo info(44100, 2, 16);
USBAudioStream in;
I2SStream i2s; // or any output
StreamCopy copier(i2s, in);
void setup() {
// Required on cores without built-in TinyUSB support (e.g. mbed RP2040)
if (!TinyUSBDevice.isInitialized()) {
TinyUSBDevice.begin(0);
}
auto config = in.defaultConfig(RX_MODE);
config.copyFrom(info);
in.begin(config);
auto i2s_cfg = i2s.defaultConfig(TX_MODE);
i2s_cfg.copyFrom(info);
i2s.begin(i2s_cfg);
// Re-enumerate so the host picks up the new audio interface
if (TinyUSBDevice.mounted()) {
TinyUSBDevice.detach();
delay(10);
TinyUSBDevice.attach();
}
}
void loop() {
copier.copy();
}Then on the host:
aplay -D hw:Audio my_song.wavAll USB audio settings live in a single USBAudioConfig struct. You get a pre-filled instance from defaultConfig() and customize what you need:
auto config = usb.defaultConfig(TX_MODE); // or RX_MODE, RXTX_MODE
config.copyFrom(info); // inherit sample_rate, channels, bits_per_sample| Field | Default | Description |
|---|---|---|
sample_rate |
44100 | Sample rate in Hz. Inherited from AudioInfo. |
channels |
2 | Number of audio channels (1 = mono, 2 = stereo). |
bits_per_sample |
16 | Bit depth per sample. Must be 16, 24, or 32. Maps to S16_LE, S24_3LE, S32_LE on the host. |
| Field | Default | Description |
|---|---|---|
enable_ep_in |
true | Enable the IN endpoint (device → host, capture/microphone). |
enable_ep_out |
true | Enable the OUT endpoint (host → device, playback/speaker). |
defaultConfig(TX_MODE) sets enable_ep_in=true, enable_ep_out=false. defaultConfig(RX_MODE) does the opposite. RXTX_MODE enables both.
| Field | Default | Description |
|---|---|---|
ep_in |
-1 | ISO IN endpoint address (capture). |
ep_out |
-1 | ISO OUT endpoint address (playback). |
ep_fb |
-1 | ISO IN feedback endpoint (RX-only mode). |
ep_int |
-1 | INT IN endpoint (AC change notifications). |
-1 means "allocate a free endpoint automatically" (via TinyUSBDevice.allocEndpoint() on RP2040/SAMD/STM32, or tinyusb_get_free_in_endpoint()/tinyusb_get_free_out_endpoint() on ESP32). Both allocators are aware of endpoints reserved by other interfaces, so this avoids conflicts with CDC automatically — set a value >= 0 yourself only if you need to pin a specific address. Some platforms can't allocate dynamically and use fixed defaults instead (nRF52, and the Arduino UNO R4 — see Arduino UNO R4 below); override any of them at compile time via the USB_AUDIO_EP_IN/_OUT/_FB/_INT macros.
| Field | Default | Description |
|---|---|---|
itf_num_ac |
0 | Interface number of the Audio Control interface. |
On ESP32 and the UNO R4/Renesas core this is detected and set automatically during begin(). On the generic TinyUSB backend (RP2040, SAMD, STM32), increment it yourself when other USB functions (CDC, HID, …) occupy lower interface numbers.
| Field | Default | Description |
|---|---|---|
fifo_packets |
32 | Number of 1 ms USB packets buffered. Higher values reduce the risk of underrun at the cost of latency. 32 packets (~32 ms at 44.1 kHz stereo 16-bit) is the minimum reliable value on RP2040 at 133 MHz, where the I2S DMA write can block for 2-3 ms. |
rx_start_fill_percent |
20 | Startup-only prime for RX: readBytes() returns no data until bufferRx() has filled past this percentage. Once reached it's never re-armed, even if a later read underruns. Set to 0 to disable and return data as soon as any is available. |
| Field | Default | Description |
|---|---|---|
vid |
0xCafe | USB Vendor ID. |
pid |
0x4002 | USB Product ID. |
manufacturer |
"Audio Tools" | Manufacturer string shown by the host. |
product |
"USB Audio" | Product name shown by the host. |
serial |
"000001" | Serial number string. |
self_powered |
true | Device is self-powered (not bus-powered). |
max_power_ma |
100 | Maximum current draw in mA. |
| Field | Default | Description |
|---|---|---|
enable_feedback_ep |
true | Enable the isochronous feedback endpoint so the host can adjust its clock. Only active in pure RX mode (no IN endpoint). |
enable_multi_sample_rate |
false | When true, the clock source is programmable and GET_RANGE advertises the discrete rates listed in supported_sample_rates (8000, 11025, 16000, 22050, 24000, 32000, 44100, 48000 Hz by default — override the vector for a different set, e.g. up to 192 kHz). When false, only the configured sample_rate is reported. |
enable_interrupt_ep |
false | Enable the AC interrupt endpoint for device-initiated volume, mute, and sample-rate change notifications. |
enable_ep_in_flow_control |
true | Vary the per-frame packet size so non-integer rates like 44100 Hz are delivered at the exact average rate. |
volume_active |
false | When true, the library applies volume and mute scaling to the audio samples directly. When false, values are available via volume() and isMute() for external processing. |
| Field | Default | Description |
|---|---|---|
begin_usb |
false | When true, beginUSB() calls USB.begin() automatically. Set to false for composite USB devices where you control the startup order. The TinyUSBDevice.attach() used in the examples is calling the ESP32-specific USB.begin(). |
| Field | Default | Description |
|---|---|---|
terminal_id |
1 | Terminal ID reported to Zephyr's usbd_uac2 callbacks (ignored on TinyUSB backends). Must match the UAC2 node topology in your board's device tree. TX_MODE uses it as the Output Terminal ID, RX_MODE as the Input Terminal ID. |
These only apply when FreeRTOS is active in your sketch on the TinyUSB backend — the library then automatically switches to a FreeRTOS-aware device class.
| Field | Default | Description |
|---|---|---|
enable_usb_task |
true | Launches a dedicated FreeRTOS task that calls tud_task() continuously, so the USB IN endpoint stays serviced regardless of what the audio loop is doing. Set to false if you manage your own USB task. |
usb_task_stack_size |
1024 | Stack depth for the USB task, in words (1024 words = 4 KB on 32-bit MCUs). |
usb_task_priority |
configMAX_PRIORITIES - 1 |
FreeRTOS priority for the USB task. Should run at least once per 1 ms USB frame, but below audio-critical tasks. |
usb_task_write_wait_ms |
5 | TX buffer blocking-write timeout in ms while the dedicated USB task is active. 0 = non-blocking (busy-spins); 5 = block briefly, relying on the USB task to drain the buffer. |
auto config = usb.defaultConfig(TX_MODE);
config.copyFrom(info);
config.product = "My Synth";
config.vid = 0x1234;
config.pid = 0x5678;
config.fifo_packets = 48; // more buffering
config.volume_active = true; // let the library handle volume
config.enable_interrupt_ep = true; // push volume/rate changes to host
usb.begin(config);The UAC2 descriptors advertise per-channel volume and mute controls. The host sees them as standard mixer controls:
# List controls
amixer -c Audio scontrols
# Set volume to 80%
amixer -c Audio sset 'Mic',0 80%
# Mute
amixer -c Audio sset 'Mic',0 muteOn the device side, you can react to volume changes with a callback:
out.setVolumeCallback([](float vol, uint8_t channel) {
Serial.printf("Volume ch%d: %.0f%%\n", channel, vol * 100);
});
out.setMuteCallback([](bool muted, uint8_t channel) {
Serial.printf("Mute ch%d: %s\n", channel, muted ? "on" : "off");
});And/or enable automatic volume processing so the library scales the audio samples directly:
config.volume_active = true;Apart from the mute and volume callbacks mentioned above, the following additional callbacks might be handy:
-
setStreamingStateCallback()informs about the start and end of streaming -
setSampleRateCallback()informs about a change of the sample rate
One subtle but important feature is TX flow control. At 44100 Hz with 1 ms USB frames, you need to send 44.1 samples per frame — not an integer. Without flow control, you'd send 45 samples every frame, and the effective rate would drift to 45000 Hz.
The flow control implementation uses a fractional accumulator: it alternates between 44-sample and 45-sample packets so the average over time is exactly 44.1 samples per frame. This is enabled by default (enable_ep_in_flow_control = true) and is essential for glitch-free audio.
Getting buffer management right on a dual-core chip like the ESP32-S3 was one of the bigger challenges. The USB stack runs on core 0, but the Arduino loop() typically runs on core 1. We need lock-free or RTOS-synchronized handoff between the cores.
The ESP32 subclass uses BufferRTOS — a FreeRTOS StreamBuffer wrapper that provides safe cross-core data transfer. The TX side uses a short write timeout (5 ms) so the audio copier blocks briefly when the buffer is full, while the USB callback side never blocks. The RX side is fully non-blocking on both ends.
On single-core targets (RP2040 without FreeRTOS, and the UNO R4/Renesas), a lock-free RingBuffer/RingBufferSPSC is sufficient since there's no cross-core contention. When FreeRTOS is active on RP2040, the library switches to the BufferRTOS-based variant instead (see FreeRTOS Task above).
You can combine USB Audio with CDC serial for debugging. On ESP32-S3, set begin_usb = false in the config, register the CDC interface first, then start audio, and finally call USB.begin() yourself:
USBCDC MySerial;
void setup() {
MySerial.begin(115200);
auto config = out.defaultConfig(TX_MODE);
config.copyFrom(info);
config.begin_usb = false; // we'll call USB.begin() manually
out.begin(config);
USB.begin(); // starts both CDC and Audio
}Endpoint addresses are allocated automatically (see Endpoint Addresses above) and steer clear of whatever CDC has already reserved, so no manual coordination is needed. On platforms with fixed endpoint numbers (nRF52, UNO R4/Renesas) the defaults are already chosen to avoid the core's built-in CDC/HID endpoints — see Arduino UNO R4 below for that platform's specifics.
Support for the UNO R4 / Nano R4 (Renesas RA4M1) was blocked for a while: the stock
arduino:renesas_uno core (as of the released version 1.6.0) builds one fixed,
hardcoded composite USB descriptor (CDC + HID + MSD) with no hook for a third-party
class driver to append its own interface — unlike RP2040's
TinyUSBDevice.addInterface() or ESP32's tinyusb_enable_interface(). That's now
fixed upstream: USB.h/USB.cpp gained a __USBGetCustomInterfaceDescriptor() hook
(mirroring the existing __USBGetHIDReport() pattern) that lets an external interface
append itself to the composite descriptor. This hook is not yet in a released core
version — it needs a build of arduino:renesas_uno newer than 1.6.0. Once it ships,
USBAudioDeviceRenesas (implementing the same USBAudioBackend/USBAudioDeviceBase
pattern as USBAudioDeviceESP32/USBAudioDeviceTinyUSB, reusing
USBAudioBackendTinyUSB as-is since this core runs TinyUSB internally via its own
dcd_rusb2.c Device Controller Driver) works out of the box.
Status, hardware-verified on a real UNO R4 Minima: both directions work.
-
RX_MODE (speaker, host → device) works correctly. Confirmed with
aplaystreaming an 8-second 44.1kHz/stereo/16-bit test tone: the device's received-byte counter climbed at exactly the expected real-time rate (~176,400 bytes/s) for the whole file,aplaycompleted with no underrun/xrun errors, andSerialUSBlogging kept working the entire time. -
TX_MODE (microphone, device → host) works correctly. Confirmed with
arecordcapturing a 6-second 44.1kHz/stereo/16-bit stream of a generated sine wave: full amplitude range (max 0.999969,RMS 0.4996), the correct tone, no dropouts, andSerialUSBlogging stayed live throughout.
Getting TX_MODE working required going around a real bug, not anything in this library:
the version of TinyUSB the arduino:renesas_uno core bundles (pinned to an
Aug-2023-era snapshot of the arduino/tinyusb fork) has a broken isochronous-IN path in
dcd_rusb2.c — adding an IN audio endpoint left ep_in never getting set on
SET_INTERFACE, and separately, the FIFO-ready wait (pipe_wait_for_ready()) could spin
forever with the USB IRQ masked if the host hadn't started draining the pipe yet, freezing
the whole MCU (breaking EP0 control requests and CDC output along with it, not just
audio). The fix: replace extras/tinyusb in the core with the official upstream
hathach/tinyusb release 0.21.0 (verified: symlinks in cores/arduino/tinyusb/*
into extras/tinyusb/src/* still resolve unchanged, so no other core files need
updating). This resolved both issues outright — no manual patching needed on top of the
plain 0.21.0 checkout. If you swap in extras/tinyusb, do a full replacement of the
directory tree (not a partial git checkout <tag> -- ., which leaves stale files behind
for anything the newer tree renamed or restructured — e.g. usbd_control.c no longer
exists as a separate file in 0.21.0 — and produces confusing signature-mismatch build
errors between old and new files).
For the record: the exact upstream commit that directly fixes the FIFO-wait hang
(7b1eb4f86, "dcd(rusb2): iso alloc/activate; bound the FIFO-ready wait") landed on
hathach/tinyusb's unreleased master branch on 2026-07-09, after the 0.21.0 tag
(2026-06-30) — so 0.21.0 doesn't contain that specific commit, yet testing showed
TX_MODE working correctly on it anyway. Whatever regressed the ep_in-never-set behavior
between the old fork base and 0.21.0 was apparently fixed as a side effect of general
upstream progress in the ~3 years between those two points, independent of the FIFO-wait
fix. If a future TinyUSB update ever regresses this again, master HEAD is the fallback
with the confirmed, named fix.
The core's main.cpp calls __USBStart() — which builds and permanently caches the
composite descriptor — automatically at boot, before the sketch's setup() runs,
unless the board defines NO_USB (UNO R4 WiFi already does this by default; Minima and
Nano R4 don't). Since our Audio interface must be registered before that first build,
USBAudioDeviceRenesas::beginUSB() calls __USBStart() itself, from your sketch's
begin() call — which only works if the core's own automatic call is disabled. Add
-DNO_USB yourself for Minima/Nano R4, e.g.:
arduino-cli compile --fqbn arduino:renesas_uno:minima --build-property "build.extra_flags=-DNO_USB" your_sketch(or a boards.local.txt/platform.local.txt override for the Arduino IDE). With
NO_USB defined, the Serial macro maps to a plain UART instead of native-USB CDC —
use SerialUSB explicitly if you also want USB serial alongside audio, same as the
composite device pattern above.
-
Endpoint addresses are fixed, not dynamically allocated — this core has no
allocEndpoint()/tinyusb_get_free_in_endpoint()equivalent. Defaults (0x85IN,0x05OUT,0x86feedback,0x87interrupt) are chosen to avoid the core's built-in CDC/HID/MSD endpoints; override viaUSB_AUDIO_EP_IN/_OUT/_FB/_INTif you need different ones. -
vid/pid/manufacturer/product/serialinUSBAudioConfigare not applicable on this platform — the whole composite device (CDC + HID + Audio) shares the one board-level identity the core'sUSB.cppcompiles in. - The RA4M1's USB peripheral has only 2 isochronous-capable pipes, which limits it to
2 simultaneous ISO endpoints. This matches how
enable_feedback_epis already scoped to RX-only mode elsewhere in this driver, so it's never actually a limit in practice through this library's configuration surface.
The library includes a QualityAnalysisStream that can be wired into the RX path to detect audio problems:
AudioInfo info(44100, 2, 16);
USBAudioStream in;
QualityAnalysisStream quality(in);
uint8_t buf[1024];
void setup() {
// Required on cores without built-in TinyUSB support (e.g. mbed RP2040)
if (!TinyUSBDevice.isInitialized()) {
TinyUSBDevice.begin(0);
}
auto config = in.defaultConfig(RX_MODE);
config.copyFrom(info);
in.begin(config);
quality.setReporting(10000, Serial);
quality.begin(info);
// Re-enumerate so the host picks up the new audio interface
if (TinyUSBDevice.mounted()) {
TinyUSBDevice.detach();
delay(10);
TinyUSBDevice.attach();
}
}
void loop() {
quality.readBytes(buf, sizeof(buf));
}On the host, play a test tone and watch Serial for results:
sox -n -r 44100 -c 2 -b 16 test_tone.wav synth 5 sine 440
aplay -D hw:Audio test_tone.wavUSBAudioHostStream turns the board into a USB host for standard USB Audio Class 1.0 and 2.0 devices: USB microphones, speakers and headsets. It uses the same API as USBAudioStream — defaultConfig(), begin(), readBytes()/write(), setVolume()/setMute(), the callbacks and addNotifyAudioChange() — so the same sketch logic works in both roles.
In both roles readBytes() receives audio from the USB peer and write() sends audio to it:
| Mode |
USBAudioStream (device) |
USBAudioHostStream (host) |
|---|---|---|
RX_MODE (readBytes()) |
the PC plays to us (we are a speaker) | we record from the attached microphone |
TX_MODE (write()) |
the PC records from us (we are a microphone) | we play to the attached speaker |
RXTX_MODE |
both | both (headset) |
#include "AudioTools.h"
#include "AudioTools/Communication/USB/USBAudioHostStream.h"
AudioInfo info(48000, 2, 16);
USBAudioHostStream mic;
I2SStream i2s;
StreamCopy copier(i2s, mic);
void setup() {
// the I2S output follows the format negotiated with the microphone
mic.addNotifyAudioChange(i2s);
auto cfg = i2s.defaultConfig(TX_MODE);
cfg.copyFrom(info);
i2s.begin(cfg);
auto config = mic.defaultConfig(RX_MODE);
config.copyFrom(info);
mic.begin(config);
}
void loop() { copier.copy(); }Playing to a USB speaker works the same way with defaultConfig(TX_MODE) and a StreamCopy from your source to the USBAudioHostStream. See the usb-host-rx, usb-host-rx-i2s, usb-host-tx and usb-host-max3421 examples.
The attached device decides which formats it supports, so the requested sample_rate, channels and bits_per_sample are matched against the alternate settings of its streaming interfaces (UAC1: the rates in the format descriptor; UAC2: the GET_RANGE of the clock source, limited by the endpoint bandwidth). If the device can't provide the requested format and host_apply_device_format is true (the default), the closest format is used and reported to the listeners registered with addNotifyAudioChange() — e.g. a mono 48 kHz microphone reconfigures your I2S output to mono. In RXTX_MODE the microphone format is reported; audioInfoRx() and audioInfoTx() give the format of each direction. Set host_apply_device_format = false to reject a device that does not match.
setVolume() and setMute() are sent as SET_CUR requests to the device's Feature Unit, for all active directions. The Feature Unit is found by following the terminal/unit links of the streaming terminal. The 0.0 – 1.0 volume is mapped linearly onto the dB range the device reports (UAC2 GET_RANGE, UAC1 GET_MIN/GET_MAX). If volume_active is true, the volume is also applied in software.
Calling setAudioInfo() with a new sample rate after begin() re-negotiates the streams: alternate setting 0, set the new rate, select the streaming alternate setting again.
Speakers with an explicit feedback endpoint are supported: the feedback value replaces the nominal packet size. Otherwise a fractional accumulator delivers the exact average rate (e.g. alternating 44/45 samples per frame at 44.1 kHz).
These fields of USBAudioConfig are only used by USBAudioHostStream:
| Field | Default | Description |
|---|---|---|
host_vid |
0 | Only accept an attached audio device with this vendor id (0 = any). |
host_pid |
0 | Only accept an attached audio device with this product id (0 = any). |
host_rhport |
0 | TinyUSB root hub port: e.g. 1 for Pico-PIO-USB or a MAX3421E. |
host_ctrl_timeout_ms |
500 | Timeout for class-specific control requests. |
host_apply_device_format |
true | Adopt the format negotiated with the device and report it via setAudioInfo() to the listeners; when false, a device that does not support the requested format is not used. |
fifo_packets, rx_start_fill_percent, volume_active and the FreeRTOS task fields apply to the host as well. The device-only fields (endpoint addresses, interface numbers, device identity, feedback/flow-control flags) are ignored.
| Platform | Implementation | Board settings |
|---|---|---|
| RP2040 / RP2350 (arduino-pico) | Adafruit TinyUSB host | USB Stack: Adafruit TinyUSB Host (native); or PIO-USB / MAX3421E with host_rhport = 1
|
| ESP32-S2 / S3 / P4 | ESP-IDF usb_host (default) |
S3: USB Mode: Hardware CDC and JTAG, so that the OTG port is free for host mode |
| ESP32-S2 / S3 / P4 | Adafruit TinyUSB host |
#define USE_TINYUSB before the include |
| SAMD, nRF52, classic ESP32, … | Adafruit TinyUSB + MAX3421E | pass your Adafruit_USBH_Host to the constructor (see usb-host-max3421) |
| STM32 | Adafruit TinyUSB host | requires an Adafruit TinyUSB build with STM32 host support |
| Arduino UNO R4 (Renesas), Zephyr | not supported | these cores provide no USB host stack (compile-time #error) |
With TinyUSB on a single-threaded platform, the USB stack runs inside readBytes() and write(). When FreeRTOS is active, or on ESP32, a dedicated task runs it instead.
- One audio device at a time (use
host_vid/host_pidto pick one). - Only PCM Type I formats with 16, 24 or 32 bit sample containers.
- The Adafruit TinyUSB Library reads the configuration descriptor into a buffer of
CFG_TUH_ENUMERATION_BUFSIZE(256) bytes. Devices with a larger configuration descriptor (common for headsets) fail enumeration; increase the value in that library'stusb_config_<mcu>.h. A warning is logged atbegin(). - The RP2040 native host controller has no per-frame isochronous scheduling, so the transfers are paced by the USB frame number. Without a FreeRTOS task, call
readBytes()/write()often enough (about once per millisecond) to avoid gaps. - The TinyUSB backend registers TinyUSB's application host class driver (
usbh_app_driver_get_cb()), so it can't be combined with another custom TinyUSB host class driver. - Status: the host implementation compiles for RP2040, ESP32-S2/S3 and MAX3421E builds, and the descriptor parsing, negotiation and streaming logic is covered by a desktop test with real UAC1/UAC2 descriptors (
tests-cmake/etc/usb-audio-host). It has not been verified on hardware yet — reports are very welcome.
The implementation is split into layers:
-
USBAudioConfig— all configuration in one struct: sample rate, channels, bit depth, endpoint addresses, buffering, device identity, feature flags, and the host-specific fields. -
USBAudioStreamBase— the role-independent front end shared by device and host:write()/readBytes(), the buffers, volume/mute state, the callbacks and the RX startup prime. This is what makes the device and host API identical. -
USBAudio2DescriptorBuilder— generates the complete UAC2 descriptor block at runtime: IAD, Audio Control interface with Clock Source, Input/Output Terminals, and Feature Units, plus Audio Streaming interfaces with Format Type descriptors and isochronous endpoints. -
USBAudioDeviceBase— the core class that implements the TinyUSB class driver interface: descriptor callbacks, control request handling (sample rate, volume, mute), isochronous endpoint management, flow control for accurate sample rates, and theAudioStreamread/write API. -
USBAudioDeviceESP32/USBAudioDeviceTinyUSB/USBAudioDeviceRenesas/USBAudioDeviceZephyr— thin platform subclasses that provide the right buffer implementation and USB stack initialization for each target. -
USBAudioStream— a dispatch header that resolves to the correct platform class automatically. -
USBAudioHostBase— the stack-independent UAC1/UAC2 host logic: descriptor parsing and topology tracing, format negotiation, the mount/reconfiguration state machine, Feature Unit and clock requests, and isochronous packet pacing. It talks to the USB stack only throughUSBAudioHostBackend, implemented byUSBAudioHostBackendTinyUSB(TinyUSB host class driver) andUSBAudioHostBackendESP32(ESP-IDFusb_host). -
USBAudioHostTinyUSB/USBAudioHostTinyUSBFreeRTOS/USBAudioHostESP32— the host platform classes, selected by theUSBAudioHostStreamdispatch header.
The key design goal was to make USB audio feel like any other AudioTools stream. StreamCopy works. addNotifyAudioChange works. Volume and mute integrate with the existing VolumeSupport interface. You can chain it with any source or sink in the library.
The code is available in the arduino-audio-tools library under src/AudioTools/Communication/USB/. Examples are in examples/examples-communication/usb/.
Feedback and bug reports are welcome — this is a complex protocol stack and real-world testing across different hosts and use cases is invaluable.
Further info can be found in my TinyUSB related blogs.