Status: fully validated on hardware (tickets 1180/2110, 2026-08-12) — loopback-measured exact sample rates, 60 s of zero-underrun playback under concurrent storage load.
What you need
- A P2 board with the USB serial-host header (base pin 16 assumed below)
- A USB Audio Class 1 adapter (the $10 headphone+mic dongles — C-Media
CM108 family and similar; validated device: CM108
$0D8C:$0014) - Headphones or a speaker on its jack
- Build gate:
USB_AUDIO; 300 MHz recommended when your loop synthesizes samples in Spin
pnut-ts -d -I <path-to>/src -D USB_AUDIO my_audio.spin2
The smallest program (play a tone)
#IFDEF USB_AUDIO
#PRAGMA EXPORTDEF USB_AUDIO
#ENDIF
CON
_clkfreq = 300_000_000
OBJ
usb : "usb_app"
VAR
word frame[96] ' 48 stereo samples = one 1 ms frame
PUB main() | i, ph, k, w, off
usb.begin(16)
repeat until usb.aud_present()
usb.poll()
waitms(50)
ifnot usb.aud_open(48_000) ' the DEVICE decides if a rate is
debug("rate refused - try 44_100") ' supported; unsupported = clean fail
repeat
wordfill(@frame, 0, 96) ' prefill the ring with silence so
repeat while usb.aud_write(@frame, 192) == 192 ' the stream starts clean
ph := 0
repeat
repeat i from 0 to 47 ' 1 kHz square (sine table & synth
k := (ph++ // 48 < 24) ? 6000 : -6000 ' patterns: examples/top_*_example)
word[@frame][i * 2] := k ' left
word[@frame][i * 2 + 1] := k ' right
off := 0
repeat while off < 192 ' write with backpressure: accept
w := usb.aud_write(@frame + off, 192 - off) ' whatever fits, retry rest
if w < 1
waitus(200)
off += w
usb.poll()
What bring-up actually does
usb.begin(16)enumerates; a UAC1 device binds (aud_present()).aud_open(rate)selects the streaming interface's active alt-setting, asks the device for your sample rate (the device is the authority — an unsupported rate STALLs and the open fails cleanly; 48,000 and 44,100 Hz are both exact, verified by measurement), sizes the isochronous frame reservation, and arms the PHY.- From then on the USB cog itself transmits audio every 1 ms frame
from a hub-RAM ring — your code never races the wire. Your only job:
keep the ring fed.
aud_writenever blocks; it returns how many bytes it accepted, and you pace on that (oraud_free()). - Format is 16-bit little-endian interleaved PCM. Stereo at 48 kHz means 192 bytes per millisecond — the loop above stays comfortably ahead.
- Health is counted, not guessed:
aud_underruns()must stay 0 while you're feeding properly (the acceptance bar was 60 s of 0 under concurrent file copies).
Microphone
usb.aud_mic_open(48_000)
repeat
n := usb.aud_read(@buf, 256) ' returns WHOLE mic frames only -
if n ' size buf >= 252 bytes, and never
process(@buf, n) ' expect partial frames
usb.poll()
Capture and playback run together (full duplex — the validation rig was a
loopback jumper, headphone out into mic in, measuring its own tones).
aud_volume(v256) (1/256 dB units) and aud_mute(yn) drive the device's
volume control where it has one.
The recommended shape for real applications
Give audio its own cog: a DSP cog owns aud_read/aud_write (they're
lock-free, single-producer/single-consumer) while your main cog keeps
usb.poll() and everything else. Worked pattern:
examples/top_audio_dsp_example.spin2.
Troubleshooting
| Symptom | Likely cause |
|---|---|
aud_open fails |
Rate unsupported by the adapter — try 44_100; check aud_present() first. |
Clicks / aud_underruns() climbing |
Your loop fell behind — synthesize into whole frames and write with the backpressure loop above; move DSP to its own cog; check clock (300 MHz for Spin synthesis). |
| Mic reads 0 forever | Buffer smaller than one mic frame (needs ≥ 252 B), or aud_mic_open not called. |
| Capture rate sags under heavy bus load | Known characteristic: synchronous-mode mics lose SOF lock when a degraded bus peer induces jitter; playback (adaptive) is immune. |
Next
- Tone + keyboard:
examples/top_tone_keyboard_example.spin2(first-hardware demo) - Full-duplex DSP:
examples/top_audio_loop_example.spin2,top_audio_dsp_example.spin2 - MIDI-driven synth: midi guide +
examples/top_midi_example.spin2 - API + stream-health reference: docs/api/usb-app-api.md (Audio section)
- How the isochronous engine works: docs/dataflow.md (the audio exception)