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/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/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] 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()