Skip to content

v0.7.0: USB audio out (M5c, #21) - #42

Merged
Worldwave merged 10 commits into
mainfrom
m5c-usb-audio
Oct 6, 2026
Merged

Worldwave merged 10 commits into
mainfrom
m5c-usb-audio

Conversation

@Worldwave

Copy link
Copy Markdown
Owner

The USB audio part of #21 (M5c). Hardware test procedure: #40.

What it does

Wakes now shows up on a USB host as an audio input named Wakes Audio Out: 48 kHz, 16-bit stereo, the same signal as the headphone jack. It's output only. The SP-1 never receives audio from the host.

  • Class: USB Audio Class 1.0, hand-rolled (firmware/src/sp1_uac.{c,h}):

    • no IAD, which faults the OP-XY;
    • the oldest audio driver on every OS;
    • the same class family as the MIDI port.

    The ISO IN endpoint is async with no feedback endpoint. Packets are 47, 48 or 49 frames.

  • Data path: Ryan Gilmore's sp1-usb-audio (vendored unmodified in third_party/sp1-usb-audio/).

    • Ring regulator: retuned for Wakes' 96-frame blocks. The target is 384 frames, with a ±32 frame dead band.
    • Fast path: the packet is filled inside the USB interrupt, ported to Zephyr 4.3.1 in zephyr-patches/udc_nrf-fast-paths.patch.
    • SOF interrupt: runs only while a host has the stream open.
  • Word copies in the ring (patches/uacring.patch): picolibc's memcpy is a byte loop. check_image.py fails an image where the ring's push or pop calls memcpy or memset.

  • Speaker: off while a host is actually taking the stream, judged by the last packet being under 20 ms old. A host that opens the stream and then sleeps gets the speaker back. Headphones always play.

  • Delay compensation: USB audio out joins the latency stack. Its path is 9.0 ms against the speaker's 8.0. clock_lead = auto is 9.5 ms while USB audio is live and 8.5 ms otherwise. A BUILD_ASSERT ties the figure to the regulator's tuning.

  • config/audio.ini (new, build-time):

    • level = parked | vol, default parked.
    • parked_level: one of VOL's steps, default −6 dB.
    • speaker = off | on: the speaker while a host records, default off.

    Parked sets and locks VOL itself while a host records, and restores it afterwards. That's the cheapest way to do it, and the file says so.

  • Names: the USB product string is now Wakes (was wakes-sp1), and the manufacturer is Worldwave. The MIDI port takes the product name, so DAW routings need re-selecting once. docs/MIDI.md says so.

  • Interim PID 0x5213: VID 0x1209 / PID 0x4440 replace it once Add Worldwave org and 1209:4440 (Wakes) pidcodes/pidcodes.github.com#1301 merges.

  • v0.7.0: firmware/VERSION, README, issue-form placeholders and the updated manual.

CPU

About 2 points with a host recording (accepted):

  • Interrupts, ~1.6: the fill and the transfer-end interrupt, at 1 kHz.
  • The ring copy in the audio thread, ~0.4.

The worst window with Chords, drive, Marbles on an external clock and USB audio all on was 88.8 %, with no over-budget blocks and no stream failures. With nothing recording, the cost is zero.

Verification

  • Build: 427 660 B, links at 0x20000, 0 warnings. check_image.py passes, including the new ring-copy checks.
  • Host:
    • uactest, Wakes' render model with the 50 %, 97.5 % and 200 % blocks, now runs in CI. The measured ring delay is 7.01 ms.
    • miditest_clock §14: the USB lead and the speaker lead are 1.00 ms apart.
    • All 9 suites pass.
  • Hardware, OP-XY, 2026-10-06:
    • The stream opened 5 times and ran ~312k packets with no underruns or overruns, drifting about 30 ppm.
    • Unplugging fell back cleanly: MIDI neutral, Marbles back on its own clock.
    • Those sessions ran the build before fd3ad2d. Its liveness race made the speaker blip and VOL unpark while streaming.

Still open

  • fd3ad2d on hardware: 5 minutes of OP-XY streaming with no SPEAKER on or LEVEL back lines (Hardware test: M5c (USB audio out) #40).
  • Windows: test the audio input explicitly. Windows' legacy audio grouping is documented as off when any IAD is present (CDC has one), although MIDI already works with this layout.
  • A macOS DAW session.
  • pid.codes: switch to 1209:4440 once the PR merges.

🤖 Generated with Claude Code

Worldwave and others added 10 commits October 5, 2026 14:37
The whole repository, unmodified (blobs checked against upstream), in
third_party/sp1-usb-audio/upstream/, with a README beside it recording the
commit, what each part is for in Wakes, and how to update. Nothing is
compiled yet. Its regulator host test passes at 128- and 256-frame blocks
and fails at Wakes' 96, so uacring needs retuning before use.

NOTICE and LICENSES/README.md gain its Apache-2.0 entry; its own NOTICE
(Nordic for the driver, chattock for the audio bring-up) is carried with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ryan Gilmore's ring and packet regulator is tuned upstream for 128- and
256-frame producer blocks; Wakes renders 96. firmware/src/sp1_uac_tuning.h
holds Wakes' values, force-included ahead of uacring.h by the firmware and the
host test alike, and stops the build if the audio block changes.

Swept against Wakes' render times (50 % light, 97.5 % held on the heaviest
patch, one block in 997 at 200 % -- the longest the speaker survives with one
queued block): first clean pass at TARGET 336 with a +/-32 dead band; 384
(8.0 ms) adds half a block, upstream's margin, and leaves 55 frames at worst.
No corrections from load alone; ~9.5/s at +/-200 ppm.

tools/host-tests/uactest.c (derived from upstream's test_uacring.c,
Apache-2.0) runs in hostbuild.sh and CI, with a -s sweep mode for re-tuning.
zephyr-patches/README.md records the ISO IN port review against Ryan's base.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
uacring.c copies with memcpy/memset, and the SDK's picolibc memcpy is a byte
loop (ldrb/strb, confirmed in the linked image; ~6.6 cycles a byte, #36). The
ring's push (audio thread, 384 B every 2 ms) and pop (USB interrupt, 192 B
every 1 ms) would cost ~4 points through it.

third_party/sp1-usb-audio/patches/uacring.patch replaces those five calls
with one 32-bit word per stereo frame (frames_copy, unrolled by four;
frames_zero). Cortex-M4 at -Os: 14 instructions per 16 bytes, ~1 cycle a
byte, ~0.65 points in all. Applied strictly to a copy (tools/apply_patch.py);
the vendored file is untouched. Without -fno-tree-loop-distribute-patterns GCC
turns the silence fill back into memset (checked), so the copy is built with
it, and check_image.py now fails an image whose uacring_push/pop call
memcpy/memset (silent until USB audio is linked; ELF helpers shared with the
#36 rule, whose results are unchanged).

uactest builds the patched copy the same way: 6 000 121 checks, 0 failures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The SP-1's output, after VOL and the output select (the samples the codecs
get), goes to the host as a 48 kHz 16-bit stereo UAC1 input. Nothing is
received from the host.

- src/sp1_uac.{c,h} (Apache-2.0; the fill function and stream start/stop
  follow Ryan Gilmore's usb_audio.c): a hand-rolled UAC1 function in the shape
  of feldd's MIDI class. AudioControl (header, input terminal "line connector",
  USB streaming output terminal) + AudioStreaming alt 0/1, Type I PCM, one ISO
  IN endpoint, asynchronous with no feedback endpoint, 9-byte endpoint
  descriptor, 196-byte packets. No IAD, like the MIDI function the OP-XY
  accepts. Sample-rate requests answered though not advertised. Named
  "Wakes Audio Out" on its interfaces and input terminal.
- Packets go out from the USB interrupt through the ISO IN fast path,
  registered when the host selects alternate setting 1 and removed on 0,
  disable, VBUS removed and bus reset; a resume re-primes the ring.
- zephyr-patches/udc_nrf-fast-paths.patch: the SOF interrupt now runs only
  while a fill is registered (kept across a restart), and no SOF event reaches
  the stack then -- a device that is not streaming pays nothing.
- sp1_audio.c: sp1_uac_claim() as a block starts rendering, sp1_uac_push()
  with the finished block. Ring = Ryan's uacring.c with Wakes' word-copy
  patch, built -fno-tree-loop-distribute-patterns with sp1_uac_tuning.h.
- Class order by name: cdc_acm_0, usb_midi1, wakes_uac (checked in the
  image), so the console and MIDI keep their interface numbers.
- USB identity: PID 0x5213 (interim; pid.codes 1209:4440 requested), product
  "Wakes" (was wakes-sp1 -- also the MIDI port's name; docs/MIDI.md says to
  re-select it), manufacturer "Worldwave". console-log.py knows 0x5213.
- main.c: "audio out=" on the USB boot line; a UAC status line every 5 s
  while the stream is open.

Build: 426 788 B (46.72 %), RAM 178 408 B, 0 warnings, check_image OK incl.
the push/pop word-copy checks; fallback 86 372 B OK. Not yet on hardware.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adara: disable the onboard speaker while USB audio out is in use. It rides on
the headphone mute: the jack is debounced as before, and every 40 ms poll now
reconciles the TAS2505's driver with what the speaker should be doing -- off
while headphones are in OR a host has the stream open (sp1_uac_open), on
otherwise. Headphones always play, so they still monitor.

- A stream closing (host stops, cable pulled) brings the speaker back within
  one poll; an ON entry with a host already recording mutes at bring-up, so
  the first 40 ms do not reach the speaker.
- Same fail-safe as before: the amp state changes only when the write
  succeeds (retried and counted otherwise), and if detection gives up the
  speaker is ON -- the USB mute with it. Nothing can leave the device silent
  by mistake.
- New poll results 3 / 4 log "SPEAKER off/on" for USB; sp1_audio_speaker_on().

Build: 427 028 B, 0 warnings, check_image OK; fallback 86 544 B OK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adara: check that the host accepts the stream, otherwise default to the
speaker. Selecting the streaming alternate setting already requires the host's
audio driver to have accepted the device and something to have opened the
input, so a charger, a host without USB audio (or rejecting ours), a MIDI-only
host and an unopened input all kept the speaker. The gap was a host that opened
the stream and then stopped running the bus -- asleep, suspended, stalled: the
speaker stayed muted.

Now the speaker is muted only while sp1_uac_live(): the stream is open AND the
USB interrupt has handed over packets since the previous 40 ms check (fill()
runs at each start-of-frame, so a sleeping host stops it). Otherwise the
speaker comes back within one poll, and goes quiet again when packets resume.
One counter increment per millisecond in the interrupt.

Build: 427 072 B, 0 warnings, check_image OK; fallback OK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ows it

Adara: include USB audio output in the delay compensation, as #32 built it.
Wakes makes up its own output delay by reading the host's MIDI clock ahead
(`clock_lead = auto`, and `notes` until it has learnt the host's offset): the
delay is counted from the code (SP1_MIDI_OUTPUT_LATENCY_MS, (queue + 3)
blocks = 8 ms) and the module that owns the pipeline BUILD_ASSERTs it. That
was the speaker's path. A host recording over USB hears a different one.

- sp1_midi.h: SP1_MIDI_USB_OUTPUT_LATENCY_US = TARGET + BLK/2 frames =
  9.0 ms (1 block to place the message + the ring's average wait from render
  start, 7.0 ms). sp1_uac.c BUILD_ASSERTs it against the ring's tuning, the
  same rule as sp1_audio.c's check, so re-tuning the ring cannot leave the
  lead stale.
- sp1_midi.cc: Lead() = fixed ms, or the learnt notes lead, or auto -- the
  USB path's delay while a host takes USB audio out, the speaker's otherwise
  (+ Plaits' TRIG delay: 9.5 vs 8.5 ms). Taken once per audio block; a change
  is followed at kFollow, never backwards. A learnt notes lead does not change
  (notes and Marbles share the path).
- sp1_uac_live() is now stateless (time of the last packet, 20 ms window), so
  main feeds sp1_midi_set_output_usb() every tick and the speaker check uses
  it too.
- main.c: an "AUD USB audio out: ~9.0 ms in to out" boot line, from the
  constant.
- Host suites: uactest measures the ring's delay through the real ring --
  7.01 ms at 0 ppm, within the dead band at +/-200 ppm (6.35 .. 7.69);
  miditest_clock §14: Marbles leads by the USB path's delay with a host taking
  USB audio, back to the speaker's when it stops, no beat skipped or doubled,
  the two leads 1.00 ms apart (want 1.0). All nine suites pass.
- midi.ini, MIDI.md, the CC chart: auto is 9.5 ms while a host records over USB.

Build: 427 272 B, 0 warnings, check_image OK; fallback OK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adara's spec: users choose how USB audio out sets its level and whether the
speaker plays while a host records, in a new config file.

config/audio.ini [usb] (-> tools/gen_audio.py -> sp1_audio_gen.h, like the
MIDI script: a bad value fails the build, naming the line):
- level = parked (default) | vol.
  parked: while a host takes USB audio out, VOL is set to parked_level and its
  buttons are locked ("LEVEL locked ..." logged on a press); when the host
  stops, VOL returns to where it was. Wakes has one output level, so this
  parks the headphones (and the speaker, if on) too -- Adara's call, and the
  file says why: it is the cheapest way (a separate USB level would need a
  second gain pass, ~0.3 % of the CPU). The level slews as VOL always does.
  vol: VOL moves USB audio out with everything else (the M5c build so far).
- parked_level = -6 (default): one of VOL's steps, 0 .. -30 dB in 3 dB.
  BUILD_ASSERTs tie gen_audio.py's step list to sp1_audio.c's table.
- speaker = off (default) | on: the speaker while a host takes USB audio out
  (speaker_wanted; headphones always play).

Built both ways (shipped settings, and vol / -30 / on), 0 warnings; the
generator rejects a bad mode, a level off VOL's steps, a positive level, a bad
on/off and an unknown key, each naming its line. README lists the file.

Build: 427 660 B, 0 warnings, check_image OK; fallback OK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ords

Seen on the OP-XY (logs/sp1-20261006-142157.log): 13 times in 5 minutes the
speaker came back for one 40 ms poll ("SPEAKER on: no host is taking USB
audio out", then "off" 43 ms later), and VOL unparked and re-parked within one
tick ~30 times -- while the stream never paused (312 075 packets, under=0,
over=0).

sp1_uac_live() read the clock, THEN last_fill. A packet handed over between
the two reads made last_fill later than now; the unsigned difference wrapped
to ~2^32 and a live stream read as dead for that call. Likelier than it
sounds: the clock read holds interrupts off, so a start-of-frame arriving
during it fires exactly in that gap.

Now last_fill is read first (a packet in between can only make it older) and
the comparison is signed (a time ahead of now counts as live). Checked in the
disassembly: the load precedes the clock call, the compare is signed.

Build: 427 660 B, 0 warnings, check_image OK; fallback OK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
firmware/VERSION, README and the issue-form placeholders go to v0.7.0 for
USB audio out (M5c). docs/Wakes - Manual.pdf is Adara's updated manual.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Worldwave
Worldwave merged commit 604e938 into main Oct 6, 2026
3 checks passed
@Worldwave
Worldwave deleted the m5c-usb-audio branch October 6, 2026 19:47
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.

1 participant