diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2231991..1e9b2c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -809,6 +809,17 @@ jobs: cmake -S async/examples/pico2_cyccnt -B build-cyccnt -DPICO_BOARD=pico2 && cmake --build build-cyccnt -j 4 + # The bench's independent clock (A11c): the UAC2 asynchronous-mode + # device, at the default and at a nonzero injected offset. + - name: Build pico2_uac2 + run: > + cmake -S async/examples/pico2_uac2 -B build-uac2 -DPICO_BOARD=pico2 + -DFETCHCONTENT_SOURCE_DIR_PICO_SDK=build-cyccnt/_deps/pico_sdk-src + && cmake --build build-uac2 -j 4 + && cmake -S async/examples/pico2_uac2 -B build-uac2-ppm -DPICO_BOARD=pico2 -DPICO2_UAC2_PPM=-250 + -DFETCHCONTENT_SOURCE_DIR_PICO_SDK=build-cyccnt/_deps/pico_sdk-src + && cmake --build build-uac2-ppm -j 4 + - name: Build pico2_dualcore run: > cmake -S async/examples/pico2_dualcore -B build-dualcore -DPICO_BOARD=pico2 diff --git a/async/README.md b/async/README.md index 6bc323b..688ff86 100644 --- a/async/README.md +++ b/async/README.md @@ -74,10 +74,12 @@ verifies the quantitative design feedback from the June 2026 music-dsp thread (image-S/N vs phase count, tap budgets, zeros-at-k·fs) against the shipped filters, with every result assertion-pinned. -For real hardware there are three more entry points: +For real hardware there are four more entry points: `examples/alsa_bridge.cpp` (two ALSA devices on their real crystals — the [hardware testing](docs/HARDWARE_TESTING.md) Setup 1 harness, with CSV -telemetry and post-ASRC capture), `examples/pico2_cyccnt/` (flashable +telemetry and post-ASRC capture), `examples/pico2_uac2/` (an RP2350 as a +USB Audio Class 2.0 asynchronous-mode device on its own crystal, the +bench's independent clock), `examples/pico2_cyccnt/` (flashable RP2350 firmware measuring real cycles per block against the QEMU instruction baselines), and `examples/pico2_dualcore/` (the one-clock-domain-per-core RP2350 deployment, self-validating). diff --git a/async/docs/HARDWARE_TESTING.md b/async/docs/HARDWARE_TESTING.md index 8b526fd..847a882 100644 --- a/async/docs/HARDWARE_TESTING.md +++ b/async/docs/HARDWARE_TESTING.md @@ -20,6 +20,16 @@ Each dongle clocks its own 48 kHz from its own crystal: `pull()` - A loopback wire (or a tone generator into A's input) provides signal. +The test is only as good as the two clocks' independence. Many cheap +dongles are adaptive or synchronous endpoints locked to the host's USB +frame clock, and two of them share one effective clock. Check +`/proc/asound/cardN/stream0` for an asynchronous endpoint before +trusting a run. [`examples/pico2_uac2/`](../examples/pico2_uac2/) is a +known-good side: a Raspberry Pi Pico 2 as a USB Audio Class 2.0 +asynchronous-mode device on its own crystal, with a feedback endpoint for +playback and a tone for capture. Its `PICO2_UAC2_PPM` option adds a known +offset, so two runs check the bench's fit against an exact difference. + This is the library's use case running on real clocks. Log `status()` once per second and check: @@ -136,8 +146,10 @@ emulation work predicted. What exists and what remains: - **Setup 1**: shipped. `examples/alsa_bridge.cpp` and - `examples/alsa_bridge_report.py` are described above. Still missing: a - script that runs the notebook analysis over a `--dump` capture. + `examples/alsa_bridge_report.py` are described above, and + `examples/pico2_uac2/` is the independent clock for one side (built in + CI; never yet enumerated by a host). Still missing: a script that runs + the notebook analysis over a `--dump` capture. - **Setup 2**: shipped — `examples/pico2_cyccnt/` (cycle measurement) and `examples/pico2_dualcore/` (dual-core deployment), both building flashable UF2s; the measured numbers await a physical Pico 2. diff --git a/async/examples/pico2_uac2/CMakeLists.txt b/async/examples/pico2_uac2/CMakeLists.txt new file mode 100644 index 0000000..f43b438 --- /dev/null +++ b/async/examples/pico2_uac2/CMakeLists.txt @@ -0,0 +1,58 @@ +# Standalone Raspberry Pi Pico 2 (RP2350) firmware: a USB Audio Class 2.0 +# asynchronous-mode device on the Pico's own crystal, the hardware bench's +# independent clock (docs/HARDWARE_TESTING.md; the family roadmap's A11). +# Deliberately NOT wired into the root build — configure this directory on +# its own: +# +# cmake -B build -DPICO_BOARD=pico2 +# cmake --build build -j +# +# The Pico SDK is fetched as pico2_cyccnt fetches it (its CMakeLists.txt says +# why by tag): its lib/tinyusb is the USB stack here. +cmake_minimum_required(VERSION 3.24) + +if(NOT CMAKE_BUILD_TYPE) + set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE) +endif() + +set(PICO_BOARD pico2 CACHE STRING "Pico SDK board") +set(PICO_PLATFORM rp2350-arm-s CACHE STRING "Pico SDK platform (secure Arm)") + +include(FetchContent) +FetchContent_Declare( + pico_sdk + GIT_REPOSITORY https://github.com/raspberrypi/pico-sdk.git + GIT_TAG 2.1.1 + GIT_SHALLOW TRUE + GIT_SUBMODULES "lib/tinyusb") +FetchContent_GetProperties(pico_sdk) +if(NOT pico_sdk_POPULATED) + FetchContent_Populate(pico_sdk) +endif() +include(${pico_sdk_SOURCE_DIR}/pico_sdk_init.cmake) + +project(pico2_uac2 C CXX ASM) +set(CMAKE_C_STANDARD 11) +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +pico_sdk_init() + +# A known offset on the device's sample clock, on top of the crystal's own +# error: two runs at different settings differ by exactly this, which checks +# the bench's fit (README). async's charter bounds it at ±1000 ppm. +set(PICO2_UAC2_PPM 0 CACHE STRING "Offset injected on the device's sample clock, in ppm (-1000..1000)") +if(NOT PICO2_UAC2_PPM MATCHES "^-?[0-9]+$" OR PICO2_UAC2_PPM LESS -1000 OR PICO2_UAC2_PPM GREATER 1000) + message(FATAL_ERROR "PICO2_UAC2_PPM must be an integer in -1000..1000, not '${PICO2_UAC2_PPM}'") +endif() + +add_executable(pico2_uac2 main.cpp usb_descriptors.c) +# tusb_config.h and usb_descriptors.h live here. +target_include_directories(pico2_uac2 PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +target_compile_definitions(pico2_uac2 PRIVATE PICO2_UAC2_PPM=${PICO2_UAC2_PPM}) +target_link_libraries(pico2_uac2 PRIVATE pico_stdlib pico_multicore pico_unique_id tinyusb_device) + +# The USB device is the audio function, so stdio goes to UART0 (GP0/GP1). +pico_enable_stdio_usb(pico2_uac2 0) +pico_enable_stdio_uart(pico2_uac2 1) +pico_add_extra_outputs(pico2_uac2) # .uf2 etc. diff --git a/async/examples/pico2_uac2/README.md b/async/examples/pico2_uac2/README.md new file mode 100644 index 0000000..48de53d --- /dev/null +++ b/async/examples/pico2_uac2/README.md @@ -0,0 +1,130 @@ +# pico2_uac2 — the bench's independent clock + +A Raspberry Pi Pico 2 (RP2350) as a USB Audio Class 2.0 device in +asynchronous mode, clocked by its own crystal. It is the clock the hardware +bench needs and a cheap dongle often is not: many USB audio adapters are +adaptive or synchronous endpoints locked to the host's USB frame clock, and +two of them share one effective clock (the family roadmap's B0). This one +shares nothing with the host but the cable. The roadmap's Q1 chose it as the +bench's asynchronous-mode device, and A11 is the item that wrote it. + +## What it is + +- **One fixed format:** 48 kHz, stereo, 16-bit, at USB Full Speed. A + playback path (the host plays to it) and a capture path (the host records + from it), each on an **asynchronous** isochronous endpoint. +- **The device's clock** is the RP2350's 1 MHz timer, which counts the + 12 MHz crystal through the tick generator. At t µs into a stream the + device has produced or consumed + `floor(t · 6 · (10⁶ + ppm) / (125 · 10⁶))` frames: 48,000 a second + times (1 + ppm / 10⁶), in exact integer arithmetic. +- **Playback** drains the endpoint's FIFO at that clock. Its explicit + feedback endpoint (USB 2.0, 5.12.4.2) tells the host the rate. TinyUSB + computes the feedback from the FIFO's level, held half full (its + `AUDIO_FEEDBACK_METHOD_FIFO_COUNT`), so the host follows the crystal. +- **Capture** sends, each millisecond, exactly the frames the clock + produced since the last packet: 47, 48 or 49, averaging the device's + rate. + - The packet callback runs in the main loop, up to a few hundred µs late, + so its times pass through a second-order delay-locked loop first + (F. Adriaensen, *Using a DLL to filter time*, LAC 2005; 1 Hz + bandwidth). + - The signal is a stereo tone, indexed by device frame: 997 Hz left, + 441 Hz right, −6 dBFS. +- **Status:** core 1 prints a line a second on UART0 (GP0/GP1, + 115200 8N1). Core 0 only queues the numbers, so a serial line never + stalls the USB loop. The USB port is the audio device, so there is no USB + serial. + +## Build + +Standalone, like the other Pico firmwares (not part of the root build). +Needs `cmake` ≥ 3.24, `arm-none-eabi-gcc` (tested with 13.2.1) and network +access on first configure, which fetches the Pico SDK 2.1.1 with its +TinyUSB 0.18.0. + +```sh +cd async/examples/pico2_uac2 +cmake -B build -DPICO_BOARD=pico2 +cmake --build build -j +``` + +This produces `build/pico2_uac2.uf2`; flash it as `pico2_cyccnt`'s README +says. One option: + +- `-DPICO2_UAC2_PPM=` offsets the device's clock by n ppm on top of the + crystal's own error. It must be an integer in −1000..1000, `async`'s + charter; the default is 0. + +## On the bench + +1. Plug it into the Pi and find it with `aplay -l` / `arecord -l`. The + product string is *SampleRateTap Pico 2 UAC2*. +2. **Check that it is asynchronous** (B0): `/proc/asound/cardN/stream0` + should report both endpoints `ASYNC`, and the playback endpoint's + feedback. +3. **Run the bridge** with the Pico as one side and another device as the + other: + - `--in hw:` records its tone; + - `--out hw:` plays into it. + + **Never use the Pico as both sides:** both directions run on one + crystal, and the bridge would measure a clock against itself. +4. **Read the UART** for the device's own account, a line a second: + + ``` + TAP_SR_UAC2 t=… out=on frames=… fifo=…..… underruns=0 (0 frames) peak=-6.0 dBFS in=on frames=… skipped=0 resyncs=0 + ``` + + - `fifo` is the playback FIFO's range over that second, which the + feedback holds near its middle (196 of 392 frames). + - `underruns`: the FIFO ran dry and playback re-primed. + - `skipped`: capture frames dropped because the host stopped polling for + more than three packets. + - `resyncs`: the DLL lost the frame clock and restarted. + - On a healthy run, all three stay 0. +5. **Check the bench against a known offset.** The crystal's own error is + unknown, but the difference between two runs is not. Run once at + `PICO2_UAC2_PPM=0` and once at, say, `+200`. The offsets that + `alsa_bridge_report.py` fits for the two runs should differ by 200 ppm, + within each fit's uncertainty, less whatever the crystal drifted with + temperature in between. + +## What has been checked, and what has not + +- **Built** with arm-none-eabi-gcc 13.2.1 against the Pico SDK 2.1.1 at + `-Wall -Wextra` (`-Wconversion` for C++), with no warning, at + `PICO2_UAC2_PPM` 0 and −250. CI's Pico 2 job builds it on every PR. +- **The configuration descriptor** was dumped from the ELF and walked, one + descriptor at a time: + - its length is the declared 218 bytes; + - the IAD groups the three interfaces; + - the clock is entity 16, the terminals 1→2 (playback) and 3→4 (capture); + - both data endpoints are isochronous, asynchronous, 196 bytes; + - the feedback endpoint is 0x81, 4 bytes, every frame. +- **The capture schedule** was modelled with the firmware's arithmetic over + 600 s of packets, with 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. + - Every packet was 47, 48 or 49 frames, with none skipped and no resync. + - The count stayed a constant 58 frames (the filter's lag at start-up) + behind the device's clock at 300, 600 and 1200 s, so the rate is + exact. +- **Not checked:** it has never been enumerated by a host. Linux's + acceptance of the descriptors, the feedback's convergence on a real bus, + and the UART lines are Block B's first minutes. + +## Limits + +- **48 kHz, 16-bit, stereo only.** The clock reports one fixed rate, and a + request for any other rate stalls. +- **Feedback is 16.16 in 4 bytes**, which Linux and Windows read at Full + Speed. macOS wants 10.14 in 3 bytes at Full Speed, which this firmware + does not serve; the bench is a Pi. +- **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.** The firmware has no ADC; a run that + needs real audio uses another device as the capture side. +- **One stream per direction.** Interrupting a stream (alternate setting 0) + restarts that direction's clock origin. The device's clock runs on; only + the count restarts. diff --git a/async/examples/pico2_uac2/main.cpp b/async/examples/pico2_uac2/main.cpp new file mode 100644 index 0000000..2368088 --- /dev/null +++ b/async/examples/pico2_uac2/main.cpp @@ -0,0 +1,385 @@ +// SPDX-License-Identifier: MIT +// Copyright 2026 Timothy Place and the SampleRateTap contributors +// +// pico2_uac2 — a Raspberry Pi Pico 2 (RP2350) as a USB Audio Class 2.0 +// asynchronous-mode device on its own crystal: the bench's independent clock +// (the family roadmap's Q1 and A11). Plugged into the Pi, it is one of the two +// `hw:` devices the ALSA bridge (examples/alsa_bridge.cpp) spans, and the one +// whose clock is known to share nothing with the host's. +// +// The device's sample clock is the RP2350's 1 MHz timer, which counts the +// 12 MHz crystal through the tick generator: at t microseconds after a +// stream starts, the device has produced (or consumed) +// +// frames(t) = floor(t * 48,000 * (1 + ppm / 10^6) / 10^6) +// = floor(t * 6 * (10^6 + ppm) / (125 * 10^6)) +// +// in exact integer arithmetic (no overflow for 2^63 / (6 * 1,001,000) us, +// some 426 hours, per stream). `ppm` is PICO2_UAC2_PPM, 0 by default: an +// offset injected on top of the crystal's own error, so a run can be checked +// against a known difference (README). +// +// Both directions are asynchronous isochronous endpoints (USB 2.0, 5.12.4.1): +// +// - Capture (IN): before every packet (TinyUSB's pre-load callback) the +// firmware writes exactly the frames its clock produced since the last +// one: 47, 48 or 49 a millisecond, averaging the device's rate, never the +// host's. The callback runs in the main loop, up to a few hundred +// microseconds after the frame it serves, so its times pass through a +// second-order delay-locked loop first (F. Adriaensen, "Using a DLL to +// filter time", Linux Audio Conference 2005; 1 Hz bandwidth): the +// packets then step 47/48/49 as the clocks beat, not with the loop's +// latency, and the DLL's time stays within a bounded offset of the +// real one, so the long-run count is the device's. A stereo tone +// (997 Hz left, 441 Hz right, -6 dBFS), indexed by device frame, so the +// tone's pitch at the host also carries the clock. +// - Playback (OUT): the main loop drains the endpoint's FIFO at the device's +// clock, and the explicit feedback endpoint (USB 2.0, 5.12.4.2) tells the +// host the rate; TinyUSB computes it from the FIFO's level, held half full +// (its AUDIO_FEEDBACK_METHOD_FIFO_COUNT), so the host follows the crystal. +// +// Core 1 prints a status line a second on UART0 (GP0/GP1, 115200 8N1): the +// frames each way, the playback FIFO's range, underruns and the level heard. +// Core 0 only queues the numbers, so a serial line never stalls the USB loop. +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "pico/multicore.h" +#include "pico/stdlib.h" +#include "pico/util/queue.h" +#include "tusb.h" +#include "usb_descriptors.h" + +#ifndef PICO2_UAC2_PPM +#define PICO2_UAC2_PPM 0 +#endif + +namespace { + + constexpr std::int64_t kPpm = PICO2_UAC2_PPM; + // async's charter is ±1000 ppm; the packet size leaves room for no more. + static_assert(kPpm >= -1000 && kPpm <= 1000, "PICO2_UAC2_PPM must lie within ±1000 ppm"); + + constexpr std::uint32_t kFrameBytes = UAC2_CHANNELS * UAC2_BYTES_PER_SAMPLE; + constexpr std::uint64_t kMaxFramesPerPacket = UAC2_EP_SIZE / kFrameBytes; // 49 + // Capture falls this far behind only if the host stopped polling or the + // loop stalled; the backlog is then skipped (and counted), never burst. + constexpr std::uint64_t kInBacklogLimit = 3 * kMaxFramesPerPacket; + // Playback starts draining once the FIFO is half full, where the + // FIFO-count feedback holds it. + constexpr std::uint32_t kOutFifoFrames = CFG_TUD_AUDIO_FUNC_1_EP_OUT_SW_BUF_SZ / kFrameBytes; + constexpr std::uint32_t kOutPrimeFrames = kOutFifoFrames / 2; + constexpr std::uint64_t kChunkFrames = 64; + + /// The device's sample clock: frames in `us` microseconds of the crystal. + std::uint64_t device_frames(std::uint64_t us) { + return static_cast(static_cast(us) * 6 * (1'000'000 + kPpm) / 125'000'000); + } + + constexpr std::size_t kTableBits = 12; + std::int16_t g_sine[std::size_t{1} << kTableBits]; + + constexpr std::uint32_t phase_increment(double hz) { + return static_cast(hz / UAC2_SAMPLE_RATE * 4294967296.0 + 0.5); + } + constexpr std::uint32_t kIncLeft = phase_increment(997.0); + constexpr std::uint32_t kIncRight = phase_increment(441.0); + + /// The tone at device frame `frame`: the phase is the frame index times + /// the increment, modulo 2^32, so a skipped frame costs no drift. + std::int16_t tone(std::uint64_t frame, std::uint32_t increment) { + const auto phase = static_cast(frame * increment); + return g_sine[phase >> (32 - kTableBits)]; + } + + struct out_state { + bool streaming = false; // the host chose alternate 1 + bool started = false; // primed, draining at the device's clock + std::uint64_t base_us = 0; + std::uint64_t consumed = 0; // device frames this stream, played or missed + std::uint64_t frames = 0; // frames taken from the FIFO, all streams + std::uint64_t underrun_frames = 0; + std::uint32_t underruns = 0; // the FIFO ran dry: re-primed + std::uint32_t fifo_min = 0; + std::uint32_t fifo_max = 0; + std::int32_t peak = 0; + }; + + struct in_state { + bool streaming = false; + bool started = false; + std::uint64_t base_us = 0; + double dll_now = 0.0; // this packet's filtered time, us since base_us + double dll_next = 0.0; // the next packet's predicted time + double dll_period = 0.0; // the host's frame period, in device us + std::uint64_t sent = 0; // device frames this stream, sent or skipped + std::uint64_t frames = 0; // frames sent, all streams + std::uint64_t skipped = 0; + std::uint32_t resyncs = 0; // the DLL lost the frame clock and restarted + }; + + // The DLL's coefficients for a 1 Hz bandwidth at one update a + // millisecond: omega = 2 pi B T, b = sqrt(2) omega, c = omega^2. + constexpr double kDllOmega = 2.0 * std::numbers::pi * 1.0 * 1e-3; + constexpr double kDllB = std::numbers::sqrt2 * kDllOmega; + constexpr double kDllC = kDllOmega * kDllOmega; + // An error this large is a lost frame clock, not jitter: restart. + constexpr double kDllResyncUs = 5000.0; + + out_state g_out; + in_state g_in; + std::int16_t g_buf[kChunkFrames * UAC2_CHANNELS]; + + /// One second's numbers, handed from core 0 to core 1. + struct status { + std::uint64_t t_s; + std::uint64_t out_frames; + std::uint64_t out_underrun_frames; + std::uint64_t in_frames; + std::uint64_t in_skipped; + std::uint32_t in_resyncs; + std::uint32_t out_underruns; + std::uint32_t fifo_min; + std::uint32_t fifo_max; + std::int32_t out_peak; + bool out_streaming; + bool in_streaming; + }; + queue_t g_status_queue; + + void reset_fifo_range() { + g_out.fifo_min = kOutFifoFrames; + g_out.fifo_max = 0; + g_out.peak = 0; + } + + /// Playback: drains what the device's clock owes since the stream + /// started, from the endpoint's FIFO. A dry FIFO is counted and the + /// stream re-primes, so the feedback starts again from half full. + void service_out() { + if (!g_out.streaming) { + return; + } + const std::uint32_t avail = tud_audio_available() / kFrameBytes; + g_out.fifo_min = std::min(g_out.fifo_min, avail); + g_out.fifo_max = std::max(g_out.fifo_max, avail); + const std::uint64_t now = time_us_64(); + if (!g_out.started) { + if (avail >= kOutPrimeFrames) { + g_out.started = true; + g_out.base_us = now; + g_out.consumed = 0; + } + return; + } + std::uint64_t owed = device_frames(now - g_out.base_us) - g_out.consumed; + while (owed > 0) { + const std::uint64_t chunk = std::min(owed, kChunkFrames); + const std::uint32_t got = + tud_audio_read(g_buf, static_cast(chunk * kFrameBytes)) / kFrameBytes; + for (std::uint32_t i = 0; i < got * UAC2_CHANNELS; ++i) { + g_out.peak = std::max(g_out.peak, std::abs(static_cast(g_buf[i]))); + } + g_out.frames += got; + g_out.consumed += chunk; + owed -= chunk; + if (got < chunk) { + g_out.underrun_frames += chunk - got; + ++g_out.underruns; + g_out.started = false; + return; + } + } + } + + void set_streaming(std::uint8_t itf, bool on) { + if (itf == ITF_NUM_AUDIO_STREAMING_OUT) { + g_out.streaming = on; + g_out.started = false; + } + else if (itf == ITF_NUM_AUDIO_STREAMING_IN) { + g_in.streaming = on; + g_in.started = false; + } + gpio_put(PICO_DEFAULT_LED_PIN, g_out.streaming || g_in.streaming); + } + + [[noreturn]] void status_core() { + while (true) { + status s{}; + queue_remove_blocking(&g_status_queue, &s); + const double peak_db = + s.out_peak > 0 ? 20.0 * std::log10(static_cast(s.out_peak) / 32768.0) : -999.0; + std::printf("TAP_SR_UAC2 t=%llu out=%s frames=%llu fifo=%lu..%lu underruns=%lu (%llu frames) peak=%.1f dBFS" + " in=%s frames=%llu skipped=%llu resyncs=%lu\n", + static_cast(s.t_s), s.out_streaming ? "on" : "off", + static_cast(s.out_frames), static_cast(s.fifo_min), + static_cast(s.fifo_max), static_cast(s.out_underruns), + static_cast(s.out_underrun_frames), peak_db, s.in_streaming ? "on" : "off", + static_cast(s.in_frames), static_cast(s.in_skipped), + static_cast(s.in_resyncs)); + } + } + +} // namespace + +//--------------------------------------------------------------------+ +// TinyUSB audio class callbacks (declared by tusb.h, C linkage) +//--------------------------------------------------------------------+ + +// Capture: called before every IN packet, the first included. +bool tud_audio_tx_done_pre_load_cb(std::uint8_t rhport, std::uint8_t func_id, std::uint8_t ep_in, + std::uint8_t cur_alt_setting) { + (void)rhport; + (void)func_id; + (void)ep_in; + (void)cur_alt_setting; + const std::uint64_t now = time_us_64(); + // The DLL (Adriaensen 2005, section 4): the event's filtered time is the + // last prediction; the error against the measured time moves the next + // prediction (b) and the period (c). + const auto measured = static_cast(now - g_in.base_us); + if (!g_in.started) { + g_in.started = true; + g_in.base_us = now; + g_in.dll_now = 0.0; + g_in.dll_period = 1000.0; + g_in.dll_next = g_in.dll_period; + g_in.sent = 0; + } + else if (const double error = measured - g_in.dll_next; std::abs(error) > kDllResyncUs) { + g_in.dll_now = measured; + g_in.dll_period = 1000.0; + g_in.dll_next = measured + g_in.dll_period; + ++g_in.resyncs; + } + else { + g_in.dll_now = g_in.dll_next; + g_in.dll_next += kDllB * error + g_in.dll_period; + g_in.dll_period += kDllC * error; + } + const std::uint64_t due = device_frames(static_cast(std::max(g_in.dll_now, 0.0))); + std::uint64_t owed = due - g_in.sent; + if (owed > kInBacklogLimit) { + g_in.skipped += owed - kMaxFramesPerPacket; + g_in.sent = due - kMaxFramesPerPacket; + owed = kMaxFramesPerPacket; + } + const std::uint64_t n = std::min(owed, kMaxFramesPerPacket); + // n <= 49 frames; g_buf holds 64. + for (std::uint64_t i = 0; i < n; ++i) { + const std::uint64_t frame = g_in.sent + i; + g_buf[i * UAC2_CHANNELS] = tone(frame, kIncLeft); + g_buf[i * UAC2_CHANNELS + 1] = tone(frame, kIncRight); + } + tud_audio_write(g_buf, static_cast(n * kFrameBytes)); + g_in.sent += n; + g_in.frames += n; + return true; +} + +void tud_audio_feedback_params_cb(std::uint8_t func_id, std::uint8_t alt_itf, audio_feedback_params_t* feedback_param) { + (void)func_id; + (void)alt_itf; + feedback_param->method = AUDIO_FEEDBACK_METHOD_FIFO_COUNT; + feedback_param->sample_freq = UAC2_SAMPLE_RATE; +} + +bool tud_audio_set_itf_cb(std::uint8_t rhport, tusb_control_request_t const* p_request) { + (void)rhport; + const std::uint8_t itf = tu_u16_low(tu_le16toh(p_request->wIndex)); + const std::uint8_t alt = tu_u16_low(tu_le16toh(p_request->wValue)); + set_streaming(itf, alt != 0); + return true; +} + +bool tud_audio_set_itf_close_EP_cb(std::uint8_t rhport, tusb_control_request_t const* p_request) { + (void)rhport; + set_streaming(tu_u16_low(tu_le16toh(p_request->wIndex)), false); + return true; +} + +// The clock's requests (UAC2 5.2.5.1): its frequency (CUR and RANGE, one +// fixed rate) and its validity. Nothing is settable. +bool tud_audio_get_req_entity_cb(std::uint8_t rhport, tusb_control_request_t const* p_request) { + const auto* request = reinterpret_cast(p_request); + if (request->bEntityID != UAC2_ENTITY_CLOCK) { + return false; + } + if (request->bControlSelector == AUDIO_CS_CTRL_SAM_FREQ) { + if (request->bRequest == AUDIO_CS_REQ_CUR) { + audio_control_cur_4_t cur = {static_cast(tu_htole32(UAC2_SAMPLE_RATE))}; + return tud_audio_buffer_and_schedule_control_xfer(rhport, p_request, &cur, sizeof(cur)); + } + if (request->bRequest == AUDIO_CS_REQ_RANGE) { + audio_control_range_4_n_t(1) range = {}; + range.wNumSubRanges = tu_htole16(1); + range.subrange[0].bMin = static_cast(UAC2_SAMPLE_RATE); + range.subrange[0].bMax = static_cast(UAC2_SAMPLE_RATE); + range.subrange[0].bRes = 0; + return tud_audio_buffer_and_schedule_control_xfer(rhport, p_request, &range, sizeof(range)); + } + } + else if (request->bControlSelector == AUDIO_CS_CTRL_CLK_VALID && request->bRequest == AUDIO_CS_REQ_CUR) { + audio_control_cur_1_t valid = {1}; + return tud_audio_buffer_and_schedule_control_xfer(rhport, p_request, &valid, sizeof(valid)); + } + return false; +} + +bool tud_audio_set_req_entity_cb(std::uint8_t rhport, tusb_control_request_t const* p_request, std::uint8_t* buf) { + (void)rhport; + const auto* request = reinterpret_cast(p_request); + // A host may still set the one rate it was offered; anything else stalls. + if (request->bEntityID == UAC2_ENTITY_CLOCK && request->bControlSelector == AUDIO_CS_CTRL_SAM_FREQ + && request->bRequest == AUDIO_CS_REQ_CUR && p_request->wLength == sizeof(audio_control_cur_4_t)) { + std::int32_t rate = 0; + std::memcpy(&rate, buf, sizeof(rate)); + return tu_le32toh(static_cast(rate)) == UAC2_SAMPLE_RATE; + } + return false; +} + +int main() { + stdio_init_all(); // UART0 only (CMakeLists.txt): the USB device is the audio function + gpio_init(PICO_DEFAULT_LED_PIN); + gpio_set_dir(PICO_DEFAULT_LED_PIN, GPIO_OUT); + + for (std::size_t i = 0; i < std::size(g_sine); ++i) { + const double x = 2.0 * std::numbers::pi * static_cast(i) / static_cast(std::size(g_sine)); + g_sine[i] = static_cast(std::lround(0.5 * 32767.0 * std::sin(x))); // -6 dBFS + } + reset_fifo_range(); + queue_init(&g_status_queue, sizeof(status), 4); + multicore_launch_core1(status_core); + + std::printf("pico2_uac2: UAC2 asynchronous, %d Hz x (1 %+lld ppm), stereo S16, playback FIFO %lu frames" + " (primed at %lu)\n", + UAC2_SAMPLE_RATE, static_cast(kPpm), static_cast(kOutFifoFrames), + static_cast(kOutPrimeFrames)); + + tud_init(0); + + std::uint64_t next_status = time_us_64() + 1'000'000; + std::uint64_t seconds = 0; + while (true) { + tud_task(); + service_out(); + if (time_us_64() >= next_status) { + next_status += 1'000'000; + ++seconds; + const status s{seconds, g_out.frames, g_out.underrun_frames, g_in.frames, + g_in.skipped, g_in.resyncs, g_out.underruns, g_out.fifo_min, + g_out.fifo_max, g_out.peak, g_out.streaming, g_in.streaming}; + queue_try_add(&g_status_queue, &s); // a full queue drops the line, never blocks the loop + reset_fifo_range(); + } + } +} diff --git a/async/examples/pico2_uac2/tusb_config.h b/async/examples/pico2_uac2/tusb_config.h new file mode 100644 index 0000000..1861ac8 --- /dev/null +++ b/async/examples/pico2_uac2/tusb_config.h @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +// Copyright 2026 Timothy Place and the SampleRateTap contributors +// +// TinyUSB configuration (the copy the Pico SDK 2.1.1 carries, 0.18.0): a +// Full-Speed device with one UAC2 function and nothing else. The SDK +// defines CFG_TUSB_MCU and CFG_TUSB_OS for the RP2350. +#ifndef PICO2_UAC2_TUSB_CONFIG_H +#define PICO2_UAC2_TUSB_CONFIG_H + +#include "usb_descriptors.h" + +#define CFG_TUD_ENABLED 1 +#define CFG_TUD_MAX_SPEED OPT_MODE_FULL_SPEED +#define CFG_TUD_ENDPOINT0_SIZE 64 + +#define CFG_TUD_AUDIO 1 +#define CFG_TUD_CDC 0 +#define CFG_TUD_MSC 0 +#define CFG_TUD_HID 0 +#define CFG_TUD_MIDI 0 +#define CFG_TUD_VENDOR 0 + +#define CFG_TUD_AUDIO_FUNC_1_DESC_LEN UAC2_FUNC_DESC_LEN +#define CFG_TUD_AUDIO_FUNC_1_N_AS_INT 2 +#define CFG_TUD_AUDIO_FUNC_1_CTRL_BUF_SZ 64 + +// Capture. Flow control off: the driver then sends what the IN FIFO holds, +// up to a packet, and the firmware puts exactly the frames its own clock +// produced since the last packet there (main.cpp). With it on, TinyUSB +// would size the packets from the host's frame clock, which is the +// synchronous device this one exists not to be. +#define CFG_TUD_AUDIO_ENABLE_EP_IN 1 +#define CFG_TUD_AUDIO_EP_IN_FLOW_CONTROL 0 +#define CFG_TUD_AUDIO_FUNC_1_EP_IN_SZ_MAX UAC2_EP_SIZE +#define CFG_TUD_AUDIO_FUNC_1_EP_IN_SW_BUF_SZ (4 * UAC2_EP_SIZE) + +// Playback, with the explicit feedback endpoint. Eight packets of FIFO +// (392 frames, 8.2 ms): the FIFO-count feedback holds it half full. +#define CFG_TUD_AUDIO_ENABLE_EP_OUT 1 +#define CFG_TUD_AUDIO_FUNC_1_EP_OUT_SZ_MAX UAC2_EP_SIZE +#define CFG_TUD_AUDIO_FUNC_1_EP_OUT_SW_BUF_SZ (8 * UAC2_EP_SIZE) +#define CFG_TUD_AUDIO_ENABLE_FEEDBACK_EP 1 + +#endif diff --git a/async/examples/pico2_uac2/usb_descriptors.c b/async/examples/pico2_uac2/usb_descriptors.c new file mode 100644 index 0000000..30a41d2 --- /dev/null +++ b/async/examples/pico2_uac2/usb_descriptors.c @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: MIT +// Copyright 2026 Timothy Place and the SampleRateTap contributors +// +// Device, configuration and string descriptors (TinyUSB's descriptor +// callbacks). The audio function follows UAC2 section 4: the class-specific +// AudioControl interface holds the clock and the four terminals; each +// AudioStreaming interface has a zero-bandwidth alternate 0 and a streaming +// alternate 1. +#include "usb_descriptors.h" + +#include + +#include "pico/unique_id.h" +#include "tusb.h" + +enum { STRID_LANGID = 0, STRID_MANUFACTURER, STRID_PRODUCT, STRID_SERIAL, STRID_AUDIO }; + +// 0xCafe is TinyUSB's test vendor ID, used by its examples: fine on a bench, +// never for a device that ships. +static tusb_desc_device_t const desc_device = { + .bLength = sizeof(tusb_desc_device_t), + .bDescriptorType = TUSB_DESC_DEVICE, + .bcdUSB = 0x0200, + // An IAD groups the audio interfaces: the device class says so (USB IAD ECN). + .bDeviceClass = TUSB_CLASS_MISC, + .bDeviceSubClass = MISC_SUBCLASS_COMMON, + .bDeviceProtocol = MISC_PROTOCOL_IAD, + .bMaxPacketSize0 = CFG_TUD_ENDPOINT0_SIZE, + .idVendor = 0xCafe, + .idProduct = 0x4A11, // A11: the roadmap item this device serves + .bcdDevice = 0x0100, + .iManufacturer = STRID_MANUFACTURER, + .iProduct = STRID_PRODUCT, + .iSerialNumber = STRID_SERIAL, + .bNumConfigurations = 1, +}; + +uint8_t const* tud_descriptor_device_cb(void) { + return (uint8_t const*)&desc_device; +} + +#define CONFIG_TOTAL_LEN (TUD_CONFIG_DESC_LEN + UAC2_FUNC_DESC_LEN) +#define UAC2_AC_TOTAL_LEN \ + (TUD_AUDIO_DESC_CLK_SRC_LEN + 2 * TUD_AUDIO_DESC_INPUT_TERM_LEN + 2 * TUD_AUDIO_DESC_OUTPUT_TERM_LEN) +#define UAC2_ISO_ASYNC_DATA \ + ((uint8_t)((uint8_t)TUSB_XFER_ISOCHRONOUS | (uint8_t)TUSB_ISO_EP_ATT_ASYNCHRONOUS | (uint8_t)TUSB_ISO_EP_ATT_DATA)) + +static uint8_t const desc_configuration[] = { + TUD_CONFIG_DESCRIPTOR(1, ITF_NUM_TOTAL, 0, CONFIG_TOTAL_LEN, 0x00, 100), + + TUD_AUDIO_DESC_IAD(ITF_NUM_AUDIO_CONTROL, ITF_NUM_TOTAL, 0x00), + TUD_AUDIO_DESC_STD_AC(ITF_NUM_AUDIO_CONTROL, 0x00, STRID_AUDIO), + TUD_AUDIO_DESC_CS_AC(0x0200, AUDIO_FUNC_IO_BOX, UAC2_AC_TOTAL_LEN, 0x00), + // The device's own clock: internal, fixed, its frequency and validity read-only. + TUD_AUDIO_DESC_CLK_SRC(UAC2_ENTITY_CLOCK, AUDIO_CLOCK_SOURCE_ATT_INT_FIX_CLK, + (AUDIO_CTRL_R << AUDIO_CLOCK_SOURCE_CTRL_CLK_FRQ_POS) + | (AUDIO_CTRL_R << AUDIO_CLOCK_SOURCE_CTRL_CLK_VAL_POS), + 0x00, 0x00), + // Playback: USB streaming -> speaker. + TUD_AUDIO_DESC_INPUT_TERM(UAC2_ENTITY_PLAY_IT, AUDIO_TERM_TYPE_USB_STREAMING, 0x00, UAC2_ENTITY_CLOCK, + UAC2_CHANNELS, AUDIO_CHANNEL_CONFIG_NON_PREDEFINED, 0x00, 0x0000, 0x00), + TUD_AUDIO_DESC_OUTPUT_TERM(UAC2_ENTITY_PLAY_OT, AUDIO_TERM_TYPE_OUT_GENERIC_SPEAKER, 0x00, UAC2_ENTITY_PLAY_IT, + UAC2_ENTITY_CLOCK, 0x0000, 0x00), + // Capture: microphone -> USB streaming. + TUD_AUDIO_DESC_INPUT_TERM(UAC2_ENTITY_REC_IT, AUDIO_TERM_TYPE_IN_GENERIC_MIC, 0x00, UAC2_ENTITY_CLOCK, + UAC2_CHANNELS, AUDIO_CHANNEL_CONFIG_NON_PREDEFINED, 0x00, 0x0000, 0x00), + TUD_AUDIO_DESC_OUTPUT_TERM(UAC2_ENTITY_REC_OT, AUDIO_TERM_TYPE_USB_STREAMING, 0x00, UAC2_ENTITY_REC_IT, + UAC2_ENTITY_CLOCK, 0x0000, 0x00), + + // Playback streaming interface. + TUD_AUDIO_DESC_STD_AS_INT(ITF_NUM_AUDIO_STREAMING_OUT, 0x00, 0x00, 0x00), + TUD_AUDIO_DESC_STD_AS_INT(ITF_NUM_AUDIO_STREAMING_OUT, 0x01, 0x02, 0x00), + TUD_AUDIO_DESC_CS_AS_INT(UAC2_ENTITY_PLAY_IT, AUDIO_CTRL_NONE, AUDIO_FORMAT_TYPE_I, AUDIO_DATA_FORMAT_TYPE_I_PCM, + UAC2_CHANNELS, AUDIO_CHANNEL_CONFIG_NON_PREDEFINED, 0x00), + TUD_AUDIO_DESC_TYPE_I_FORMAT(UAC2_BYTES_PER_SAMPLE, UAC2_BITS_PER_SAMPLE), + TUD_AUDIO_DESC_STD_AS_ISO_EP(UAC2_EP_OUT, UAC2_ISO_ASYNC_DATA, UAC2_EP_SIZE, 0x01), + TUD_AUDIO_DESC_CS_AS_ISO_EP(AUDIO_CS_AS_ISO_DATA_EP_ATT_NON_MAX_PACKETS_OK, AUDIO_CTRL_NONE, + AUDIO_CS_AS_ISO_DATA_EP_LOCK_DELAY_UNIT_UNDEFINED, 0x0000), + TUD_AUDIO_DESC_STD_AS_ISO_FB_EP(UAC2_EP_FB, UAC2_EP_FB_SIZE, 0x01), + + // Capture streaming interface. + TUD_AUDIO_DESC_STD_AS_INT(ITF_NUM_AUDIO_STREAMING_IN, 0x00, 0x00, 0x00), + TUD_AUDIO_DESC_STD_AS_INT(ITF_NUM_AUDIO_STREAMING_IN, 0x01, 0x01, 0x00), + TUD_AUDIO_DESC_CS_AS_INT(UAC2_ENTITY_REC_OT, AUDIO_CTRL_NONE, AUDIO_FORMAT_TYPE_I, AUDIO_DATA_FORMAT_TYPE_I_PCM, + UAC2_CHANNELS, AUDIO_CHANNEL_CONFIG_NON_PREDEFINED, 0x00), + TUD_AUDIO_DESC_TYPE_I_FORMAT(UAC2_BYTES_PER_SAMPLE, UAC2_BITS_PER_SAMPLE), + TUD_AUDIO_DESC_STD_AS_ISO_EP(UAC2_EP_IN, UAC2_ISO_ASYNC_DATA, UAC2_EP_SIZE, 0x01), + TUD_AUDIO_DESC_CS_AS_ISO_EP(AUDIO_CS_AS_ISO_DATA_EP_ATT_NON_MAX_PACKETS_OK, AUDIO_CTRL_NONE, + AUDIO_CS_AS_ISO_DATA_EP_LOCK_DELAY_UNIT_UNDEFINED, 0x0000), +}; + +_Static_assert(sizeof(desc_configuration) == CONFIG_TOTAL_LEN, "the configuration's length must match its contents"); + +uint8_t const* tud_descriptor_configuration_cb(uint8_t index) { + (void)index; + return desc_configuration; +} + +static char const* const string_desc_arr[] = { + NULL, // the language ID, written below + "SampleRateTap", + "SampleRateTap Pico 2 UAC2", + NULL, // the board's unique ID, written below + "Pico 2 UAC2 (asynchronous)", +}; + +static uint16_t desc_str[32 + 1]; + +uint16_t const* tud_descriptor_string_cb(uint8_t index, uint16_t langid) { + (void)langid; + size_t chr_count = 0; + if (index == STRID_LANGID) { + desc_str[1] = 0x0409; // English (United States) + chr_count = 1; + } + else { + char serial[2 * PICO_UNIQUE_BOARD_ID_SIZE_BYTES + 1]; + char const* str = NULL; + if (index == STRID_SERIAL) { + pico_get_unique_board_id_string(serial, sizeof(serial)); + str = serial; + } + else if (index < sizeof(string_desc_arr) / sizeof(string_desc_arr[0])) { + str = string_desc_arr[index]; + } + if (str == NULL) { + return NULL; + } + chr_count = strlen(str); + size_t const max_count = sizeof(desc_str) / sizeof(desc_str[0]) - 1; + if (chr_count > max_count) { + chr_count = max_count; + } + for (size_t i = 0; i < chr_count; ++i) { + desc_str[1 + i] = (uint16_t)str[i]; + } + } + desc_str[0] = (uint16_t)((TUSB_DESC_STRING << 8) | (2 * chr_count + 2)); + return desc_str; +} diff --git a/async/examples/pico2_uac2/usb_descriptors.h b/async/examples/pico2_uac2/usb_descriptors.h new file mode 100644 index 0000000..7b0fa19 --- /dev/null +++ b/async/examples/pico2_uac2/usb_descriptors.h @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: MIT +// Copyright 2026 Timothy Place and the SampleRateTap contributors +// +// The device's USB Audio Class 2.0 topology (USB-IF, "Universal Serial Bus +// Device Class Definition for Audio Devices", Release 2.0): one internal +// fixed clock, a playback path (USB streaming -> speaker) and a capture path +// (microphone -> USB streaming), both at 48 kHz, stereo, 16-bit, each on an +// asynchronous isochronous endpoint; the playback path's explicit feedback +// endpoint tells the host the device's rate (USB 2.0, 5.12.4.2). Included by +// tusb_config.h, so it holds macros only. +#ifndef PICO2_UAC2_USB_DESCRIPTORS_H +#define PICO2_UAC2_USB_DESCRIPTORS_H + +enum { ITF_NUM_AUDIO_CONTROL = 0, ITF_NUM_AUDIO_STREAMING_OUT, ITF_NUM_AUDIO_STREAMING_IN, ITF_NUM_TOTAL }; + +#define UAC2_ENTITY_CLOCK 0x10 +#define UAC2_ENTITY_PLAY_IT 0x01 // USB streaming in, from the host +#define UAC2_ENTITY_PLAY_OT 0x02 // the speaker it plays to +#define UAC2_ENTITY_REC_IT 0x03 // the microphone it records from +#define UAC2_ENTITY_REC_OT 0x04 // USB streaming out, to the host + +#define UAC2_EP_OUT 0x01 +#define UAC2_EP_FB 0x81 +#define UAC2_EP_IN 0x82 + +#define UAC2_SAMPLE_RATE 48000 +#define UAC2_CHANNELS 2 +#define UAC2_BYTES_PER_SAMPLE 2 +#define UAC2_BITS_PER_SAMPLE 16 + +// One frame's packet at Full Speed: 48 frames a millisecond, plus one for +// the frames an asynchronous clock runs ahead (196 bytes, 49 frames). +#define UAC2_EP_SIZE TUD_AUDIO_EP_SIZE(UAC2_SAMPLE_RATE, UAC2_BYTES_PER_SAMPLE, UAC2_CHANNELS) + +// 16.16 feedback in 4 bytes: what Linux and Windows read at Full Speed +// (the 10.14 format macOS wants is not served; README). +#define UAC2_EP_FB_SIZE 4 + +#define UAC2_FUNC_DESC_LEN \ + (TUD_AUDIO_DESC_IAD_LEN + TUD_AUDIO_DESC_STD_AC_LEN + TUD_AUDIO_DESC_CS_AC_LEN + TUD_AUDIO_DESC_CLK_SRC_LEN \ + + 2 * TUD_AUDIO_DESC_INPUT_TERM_LEN \ + + 2 * TUD_AUDIO_DESC_OUTPUT_TERM_LEN /* playback AS interface: alt 0, alt 1 with its data and feedback endpoints \ + */ \ + + 2 * TUD_AUDIO_DESC_STD_AS_INT_LEN + TUD_AUDIO_DESC_CS_AS_INT_LEN + TUD_AUDIO_DESC_TYPE_I_FORMAT_LEN \ + + TUD_AUDIO_DESC_STD_AS_ISO_EP_LEN + TUD_AUDIO_DESC_CS_AS_ISO_EP_LEN \ + + TUD_AUDIO_DESC_STD_AS_ISO_FB_EP_LEN /* capture AS interface: alt 0, alt 1 with its data endpoint */ \ + + 2 * TUD_AUDIO_DESC_STD_AS_INT_LEN + TUD_AUDIO_DESC_CS_AS_INT_LEN + TUD_AUDIO_DESC_TYPE_I_FORMAT_LEN \ + + TUD_AUDIO_DESC_STD_AS_ISO_EP_LEN + TUD_AUDIO_DESC_CS_AS_ISO_EP_LEN) + +#endif diff --git a/book/src/part5/hardware.md b/book/src/part5/hardware.md index 96b305e..5a60612 100644 --- a/book/src/part5/hardware.md +++ b/book/src/part5/hardware.md @@ -412,6 +412,49 @@ that is what makes +200.0 an exact, assertable truth — so this firmware It proves the deployment shape: two cores, two clock domains, lock-free handoff, real cycle headroom. +## `pico2_uac2`: a clock the host does not own + +Setup 1 assumes its two devices run on two clocks. A cheap USB dongle often +does not: an adaptive or synchronous endpoint locks its sample clock to the +host's USB frames, and two such dongles share one clock. The servo then +locks, flawlessly, to an offset of zero, and a hair dryer changes nothing. +`examples/pico2_uac2/` is the side that cannot fail that way: a Pico 2 that +enumerates as a USB Audio Class 2.0 device in asynchronous mode, with its +sample clock taken from its own crystal. + +The clock is one line of integer arithmetic over the RP2350's microsecond +timer, which counts the 12 MHz crystal: + +```cpp +std::uint64_t device_frames(std::uint64_t us) { + return static_cast(static_cast(us) * 6 * (1'000'000 + kPpm) / 125'000'000); +} +``` + +That is 48,000 frames a second, times (1 + ppm / 10⁶), and it sets both +directions: + +- **Playback.** The firmware drains the endpoint's FIFO at that rate, and + the feedback endpoint tells the host how fast to send. TinyUSB derives + the feedback from the FIFO's level and holds it half full, so the host + follows the crystal. +- **Capture.** Each packet carries the frames the clock produced since the + last one: 47, 48 or 49 a millisecond. The packet callback runs late by + whatever the main loop was doing, so its times pass through the + delay-locked loop JACK and PipeWire use (Adriaensen, 2005) before + they become a frame count. Modelled over 600 s of packets with up to + 0.4 ms of lateness and a 3 ms stall every ten seconds, every packet + was 47, 48 or 49 frames. The count ran a constant 58 frames behind the + clock, the filter's lag at start-up, so the rate is exact. + +The `kPpm` in that line is the trick `pico2_dualcore` used, turned +around. There, an offset synthesized from one shared timebase gave an +exact truth but proved nothing about two crystals. Here the crystal is +real and unknown, and `PICO2_UAC2_PPM` adds a *known* offset on top. Two +runs at different settings must differ by exactly that much in the +report's fit, which checks the bench's own arithmetic against an exact +number without knowing either crystal. + ## What is measured, and what is not yet The project's culture is that numbers are measured or absent, so here is @@ -420,8 +463,8 @@ the ledger as it stands: - **Shipped and measured on real clocks: nothing yet.** All quality and performance figures in this book so far come from deterministic simulation, host benchmarks, and QEMU instruction counting. -- **Shipped and awaiting hardware:** all three harnesses build — the ALSA - bridge wherever ALSA exists, both Pico 2 firmwares as flashable UF2s — +- **Shipped and awaiting hardware:** all four harnesses build — the ALSA + bridge wherever ALSA exists, the three Pico 2 firmwares as flashable UF2s — but `docs/HARDWARE_TESTING.md` says it plainly: *the measured numbers await a physical Pico 2*, and the multi-hour dongle soak awaits an afternoon with a Pi. The cycles-per-instruction calibration constant, @@ -461,6 +504,8 @@ python3 async/examples/alsa_bridge_report.py trace.csv --plot trace.png # the Pico SDK fetch): cd examples/pico2_cyccnt && cmake -B build -DPICO_BOARD=pico2 && cmake --build build -j cd examples/pico2_dualcore && cmake -B build -DPICO_BOARD=pico2 && cmake --build build -j +# Setup 1's independent clock (a UAC2 asynchronous-mode device; status on UART0): +cd examples/pico2_uac2 && cmake -B build -DPICO_BOARD=pico2 && cmake --build build -j # Flash the UF2s, open the USB serial port, and wait for the sentinel # lines: TAP_SR_PICO2_DONE / TAP_SR_PICO2_DUALCORE_DONE with per-phase PASS/FAIL. ``` diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 37ff27b..a304c13 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -130,7 +130,7 @@ gated on shipping. `docs/AUDIT_2026-10_PLAN.md` still says "execution in progress"; the comparison records' competitor pins are frozen by policy and each `COMPARISON.md` should say so with the date. -- **A11. Harness prerequisites for Block B** (written now, run then). *In three PRs. A11a landed 2026-10-15: the ALSA bridge's counters, xruns, `hw:`-only refusal, `--rt` and hardware timestamps, `examples/alsa_bridge_report.py` (the regression slope, wobble and breathing; self-tested), and `libasound2-dev` on the Linux legs and in the tidy gate. A11b landed 2026-10-15: `pico2_cyccnt` times `bridge` (both directions) and `rational` (↑2, ↓2, 2/3) at Q15 `economy`, per output frame, beside `async`, and runs from SRAM by default (`PICO2_COPY_TO_RAM`), Release, as the ratchet's workloads; CI links both layouts. A11c is the UAC2 firmware.* the ALSA bridge +- **A11. Harness prerequisites for Block B** (written now, run then). *In three PRs. A11a landed 2026-10-15: the ALSA bridge's counters, xruns, `hw:`-only refusal, `--rt` and hardware timestamps, `examples/alsa_bridge_report.py` (the regression slope, wobble and breathing; self-tested), and `libasound2-dev` on the Linux legs and in the tidy gate. A11b landed 2026-10-15: `pico2_cyccnt` times `bridge` (both directions) and `rational` (↑2, ↓2, 2/3) at Q15 `economy`, per output frame, beside `async`, and runs from SRAM by default (`PICO2_COPY_TO_RAM`), Release, as the ratchet's workloads; CI links both layouts. A11c landed 2026-10-15: `examples/pico2_uac2/`, the Pico 2 as a UAC2 asynchronous-mode device on its own crystal (playback with a FIFO-count feedback endpoint, capture paced by a DLL-filtered device clock, `PICO2_UAC2_PPM` for a known offset), built in CI and not yet enumerated by a host.* the ALSA bridge gains per-device cumulative frame counters sampled with `clock_gettime` in the CSV, separate ALSA xrun counters, a refusal of non-`hw:` devices (the `default` device on a Pi routes through PipeWire and its own resampler), an `--rt` flag (`SCHED_FIFO`,