Node Design Pattern

Every node in g.Pype has the same shape, whether it ships with the library or you wrote it this morning. Knowing the shape is most of knowing how to read the source.

A node is a class with three responsibilities, and they run at three different times.

Construction declares what the node can be configured with. The parameters live in a nested Configuration.Keys holder and are passed to the base class, which is what makes them part of the node’s identity: they are written into a serialised pipeline and restored from one. A parameter that is not declared there exists only for the lifetime of the object.

Setup runs on the node’s first cycle, not when the pipeline starts. By then the node can see its input context – sampling rate, channel count, frame size, channel roles – and must return the context of its output. This is the only point where the two are negotiated, and it is why anything that depends on the rate is sized here rather than at construction. A node that changes the shape of the stream says so here; a node that changes only the meaning returns what it was given.

Stepping does the work, one frame at a time, and is the only part on the real-time path.

Why the split matters

The three phases exist because a pipeline is assembled before it is configured, and configured before it runs.

An author writes Bandpass(f_lo=9, f_hi=11) without saying what rate it will run at; the rate arrives with the data. So the filter’s coefficients cannot be computed at construction – they are computed in setup, from a rate the constructor never saw. The same split is what lets a pipeline be serialised on one machine and run on another: construction is what travels, setup is what happens where the data is.

It is also why a node declares its ports rather than discovering them. Ports and their timing – continuous or sparse – are part of the declaration, so a pipeline can be checked for coherence before anything runs, and a mismatch is refused at connect() rather than surfacing as a wrong number later.

Node kinds

IONode has inputs and outputs and is what most nodes are. ONode has outputs only and is a source: it produces data rather than transforming it. INode has inputs only and is a sink: a file, a socket, a display. A node that touches the world – a device, a file, a socket or a screen – is carried by a chain the pipeline builds around it: a transport where the pipeline is split across processes, and the placement of its samples on the pipeline’s timeline. You write the node; the pipeline writes the chain.

Writing one

The library’s nodes cover the common cases. When yours is not one of them, you write a node: one class, and afterwards it behaves like a built-in one. It serializes, it goes into a serialized pipeline, and it runs on a runtime host. Three things to fill in; the rest is the base class’s.

1. Declare your parameters. They go in a Configuration.Keys holder, and __init__ passes them to the base class. This is not bookkeeping: what reaches the base class is what a serialized pipeline stores, and a value kept only on self is dropped when the pipeline is serialized, so it cannot be set from one, and the node will run with its default on a runtime host without saying so. A key named in Keys must be given; one in OptionalKeys may be left out.

2. Negotiate the output context in setup(). You are given the input’s sampling rate, channel count, frame size and channel descriptions, and you return what your output looks like. The node downstream is configured from what you return, so a wrong answer here misconfigures it rather than raising. setup() is also the earliest point at which the sampling rate is knowable, which is why anything sized in seconds is computed there rather than in __init__.

3. Do the work in step(), one frame at a time.

Season 7 of the training builds the whole authoring surface, one episode each:

  • Episode 1: the three parts in one node, optional parameters, an output wider than its input and the channel descriptions that go with it, saving and loading, and process(), the whole-recording counterpart to step();

  • Episode 2: several ports, what start() checks across them, and an asynchronous output for a decision that leaves the signal path;

  • Episode 3: a source on gp.Source and a sink on gp.Sink, and the chain the pipeline builds around each, which a node on ONode or INode directly does not get;

  • Episode 4: a control surface an operator moves while the pipeline runs;

  • Episode 5: a widget on gp.Scope, or on gp.Widget beside gp.INode.

For offline work, a transform often needs no class at all: see Offline Processing. Shipping your node’s package to a runtime host, with serialize(packages=[...]), is Season 8’s Episode 2.