Patcher: modular synthesis in code#
Goal: build a small synthesis graph programmatically, drive its parameters at runtime, then save and reload the graph as JSON.
This tutorial walks through example/demo13_patcher.dart line by
line. By the end you will have a sine oscillator with a named control
that re-tunes it from Dart code, plus a JSON round trip that can
rebuild the same graph from a string.
Source: example/demo13_patcher.dart.
If you have read the upstream libYSE 05_patcher tutorial that
covers Demo13_Patcher.cpp and Demo14_LoadPatcher.cpp, the
underlying graph is the same — only the call site moves from C++ to
Dart.
Mental model#
A Patcher is a graph of small objects. Each object has zero
or more inlets (left side, accepting data) and zero or more
outlets (right side, emitting data). You wire them together with
Patcher.connect(); data flows from outlets into inlets.
Object type strings follow a two-character convention that mirrors Max/MSP:
~prefix — audio-rate (DSP) object.~sineevaluates one sample per audio frame..prefix — control-rate (event) object..r(receive) fires only when you push data into it withPatcher.passFloat()/Patcher.passInt()/Patcher.passString()/Patcher.passBang().
The full list of supported types is exposed as constants on the
Obj class — pass any of them to
Patcher.createObject() in place of raw string literals.
Every object’s accepted inlets, outlets, parameters, and message
types are listed on the Patcher object reference reference page,
generated directly from the engine source so it can never drift from
what createObject actually accepts.
Creating a patcher and attaching it to a sound#
A patcher is just an object; you initialise it with the number of
audio outputs it should expose, then hand it to
Sound.fromPatcher() so the engine drives the graph as a
sound source:
final p = Patcher(mainOutputs: 1); // 1 = mono; 2 = stereo
// ... build the graph here ...
final sound = Sound.fromPatcher(p, volume: 0.3);
sound.play();
The first argument to Sound.fromPatcher() is the
Patcher; the optional channel argument routes through a
specific Channel (default: master mix). The Sound holds a
borrowed reference to the patcher — keep the Patcher instance
alive as long as the Sound is alive.
Building a graph#
Patcher.createObject() returns a PHandle — an owned
pointer into the patcher’s object list. There are two ways to
identify a type: the literal string ('~sine') or the corresponding
constant on Obj (Obj.dSine). Both are
interchangeable — Obj.dSine literally is the string
'~sine' at runtime. Use whichever style reads better at the call
site.
final sine = p.createObject(Obj.dSine, args: '220');
final dac = p.createObject(Obj.dDac);
final recv = p.createObject(Obj.gReceive, args: 'freq');
The optional args parameter to Patcher.createObject() is
the object’s creation argument string, parsed by the object exactly
as it would in a saved patch. ~sine with '220' starts
oscillating at 220 Hz; .r with 'freq' registers the receive
under the name "freq"; an empty args leaves the object at its
default configuration.
Connecting objects is the next step. Patcher.connect() wires
one outlet to one inlet — both are zero-indexed:
p.connect(sine, outlet: 0, to: dac, inlet: 0); // audio out
p.connect(recv, outlet: 0, to: sine, inlet: 0); // freq control
The signal flow in this graph is: ~sine (initially 220 Hz, but
overridable through the "freq" receive) → ~dac (hands the
buffer back to the engine).
Driving parameters at runtime#
The .r (receive) objects are the patcher’s external interface.
Each one has a name; calling
Patcher.passFloat() /
Patcher.passInt() /
Patcher.passString() /
Patcher.passBang() on the patcher delivers the value to
every .r registered under that name.
final ok = p.passFloat(440, 'freq'); // change pitch to A4
if (!ok) print('No receiver named "freq" — graph not wired correctly');
Return value of every pass* method is false when no receiver
was registered under that name. The four methods cover the four data
types the engine knows about:
Patcher.passFloat()for continuous controls (frequency, gain).Patcher.passInt()for discrete steps (MIDI note numbers, selector indices).Patcher.passString()for symbolic values (a preset name, an arbitrary tag the graph dispatches on).Patcher.passBang()for bare triggers with no payload (fire an envelope, advance a counter).
Driving an object directly via PHandle#
The PHandle returned by Patcher.createObject() is
not just a token — it exposes the object’s full state to Dart. Use
this to:
Read structural information.
PHandle.type,PHandle.inputs,PHandle.outputs, andPHandle.isDspInput()describe the object’s I/O surface.PHandle.outputDataType()returns anOutTypeenum value (bang,float,integer,buffer,list,any) describing what a given outlet emits — useful when you build an external patcher editor on top of the engine.Walk connections.
PHandle.connectionCount(),PHandle.connectionTargetId(), andPHandle.connectionTargetInlet()return the wiring graph outward from each outlet so you can serialise it yourself or check what is connected to what. Both returnnullwhen there is no such connection, andPHandle.idisnullfor a handle that refers to no object. IDs start at 0 in every patcher and the smallest free number is reused after a delete, so0is a real object, never “none”.Send messages directly to a specific inlet — bypassing the named-receiver layer.
PHandle.sendBang(),PHandle.sendInt(),PHandle.sendFloat(), andPHandle.sendList()are the same API a connected outlet would call.Reconfigure an object after the fact.
PHandle.setParams()re-runs the object’s creation parser on a new argument string — equivalent to deleting and recreating it but with the connections preserved.Bridge a GUI.
PHandle.guiValue(see the “GUI controls” section of Patcher),PHandle.getGuiProperty(), andPHandle.setGuiProperty()expose the per-object metadata an external editor reads and writes back.
In short: Patcher.pass* is the convenient broadcast path through
named receives, and PHandle.send* is the targeted path to one
specific inlet.
Enumerating the graph#
You can ask the patcher how many objects it contains and fetch a handle to each one by index or by ID:
for (var i = 0; i < p.objects; i++) {
final h = p.getHandleAt(i);
print('${h.id}: ${h.type} (${h.inputs} in, ${h.outputs} out)');
}
This is the entry point for tooling that walks an existing graph — visualisers, exporters, automated tests that assert on object counts.
Persistence#
The graph — every object, parameter, and connection — can be
serialised to JSON with Patcher.dumpJson(). The result is
plain UTF-8 text, suitable for writing to disk or shipping as a
preset:
final json = p.dumpJson();
File('mypatch.yap').writeAsStringSync(json);
Reloading is the inverse — read the file, hand the contents to
Patcher.parseJson(), and the patcher rebuilds itself in
place:
final p2 = Patcher(mainOutputs: 1);
p2.parseJson(File('mypatch.yap').readAsStringSync());
Patcher.parseJson() adds to the current graph — objects
already in the patcher are kept, so call Patcher.clear()
first to replace the patch. Malformed JSON throws
YseException, and a load that fails part-way removes the
objects it had already created, so the patcher is left as it was. After
loading, re-send the initial control values so the receives have
something to forward.
A few things about the file itself:
Object IDs are per patcher.
dumpJsonnumbers the objects from 0 in ID order, so two identical patches save identically. A loaded patch is renumbered on its next save; if you keep object IDs outside the file, look them up again after loading.The name is saved. A name set with
Patcher.name(orPatcher(name: ...)) is written as a top-level"name"key and applied byparseJsonto a patcher that still has its auto-name; a name you already set wins. The name is what puts the patcher’s.s/.rslots on the bus aspatcher.<name>.<slot>(see Bus).GUI properties ride along. Whatever an editor stored with
PHandle.setGuiProperty()is saved under each object’s"gui"key.
The round trip — patch built once in code, saved to a string, rebuilt from that string with no code-side knowledge of its internal structure — is what makes external editors possible. Build the patch in a desktop tool, ship the resulting JSON as an asset, deserialise it at runtime.
Subpatchers#
A subpatcher groups part of a graph into one object. The parent
connects to the group by pin number, like any other object, and does
not need to know what is inside. It is an object of type
Obj.patcher — not a separate Patcher. Create
its contents with Patcher.createObject() on the same patcher,
then move them inside with Patcher.setContainer().
The pins of a subpatcher are the boundary objects among its direct contents. Each takes one argument, its index (default 0):
.inlet/.outlet(Obj.gInlet/Obj.gOutlet) carry messages.~inlet/~outlet(Obj.dInlet/Obj.dOutlet) carry audio.
Messages and signals share one index space per side, so a
.inlet 0 next to a ~inlet 1 gives the subpatcher two inlets.
Give every boundary object its own number.
This builds a half-gain stage and plays a sine through it:
// ~sine -> [ ~inlet 0 -> ~* 0.5 -> ~outlet 0 ] -> ~dac
final p = Patcher();
final sub = p.createObject(Obj.patcher);
final inlet = p.createObject(Obj.dInlet, args: '0');
final gain = p.createObject(Obj.dMultiply, args: '0.5');
final outlet = p.createObject(Obj.dOutlet, args: '0');
for (final h in [inlet, gain, outlet]) {
p.setContainer(h, sub);
}
p.connect(inlet, outlet: 0, to: gain, inlet: 0);
p.connect(gain, outlet: 0, to: outlet, inlet: 0);
// The parent wires the group by pin number only.
final sine = p.createObject(Obj.dSine, args: '440');
final dac = p.createObject(Obj.dDac);
p.connect(sine, outlet: 0, to: sub, inlet: 0);
p.connect(sub, outlet: 0, to: dac, inlet: 0);
Things to know:
Place objects before you connect. Moving an object changes no connection.
Patcher.connect()andPatcher.disconnect()on a subpatcher look up the boundary object that claims the pin and record the edge straight to it, so walking the graph (orPatcher.dumpJson()) reports the boundary object’s ID, not the subpatcher’s.Ask the subpatcher for its shape. A subpatcher owns no pins of its own, so
PHandle.inputs/PHandle.outputsanswer 0.PHandle.subpatcherInlets/PHandle.subpatcherOutletsreport one past the highest index claimed on that side.Containment reads back.
PHandle.containeris the subpatcher an object is directly inside, ornullat the top level.p.setContainer(obj, null)moves it back out. Subpatchers nest.Illegal moves throw. A container that is not a
patcherobject, or a move that would put a subpatcher inside itself or one of its descendants, throwsYseExceptionand changes nothing. A handle from anotherPatcherthrowsArgumentError.Deleting is transitive.
Patcher.deleteObject()on a subpatcher deletes everything inside it, nested subpatchers included. AnyPHandleyou held that pointed inside is dangling afterwards.It costs nothing to nest. Every object stays in the patcher’s one flat graph, so the audio thread’s work does not grow with nesting depth.
Names are not local.
.s/.rinside a subpatcher use the whole patcher’sPatcher.name, like everything else.Saving works unchanged.
Patcher.dumpJson()writes each object’s container, andPatcher.parseJson()restores the same nesting.
Upstream’s patcher subpatchers guide covers the same model from the C++ side.
Lifetime and cleanup#
Three rules cover the patcher’s memory model:
A
PHandleis owned by itsPatcher. Don’t try todisposeit directly — callPatcher.deleteObject()or letPatcher.clear()/Patcher.dispose()handle it.A
Soundcreated from a patcher (Sound.fromPatcher()) holds a borrowed pointer to that patcher’s native handle. The patcher must outlive the sound. The sound keeps a Dart reference to it, so dropping your own reference is safe; disposing the patcher before the sound is not.Patcheris aFinalizable, so a dropped reference eventually frees the native graph — but for predictable timing (especially when the sound that wraps it is also being disposed), callPatcher.dispose()explicitly when you’re done.
Where to find the full object reference#
The full set of registered object types — every inlet, outlet,
parameter, and accepted message type — is on the
Patcher object reference reference, one page per category.
Those pages are generated from the registry of the engine this package
binds (a JSON snapshot that a test keeps in step with the registry), so
they list exactly what Patcher.createObject() accepts.
What you learned#
A patcher is a node graph. Objects have inlets and outlets; data flows along
Patcher.connect()edges.~types run at audio rate,.types fire on events.Use
.r(receive) objects plusPatcher.passFloat()/Patcher.passInt()etc. to drive the graph from application code, orPHandle.sendFloat()etc. to address one specific inlet.PHandleexposes every piece of structural and runtime state for an object — type, connections, GUI properties.Patcher.dumpJson()andPatcher.parseJson()round- trip the whole graph — ship patches as plain text or build an external editor on top.
Next#
Patcher — the full
PatcherandPHandleclass reference.Patcher object reference — every patcher object, with inlets, outlets, parameters, and value ranges.
Tutorials — index of remaining tutorials.