Skip to content

usb_audio: refill the mic FIFO from tud_audio_tx_done_isr() - #11195

Merged
tannewt merged 2 commits into
adafruit:mainfrom
mikeysklar:usb-audio-espressif-main
Aug 14, 2026
Merged

usb_audio: refill the mic FIFO from tud_audio_tx_done_isr()#11195
tannewt merged 2 commits into
adafruit:mainfrom
mikeysklar:usb-audio-espressif-main

Conversation

@mikeysklar

@mikeysklar mikeysklar commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Jumped in on a usb_audio PR# 11102 that @relic-se @FoamyGuy had been working on. This is to add ESP32-S2 and S3 usb_audio support. It already worked on
RP2040, RP2350 and nRF52840. On ESP32 the device enumerated as a correct UAC2
microphone, the host opened the stream, and then nothing came out.

I put a Cynthion on the bus to see what the host was actually doing. It was
behaving perfectly:

before after
stream held open 578 ms, host gave up 4.01 s, normal close
IN tokens on the audio endpoint 573 4007
full size packets 8 4006
zero length packets 564 0
CRC16 errors 0 0
arecord -d 4 EIO, 44 bytes full length capture

The host polled every 1 ms for 578 ms and only then gave up. Nothing was corrupt.
The device sent 8 real frames and then zero length packets for the remaining 565.

TinyUSB handles audio completions in the ISR, so they never wake tud_task(). On
espressif the class tasks run after tud_task(), so they never run either and the
FIFO is filled once. tud_audio_tx_done_isr() is TinyUSB's hook for exactly this,
and refilling from there needs no port changes.

Also here: CIRCUITPY_USB_AUDIO ?= 1 on esp32s3, which claims no endpoints until
enable() is called. esp32s2 stays off by default.

S2 and S3 allow five IN endpoints including EP0 and the default build uses all
five, so one has to be freed:

# boot.py
import usb_hid, usb_audio
usb_hid.disable()
usb_audio.enable(sample_rate=48000, channel_count=1, microphone=True, speaker=False)
# code.py, 1 kHz tone
import array, math, audiocore, usb_audio
N = 480
buf = array.array("h", [int(18000 * math.sin(2 * math.pi * 10 * i / N)) for i in range(N)])
sample = audiocore.RawSample(buf, channel_count=1, sample_rate=48000)
usb_audio.usb_microphone.play(sample, loop=True)

Tested on Metro ESP32-S3 and Metro ESP32-S2 against this branch.

arecord -f S16_LE -r 48000 -c 1 -d 4

writes 384044 bytes on both.

TinyUSB services audio transfer completions in ISR context, so they never queue a
USB event. On ports that pump the optional class tasks from a task loop sitting
behind tud_task() (espressif), tud_task() blocks on a permanently empty queue and
usb_audio_task() never runs. The mic FIFO is filled once, drains in a few frames,
and the device emits zero length packets until the host gives up.

Refill from tud_audio_tx_done_isr() instead, which TinyUSB invokes from the
transfer complete path. usb_background_schedule() uses background_callback_add(),
which is ISR safe and already called from usb_irq_handler() on other ports. No
port changes are needed.

Also enable usb_audio by default on esp32s3. The descriptor is gated on
usb_audio_enabled(), so compiling it in claims no endpoints until enable() is
called from boot.py.

Tested on Metro ESP32-S3 and Metro ESP32-S2.
tannewt
tannewt previously approved these changes Aug 13, 2026

@tannewt tannewt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you! Not sure why some board builds are failing though.

@mikeysklar

Copy link
Copy Markdown
Collaborator Author

Six of them use a CH340C or CP2102 serial chip instead of native USB, so
CIRCUITPY_USB_DEVICE = 0 and usb_audio had no TinyUSB device stack to build
against. Fixed in 1fb4196 by clearing CIRCUITPY_USB_AUDIO when its
dependencies are off.

@mikeysklar
mikeysklar force-pushed the usb-audio-espressif-main branch from a55558c to 1fb4196 Compare August 14, 2026 17:13

@tannewt tannewt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is going to make it really confusing to have one place seemingly set it and the global force it off. Instead, make the port-level setting conditional on the other USB settings. Generally more specific settings should override general ones global > port > board.

The port-level default for esp32s3 now checks the settings usb_audio needs,
so a board that turns off the TinyUSB device stack or audiocore keeps it off.

Six S3 boards use a CH340C or CP2102 serial chip and set CIRCUITPY_USB_DEVICE
= 0, and adafruit_esp32s3_camera sets CIRCUITPY_AUDIOBUSIO = 0, which leaves
CIRCUITPY_AUDIOCORE at 0 since the S3 has no AUDIOIO or AUDIOPWMIO.
@mikeysklar
mikeysklar force-pushed the usb-audio-espressif-main branch from 1fb4196 to faa9d77 Compare August 14, 2026 17:32

@tannewt tannewt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you!

@mikeysklar

Copy link
Copy Markdown
Collaborator Author

Moved it into the port setting, so py/circuitpy_mpconfig.mk is untouched now. The esp32s3 default only applies when the board has not turned off the TinyUSB device stack or audiocore.

Checked with print-CIRCUITPY_USB_AUDIO: the six CIRCUITPY_USB_DEVICE = 0 boards and adafruit_esp32s3_camera all stay off, metro and feather S3 stay on. heltec_wireless_paper and adafruit_esp32s3_camera build clean with no usb_audio objects, metro S3 keeps them.

@tannewt
tannewt merged commit 0e74c18 into adafruit:main Aug 14, 2026
269 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants