The logical side: groups as signal graphs
Every group so far has been concrete: a relation in time between
contents placed in time. The other kind is logical: the members relate by
processing — a signal chain wired through buses — and time has nothing to do
with it. Same Group, different kind, and a different render: a logical
group does not flatten into a timeline, it translates into a GraphDef — the
server's own notion of a named configuration of nodes wired by buses.
Two defs that wire
A member of a signal chain reads and writes buses through its own controls —
by convention named in and out:
from clausters.defs import SynthDef, control, in_, out, sin_osc
freq, out_bus = control("freq", 440.0), control("out", 0.0)
tone = SynthDef("tone", out(out_bus, sin_osc(freq) * 0.15))
in_bus, level = control("in", 0.0), control("level", 0.4)
gain = SynthDef("gain", out(0.0, in_(in_bus) * level),
out(1.0, in_(in_bus) * level))
server.add_synthdef(tone)
server.add_synthdef(gain)
tone writes a sine onto whatever bus its out control names; gain reads
its in bus, scales it, and puts it on the hardware outputs. Neither def
knows the other exists — the wiring is the group's business.
The logical group
from clausters.form import Generator, Group
chain = Group(kind="logical", name="chain", buses=["mix"])
chain.add(Generator("tone", controls={"out": "mix", "freq": 220.0}))
chain.add(Generator("tone", controls={"out": "mix", "freq": 331.0}))
chain.add(Generator("gain", controls={"in": "mix"}))
The members are Generator elements — the Function kind, wrapping a def by
name. A control's value is a number (set at creation), the name of one of the
group's buses ("mix", a private internal audio bus each instance
allocates for itself), or the reserved "OUT" (the hardware). Placement
offsets exist but are ignored here: a logical group is a signal graph, not
a timeline.
The translation is pure — inspect it before it goes anywhere:
import json
print(json.dumps(chain.to_graphdef().spec(), indent=2))
Two tones summed on mix, one gain stage reading it: the 1:1 mapping of the
group onto a GraphDef. Realize it — for a logical group that means
send and instance, not flatten and play:
inst = chain.render(server) # /d_graph, then /graph_new — it sounds now
A fifth and its gain stage, sounding continuously — a graph instance is a running configuration, not a scheduled event. It lives until you free it:
server.free(inst) # the instance group and its private buses
One boundary, stated plainly: a logical group is rendered on its own. It
is not placed inside the concrete song and flattened with it — the two
kinds answer different questions (what sounds when versus what is wired to
what), and render routes each to its own path.
The patcher
A logical group has a view too, and it is not a lane — its shape is not time. Open an editor on the chain itself:
patcher = Editor(chain, sample_rate=SR, tempo=TEMPO, title="chain")
pwin = patcher.open(gui)
A patch: the member boxes down one side (each with its wirable controls as
ports), the bus nodes down the other (mix, and OUT if a member names it),
and one wire per (member, control) ↔ bus connection.
Notice the patch is undirected — no arrows. That is deliberate honesty: a
GraphDef records that a control names a bus; which end writes and which
reads is the server's own analysis of the running graph, not something the
data knows. The view shows the connection and leaves the direction to the
engine, so it can never lie about signal flow.
Rewire it
The wires are live, and the rhythm is the one you know:
- drag a port onto a bus — that control now names that bus;
- drag a port onto empty space — unwired.
Re-instance the chain so you can hear the difference, then unplug the gain
stage's input on screen (drag its in port to empty space), and:
inst = chain.render(server)
patcher.poll() # the edit lands on the group
print(chain.members[2][2].controls) # {} — 'in' no longer names a bus
The edit rewrote the member Generator's controls — the data again, nothing
else. And exactly as with a moved clip, what is running does not rewire
itself; the next render sends the graph as drawn:
server.free(inst)
inst = chain.render(server) # silent: the gain stage reads nothing
Wire in back to mix on screen, then:
patcher.poll()
server.free(inst)
inst = chain.render(server) # and it sounds again, wired as drawn
Clean up the demo:
server.free(inst)
patcher.poll() # drain anything left
gui.close(pwin)
The piece itself never needed the logical side — but a real composition grows
one the moment two nodes share a bus: a send, a master chain, a layered
instrument. It is the same Group, the same five primitives, and the same
loop: build in code, see it drawn, edit either side, render.