Skip to content

A11c: pico2_uac2, the bench's independent clock - #96

Merged
tap merged 1 commit into
mainfrom
claude/sample-rate-solutions-comparison-mqc190
Oct 16, 2026
Merged

tap merged 1 commit into
mainfrom
claude/sample-rate-solutions-comparison-mqc190

Conversation

@tap

@tap tap commented Oct 15, 2026

Copy link
Copy Markdown
Owner

What this changes

This is the last of the three PRs for the roadmap's A11. It adds async/examples/pico2_uac2/, standalone firmware for a Raspberry Pi Pico 2 (RP2350). The board enumerates as a USB Audio Class 2.0 device in asynchronous mode, clocked by its own crystal. This is the asynchronous-mode device that Q1 chose for the bench.

  • One format: 48 kHz, stereo, 16-bit, at Full Speed. Playback and capture each run on an asynchronous isochronous endpoint.
  • The device's clock is the RP2350's 1 MHz timer, which counts the 12 MHz crystal. At t µs into a stream, frames(t) = floor(t · 6 · (10⁶ + ppm) / (125 · 10⁶)), computed in exact integers. The same function paces both directions.
  • Playback drains the endpoint FIFO at the device's clock. TinyUSB's FIFO-count feedback endpoint holds that FIFO half full, so the host follows the crystal.
  • Capture sends, in each packet, exactly the frames the clock produced since the last one.
    • The packet callback runs in the main loop, so its times first pass through a second-order delay-locked loop (Adriaensen, Using a DLL to filter time, LAC 2005; 1 Hz bandwidth).
    • The signal is a stereo tone (997 Hz / 441 Hz, −6 dBFS), indexed by device frame.
  • PICO2_UAC2_PPM (an integer in −1000..1000, async's charter; default 0) injects a known offset on top of the crystal's own error. Two bench runs at different settings must differ by exactly that much in alsa_bridge_report.py's fit, which checks the bench against an exact number.
  • Status: core 1 prints one line a second on UART0. Core 0 only queues the numbers, so serial output never stalls the USB loop.
  • CI: the Pico 2 job builds the firmware twice, at offsets 0 and −250.
  • Docs: the firmware's README, async/README.md, async/docs/HARDWARE_TESTING.md (Setup 1 now says how to check that a device is asynchronous), a new section in the book's part5/hardware.md, and the ROADMAP A11 note.

Why

Block B's verdict on async (P8) rests on clock recovery measured between independent crystals. Many cheap USB dongles are adaptive or synchronous endpoints locked to the host's USB frames, so two of them share one effective clock (B0). A run on such a pair proves nothing. This device can't fail that way, and its injected offset gives the bench a known truth to check its own fit against.

Verification

  • Builds. I built the firmware with arm-none-eabi-gcc 13.2.1 against the Pico SDK 2.1.1 (TinyUSB 0.18.0):

    • with -Wall -Wextra (C) and -Wall -Wextra -Wconversion (C++);
    • at PICO2_UAC2_PPM 0 and −250;
    • with zero warnings, producing a UF2 each time. At offset 0 the image has 39,392 B of .text and 16,692 B of .bss.

    CMake refuses PICO2_UAC2_PPM=2000 with the intended message.

  • Descriptors. I dumped the configuration descriptor from the ELF and walked it one descriptor at a time:

    • Its length is the declared 218 bytes, and the IAD groups the three interfaces.
    • The clock is entity 16, and the terminals are 1→2 (playback) and 3→4 (capture). Each AS general descriptor names its own terminal.
    • Both data endpoints are isochronous, asynchronous, 196 bytes.
    • The feedback endpoint is 0x81, 4 bytes, polled every frame.
  • Capture schedule, simulated. I modelled the schedule in Python, using the firmware's own arithmetic, over 600 s of packets.

    • Conditions: up to 0.4 ms of callback lateness (2 ms in one case), a 3 ms stall every 10 s, host offsets of 0 to +50 ppm and device offsets of ±1000 ppm.
    • Packet sizes: every packet was 47, 48 or 49 frames, with nothing skipped and no resync.
    • Rate: the count trailed the device's clock by a constant 58 frames at 300, 600 and 1200 s (the filter's lag at start-up), so the rate is exact.
    • Without the DLL: the same model gave packets from 33 to 49 frames. The bench would have read that jitter as converter wobble.
  • Style: clang-format and scripts/check_retired_words.py are clean. Like the other Pico firmwares, this one is outside the clang-tidy gate's compile database.

  • Not verified: the device has never been enumerated by a host. Whether Linux accepts the descriptors, how the feedback converges on a real bus, and the UART output are all Block B's first minutes. CI is the gate here for the build only.

Notes for the reviewer

  • Limits, also in the README:
    • One format only.
    • The feedback is 16.16 in 4 bytes, which Linux and Windows read at Full Speed; macOS's 10.14 format is not served.
    • The vendor ID is 0xCafe, TinyUSB's test ID: fine on a bench, never for a device that ships.
    • Capture is a tone, not an input.
  • Bench rule: never use the Pico as both sides of the bridge. Its two directions share one crystal, so the bridge would measure a clock against itself. The README and the book both say so.
  • Library use: the firmware doesn't use the converter library at all. It is a clock source, so it has no stake in the -fno-exceptions question (Q5).

🤖 Generated with Claude Code

https://claude.ai/code/session_016GKzq1VSdcMYCRMhddiMaf


Generated by Claude Code

A Raspberry Pi Pico 2 as a USB Audio Class 2.0 asynchronous-mode device
on its own crystal (the roadmap's Q1): 48 kHz stereo S16 at Full Speed,
playback and capture on asynchronous isochronous endpoints. The device's
clock is the RP2350's 1 MHz timer (the 12 MHz crystal), frames(t) =
floor(t * 6 * (10^6 + ppm) / (125 * 10^6)) in exact integers.

Playback drains the endpoint FIFO at that clock; TinyUSB's FIFO-count
feedback endpoint makes the host follow it. Capture sends each packet
the frames the clock produced since the last, with the callback's times
filtered by a second-order DLL (Adriaensen, LAC 2005; 1 Hz): modelled
over 600 s with up to 0.4 ms of callback lateness and a 3 ms stall every
10 s, every packet is 47, 48 or 49 frames, with a constant 58-frame lag
and an exact rate. PICO2_UAC2_PPM (-1000..1000) injects a known offset so
two bench runs check the fit against an exact difference. Core 1 prints
a status line a second on UART0, so serial output never stalls the USB
loop.

Built with arm-none-eabi-gcc 13.2.1 against the Pico SDK 2.1.1 (TinyUSB
0.18.0) at -Wall -Wextra (-Wconversion for C++) with no warning; the
configuration descriptor (218 bytes) walked from the ELF. CI's Pico 2 job
builds it at offsets 0 and -250. It has not been enumerated by a host.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GKzq1VSdcMYCRMhddiMaf
@tap
tap merged commit b9dd284 into main Oct 16, 2026
32 checks passed
@tap
tap deleted the claude/sample-rate-solutions-comparison-mqc190 branch October 16, 2026 18:34
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