From 3c52b29a5538686d995ed7aacd9854e47e3198cb Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:46:17 -0500 Subject: [PATCH 1/3] Change a patch while it plays: recompile beside the running graph and carry its state --- .github/workflows/ci.yml | 2 +- ROADMAP.md | 3 + docs/architecture.md | 22 +- lib/vrack/adapter.py | 47 +++- lib/vrack/core/graph.py | 120 +++++++++- pyproject.toml | 2 +- tests/mpy/run.py | 1 + tests/mpy/test_graph_live_patch.py | 57 +++++ tests/test_graph_live_patch.py | 114 ++++++++++ tools/live_patch.py | 348 +++++++++++++++++++++++++++++ 10 files changed, 708 insertions(+), 8 deletions(-) create mode 100644 tests/mpy/test_graph_live_patch.py create mode 100644 tests/test_graph_live_patch.py create mode 100644 tools/live_patch.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cebf0c3..950c567 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,7 +57,7 @@ jobs: run: | python -m pip install -q -i https://test.pypi.org/simple/ \ --extra-index-url https://pypi.org/simple/ \ - "omnipython-audiodsp>=0.6.11.dev8" numpy pytest ruff==0.17.1 + "omnipython-audiodsp>=0.6.11.dev9" numpy pytest ruff==0.17.1 python -m pip list 2>/dev/null | grep -iE "^(omnipython-audiodsp|numpy|pytest|ruff) " - name: Lint diff --git a/ROADMAP.md b/ROADMAP.md index 35185e5..64f7565 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -34,6 +34,9 @@ Planned work, in rough order: - `examples/hmi_board.py` on the audio pump. It renders on the interpreter's thread, so a busy screen eats the audio's time; `examples/hmi_instrument.py --pump` plays the graph on the pump's core instead, and this one should too. +- Changing the patch of a graph on the audio pump. `recompile()` swaps a new graph + in between two `step()` calls; an attached graph needs the pump to switch graphs at + a block boundary, and refuses for now. - Notes on their own frame with the graph on the audio pump. A note played in a turn lands at the next block; with `audiograph` setting a value at a frame inside a block, `schedulable` can be True and a sequencer can time notes. diff --git a/docs/architecture.md b/docs/architecture.md index 1c60d0c..e4ef9d6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -168,8 +168,26 @@ parameter for: an oscillator's starting phase, which the rack scatters from voice to voice so a stack of voices on one pitch does not start in phase, and where each voice's noise starts. -Compile after the patch is complete: a cable plugged after `compile_rack()` -is not in the graph. The fragments that exist: `PolyMidiCV` (poly and mono), `VoiceMix` (a mix with buses has none: see [polyphony.md](polyphony.md#buses)), the voice, LFO +A cable plugged after `compile_rack()` is not in the graph until you compile +again, and a patch can change while it plays without stopping a note. Change +the rack (plug or pull a cable, add or remove a module), then, between two +`step()` calls, `compiled = compiled.recompile(groups)`. It compiles the rack +as it now stands beside the running graph and hands that graph's state over +(audiodsp 0.6.11.dev9 and later, for `Graph.carry`): each module's nodes take +the state of the nodes of the same kind it added before, in order, so voices, +envelopes, oscillator phases, filter memory and delay lines carry on; wires +keep their last block, so a late cable still carries the previous one; and +controls keep their values. A node a module now builds differently starts +fresh. The next `step()` renders the block the old graph would have, with the +change in it. If the compile fails, the old graph is left as it was. An +instrument does this for you: `connect()`, `disconnect()`, `add_module()` and +`remove_module()` take a `sample_position` and swap the new graph in at the +block it falls in. A swap costs the compile and little more: about 1.3 ms for +the Poly6 (99 nodes) and 6.7 ms for the PolyA6 (676) on a desktop CPython, +and 6 ms and 44 ms on the MicroPython unix port, with the handover itself +under 0.4 ms and 1.5 ms. An attached graph cannot be swapped yet. + +The fragments that exist: `PolyMidiCV` (poly and mono), `VoiceMix` (a mix with buses has none: see [polyphony.md](polyphony.md#buses)), the voice, LFO and output section of the Poly106 and of the Poly6, the voice and LFO of the Poly5, the voice, noise and output of the PolyA6, the voice and LFO of the Poly8, the voice, LFO and output of the PolyXa, the voice and LFO of the Mono101, the voice of the MonoSynth, the voice, LFO and output of the DuoSynth, and the Duo20, HybridMono, WestCoastSynth and ModalResonator modules. The diff --git a/lib/vrack/adapter.py b/lib/vrack/adapter.py index 6402589..33c9bfe 100644 --- a/lib/vrack/adapter.py +++ b/lib/vrack/adapter.py @@ -806,6 +806,47 @@ def deinit(self): self._output = None self._ring = None + # -- changing the patch while it plays ---------------------------------------------- + + def connect(self, source, dest, sample_position=0): + """Plug a cable from ``source`` to ``dest`` (jacks, or names such as + ``"vcf.cv"``) while the instrument plays. On a graph the patch is + compiled again beside the running one and swapped in at the block + ``sample_position`` falls in, with every voice, envelope and filter + carrying on where it was.""" + self._live(sample_position, self.rack.connect, source, dest) + + def disconnect(self, dest, sample_position=0): + """Pull the cable into ``dest`` (the jack, its name or the cable) while + the instrument plays, as ``connect()`` plugs one.""" + self._live(sample_position, self.rack.disconnect, dest) + + def add_module(self, module, sample_position=0): + """Install ``module`` in the rack while the instrument plays. Patch its + cables with ``connect()``; until then it sits unpatched. It runs in no + voice: it is always on, as a shared effect is.""" + self._live(sample_position, self.rack.add_module, module) + + def remove_module(self, module, sample_position=0): + """Take ``module`` and its cables out of the rack while the instrument plays.""" + self._live(sample_position, self.rack.remove_module, module) + + def _live(self, sample_position, change, *args): + self._check_live() + if self._attached: + raise RuntimeError( + 'an instrument with output="graph" cannot change its patch while it plays yet' + ) + self._at(_sample_position(sample_position), self._swap, change, args) + + def _swap(self, change, args): + """Change the rack, then compile it beside the running graph and swap that in.""" + change(*args) + if self._compiled is not None: + self._compiled = self._compiled.recompile( + groups=[voice.members for voice in self._pool.voices] + ) + # -- rendering ---------------------------------------------------------------------- def _run_events(self, frames): @@ -824,12 +865,12 @@ def _render(self, raw, views): int16 views per block, when the rack renders; the graph writes ``raw``).""" rack = self.rack size = self._block_size - compiled = self._compiled - if compiled is not None: + if self._compiled is not None: for k in range(self._blocks): if self._events: self._run_events(size) - compiled.step(raw, k) + # read each block: an event may have swapped in a recompiled graph + self._compiled.step(raw, k) return conv = self._conv mono = self._channel_count == 1 diff --git a/lib/vrack/core/graph.py b/lib/vrack/core/graph.py index f416626..56fd24b 100644 --- a/lib/vrack/core/graph.py +++ b/lib/vrack/core/graph.py @@ -226,6 +226,8 @@ def __init__(self, compiler, module, group): self.module = module self.group = group self.nodes = [] + self.kinds = [] # each node's kind, for a recompile to match nodes by + self.own = [] # the wires buffer() handed out, in order self.before = [] self.after = [] self.resets = [] @@ -258,7 +260,9 @@ def output(self, jack): def buffer(self): """A buffer of the module's own.""" - return self._c.graph.buffer() + buf = self._c.graph.buffer() + self.own.append(buf) + return buf def keep(self, buf): """Give wire ``buf`` a graph buffer of its own, for a module that reads it from Python @@ -283,6 +287,7 @@ def add(self, kind, inputs, output, arg=0.0, group=None): output, arg = output # the graph itself: the second output is the node's arg node = graph.add(kind, self.group if group is None else group, inputs, output, arg) self.nodes.append(node) + self.kinds.append(kind) return node def set(self, node, index, value): @@ -996,6 +1001,119 @@ def reset(self): finally: self.commit() + # -- changing the patch while it plays -------------------------------------------------- + + def recompile(self, groups=(), carry=True): + """The rack as it stands now, compiled beside this graph; returns the new ``CompiledRack``. + + Call it between two ``step()`` calls, after changing the patch (a cable + plugged or pulled, a module added or removed). The new graph takes this + one's running state (see ``take_state``), so the next ``step()`` on it + renders the block this one would have rendered, with the change in it. + This graph is dropped and must not be stepped again. ``groups`` are the + module groups, as for ``compile_rack``. If the compile fails, this graph + is left as it was and goes on playing. + """ + if self.pumped: + raise RuntimeError("an attached graph cannot be recompiled while something pulls it") + if self.graph is None: + raise RuntimeError("this graph has been dropped") + old = [(f.module, f) for f in self.fragments] + try: + new = CompiledRack( + self.rack, + groups=groups, + scale=self._scale0, + channel_count=self.channel_count, + ) + except BaseException: + # the failed compile bound the modules it reached: hand them back + for module in self.rack.modules: + module._fragment = None + try: + delattr(module, "reset") + except AttributeError: + pass + for module, f in old: + module._fragment = f + module.reset = f.do_reset + raise + if carry: + new.take_state(self) + now = set(f.module for f in new.fragments) + for module, _f in old: + if module not in now: # taken out of the rack: it gets its own reset() back + module._fragment = None + try: + delattr(module, "reset") + except AttributeError: + pass + graph = self.graph + self.graph = None + self.fragments = [] + if hasattr(graph, "deinit"): + graph.deinit() + return new + + def take_state(self, old): + """Take over the running state of ``old``, a graph of the same rack. + + Each module's nodes take the state of the old graph's nodes of the same + kind, in the order the module added them (``Graph.carry``): voices, + envelopes, phases, filter memory, delay lines. A node the module now + builds differently, or one it did not have, starts fresh. The wires + carry their last block across (a late cable's previous block, a + buffer a module keeps), controls keep their values, and a ``watch`` + does not fire again for a value it has seen. + """ + g, og = self.graph, old.graph + mine = {f.module: f for f in old.fragments} + for f in self.fragments: + o = mine.get(f.module) + if o is None: + continue + seen = {} + for kind, node in zip(o.kinds, o.nodes): + seen.setdefault(kind, []).append(node) + taken = {} + for kind, node in zip(f.kinds, f.nodes): + nodes = seen.get(kind) + i = taken.get(kind, 0) + if nodes is None or i >= len(nodes): + continue + taken[kind] = i + 1 + try: + g.carry(node, og, nodes[i]) + except ValueError: + pass # built differently (a delay of another length): fresh + for a, b in zip(o.own, f.own): + self._copy_wire(old, a, b) + for jack, buf in self.buffers.items(): + src = old.buffers.get(jack) + if src is not None: + self._copy_wire(old, src, buf) + for jack, control in self.controls.items(): + was = old.controls.get(jack) + if was is not None: + control.set(was.value) + if control.node is not None and was.node is not None: + g.carry(control.node, og, was.node) + g.carry(self.out_node, og, old.out_node) + lasts = {} + for jack, (_, _, last) in zip(old._watch_jacks, old._watch_buffers): + lasts[jack] = last[0] + for jack, (_, _, last) in zip(self._watch_jacks, self._watch_buffers): + last[0] = lasts.get(jack) + + def _copy_wire(self, old, src, dst): + """Copy the last block of ``old``'s wire ``src`` into this graph's wire ``dst``.""" + if old.phys is not None: + src = old.phys[src] + if self.phys is not None: + dst = self.phys[dst] + old.graph.read(src, self._i16) + self.graph.write(dst, self._i16) + def deinit(self): """Drop the graph; the modules get their own ``reset()`` back.""" for f in self.fragments: diff --git a/pyproject.toml b/pyproject.toml index ab2a1be..adc165e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ classifiers = [ keywords = ["instruments", "synthesizer", "eurorack", "audio", "dsp", "micropython", "midi"] dependencies = [ "numpy>=1.20", - "omnipython-audiodsp>=0.6.11.dev8", + "omnipython-audiodsp>=0.6.11.dev9", ] [project.optional-dependencies] diff --git a/tests/mpy/run.py b/tests/mpy/run.py index 6181958..e3c889d 100644 --- a/tests/mpy/run.py +++ b/tests/mpy/run.py @@ -35,6 +35,7 @@ "test_poly6", "test_graph_poly6", "test_graph_pump", + "test_graph_live_patch", "test_graph_mono_duo_a6", "test_graph_west_coast", "test_graph_modal_resonator", diff --git a/tests/mpy/test_graph_live_patch.py b/tests/mpy/test_graph_live_patch.py new file mode 100644 index 0000000..717d015 --- /dev/null +++ b/tests/mpy/test_graph_live_patch.py @@ -0,0 +1,57 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# SPDX-License-Identifier: MIT +"""A patch changed while it plays, on MicroPython: the checks of tools/live_patch.py. + +Needs a build whose ``audiograph`` has ``Graph.carry``; every test skips +(returns) without it. ``tests/test_graph_live_patch.py`` runs them on CPython. +""" + +import sys + +try: + import audiograph + + HAVE = hasattr(audiograph.Graph, "carry") +except ImportError: + HAVE = False + + +def _lp(): + if "." not in sys.path: + sys.path.insert(0, ".") + from tools import live_patch + + return live_patch + + +def test_a_cable_plugged_mid_phrase_renders_what_a_rack_built_with_it_renders(): + if not HAVE: + return + lp = _lp() + assert lp.first_difference(lp.cable(live=False), lp.cable(live=True), lp.SWAP) is None + + +def test_each_planted_fault_is_caught(): + if not HAVE: + return + lp = _lp() + reference = lp.cable(live=False) + for fault in lp.FAULTS: + got = lp.first_difference(reference, lp.cable(live=True, fault=fault), lp.SWAP) + assert got == lp.SWAP, (fault, got) + + +def test_a_swap_that_changes_nothing_renders_what_playing_on_renders(): + if not HAVE: + return + lp = _lp() + for voice in ("poly6", "duosynth", "mono101"): + assert lp.same(voice, swap=True) == lp.same(voice, swap=False), voice + assert lp.same("poly6", swap=True, fault="dropped") != lp.same("poly6", swap=False) + + +def test_a_change_at_a_sample_position_lands_on_its_block(): + if not HAVE: + return + lp = _lp() + assert lp.boundary(2) == lp.boundary(1) diff --git a/tests/test_graph_live_patch.py b/tests/test_graph_live_patch.py new file mode 100644 index 0000000..812ead2 --- /dev/null +++ b/tests/test_graph_live_patch.py @@ -0,0 +1,114 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# SPDX-License-Identifier: MIT +"""A patch changed while it plays: compiled beside the running graph, swapped in at a block. + +``tools/live_patch.py`` holds the racks and the renders, and its docstring +says what each comparison is; ``tests/mpy/test_graph_live_patch.py`` runs the +same checks on MicroPython. +""" + +import pytest + +ag = pytest.importorskip("audiograph") +if not hasattr(ag.Graph, "carry"): + pytest.skip("needs an audiograph with Graph.carry", allow_module_level=True) + +from tools import live_patch as lp # noqa: E402 + + +def test_a_cable_plugged_mid_phrase_renders_what_a_rack_built_with_it_renders(): + reference = lp.cable(live=False) + live = lp.cable(live=True) + assert len(live) == lp.BLOCKS + assert lp.first_difference(reference, live, lp.SWAP) is None + # the cable changes the sound: the comparison is not of two silences + assert lp.first_difference(lp.cable(live=False), _without_the_cable(), lp.SWAP) is not None + + +def _without_the_cable(): + rack, m = lp.build(cable=False) + compiled = lp.compile_rack(rack) + out = lp.array("h", bytes(4 * lp.BLOCK)) + blocks = [] + for b in range(lp.BLOCKS): + if b == lp.SWAP: + m["att"].set_param("gain", 0.75) + if b == lp.SWAP + 30: + m["osc"].set_param("hz", 165.0) + compiled.step(out, 0) + blocks.append(bytes(out)) + compiled.deinit() + return blocks + + +@pytest.mark.parametrize("fault", lp.FAULTS) +def test_each_planted_fault_is_caught(fault): + reference = lp.cable(live=False) + assert lp.first_difference(reference, lp.cable(live=True, fault=fault), lp.SWAP) == lp.SWAP + + +@pytest.mark.parametrize("voice", lp.VOICES) +def test_a_swap_that_changes_nothing_renders_what_playing_on_renders(voice): + assert lp.same(voice, swap=True) == lp.same(voice, swap=False) + + +def test_a_swap_that_drops_the_state_is_caught(): + assert lp.same("poly6", swap=True, fault="dropped") != lp.same("poly6", swap=False) + + +def test_a_change_at_a_sample_position_lands_on_its_block(): + one = lp.boundary(1) + assert lp.boundary(2) == one + left = lp.array("h", one)[0::2] + assert max(abs(v) for v in left[: 21 * 128]) > 1000 + assert max(abs(v) for v in left[21 * 128 :]) == 0 + + +def test_a_module_added_and_removed_while_it_plays(): + import vrack.adapter as adapter + + inst = adapter.create(48000, voice="poly6") + inst.note_on(60, 100) + out = bytearray() + lp._pull(inst, 4, out) + echo = lp.Echo("live_echo") + inst.add_module(echo) + inst.connect(inst.rack.master_left_in.cable.source, echo.inp) + inst.connect(echo.out, inst.rack.master_left_in) + assert any(f.module is echo for f in inst._compiled.fragments) + lp._pull(inst, 4, out) + inst.remove_module(echo) + assert echo._fragment is None and not any(f.module is echo for f in inst._compiled.fragments) + echo.set_param("gain", 0.5) # a module out of the rack reaches no graph + lp._pull(inst, 4, out) + inst.deinit() + + +def test_a_failed_recompile_leaves_the_running_graph_playing(): + rack, m = lp.build(cable=False) + compiled = lp.compile_rack(rack) + out = lp.array("h", bytes(4 * lp.BLOCK)) + compiled.step(out, 0) + fragment = m["vcf"]._fragment + keep = lp.Filter.fragment + + def broken(self, f): + raise MemoryError("no room") + + lp.Filter.fragment = broken + try: + with pytest.raises(MemoryError): + compiled.recompile() + finally: + lp.Filter.fragment = keep + assert m["vcf"]._fragment is fragment + compiled.step(out, 0) + compiled.deinit() + + +def test_an_attached_graph_refuses(): + rack, _ = lp.build(cable=False) + compiled = lp.compile_rack(rack) + compiled.attach() + with pytest.raises(RuntimeError): + compiled.recompile() diff --git a/tools/live_patch.py b/tools/live_patch.py new file mode 100644 index 0000000..2ce4c21 --- /dev/null +++ b/tools/live_patch.py @@ -0,0 +1,348 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# SPDX-License-Identifier: MIT +"""Change a patch while it plays, and check the swap, on any interpreter. + + micropython tools/live_patch.py # the checks, then what a swap costs + python tools/live_patch.py + +``tests/test_graph_live_patch.py`` and ``tests/mpy/test_graph_live_patch.py`` +run these checks on CPython and MicroPython. Each returns renders the caller +compares, so the same code proves the swap on both. + +``cable(live)`` is the honest comparison for a cable plugged mid-phrase. Both +racks are the same six modules: an oscillator and a feedback echo (its return +a late cable, so a wire carries its last block across the swap) through a +filter, and an LFO into an attenuator. The reference rack has the cable from +the attenuator to the filter's cutoff from the start, with the attenuator at +0 until block ``SWAP``, where it opens. The live rack has no such cable; at +block ``SWAP`` it plugs the cable, recompiles beside the running graph, and +opens the attenuator. Every module runs the same blocks on the same inputs +either way (an attenuator at 0 writes zeros, and the filter hears zeros or an +open cutoff input the same), so from ``SWAP`` on the two must render the +same samples. Neither render is made from the other's state. + +``same(voice, swap)`` plays a chord on a stock voice and recompiles mid-note +with nothing changed; it must render what playing on renders. + +The planted faults, for the checks to show they can fail: ``dropped`` swaps +in a graph that took no state, ``wires`` one that took the nodes' state but +not the wires' (the echo's late return starts silent), and ``late`` swaps +one block after the cable went in, off the block the change belongs to. +""" + +import sys + +if sys.argv and sys.argv[0].endswith("live_patch.py"): + # No os.path on MicroPython: the script's own path says where lib/ is. + _parts = sys.argv[0].split("/") + _root = "/".join(_parts[:-2]) or "." + if _root + "/lib" not in sys.path: + sys.path.insert(0, _root + "/lib") + +from array import array # noqa: E402 + +import audiograph as ag # noqa: E402 +from vrack.core import Module, Rack, SignalType, compile_rack # noqa: E402 +from vrack.core.graph import UNIT, CompiledRack # noqa: E402 + +try: + from time import ticks_diff, ticks_us + + def _now(): + return ticks_us() + + def _ms(start): + return ticks_diff(ticks_us(), start) / 1000.0 +except ImportError: + from time import perf_counter + + def _now(): + return perf_counter() + + def _ms(start): + return (perf_counter() - start) * 1000.0 + + +BLOCK = 64 +BLOCKS = 120 +SWAP = 40 +FAULTS = ("dropped", "wires", "late") +VOICES = ("poly6", "poly8", "polyxa", "polya6", "duosynth", "mono101") + + +class Osc(Module): + NAME = "LPOsc" + + def _build(self): + self.out = self.add_output("out", signal_type=SignalType.AUDIO) + self.add_param("hz", default=110.0, min_val=20.0, max_val=2000.0) + + def fragment(self, f): + node = f.add(ag.OSC, (None, None, None, None), f.output(self.out)) + f.set(node, ag.OSC_WAVEFORM, 1) + f.set(node, ag.OSC_LEVEL, 0.5) + f.bind(("hz",), lambda: f.set(node, ag.OSC_FREQUENCY, self.params["hz"])) + + +class Lfo(Module): + NAME = "LPLfo" + + def _build(self): + self.out = self.add_output("out", signal_type=SignalType.CV) + + def fragment(self, f): + node = f.add(ag.LFO, (), f.output(self.out)) + f.set(node, ag.LFO_SHAPE, ag.LFO_SINE) + f.set(node, ag.LFO_RATE, 3.0) + f.set(node, ag.LFO_AMPLITUDE, UNIT * 0.5) + + +class Att(Module): + NAME = "LPAtt" + + def _build(self): + self.inp = self.add_input("in", signal_type=SignalType.CV) + self.out = self.add_output("out", signal_type=SignalType.CV) + self.add_param("gain", default=0.0) + + def fragment(self, f): + node = f.add(ag.MIX, (f.input(self.inp),), f.output(self.out)) + f.bind(("gain",), lambda: f.set(node, 0, self.params["gain"])) + + +class Mix(Module): + NAME = "LPMix" + + def _build(self): + self.a = self.add_input("a", signal_type=SignalType.AUDIO) + self.b = self.add_input("b", signal_type=SignalType.AUDIO) + self.out = self.add_output("out", signal_type=SignalType.AUDIO) + + def fragment(self, f): + node = f.add(ag.MIX, (f.input(self.a), f.input(self.b)), f.output(self.out)) + f.set(node, 0, 1.0) + f.set(node, 1, 0.5) + + +class Filter(Module): + NAME = "LPFilter" + + def _build(self): + self.inp = self.add_input("in", signal_type=SignalType.AUDIO) + self.cv = self.add_input("cv", signal_type=SignalType.CV) + self.out = self.add_output("out", signal_type=SignalType.AUDIO) + + def fragment(self, f): + node = f.add(ag.LADDER, (f.input(self.inp), f.input(self.cv)), f.output(self.out)) + f.set(node, ag.LADDER_CUTOFF_HZ, 900.0) + f.set(node, ag.LADDER_RESONANCE, 0.5) + f.set(node, ag.LADDER_CUTOFF_OCTAVES, 2.0) + + +WOW = array("h", [(i if i < 32 else 63 - i) * 1024 - 16384 for i in range(64)]) + + +class Echo(Module): + NAME = "LPEcho" + + def _build(self): + self.inp = self.add_input("in", signal_type=SignalType.AUDIO) + self.out = self.add_output("out", signal_type=SignalType.AUDIO) + + def fragment(self, f): + node = f.add(ag.FBDELAY, (f.input(self.inp),), f.output(self.out), 40.0) + f.set(node, ag.DELAY_MS, 9.5) + f.set(node, ag.DELAY_FEEDBACK, 0.5) + f.set(node, ag.DELAY_MIX, 0.5) + f.set(node, ag.DELAY_WOW_HZ, 1.5) + f.set(node, ag.DELAY_WOW_DEPTH_MS, 1.0) + f.table(node, WOW) + + +def build(cable): + """The six-module rack; with ``cable``, the attenuator already drives the cutoff.""" + rack = Rack(sample_rate=48000, block_size=BLOCK) + m = {} + for name, cls in ( + ("osc", Osc), + ("lfo", Lfo), + ("att", Att), + ("mix", Mix), + ("vcf", Filter), + ("echo", Echo), + ): + m[name] = rack.add_module(cls(name)) + rack.connect(m["osc"].out, m["mix"].a) + rack.connect(m["mix"].out, m["vcf"].inp) + rack.connect(m["vcf"].out, m["echo"].inp) + rack.connect(m["echo"].out, m["mix"].b) # the echo's return: a late cable + rack.connect(m["echo"].out, rack.master_left_in) + rack.connect(m["vcf"].out, rack.master_right_in) + rack.connect(m["lfo"].out, m["att"].inp) + if cable: + rack.connect(m["att"].out, m["vcf"].cv) + return rack, m + + +def _swap(compiled, fault): + if fault == "dropped": + return compiled.recompile(carry=False) + if fault == "wires": + keep = CompiledRack._copy_wire + CompiledRack._copy_wire = lambda self, old, src, dst: None + try: + return compiled.recompile() + finally: + CompiledRack._copy_wire = keep + return compiled.recompile() + + +def cable(live, fault=None): + """The cable phrase's blocks, as bytes: plugged at ``SWAP`` (``live``) or there all along.""" + rack, m = build(cable=not live) + compiled = compile_rack(rack) + out = array("h", bytes(4 * BLOCK)) + blocks = [] + for b in range(BLOCKS): + if b == SWAP: + if live: + rack.connect(m["att"].out, m["vcf"].cv) + if fault == "late": + compiled.step(out, 0) # one more block on the old graph first + compiled = _swap(compiled, fault) + m["att"].set_param("gain", 0.75) + if b == SWAP + 30: + m["osc"].set_param("hz", 165.0) # a knob after the swap reaches the new graph + compiled.step(out, 0) + blocks.append(bytes(out)) + compiled.deinit() + return blocks + + +def _pull(inst, buffers, into): + import audiocore + + for _ in range(buffers): + inst.fill() + _, data = audiocore.get_buffer(inst.output) + into.extend(bytes(data)) + + +def _rendered(inst, buffers, into): + # The buffers as rendered, not as a ring hands them back: a ring build renders + # ahead, so a change between pulls would land some buffers after the pull. + for _ in range(buffers): + into.extend(bytes(inst._pull())) + + +def same(voice, swap, fault=None): + """A chord on ``voice`` with a recompile mid-note (``swap``) or none; the PCM as bytes.""" + import vrack.adapter as adapter + + inst = adapter.create(48000, voice=voice) + inst.program_change(1) + for note in (48, 60, 64, 67): + inst.note_on(note, 100) + out = bytearray() + _pull(inst, 12, out) + if swap: + inst._compiled = _swap(inst._compiled, fault) + _pull(inst, 8, out) + inst.note_off(60) + _pull(inst, 12, out) + inst.deinit() + return bytes(out) + + +def boundary(per_buffer): + """The master's left cable pulled mid-note, two blocks into the phrase's 21st block, + with ``per_buffer`` blocks a buffer; the PCM as bytes. One block a buffer pulls it + between buffers; two a buffer, by ``sample_position`` inside one.""" + import vrack.adapter as adapter + + inst = adapter.create(48000, voice="poly6", block_size=128, buffer_frames=128 * per_buffer) + for note in (48, 60, 64, 67): + inst.note_on(note, 100) + out = bytearray() + if per_buffer == 1: + _rendered(inst, 21, out) + inst.disconnect("master_l") + _rendered(inst, 19, out) + else: + _rendered(inst, 10, out) + inst.disconnect("master_l", sample_position=128) + _rendered(inst, 10, out) + inst.deinit() + return bytes(out) + + +def cost(voice): + """What a recompile beside a playing ``voice`` costs: (compile ms, carry ms, nodes).""" + import vrack.adapter as adapter + + inst = adapter.create(48000, voice=voice) + for note in (48, 60, 64, 67): + inst.note_on(note, 100) + out = bytearray() + _pull(inst, 4, out) + timed = [] + take = CompiledRack.take_state + + def timed_take(self, old): + start = _now() + take(self, old) + timed.append(_ms(start)) + + CompiledRack.take_state = timed_take + try: + start = _now() + inst._compiled = inst._compiled.recompile( + groups=[v.members for v in inst.voice_pool.voices] + ) + total = _ms(start) + finally: + CompiledRack.take_state = take + nodes = sum(len(f.nodes) for f in inst._compiled.fragments) + inst.deinit() + return total - timed[0], timed[0], nodes + + +def first_difference(a, b, start=0): + """The first block from ``start`` where two block lists differ, or None.""" + for i in range(start, min(len(a), len(b))): + if a[i] != b[i]: + return i + return None + + +def main(): + reference = cable(live=False) + live = cable(live=True) + print( + "cable: first differing block %s (swap at %d)" % (first_difference(reference, live), SWAP) + ) + print( + "cable: from the swap on %s" + % ("the same" if first_difference(reference, live, SWAP) is None else "DIFFERENT") + ) + for fault in FAULTS: + print( + "cable fault %s: first differing block %s" + % (fault, first_difference(reference, cable(live=True, fault=fault), SWAP)) + ) + for voice in VOICES: + print( + "same %s: %s" + % (voice, "the same" if same(voice, False) == same(voice, True) else "DIFFERENT") + ) + print("boundary: %s" % ("the same" if boundary(1) == boundary(2) else "DIFFERENT")) + for voice in VOICES: + compile_ms, carry_ms, nodes = cost(voice) + print( + "cost %s: %d nodes, compile %.2f ms, carry %.2f ms" + % (voice, nodes, compile_ms, carry_ms) + ) + + +if __name__ == "__main__": + main() From 754f29aa54b871c3fed5662723a571c16c4b31a6 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:46:24 -0500 Subject: [PATCH 2/3] Change the patch of a graph the audio pump plays --- ROADMAP.md | 3 - docs/architecture.md | 17 +- lib/vrack/adapter.py | 33 ++- lib/vrack/core/graph.py | 113 ++++++++-- tests/mpy/run.py | 1 + tests/mpy/test_graph_pump_swap.py | 58 ++++++ tests/test_graph_live_patch.py | 8 - tests/test_graph_pump_swap.py | 121 +++++++++++ tools/pump_swap.py | 335 ++++++++++++++++++++++++++++++ 9 files changed, 651 insertions(+), 38 deletions(-) create mode 100644 tests/mpy/test_graph_pump_swap.py create mode 100644 tests/test_graph_pump_swap.py create mode 100644 tools/pump_swap.py diff --git a/ROADMAP.md b/ROADMAP.md index 64f7565..35185e5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -34,9 +34,6 @@ Planned work, in rough order: - `examples/hmi_board.py` on the audio pump. It renders on the interpreter's thread, so a busy screen eats the audio's time; `examples/hmi_instrument.py --pump` plays the graph on the pump's core instead, and this one should too. -- Changing the patch of a graph on the audio pump. `recompile()` swaps a new graph - in between two `step()` calls; an attached graph needs the pump to switch graphs at - a block boundary, and refuses for now. - Notes on their own frame with the graph on the audio pump. A note played in a turn lands at the next block; with `audiograph` setting a value at a frame inside a block, `schedulable` can be True and a sequencer can time notes. diff --git a/docs/architecture.md b/docs/architecture.md index e4ef9d6..8583c8a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -185,7 +185,22 @@ instrument does this for you: `connect()`, `disconnect()`, `add_module()` and block it falls in. A swap costs the compile and little more: about 1.3 ms for the Poly6 (99 nodes) and 6.7 ms for the PolyA6 (676) on a desktop CPython, and 6 ms and 44 ms on the MicroPython unix port, with the handover itself -under 0.4 ms and 1.5 ms. An attached graph cannot be swapped yet. +under 0.4 ms and 1.5 ms. + +A graph the audio pump plays (`attach()`) changes the same way, and the +instrument's calls work unchanged; it needs an audiodsp with `Graph.swap`. +The new graph is compiled while the pump plays on. Then the pump is parked +at a block boundary, the state is carried, and the two graphs trade patches, +so the pump, output or mixer pulling the graph keeps the object it has and +hears the new patch from its next block. Everything an instrument changes in +one turn lands together, on the block after the turn, and `since` on the new +rack is the frame it took over at. The stream stays byte-equal to a rack +built with the change from that block on (`tools/pump_swap.py`). The pump +stands parked for 0.05 to 0.4 ms on a desktop CPython and 0.3 to 1.5 ms on +the MicroPython unix port, from the 26-node Mono101 to the 676-node PolyA6; +the compile, which it plays through, takes 0.5 to 7 ms and 1.6 to 54 ms. +A module that still runs in Python can't be added to an attached graph: the +change is refused and the old patch plays on. The fragments that exist: `PolyMidiCV` (poly and mono), `VoiceMix` (a mix with buses has none: see [polyphony.md](polyphony.md#buses)), the voice, LFO and output section of the Poly106 and of the Poly6, the voice and LFO of the diff --git a/lib/vrack/adapter.py b/lib/vrack/adapter.py index 33c9bfe..86878c0 100644 --- a/lib/vrack/adapter.py +++ b/lib/vrack/adapter.py @@ -415,6 +415,7 @@ def __init__( output = "ring" if _pull_is_native_only() else "pull" self._ring = None self._attached = False + self._due = False # a patch change waits for its recompile (attached) if output == "graph": if self._compiled is None: raise ValueError( @@ -813,7 +814,8 @@ def connect(self, source, dest, sample_position=0): ``"vcf.cv"``) while the instrument plays. On a graph the patch is compiled again beside the running one and swapped in at the block ``sample_position`` falls in, with every voice, envelope and filter - carrying on where it was.""" + carrying on where it was. On a graph the audio pump plays, every change + made in one turn lands together, on the block after the turn.""" self._live(sample_position, self.rack.connect, source, dest) def disconnect(self, dest, sample_position=0): @@ -833,19 +835,27 @@ def remove_module(self, module, sample_position=0): def _live(self, sample_position, change, *args): self._check_live() - if self._attached: - raise RuntimeError( - 'an instrument with output="graph" cannot change its patch while it plays yet' - ) self._at(_sample_position(sample_position), self._swap, change, args) + if self._due: + with self._lock: + self._recompile() def _swap(self, change, args): - """Change the rack, then compile it beside the running graph and swap that in.""" + """Change the rack, then compile it beside the running graph and swap that in. + On an attached graph the recompile waits for the end of the turn, so a + turn's changes land on one block whether or not the patch changed.""" change(*args) if self._compiled is not None: - self._compiled = self._compiled.recompile( - groups=[voice.members for voice in self._pool.voices] - ) + if self._attached: + self._due = True + else: + self._recompile() + + def _recompile(self): + self._due = False + self._compiled = self._compiled.recompile( + groups=[voice.members for voice in self._pool.voices] + ) # -- rendering ---------------------------------------------------------------------- @@ -922,7 +932,10 @@ def fill(self): self._check_live() if self._attached: with self._lock: - return self._compiled.control(self._run_events if self._events else None) + blocks = self._compiled.control(self._run_events if self._events else None) + if self._due: + self._recompile() + return blocks ring = self._ring if ring is None: return 0 diff --git a/lib/vrack/core/graph.py b/lib/vrack/core/graph.py index 56fd24b..36f0495 100644 --- a/lib/vrack/core/graph.py +++ b/lib/vrack/core/graph.py @@ -490,6 +490,7 @@ def __init__(self, rack, groups=(), scale=1.0, channel_count=2, graph=None): self._tmp = create_buffer(size) self._i16 = create_int16_buffer(size) self.pumped = False + self.since = 0 # the frame an attached graph took over at (recompile) self.elapsed = 1 self.rendered = 1 self._turn = set() @@ -866,6 +867,22 @@ def attach(self): """ if self.pumped: return self.graph + self._check_pullable() + g = self.graph + if not hasattr(g, "hold"): + raise RuntimeError("this audiograph cannot be pulled: it has no change queue") + _, frame, _, _ = g.sounding() + self._seen = frame + self._credited = frame + self._mask = None + self._touched = {} # group -> changes queued when its last note went in + self._depth = 0 + self.pumped = True + self.elapsed = 0 + return g + + def _check_pullable(self): + """Refuse a rack with a module that still runs in Python, naming it.""" for module in self.rack_side: raise ValueError( "%s has no graph fragment and would run in Python every block," @@ -886,18 +903,6 @@ def attach(self): "%s watches a signal block by block, which an attached graph cannot" " do; feed it from a control" % jack.qualified_name ) - g = self.graph - if not hasattr(g, "hold"): - raise RuntimeError("this audiograph cannot be pulled: it has no change queue") - _, frame, _, _ = g.sounding() - self._seen = frame - self._credited = frame - self._mask = None - self._touched = {} # group -> changes queued when its last note went in - self._depth = 0 - self.pumped = True - self.elapsed = 0 - return g def hold(self): """Start a batch: the graph applies everything until ``commit()`` on one block. @@ -1013,11 +1018,22 @@ def recompile(self, groups=(), carry=True): This graph is dropped and must not be stepped again. ``groups`` are the module groups, as for ``compile_rack``. If the compile fails, this graph is left as it was and goes on playing. + + On an attached graph, call it from the control side, between two of + its turns. The new graph is compiled while the old one plays; then the + pump is parked at a block boundary, the state is carried, and the two + graphs trade patches (``Graph.swap``), so whatever pulls the graph + goes on pulling the same object and hears the new patch from the next + block. The pump is parked only for the carry and the swap. A batch + held with ``hold()`` lands on the old graph's last block, as the + changes before the recompile would have, and the hold carries over to + the new graph. ``since`` on the new rack is the frame it took over at. + It needs an audiograph with ``Graph.swap``. """ - if self.pumped: - raise RuntimeError("an attached graph cannot be recompiled while something pulls it") if self.graph is None: raise RuntimeError("this graph has been dropped") + if self.pumped and not hasattr(self.graph, "swap"): + raise RuntimeError("an attached graph needs an audiograph with Graph.swap to change") old = [(f.module, f) for f in self.fragments] try: new = CompiledRack( @@ -1026,6 +1042,11 @@ def recompile(self, groups=(), carry=True): scale=self._scale0, channel_count=self.channel_count, ) + if self.pumped: + new._check_pullable() + self._hand_over(new, carry) + elif carry: + new.take_state(self) except BaseException: # the failed compile bound the modules it reached: hand them back for module in self.rack.modules: @@ -1038,8 +1059,6 @@ def recompile(self, groups=(), carry=True): module._fragment = f module.reset = f.do_reset raise - if carry: - new.take_state(self) now = set(f.module for f in new.fragments) for module, _f in old: if module not in now: # taken out of the rack: it gets its own reset() back @@ -1055,6 +1074,53 @@ def recompile(self, groups=(), carry=True): graph.deinit() return new + def _hand_over(self, new, carry): + """Put ``new`` in this attached graph's place on a block boundary (see ``recompile``). + + On success ``self.graph`` is the spare, holding the old patch, and + ``new.graph`` the object that is pulled. On a failure nothing has + changed: the old patch plays on and ``new`` is dropped. + """ + g = self.graph + depth = self._depth + if depth: + g.commit() # the held batch belongs to the old patch's last block + mask = 0 + for module in new.grouped: + if module.enabled: + mask |= module._fragment.bits + new.graph.enable(mask) + resume = _park() + try: + if carry: + new.take_state(self) + frame = g.sounding()[1] + g.swap(new.graph) + except BaseException: + if resume is not None: + resume() + if depth: + g.hold() + new.graph.deinit() + raise + if resume is not None: + resume() + spare = new.graph + new.graph = g + self.graph = spare + new.since = frame + new.pumped = True + new._seen = self._seen + new._credited = self._credited + new._mask = mask + new._touched = {} # the carry applied every queued change + new._turn = self._turn + new.elapsed = self.elapsed + new.rendered = self.rendered + new._depth = depth + if depth: + g.hold() # the rest of the caller's batch goes to the new patch + def take_state(self, old): """Take over the running state of ``old``, a graph of the same rack. @@ -1125,6 +1191,21 @@ def deinit(self): self.graph = None +def _park(): + """Park the audio pump at a block boundary, if one is running; returns what + resumes it, or None when nothing needed parking. Raises when the pump does + not park in time, so a change waits rather than racing a block.""" + try: + import audiopump + except ImportError: + return None + if not audiopump.running(): + return None + if not audiopump.park(): + raise RuntimeError("the audio pump did not park; the patch is unchanged") + return audiopump.unpark + + def compile_rack(rack, groups=(), scale=1.0, channel_count=2): """Compile ``rack`` into a ``CompiledRack``. diff --git a/tests/mpy/run.py b/tests/mpy/run.py index e3c889d..1f497b2 100644 --- a/tests/mpy/run.py +++ b/tests/mpy/run.py @@ -36,6 +36,7 @@ "test_graph_poly6", "test_graph_pump", "test_graph_live_patch", + "test_graph_pump_swap", "test_graph_mono_duo_a6", "test_graph_west_coast", "test_graph_modal_resonator", diff --git a/tests/mpy/test_graph_pump_swap.py b/tests/mpy/test_graph_pump_swap.py new file mode 100644 index 0000000..5a2dee5 --- /dev/null +++ b/tests/mpy/test_graph_pump_swap.py @@ -0,0 +1,58 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# SPDX-License-Identifier: MIT +"""A patch changed while the audio pump plays it, on MicroPython: tools/pump_swap.py's checks. + +Here the pump has a thread of its own, so it runs ahead of the control side +and the swap reports the block it landed on. Needs a build whose +``audiograph`` has ``Graph.swap``; every test skips (returns) without it. +``tests/test_graph_pump_swap.py`` runs them on CPython. +""" + +import sys + +try: + import audiograph + import audiopump # noqa: F401 + + HAVE = hasattr(audiograph.Graph, "swap") +except ImportError: + HAVE = False + + +def _ps(): + if "." not in sys.path: + sys.path.insert(0, ".") + from tools import pump_swap + + return pump_swap + + +def test_a_cable_plugged_while_the_pump_plays_renders_what_a_rack_built_with_it_renders(): + if not HAVE: + return + ps = _ps() + first, at, refused = ps.check() + assert refused is None, refused + assert at >= ps.AFTER, at + assert first is None, (first, at) + + +def test_each_planted_fault_is_caught(): + if not HAVE: + return + ps = _ps() + for fault in ("dropped", "late"): + first, at, refused = ps.check(fault) + assert refused is None, (fault, refused) + assert first == at, (fault, first, at) + first, _, refused = ps.check("unparked") + if audiopump.threaded(): + assert isinstance(refused, RuntimeError), refused + assert first is None, first + + +def test_the_pump_keeps_pulling_the_same_graph(): + if not HAVE: + return + nodes, compile_ms, held_ms, worst_ms = _ps().cost("poly6", swaps=2) + assert nodes > 0 and compile_ms > 0 and 0 <= held_ms <= worst_ms diff --git a/tests/test_graph_live_patch.py b/tests/test_graph_live_patch.py index 812ead2..f9ec4b1 100644 --- a/tests/test_graph_live_patch.py +++ b/tests/test_graph_live_patch.py @@ -104,11 +104,3 @@ def broken(self, f): assert m["vcf"]._fragment is fragment compiled.step(out, 0) compiled.deinit() - - -def test_an_attached_graph_refuses(): - rack, _ = lp.build(cable=False) - compiled = lp.compile_rack(rack) - compiled.attach() - with pytest.raises(RuntimeError): - compiled.recompile() diff --git a/tests/test_graph_pump_swap.py b/tests/test_graph_pump_swap.py new file mode 100644 index 0000000..ffb9369 --- /dev/null +++ b/tests/test_graph_pump_swap.py @@ -0,0 +1,121 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# SPDX-License-Identifier: MIT +"""A patch changed while the audio pump plays it: swapped in on a block boundary. + +``tools/pump_swap.py`` holds the phrase and the pump, and its docstring says +what each comparison is; ``tests/mpy/test_graph_pump_swap.py`` runs the same +checks on MicroPython, where the pump has a thread of its own. +""" + +import pytest + +ag = pytest.importorskip("audiograph") +audiopump = pytest.importorskip("audiopump") +if not hasattr(ag.Graph, "swap"): + pytest.skip("needs an audiograph with Graph.swap", allow_module_level=True) + +from tools import live_patch as lp # noqa: E402 +from tools import pump_swap as ps # noqa: E402 + + +def test_a_cable_plugged_while_the_pump_plays_renders_what_a_rack_built_with_it_renders(): + first, at, refused = ps.check() + assert refused is None + assert at >= ps.AFTER + assert first is None + # the cable changes the sound from that block: the comparison is not of two silences + assert lp.first_difference(ps.reference(at), ps.reference(0, cable=False)) == at + + +@pytest.mark.parametrize("fault", ["dropped", "late"]) +def test_each_planted_fault_is_caught(fault): + first, at, refused = ps.check(fault) + assert refused is None + assert first == at + + +def test_a_swap_without_parking_the_pump(): + first, _, refused = ps.check("unparked") + if audiopump.threaded(): + # carrying out of a block being rendered is refused; the old patch plays on, whole + assert isinstance(refused, RuntimeError) + else: + # the pump renders only inside service(): between two calls is a block boundary + assert refused is None + assert first is None + + +@pytest.mark.parametrize("voice", lp.VOICES) +def test_a_swap_that_changes_nothing_renders_what_playing_on_renders(voice): + if audiopump.threaded(): + pytest.skip("a threaded pump lands notes on blocks of its own choosing") + assert ps.same(voice, swap=True) == ps.same(voice, swap=False) + + +def test_the_pump_keeps_pulling_the_same_graph(): + nodes, compile_ms, held_ms, worst_ms = ps.cost("poly6", swaps=2) + assert nodes > 0 and compile_ms > 0 and 0 <= held_ms <= worst_ms + + +def test_an_instrument_patched_while_the_pump_plays(): + import vrack.adapter as adapter + + inst = adapter.create(48000, voice="poly6", output="graph") + pump = ps._Pump(inst.output, 0x7FFFFFFF) + try: + inst.note_on(60, 100) + for _ in range(4): + inst.fill() + pump.turn() + output = inst.output + echo = lp.Echo("live_echo") + inst.hold() + inst.add_module(echo) + inst.connect(inst.rack.master_left_in.cable.source, echo.inp) + inst.connect(echo.out, inst.rack.master_left_in) + inst.commit() + assert any(f.module is echo for f in inst._compiled.fragments) + for _ in range(4): + inst.fill() + pump.turn() + inst.remove_module(echo) + assert echo._fragment is None + for _ in range(4): + inst.fill() + pump.turn() + assert inst.output is output + assert inst._compiled.graph is output + assert len(pump.played) > 0 + finally: + audiopump.shutdown() + inst.deinit() + + +def test_a_module_that_runs_in_python_is_refused_and_the_patch_plays_on(): + from vrack.core import Module, SignalType + + class Slow(Module): + NAME = "PSSlow" + + def _build(self): + self.inp = self.add_input("in", signal_type=SignalType.AUDIO) + self.out = self.add_output("out", signal_type=SignalType.AUDIO) + + def process(self, size): + pass + + rack, m = lp.build(cable=False) + compiled = ps.compile_rack(rack) + pump = ps._Pump(compiled.attach(), 0x7FFFFFFF) + try: + pump.turn() + slow = rack.add_module(Slow("slow")) + rack.connect(m["vcf"].out, slow.inp) + with pytest.raises(ValueError): + compiled.recompile() + assert m["vcf"]._fragment in compiled.fragments + assert compiled.graph is not None + pump.turn() + finally: + audiopump.shutdown() + compiled.deinit() diff --git a/tools/pump_swap.py b/tools/pump_swap.py new file mode 100644 index 0000000..e4bcbda --- /dev/null +++ b/tools/pump_swap.py @@ -0,0 +1,335 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# SPDX-License-Identifier: MIT +"""Change a patch while the audio pump plays it, and check the swap, on any interpreter. + + micropython tools/pump_swap.py # the checks, then what a swap costs the pump + python tools/pump_swap.py + +``tests/test_graph_pump_swap.py`` and ``tests/mpy/test_graph_pump_swap.py`` +run these checks on CPython and MicroPython. + +``cable(fault)`` is ``tools/live_patch.py``'s cable phrase, played by the +pump: the rack without the cable is attached and handed to +``audiopump.spawn``, and while the pump plays it, the control side opens the +attenuator and plugs the cable in one held batch and recompiles. Where the +pump has a thread of its own it runs ahead of the control side, so nobody +picks the block the change lands on: the swap reports it (``since``). The +whole stream the pump played, every block of it, must then be what a rack +built with the cable renders when its attenuator opens at that block, and +the pump must have dropped nothing. + +The planted faults: ``dropped`` swaps in a graph that took no state; +``late`` lets the pump render one more block of the old patch after the +batch went in, so the change lands a block after the one it belongs to; +``unparked`` carries and swaps without parking the pump. Where the pump has +a thread, ``unparked`` would carry state out of a block being rendered, and +audiograph refuses the carry, so the patch must be left as it was and the +stream must be the old patch's, whole. Where the pump runs inside +``service()`` on the caller's own thread it renders nothing between two +calls, so a swap there cannot land inside a block, and ``unparked`` is no +fault. + +``cost(voice)`` times a recompile of a playing instrument while the pump +pulls it: the compile, which runs while the pump plays on, and the time the +pump stood parked for the carry and the swap. +""" + +import sys + +if sys.argv and sys.argv[0].endswith("pump_swap.py"): + _parts = sys.argv[0].split("/") + _root = "/".join(_parts[:-2]) or "." + for _p in (_root + "/lib", _root): + if _p not in sys.path: + sys.path.insert(0, _p) + +from array import array # noqa: E402 + +import audiopump # noqa: E402 +from vrack.core import compile_rack # noqa: E402 +from vrack.core import graph as graph_mod # noqa: E402 + +try: + from tools import live_patch as lp # noqa: E402 +except ImportError: + import live_patch as lp # noqa: E402 + +try: + from time import sleep_ms, ticks_diff, ticks_us + + def _now(): + return ticks_us() + + def _ms(start): + return ticks_diff(ticks_us(), start) / 1000.0 + + def _nap(): + sleep_ms(1) +except ImportError: + from time import perf_counter, sleep + + def _now(): + return perf_counter() + + def _ms(start): + return (perf_counter() - start) * 1000.0 + + def _nap(): + sleep(0.001) + + +BLOCK = lp.BLOCK +BLOCK_BYTES = BLOCK * 4 # stereo int16 +BLOCKS = 160 +AFTER = 40 # blocks the control side hears before it changes the patch +FAULTS = ("dropped", "late", "unparked") +RING = 16 * BLOCK_BYTES # small, so the pump's back-pressure keeps it near the control side +PER_TURN = 2 # blocks a turn lets the pump render where it has no thread +STATUS_RING_OVF = 10 + + +def _status(status): + return array("I", bytes(status)) + + +class _Pump: + """The pump playing ``sample``, and everything it has played so far.""" + + def __init__(self, sample, blocks, keep=True): + self.status = bytearray(audiopump.STATUS_BYTES) + self.ring = bytearray(RING) + self.scratch = bytearray(4096) + self.played = bytearray() + self.keep = keep # False: count what it played, keep none of it + self.count = 0 + self.threaded = audiopump.threaded() + audiopump.spawn(sample, blocks, self.status, ring=self.ring) + + def turn(self): + """What a player's tick does: let the pump run, take what it played.""" + if not self.threaded: + audiopump.service(PER_TURN) + while True: + n = audiopump.drain(self.scratch) + if not n: + break + self.count += n + if self.keep: + self.played.extend(self.scratch[:n]) + return self.count // BLOCK_BYTES + + def finish(self, blocks): + start = _now() + while self.turn() < blocks: + if _ms(start) > 20000: + raise RuntimeError("the pump stopped at block %d" % self.turn()) + _nap() + audiopump.shutdown() + return [ + bytes(self.played[i : i + BLOCK_BYTES]) + for i in range(0, blocks * BLOCK_BYTES, BLOCK_BYTES) + ] + + +def reference(at, cable=True): + """The phrase rendered by ``step()``: the cable there all along, the attenuator opening + at block ``at`` (``cable=False``: no cable, the attenuator never opens).""" + rack, m = lp.build(cable=cable) + compiled = compile_rack(rack) + out = array("h", bytes(BLOCK_BYTES)) + blocks = [] + for b in range(BLOCKS): + if cable and b == at: + m["att"].set_param("gain", 0.75) + compiled.step(out, 0) + blocks.append(bytes(out)) + compiled.deinit() + return blocks + + +def _late(pump): + """The planted ``late`` fault: one more block of the old patch before the swap.""" + hand_over = graph_mod.CompiledRack._hand_over + seen = [] + + def late(self, new, carry): + frame = self.graph.sounding()[1] + seen.append(frame) + start = _now() + while self.graph.sounding()[1] == frame: + pump.turn() + if _ms(start) > 5000: + raise RuntimeError("the pump rendered nothing") + if pump.threaded: + _nap() + hand_over(self, new, carry) + + graph_mod.CompiledRack._hand_over = late + return lambda: setattr(graph_mod.CompiledRack, "_hand_over", hand_over), seen + + +def cable(fault=None): + """The phrase played by the pump with the cable plugged after ``AFTER`` blocks. + + Returns ``(blocks, at, refused)``: every block the pump played, the block + the change belongs to, and the error a refused swap raised (or None). + """ + rack, m = lp.build(cable=False) + compiled = compile_rack(rack) + pump = _Pump(compiled.attach(), BLOCKS) + start = _now() + while pump.turn() < AFTER: + compiled.control() + if _ms(start) > 10000: + raise RuntimeError("the pump never played %d blocks" % AFTER) + _nap() + restore = None + seen = [] + keep_park = graph_mod._park + if fault == "late": + restore, seen = _late(pump) + elif fault == "unparked": + graph_mod._park = lambda: None + refused = None + try: + compiled.hold() + m["att"].set_param("gain", 0.75) + rack.connect(m["att"].out, m["vcf"].cv) + try: + compiled = compiled.recompile(carry=fault != "dropped") + except RuntimeError as e: + refused = e + rack.disconnect(m["vcf"].cv) + compiled.commit() + finally: + graph_mod._park = keep_park + if restore is not None: + restore() + at = seen[0] // BLOCK if seen else compiled.since // BLOCK + while pump.turn() < BLOCKS: + compiled.control() + _nap() + blocks = pump.finish(BLOCKS) + overflows = _status(pump.status)[STATUS_RING_OVF] + compiled.deinit() + if overflows: + raise RuntimeError("the pump dropped %d blocks" % overflows) + return blocks, at, refused + + +def check(fault=None): + """``cable(fault)`` against its reference; returns (first differing block, at, refused).""" + blocks, at, refused = cable(fault) + if refused is not None: + return lp.first_difference(reference(0, cable=False), blocks), at, refused + return lp.first_difference(reference(at), blocks), at, refused + + +def same(voice, swap): + """A chord on ``voice`` played by the pump, recompiled mid-note with nothing changed + (``swap``) or not; the PCM as bytes. Deterministic only where the pump has no + thread, so the notes land on the same blocks every time.""" + import vrack.adapter as adapter + + inst = adapter.create(48000, voice=voice, output="graph") + inst.program_change(1) + pump = _Pump(inst.output, 0x7FFFFFFF) + for note in (48, 60, 64, 67): + inst.note_on(note, 100) + for t in range(40): + if t == 20 and swap: + with inst._lock: + inst._recompile() + if t == 30: + inst.note_off(60) + inst.fill() + pump.turn() + audiopump.shutdown() + inst.deinit() + return bytes(pump.played) + + +def cost(voice, swaps=8): + """A chord on ``voice`` played by the pump, recompiled ``swaps`` times with nothing changed. + + Returns ``(nodes, compile ms, held ms, worst held ms)``. The compile + runs while the pump plays on. Held is the time from asking the pump to + park to letting it go, which is how long it could not render: the wait + for the block in hand, the carry and the swap. (The pump's own parked + count in ``status`` is no measure of it: it also counts the time the pump + waits for room in a full ring.) + """ + import vrack.adapter as adapter + + inst = adapter.create(48000, voice=voice, output="graph") + for note in (48, 60, 64, 67): + inst.note_on(note, 100) + pump = _Pump(inst.output, 0x7FFFFFFF, keep=False) + for _ in range(10): + inst.fill() + pump.turn() + _nap() + held = [] + park = graph_mod._park + + def timed_park(): + start = _now() + resume = park() + + def timed_resume(): + if resume is not None: + resume() + held.append(_ms(start)) + + return timed_resume + + total = 0.0 + output = inst.output + graph_mod._park = timed_park + try: + for _ in range(swaps): + start = _now() + with inst._lock: + inst._recompile() + total += _ms(start) + for _ in range(3): + inst.fill() + pump.turn() + _nap() + finally: + graph_mod._park = park + assert inst.output is output, "the pump's sample changed" + nodes = sum(len(f.nodes) for f in inst._compiled.fragments) + played = pump.count + audiopump.shutdown() + overflows = _status(pump.status)[STATUS_RING_OVF] + inst.deinit() + if overflows: + raise RuntimeError("the pump dropped %d blocks" % overflows) + if not played: + raise RuntimeError("the pump played nothing") + held_ms = sum(held) / swaps + return nodes, total / swaps - held_ms, held_ms, max(held) + + +def main(): + first, at, _ = check() + print("cable: swap at block %d, first differing block %s" % (at, first)) + unchanged = lp.first_difference(reference(at), reference(0, cable=False)) + print("cable: the change is heard from block %s" % unchanged) + for fault in FAULTS: + first, at, refused = check(fault) + print( + "cable fault %s: at %d, first differing block %s%s" + % (fault, at, first, " (refused: %s)" % refused if refused else "") + ) + for voice in lp.VOICES: + nodes, compile_ms, held_ms, worst_ms = cost(voice) + print( + "cost %s: %d nodes, compile %.2f ms, pump held %.3f ms (worst %.3f)" + % (voice, nodes, compile_ms, held_ms, worst_ms) + ) + + +if __name__ == "__main__": + main() From 2a1a1531ef2d6ee720e8a1b1be2475946dd5df25 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:54:29 -0500 Subject: [PATCH 3/3] The pump swap needs audiodsp 0.6.11.dev10, the first release with Graph.swap --- .github/workflows/ci.yml | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 950c567..d52460d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,7 +57,7 @@ jobs: run: | python -m pip install -q -i https://test.pypi.org/simple/ \ --extra-index-url https://pypi.org/simple/ \ - "omnipython-audiodsp>=0.6.11.dev9" numpy pytest ruff==0.17.1 + "omnipython-audiodsp>=0.6.11.dev10" numpy pytest ruff==0.17.1 python -m pip list 2>/dev/null | grep -iE "^(omnipython-audiodsp|numpy|pytest|ruff) " - name: Lint diff --git a/pyproject.toml b/pyproject.toml index adc165e..c5e2c6d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ classifiers = [ keywords = ["instruments", "synthesizer", "eurorack", "audio", "dsp", "micropython", "midi"] dependencies = [ "numpy>=1.20", - "omnipython-audiodsp>=0.6.11.dev9", + "omnipython-audiodsp>=0.6.11.dev10", ] [project.optional-dependencies]