Skip to content

Communication USB Audio Class 2.0

pschatzmann edited this page Oct 22, 2026 · 1 revision

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.

Table of Contents

USB Audio Device

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.

Why USB Audio?

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.

What's Supported

  • 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.

Supported Hardware

  • 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_uac2 driver (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)

Getting Started

TX Mode — USB Microphone

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.wav

RX Mode — USB Speaker

The 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.wav

Configuration

All 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

Audio Format

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.

Direction

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.

Endpoint Addresses

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.

Interface Numbering

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.

Buffering

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.

Device Identity

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.

Feature Flags

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.

ESP32-Specific

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().

Zephyr-Specific

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.

FreeRTOS Task (RP2040 + FreeRTOS only)

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.

Example

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);

Volume and Mute

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 mute

On 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;

Other important Callbacks

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

Flow Control

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.

Buffering

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).

Composite USB Devices

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.

Arduino UNO R4 (Renesas)

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 aplay streaming 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, aplay completed with no underrun/xrun errors, and SerialUSB logging kept working the entire time.
  • TX_MODE (microphone, device → host) works correctly. Confirmed with arecord capturing 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, and SerialUSB logging 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.

Requires building with -DNO_USB

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.

Other UNO R4-specific notes

  • Endpoint addresses are fixed, not dynamically allocated — this core has no allocEndpoint()/tinyusb_get_free_in_endpoint() equivalent. Defaults (0x85 IN, 0x05 OUT, 0x86 feedback, 0x87 interrupt) are chosen to avoid the core's built-in CDC/HID/MSD endpoints; override via USB_AUDIO_EP_IN/_OUT/_FB/_INT if you need different ones.
  • vid/pid/manufacturer/product/serial in USBAudioConfig are not applicable on this platform — the whole composite device (CDC + HID + Audio) shares the one board-level identity the core's USB.cpp compiles 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_ep is 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.

Testing

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.wav

USB Audio Host

USBAudioHostStream 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)

Example — record from a USB microphone

#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.

Format negotiation

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.

Volume, mute and sample rate

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).

Host Configuration Fields

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.

Supported Platforms (Host)

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.

Limitations

  • One audio device at a time (use host_vid/host_pid to 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's tusb_config_<mcu>.h. A warning is logged at begin().
  • 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.

Architecture

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 the AudioStream read/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 through USBAudioHostBackend, implemented by USBAudioHostBackendTinyUSB (TinyUSB host class driver) and USBAudioHostBackendESP32 (ESP-IDF usb_host).
  • USBAudioHostTinyUSB / USBAudioHostTinyUSBFreeRTOS / USBAudioHostESP32 — the host platform classes, selected by the USBAudioHostStream dispatch 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.

Source Code and Feedback

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.

Clone this wiki locally