Skip to content

audiopump.Ring on CircuitPython: audio your code computes, pulled by the board's outputs - #298

Merged
bdbarnett merged 3 commits into
mainfrom
feat/stream-sample
Oct 21, 2026
Merged

bdbarnett merged 3 commits into
mainfrom
feat/stream-sample

Conversation

@bdbarnett

Copy link
Copy Markdown
Contributor

CircuitPython's audio outputs play samples, and a sample is filled from C, so there was no way to play audio that Python code or a graph of nodes renders as it goes. This adds audiopump.Ring to the CircuitPython build: a sample your code writes PCM into, which audiobusio.I2SOut, audioio.AudioOut, audiopwmio.PWMAudioOut and audiomixer pull in C, with no Python in the pull path.

from audiopump import Ring
ring = Ring(sample_rate=22050, channel_count=2, frames=256, capacity=8)
ring.write(first_blocks)
i2s.play(ring)
while True:
    if ring.space() >= len(block):
        ring.write(render(block))

It has MicroPython's audiopump.Ring surface (write, space, level, stats, clear, deinit, the audiosample fields; from_usb stays MicroPython's), so one producer runs on both. An underrun plays a whole block of silence, keeps the partial block and counts it in stats()[3]; a write that doesn't fit is short, in whole frames, and counts an overrun.

What differs from the MicroPython twin, and why:

  • There is no pump lock on CircuitPython, and a few ports (stm's PWMAudioOut) pull from an interrupt. So clear() doesn't write the consumer's indices: it posts one word saying where to drop to, and the next pull carries it out. space() and level() count the dropped room at once.
  • An output that plays the two channels from separate pulls (two DACs on one sample) gets one block per pair of pulls, the right channel offset by a sample, the way audiomixer does it.

It is a new module under src/circuitpython_spike/ and one word in circuitpython.mk. No existing node changes, and nothing in CircuitPython is patched.

Tests. tests/circuitpython/ring_through_mixer.py checks the ring alone bit-exact (with the digests), underrun, overrun, clear(), a wrap many times round a ring whose capacity is not a power of two, and then pulls it through audiomixer in five shapes (mixer buffers that split and span ring blocks, mono, mono into stereo) with a starved stretch, checking every frame's left and right value and that the silence the mixer handed out is the silence the ring counted. 1063 checks pass on a CircuitPython 11.0.0-alpha.1 unix build. Three faults planted in the CircuitPython Ring each fail it: channels swapped, every seventh block dropped, an underrun not counted. The same file passes on MicroPython's unix build, and CI now runs it there (clean-build.yml). tests/test_binding_parity.py holds the two Rings to the same names, and fails when one is removed from the CircuitPython binding.

@bdbarnett
bdbarnett merged commit 7d83333 into main Oct 21, 2026
31 checks passed
@bdbarnett
bdbarnett deleted the feat/stream-sample branch October 21, 2026 17:22
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