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 tostep();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.Sourceand a sink ongp.Sink, and the chain the pipeline builds around each, which a node onONodeorINodedirectly does not get;Episode 4: a control surface an operator moves while the pipeline runs;
Episode 5: a widget on
gp.Scope, or ongp.Widgetbesidegp.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.