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. ~sine evaluates one sample per audio frame.

  • . prefix — control-rate (event) object. .r (receive) fires only when you push data into it with Patcher.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, and PHandle.isDspInput() describe the object’s I/O surface. PHandle.outputDataType() returns an OutType enum 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(), and PHandle.connectionTargetInlet() return the wiring graph outward from each outlet so you can serialise it yourself or check what is connected to what. Both return null when there is no such connection, and PHandle.id is null for a handle that refers to no object. IDs start at 0 in every patcher and the smallest free number is reused after a delete, so 0 is a real object, never “none”.

  • Send messages directly to a specific inlet — bypassing the named-receiver layer. PHandle.sendBang(), PHandle.sendInt(), PHandle.sendFloat(), and PHandle.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(), and PHandle.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. dumpJson numbers 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 (or Patcher(name: ...)) is written as a top-level "name" key and applied by parseJson to a patcher that still has its auto-name; a name you already set wins. The name is what puts the patcher’s .s / .r slots on the bus as patcher.<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() and Patcher.disconnect() on a subpatcher look up the boundary object that claims the pin and record the edge straight to it, so walking the graph (or Patcher.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.outputs answer 0. PHandle.subpatcherInlets / PHandle.subpatcherOutlets report one past the highest index claimed on that side.

  • Containment reads back. PHandle.container is the subpatcher an object is directly inside, or null at the top level. p.setContainer(obj, null) moves it back out. Subpatchers nest.

  • Illegal moves throw. A container that is not a patcher object, or a move that would put a subpatcher inside itself or one of its descendants, throws YseException and changes nothing. A handle from another Patcher throws ArgumentError.

  • Deleting is transitive. Patcher.deleteObject() on a subpatcher deletes everything inside it, nested subpatchers included. Any PHandle you 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 / .r inside a subpatcher use the whole patcher’s Patcher.name, like everything else.

  • Saving works unchanged. Patcher.dumpJson() writes each object’s container, and Patcher.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:

  1. A PHandle is owned by its Patcher. Don’t try to dispose it directly — call Patcher.deleteObject() or let Patcher.clear() / Patcher.dispose() handle it.

  2. A Sound created 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.

  3. Patcher is a Finalizable, so a dropped reference eventually frees the native graph — but for predictable timing (especially when the sound that wraps it is also being disposed), call Patcher.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 plus Patcher.passFloat() / Patcher.passInt() etc. to drive the graph from application code, or PHandle.sendFloat() etc. to address one specific inlet.

  • PHandle exposes every piece of structural and runtime state for an object — type, connections, GUI properties.

  • Patcher.dumpJson() and Patcher.parseJson() round- trip the whole graph — ship patches as plain text or build an external editor on top.

Next#

  • Patcher — the full Patcher and PHandle class reference.

  • Patcher object reference — every patcher object, with inlets, outlets, parameters, and value ranges.

  • Tutorials — index of remaining tutorials.