From 146665fe0b792515ae849c069163e31e59b0a56b Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:06:53 -0500 Subject: [PATCH] fill(): fill the room the ring has once, so a slow patch cannot hang it On a board, a patch that renders on the rack slower than real time never caught up with a ring the pump drains while it renders, so fill() looped for ever and the interpreter stopped. It now renders the blocks that fit when it is called and returns; the audio underruns instead. Claude-Session: https://claude.ai/code/session_0156jxeQhrF9XaLXNUos1NHF --- lib/audioinstruments/_rack/adapter.py | 16 ++++++---- tests/test_fill_bounded.py | 42 +++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 5 deletions(-) create mode 100644 tests/test_fill_bounded.py diff --git a/lib/audioinstruments/_rack/adapter.py b/lib/audioinstruments/_rack/adapter.py index c0b55bc..c4dae33 100644 --- a/lib/audioinstruments/_rack/adapter.py +++ b/lib/audioinstruments/_rack/adapter.py @@ -1006,6 +1006,9 @@ def _pull(self): def fill(self): """Top the ring up with rendered audio; returns the buffers written. + It fills the room the ring has when you call it and returns, even when + the patch renders slower than real time and the ring has drained again + by then; the audio then underruns rather than the call never ending. Call it from a timer on a build whose output is a ring (MicroPython). On CPython the pull renders and this does nothing. With ``output="graph"`` it is one turn of the graph's Python side (see the @@ -1022,14 +1025,17 @@ def fill(self): if ring is None: return 0 raw, views = self._slots[0] - written = 0 - need = len(raw) - while ring.space() >= need: + # Fill the room there is now, once. A patch that renders slower than + # real time never catches a ring that drains while it renders, so + # looping until the ring is full would never return. + blocks = ring.space() // len(raw) + left = blocks + while left > 0: with self._lock: self._render(raw, views) ring.write(raw) - written += 1 - return written + left -= 1 + return blocks def graph_available(): diff --git a/tests/test_fill_bounded.py b/tests/test_fill_bounded.py new file mode 100644 index 0000000..35b4e0b --- /dev/null +++ b/tests/test_fill_bounded.py @@ -0,0 +1,42 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# SPDX-License-Identifier: MIT +"""fill() returns when the patch renders slower than the ring drains.""" + +import audioinstruments + + +class _DrainingRing: + """A ring whose reader keeps up with every write, so it never fills.""" + + def __init__(self, room_blocks, block_bytes): + self.room = room_blocks * block_bytes + self.writes = 0 + + def space(self): + if self.writes > 1000: + raise AssertionError("fill() is still writing after 1000 blocks") + return self.room + + def write(self, buf): + self.writes += 1 + + +def test_fill_fills_the_room_once_and_returns(): + inst = audioinstruments.create("electric_piano", 48000, output="pull") + inst.note_on(60, 100) + ring = _DrainingRing(5, len(inst._slots[0][0])) + inst._ring = ring + assert inst.fill() == 5 + assert ring.writes == 5 + # The room is still there: the next call fills it again, it does not + # carry on from the last one. + assert inst.fill() == 5 + assert ring.writes == 10 + + +def test_fill_with_no_room_renders_nothing(): + inst = audioinstruments.create("electric_piano", 48000, output="pull") + ring = _DrainingRing(0, len(inst._slots[0][0])) + inst._ring = ring + assert inst.fill() == 0 + assert ring.writes == 0