Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 0 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 16 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 23 additions & 10 deletions lib/vrack/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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):
Expand All @@ -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 ----------------------------------------------------------------------

Expand Down Expand Up @@ -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
Expand Down
113 changes: 97 additions & 16 deletions lib/vrack/core/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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,"
Expand All @@ -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.
Expand Down Expand Up @@ -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(
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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.

Expand Down Expand Up @@ -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``.

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
1 change: 1 addition & 0 deletions tests/mpy/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
58 changes: 58 additions & 0 deletions tests/mpy/test_graph_pump_swap.py
Original file line number Diff line number Diff line change
@@ -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
8 changes: 0 additions & 8 deletions tests/test_graph_live_patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading
Loading