Skip to content

Choosing a Processing Model

Pulp has one way to author DSP and one way to compose already-built units into a runtime-editable graph. They sit at different altitudes and are not interchangeable. This page is the canonical answer to "should I write a Processor or reach for SignalGraph?"

The two layers

Layer What it is You use it to…
Processor (pulp::format::Processor) The single authoring unit for a plugin/effect/instrument. One process() callback per block; internal DSP is composed from pulp::signal::* helpers inside that callback. Write a plugin, effect, instrument, or MIDI effect. The default for essentially all DSP authoring.
SignalGraph (pulp::host::SignalGraph) A host-side, runtime-editable routing engine that connects already-built units — loaded plugins (PluginSlot), owned in-process ProcessorNode instances, I/O, gain, and custom nodes — into a DAG. Host other plugins, compose native processors, build a rack/mixer, or drive a node editor.

A SignalGraph node wraps a unit (a hosted plugin, or a CustomNodeType); it is not a new way to write DSP. Authoring still happens in a Processor.

Sample regions: the bounded graph extension

Sample regions are a narrow extension of this same SignalGraph for a bounded, author-declared scalar DSP program that must remain editable at sample cadence. They are composition metadata, not a second authoring model, routing backend, or generated-DSP ABI. The canonical GraphRuntimeExecutor lowers an accepted region into one prepared binding; a BakedGraphProcessor uses that executor too. Every recurrence crosses an explicit UnitDelay; an instantaneous cycle remains refused. Exact kernel versions, boundaries, state limits, promoted StateStore parameters, and causality are proved before publication. Live edits adopt immutable snapshots and retain state only for exact identities, so changed identities start fresh and old/new bindings cannot execute concurrently.

The decision

Is the signal topology fixed when you build the plugin? → Processor. Is it edited or loaded at run time? → SignalGraph.

"Fixed topology" does not mean "simple" or "one-in/one-out." A Processor is still the right tool when its internal DSP is:

  • polyphonic (synths, samplers — voices are not graph nodes),
  • multi-bus — sidechain input and descriptor-declared aux / secondary output buses via the ProcessBuffers surface, not a graph. CLAP and VST3 route writable secondary outputs through ProcessBuffers; AUv3 currently exposes the sidechain input but not secondary outputs.
  • sidechained (a sidechain is an input bus on the Processor),
  • internally branching (parallel/serial chains, mid/side, dry/wet) — compose pulp::signal::* blocks in process(),
  • oversampled or convolution-based — an implementation detail of one Processor.

Reach for SignalGraph only when the routing itself is dynamic: hosting external plugins, a user-editable rack/mixer, a node editor, or a topology loaded from disk. Do not express a fixed internal signal chain as a graph — it is slower and harder to reason about than a straight process().

Quick reference

Task Model
Reverb, compressor, EQ, filter Processor
Synth or sampler (any polyphony) Processor
Drum instrument with a sidechain input or aux outputs Processor + ProcessBuffers
Sidechain compressor Processor + sidechain input bus
Parallel/serial FX inside one plugin Processor (compose signal::*)
A host app that loads and routes VST3/AU/CLAP plugins SignalGraph
A user-editable effects rack / modular patch SignalGraph
A visual node editor SignalGraph (the editor is UI over the graph)

What goes inside a node

Deciding you genuinely need a SignalGraph raises the next question: what does each node actually contain? There are three sources, and they are not interchangeable.

Node content Where it comes from Use it for
Built-ins — Input, Output, Gain, MidiInput, MidiOutput The graph itself; no code to write Plumbing — host I/O, a gain stage, MIDI in and out.
Plugin node, wrapping a PluginSlot A VST3 / AU / CLAP / LV2 binary on disk, discovered by PluginScanner and opened by PluginSlot::load() Third-party components — units you did not write and cannot recompile.
Processor node, retaining a ProcessorNodeInstance A native Processor created in the same process and added with SignalGraph::add_processor_node() Owned first-party components that need dense AudioRateModulationView delivery. This runtime node is intentionally not serialized by .pulpgraph or .pulpbake v1.
Custom node, from a registered CustomNodeType Your own C++, registered per graph with a type_id, version, and port shape Your own graph-native components — utility and extension nodes.

The line between the last two rows is the one worth internalizing: a CustomNodeType is a graph utility, not a plugin authoring surface. If the unit is a plugin you intend to ship, author it as a Processor and let a graph host it like any other. Reach for CustomNodeType when the node only ever makes sense inside your graph.

A CustomNodeType can be stateless (just a process callback) or stateful (supply create/destroy and the graph owns one instance per node, with prepare/release/reset/save_state/load_state on it). Stateful custom nodes follow the same threading contract as PluginSlot: lifecycle calls on the UI/main thread, process_instance on the audio thread and real-time-safe.

Reading MIDI from a custom node

A custom node can consume the MIDI routed to it. The graph has always gathered each node's inbound events before it dispatches on node kind, which is how a Processor node sees them; a CustomNodeType reaches the same scratch by declaring an event-aware callback instead of the plain one:

#include <pulp/host/custom_node_events.hpp>

CustomNodeType gate;
gate.type_id = "myplugin.midi_gate";
gate.num_input_ports = 1;
gate.num_output_ports = 1;
gate.create  = []() -> void* { return new MyGate(); };
gate.destroy = [](void* p) { delete static_cast<MyGate*>(p); };
gate.process_instance_events =
    [](void* p, audio::BufferView<float>& out,
       const audio::BufferView<const float>& in, int frames,
       const CustomNodeEventBlock& events) {
        if (events.in != nullptr)
            for (const auto& ev : *events.in)
                if (ev.is_note_on()) static_cast<MyGate*>(p)->note_on(ev);
        /* ... write out ... */
    };
graph.register_custom_node_type(gate);           // then connect_midi(source, node)

process_events is the stateless form. Three things are worth knowing before you reach for either:

  • A null events.in means "no events this block" and is not an error — the graph only allocates an event port when the compiled graph carries MIDI, so an audio-only graph hands the callback nothing. Treat null and empty the same.
  • Emission goes through events.out. It is the node's own MIDI output buffer, so appending to it feeds whatever connect_midi(node, destination) wires downstream:
if (events.out != nullptr)
    events.out->add(midi::MidiEvent::note_on(0, 64, 100));   // sample_offset defaults to 0

The graph clears out immediately before each call, so a block emits exactly what that call appended — there is nothing to reset, and nothing carries over from the previous block. out is nullable for the same reason in is, so null-check it. An append past the buffer's realtime capacity is dropped rather than allocating, and the graph then marks the node's output incomplete, which propagates downstream and surfaces as a false return from extract_midi() at the sink — the same reporting a hosted plugin gets. * Registration refuses incoherent combinations rather than degrading quietly. A stateful event callback needs create and destroy; an event-aware type cannot also be lowerable, because the bake layer has no event plane and a lowered node would run silently MIDI-less; and it cannot also declare a transport callback, because the two would have no defined dispatch precedence. consumes_events() reports whether a type declares the lane, and custom_node_types() mirrors it as consumes_events.

If you are building a node editor

The graph is designed to be the model under an editor, so the pieces an editor needs are already contracts rather than conventions:

  • NodeId is stable across connection edits, so it is a safe identity anchor for the editor's own view state.
  • Unresolved types survive a round trip. Serialization stores each custom node's type_id and version. Loading a topology whose custom types this machine has not registered still produces a placeholder node with the saved identity, port shape, connections, and opaque state, and reports the type in LoadResult::missing_custom_node_types — so a patch does not silently lose nodes when it moves between machines.
  • Bound user-built graphs with SignalGraph::set_limits() before prepare(), and preflight generated or scripted topologies with validate_generated_graph(). A graph assembled by a user (or a script) is untrusted input.
  • Editing while it plays is supported: connection edits, and hot-swapping the plugin inside a node via the begin_swap_edit() / prepare_swap() transaction.
  • GraphEditorView (pulp::view::widgets) already renders a SignalGraph as a draggable, connectable canvas if you want the model and the view together. It is a host-backed, desktop-only opt-in rather than part of the view-only link surface. A CMake consumer must link both targets explicitly: target_link_libraries(my_app PRIVATE pulp::view pulp::host).

Embedding a hosted plugin's own editor

A Plugin node can show the plugin's real GUI rather than a parameter list you rebuild yourself. pulp::view::EditorAttachment::create(slot, window) asks the slot for its editor and embeds it in a WindowHost, returning an RAII handle that detaches and destroys in the right order.

Two constraints shape what you can build on top of it:

  • A native child always composites ABOVE Pulp's GPU layer. The OS window server draws it over the Skia surface, so a node editor cannot paint wires, selection chrome, or overlays on top of an embedded plugin GUI. Native children suit full-region embeds — a detail pane, an inspector — better than a live GUI floating mid-canvas under other widgets. Clipping still works: set_native_child_view_clip masks a child to its scroll viewport.
  • Coverage is per format and per platform. CLAP, VST3, and AU v2 editors embed on macOS today. LV2 slots report no editor, and Windows/Linux have no desktop WindowHost implementing the native-child seam, so EditorAttachment::create returns nullptr there. Null is the honest "no editor" answer everywhere — branch on it rather than assuming a view.

The two layers compose

Processor and SignalGraph are altitudes, not a fork in the road. A graph that has stopped being dynamic can be lowered back down: bake() freezes a prepared graph into a BakedGraphProcessor — a self-contained Processor you can ship like any other. It drives the frozen plan through the same executor the live graph uses, so its output matches the live walk for the lowerable subset.

Baking fails loudly rather than silently mis-baking. A hosted Plugin node is refused because it owns opaque external state and the result would not be self-contained; a Custom node lowers only if its type opted in with lowerable = true, matches the node's shape, and is transport-independent. See Signal Graph Reference for the .pulpbake file format and its signature gate.

Reserved terminology

To keep the two layers from blurring, these terms each mean exactly one thing.

Term Means Not
Processor The DSP authoring unit. A generic graph node.
SignalGraph The host-side routing engine (the user-facing graph). Any fixed internal DSP chain.
node A vertex in a SignalGraph; wraps a unit. A Processor itself.
PluginSlot The host wrapper around a loaded plugin. A Processor you authored directly.
CustomNodeType A graph utility/extension node. A plugin authoring surface.
bus / output A named multi-channel I/O group on a unit. A SignalGraph "output node" — say "output node" explicitly.

When writing docs or code comments, avoid the bare word "graph" for authoring work — qualify it as SignalGraph. A plugin is always a Processor; there is no such thing as a graph-authored plugin.

How this is enforced

Two CI guards keep the model from blurring back into two surfaces. Both run in gates.sh, the pre-push hook, and the Versioning & Skill-Sync workflow.

  • tools/scripts/processing_model_terms_lint.py flags the reserved-terminology anti-phrases (e.g. "graph plugin") in docs and source.

  • tools/scripts/single_backend_guard.py asserts the structural invariants: exactly one routing backend (GraphRuntimeExecutor) defines the routed-graph execution entry points; pulp create offers no scaffold that treats a SignalGraph as an authoring surface; the public generated-DSP ABI entry symbols stay the sanctioned pair (the native_core.h Processor-level FFI and the pulp_node_v1 custom-node ABI); and the routed-vs-SignalGraph differential parity test stays wired into the build. Sanctioning a genuinely-new engine, scaffold, or ABI is a deliberate allowlist edit in that guard.

See also