Skip to content

UAC host: play and record at once, one stream per direction - #86

Merged
bdbarnett merged 2 commits into
mainfrom
uac-duplex
Oct 10, 2026
Merged

bdbarnett merged 2 commits into
mainfrom
uac-duplex

Conversation

@bdbarnett

Copy link
Copy Markdown
Contributor

A board hosting a USB sound card can now play to it and record from it at the same time. Open uac_audio.output(dev) and uac_audio.input(dev) side by side, on one device (a headset, or a sound card with its output looped into its input) or on two devices. Before this, the host kept a single UAC stream, and opening the second direction failed with host_uac_open failed (-1).

What changed. usbif_host_uac.c keeps one stream per direction instead of one in all. Each stream has its own transfers, ring and counters, and a transfer carries its stream in its context, so the callbacks never ask which one. host_uac_queued, _space, _capacity, _stats and _close take the stream's endpoint address. Without one, they mean what they always did (close closes both), and uac_audio falls back to that on firmware from before this change. UacHostInput gains available().

A fix found on the way. Closing a stream now sets the device back to alt 0. usb_host_interface_release() sends no SET_INTERFACE, so until now a closed stream left the device streaming, and the next open's SET_INTERFACE changed nothing. It is skipped during host_stop()'s teardown, where no one would deliver the answer.

On hardware. An ESP32-P4 panel hosting a full-speed USB-C headset dongle, its 3.5 mm output looped into its own mic through a splitter. tests/hardware/uac_duplex_loopback.py keeps both streams open for the whole run:

streams: 384 B out on ep 0x03, 208 B in on ep 0x83
RESULT quiet: level -68.2 dBFS, peak 44; period correlation 0.65
RESULT tone 1000 Hz at -20 dBFS: back at 1000.0 Hz fundamental (period correlation 1.00), level -13.3 dBFS, peak 19532; FFT peak 3000.0 Hz = 3.00 x sent; zero crossings 3000 Hz; starved 0
RESULT tone 1500 Hz at -20 dBFS: back at 500.0 Hz fundamental (period correlation 0.99), level -14.2 dBFS, peak 16992; FFT peak 4500.0 Hz = 3.00 x sent; zero crossings 4500 Hz; starved 0
RESULT latency, write to read-back: 10 of 10 bursts, min 91.4, median 99.4, max 107.4 ms; output ring ahead of the burst 40.0 ms median; threshold 1000; starved 0
RESULT latency beyond the output ring: min 59.4, median 67.4, max 67.4 ms
RESULT soak 30 s out: packets 30000, bytes 5760000, dropped 0, starved 0, errors 0, empty 0
RESULT soak 30 s in:  packets 30000, bytes 5760000, dropped 0, starved 0, errors 0, empty 0
RESULT soak (planted 300 ms stall) 5 s out: packets 5000, bytes 960000, dropped 0, starved 162, errors 0, empty 0
GATE PASS

The planted stall is the proof that the soak's zeros can fail. Before this change, on usbif main, the same dongle's capture opened, and opening its playback beside it failed with -1.

This dongle processes its mic. It sends capture only while playback is running: alone, every capture packet comes back empty. A 1 kHz sine loops back with a 1 ms period but the 3rd harmonic on top, with the fundamental 57 dB under it. 1.5 kHz comes back repeating every 2 ms, and quiet steady tones are suppressed. The stream itself is intact (packet for packet, byte for byte above), so the tone gate checks the waveform's period, and checks that spectral peaks land on harmonics of the tone, rather than asking for a clean sine. A stream at the wrong rate would still fail it.

Python tests: three new ones in tests/test_usbif.py (output and input side by side, closing one leaves the other, older firmware still closes).

The host kept one UAC stream, so a board could play to a device or record
from it, never both. It now keeps one per direction: an output and an
input open side by side, on the same device (a headset, a DAC looped into
its own input) or on two. Each has its own transfers, ring and counters,
and a transfer carries its stream in its context.

queued, space, capacity, stats and close take the stream's endpoint;
without one they keep their old meaning, and uac_audio falls back to that
on firmware from before. UacHostInput gains available().

Closing a stream now sets the device's interface back to alt 0. Releasing
the interface never did: IDF sends no SET_INTERFACE, so a closed stream
left the device streaming.
@bdbarnett
bdbarnett merged commit d8acf6b into main Oct 10, 2026
6 checks passed
@bdbarnett
bdbarnett deleted the uac-duplex branch October 10, 2026 21:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant