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

  • . prefix — control-rate (event) object. .r (receive) fires only when you push data into it with PassData.

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 → mtofline (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 Connect edges.

  • ~ types run at audio rate, . types fire on events.

  • Use .r (receive) objects plus PassData / PassBang to drive the graph from application code.

  • DumpJSON and ParseJSON round-trip the whole graph — ship patches as plain text or build an external editor on top.

Next#