Skip to content

audiograph: a graph the pump pulls, changed through its own queue - #317

Merged
bdbarnett merged 2 commits into
mainfrom
graph-on-pump
Oct 27, 2026
Merged

bdbarnett merged 2 commits into
mainfrom
graph-on-pump

Conversation

@bdbarnett

@bdbarnett bdbarnett commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

A compiled audiograph.Graph can now play the way any other sample does. Hand it to the pump, an output's play() or a mixer voice, and it renders a block each time one is pulled. On a board where the pump has a core of its own, the whole patch renders there, and your code only changes it.

g.hold()
g.set(f, ag.LADDER_CUTOFF_HZ, 900.0)
g.set(f, ag.LADDER_RESONANCE, 0.6)
g.enable(0b11)
g.commit()            # all of it lands on one block

What changes for you

  • set(), reset() and the new enable(mask) go into the graph's own lock-free queue. The renderer drains it at the top of each block, so a change never lands partway through a block.
  • hold() and commit() turn a batch into one change. A patch change no longer sounds its cutoff in one block and its resonance in the next.
  • When the queue is full, the control side waits for the block in flight and applies what's waiting itself, so nothing is dropped. Graph(queue=...) sizes the queue; by default it holds the larger of 256 and four per node.
  • get() returns the last value set() stored.
  • sounding() returns (sounding, frame, applied, pushed), so you can tell which block a mask came from and whether your last change has been heard.
  • stats() counts the queue.
  • While the pump is pulling a graph, buffer(), add(), table(), write() and run() raise RuntimeError.
  • deinit() stops the pump cleanly. It works as a context manager too.
  • When nothing else is pulling the graph, run() behaves as before, except that it drains the queue first.

All of this behaves the same on MicroPython, CircuitPython and CPython. See "Playing a graph from the pump" in docs/audiograph.md.

How it's checked

  • tests/parity/graph_source_probe.py plays one script of changes three ways: through run(), pulled with batches held, and pulled through a 16-record queue that splits every batch. All three are identical to the sample. verify_graph.py compares the pulled render across interpreters at 0 counts: CPython, MicroPython double and float, and the CircuitPython unix build. Planted faults drain and split must fail.
  • tests/pump/pump_probe.py graph hands a graph to the pump and changes it from the interpreter's thread. Every block must hold one value per channel from one batch. A held batch must stay silent until commit(), and a committed batch must sound no later than the block after the one in flight. With the pthread driver, 3000 batches are also committed while the pump pulls, and none may tear. Planted faults split, late and unqueued must fail; CI runs them first.
  • The graph also joins the pump's identity and heap-locked alloc gates, so a pull allocates nothing.
  • New CPython tests cover hold/commit, queue overflow, refused sets and deinit.
  • check_portable.py and check_lock_shape.py pass.

Carrying these changes on audiopump.Events, or taking the pump lock in every set(), was rejected. Either way, every set would wait for the block being rendered, so a patch change of a hundred sets could stall the caller for hundreds of milliseconds.

On the ESP32-P4 DEV-KIT

The Poly6 at two voices, compiled to one graph and played by the audio pump on the core the interpreter isn't on (48 kHz stereo, 128-frame blocks of 2.67 ms, examples/hmi_instrument.py --pump in vrack):

run starved after priming pull per block, mean of 5 s windows worst pull interpreter turn
no screen, 60 s 0 bytes 899-1790 us 4456 us —
LVGL panel open, 60 s 0 bytes 1082-2036 us 6306 us 85-131 ms (1.61 s once, as the panel opened)
panel open, a patch change every 30 s, an hmilink client changing params, 32 min 0 bytes in all 384 windows 620-2204 us, 1751 overall 4801-5999 us 58-169 ms (2.12 s once)

Every run had no sink timeouts, no queue overflows or split batches, and 0 us of interpreter wait on the pump lock (lock_stats()); the queue's high water was 261 records. The single slow pulls are absorbed by the I2S DMA queue (6 x 240 frames, about 30 ms). The pump task finished with 12428 bytes of its stack never used. The client's 41 changes were all acknowledged.

…h its own queue

A Graph can now be handed to the pump, an output's play() or a mixer voice,
and it renders a block each time one is pulled, on whichever thread pulls it.
set(), reset() and the new enable() go into a lock-free single-producer queue
the renderer drains at the top of each block, so a change never lands inside
a block; hold() and commit() make a batch land on one block together. A full
queue is drained by the control side under the pump lock, so nothing is
dropped. get() reads a shadow of the last value set; sounding() returns the
sounding mask with the frame and the applied and queued counts it belongs to;
stats() counts the queue. While the pump pulls a graph, buffer(), add(),
table(), write() and run() raise RuntimeError, and deinit() stops the pump
cleanly. Same API on MicroPython, CircuitPython and CPython.

Checked by graph_source_probe.py (pulled renders equal run() to the sample, on
all three interpreters, with planted faults drain and split) and a new graph
case in pump_probe.py (one value per channel per block, held batches silent
until commit, landing no later than the block after; planted faults split,
late and unqueued), and the graph joins the pump's identity and heap-locked
allocation gates.
@bdbarnett
bdbarnett merged commit f763ec3 into main Oct 27, 2026
31 checks passed
@bdbarnett
bdbarnett deleted the graph-on-pump branch October 27, 2026 05:43
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