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 Demo13_Patcher and Demo14_LoadPatcher
in tandem. By the end you will have a sine oscillator modulated by a
low-frequency oscillator, with three named controls for note, LFO rate,
and volume — and a JSON file you can reload from disk to restore the
patch verbatim.
Source: Demo13_Patcher.cpp and Demo14_LoadPatcher.cpp.
Mental model#
A YSE::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
Connect(from, outletIndex, to, inletIndex); 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 withPassData.
The full list of types lives in YseEngine/patcher/pObjectList.hpp
as YSE::OBJ::* constants, and is rendered in the
Patcher object reference reference page.
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::create so the
engine drives the graph as a sound source:
patcher.create(1);
sound.create(patcher);
sound.play();
The first argument to patcher::create is the number of main outputs.
1 is mono; pass 2 for stereo. sound::create(patcher, channel,
volume) takes optional channel and volume arguments; omit them
to attach to MainMix at full volume.
Building a graph#
CreateObject returns a YSE::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 YSE::OBJ
constant. Both are interchangeable; the demo mixes them to show this:
sine = patcher.CreateObject("~sine");
lfo = patcher.CreateObject(OBJ::D_SINE);
mtof = patcher.CreateObject(OBJ::MIDITOFREQUENCY);
volume = patcher.CreateObject("~*");
controlPitch = patcher.CreateObject(".r", "pitch");
controlVolume = patcher.CreateObject(".r", "volume");
controlLFO = patcher.CreateObject(".r", "lfo");
pHandle * multiplier = patcher.CreateObject(OBJ::D_MULTIPLY);
pHandle * dac = patcher.CreateObject(OBJ::D_DAC);
pHandle * line = patcher.CreateObject(OBJ::D_LINE);
line->SetParams("0 100");
The optional second argument to CreateObject is the object’s
creation arguments, parsed by the object exactly as it would in a saved
patch. The ~sine oscillator above starts silent (no frequency given);
.r takes the receive name ("pitch"); D_LINE is configured by
calling SetParams("0 100") after creation (start at 0, ramp over
100 ms).
Connecting objects is the next step. Connect(from, outletIndex, to,
inletIndex) wires one outlet to one inlet — both are zero-indexed:
patcher.Connect(mtof, 0, line, 0);
patcher.Connect(line, 0, sine, 0);
patcher.Connect(sine, 0, multiplier, 0);
patcher.Connect(lfo, 0, multiplier, 1);
patcher.Connect(multiplier, 0, volume, 0);
patcher.Connect(volume, 0, dac, 0);
The signal flow is: MIDI note → mtof → line (smoothed) →
~sine frequency. The sine output is multiplied by the LFO, then
scaled by the master ~* volume, and finally sent to D_DAC which
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 PassData(value, name) on the patcher delivers
value to every .r registered under that name. To use a receive,
connect its outlet to the inlet of whatever should react to the value:
patcher.Connect(controlPitch, 0, mtof, 0);
patcher.Connect(controlVolume, 0, volume, 1);
patcher.Connect(controlLFO, 0, lfo, 0);
Once the graph is wired, push initial values in (and update them whenever the application wants to change a parameter):
noteValue = 60.f;
lfoValue = 4.f;
volumeValue = 0.5;
patcher.PassData(noteValue, "pitch");
patcher.PassData(lfoValue, "lfo");
patcher.PassData(volumeValue, "volume");
PassData is overloaded for int, float, and std::string;
PassBang(name) fires a bare trigger with no payload. The demo’s
hotkeys increment the cached values and re-send them on each press:
void DemoPatcher::FreqUp() {
patcher.PassData(++noteValue, "pitch");
}
void DemoPatcher::FreqDown() {
patcher.PassData(--noteValue, "pitch");
}
void DemoPatcher::LfoUp() {
lfoValue += 0.1f;
patcher.PassData(lfoValue, "lfo");
}
Persistence#
The graph — every object, parameter, and connection — can be serialised
to JSON with DumpJSON. The demo writes it to a sibling file:
void DemoPatcher::SaveToFile() {
std::string result = patcher.DumpJSON();
std::ofstream out("patcher.yap");
out << result;
out.close();
}
Reloading is the inverse: read the file, hand the contents to
ParseJSON, and the patcher rebuilds itself in place. Demo14
attaches an empty patcher to a sound first, then populates it on
demand:
void DemoLoadPatcher::LoadPatch1() {
std::ifstream in(YSE_TEST_RESOURCES_DIR "/patcher.yap");
if (in.fail()) {
in.close();
std::cout << "File not found" << std::endl;
return;
}
std::string result;
in.seekg(0, std::ios::end);
result.reserve(in.tellg());
in.seekg(0, std::ios::beg);
result.assign((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
patcher.ParseJSON(result);
in.close();
ParseJSON replaces the current graph wholesale — any objects already
in the patcher are discarded. After loading, push the initial control
values back in so the receives have something to forward:
noteValue = 60.f;
lfoValue = 4.f;
volumeValue = 0.5;
patcher.PassData(noteValue, "pitch");
patcher.PassData(lfoValue, "lfo");
patcher.PassData(volumeValue, "volume");
This is the round trip: a patch built once in code in Demo13, saved
to patcher.yap, and rebuilt from that file in Demo14 with no
code-side knowledge of its internal structure.
Where to find the full object reference#
The 37 registered object types — every inlet, outlet, parameter, default
value, and accepted message type — are listed on the
Patcher object reference reference page. That page is generated
directly from the engine source, so it can never drift from what
CreateObject actually accepts.
What you learned#
A patcher is a node graph. Objects have inlets and outlets; data flows along
Connectedges.~types run at audio rate,.types fire on events.Use
.r(receive) objects plusPassData/PassBangto drive the graph from application code.DumpJSONandParseJSONround-trip the whole graph — ship patches as plain text or build an external editor on top.
Next#
Patcher object reference — every patcher object, with inlets, outlets, parameters, and value ranges.
YSE::patcher— patcher class reference.YSE::pHandle— per-object handle reference.Tutorials — index of remaining tutorials.