Writing a node
Drop a file in the patch workspace's nodes_signal/ or nodes_audio/, or straight into a node root, as smooth.py or Smooth.rs. The stem names the type, and a leading underscore hides it.
import goofi
import numpy as np
class Smooth(goofi.Node):
"""Rolling mean over the last axis."""
INPUTS = {"data": goofi.InputSlot(goofi.DataType.ARRAY, required=True)}
OUTPUTS = {"out": goofi.DataType.ARRAY}
PARAMS = {"smoothing": {"window": goofi.IntParam(8, 1, 512, doc="Samples in the mean.")}}
def process(self, data):
w = self.params.smoothing.window
kernel = np.ones(w, dtype=np.float32) / w
return np.apply_along_axis(lambda v: np.convolve(v, kernel, mode="same"), -1, data.data)The stem names the type and a leading _ hides it. A .rs file names the
SDK it is written against, and that is what routes it to its engine: the signal
plane or the audio one. The type a node ends up with is namespaced by that plane: signal:Smooth.
A node declares itself in constants
Read once by the import, not in hooks. Each may be omitted.
| Constant | Shape |
|---|---|
INPUTS | {slot: DataType}, or {slot: InputSlot(dtype, required=…, trigger=…)} for the per-slot options. |
OUTPUTS | {slot: DataType}. |
PARAMS | {group: {name: IntParam / FloatParam / BoolParam / StringParam}}, read as self.params.<group>.<name>. |
PRODUCER | True for a node that paces itself rather than waiting for a frame. |
process()
process receives one keyword argument per declared input slot: a goofi.Data, or None when the slot holds no frame. A required=True slot never arrives empty, so it may be read unconditionally.
It returns {slot: value}, or a bare value when the node has exactly one output
slot; a value is a goofi.Data, an (array, meta) pair, or an
array-like. Returning None emits nothing. setup() runs once, after the
params are seeded.
The file does not choose its tier
The same file runs on either tier, and it does not decide which. A discovery probe imports it in a real interpreter and routes it in-process when its imports keep the GIL disabled, else to a subprocess. The palette shows the tags a node declares, never the tier it runs on. Where it ends up is the probe's business.
The two interpreters are .gfivenv-ft (free-threaded 3.14t) and .gfivenv (a GIL Python), both made by goofi-init, and goofi uses no
others.
Nothing fails silently
A node whose dependencies are missing everywhere is listed unavailable, greyed out
and naming the missing module. An exception inside process() surfaces on the
node's error channel instead of taking anything down.
Rust nodes
A .rs file in the same folder is a node too, named by its stem as written. The
nodes goofi ships are .rs files built at goofi's own build time and embedded, so a
toolchain is needed to author a Rust node and never to run one. goofi library get <type> hands back any node's source to copy, which is
where every source listing in the node reference comes from. The
nodes goofi ships are the same kind of file, under a bundle.
A shipped example
signal:SpectralEntropy, from the complexity bundle. Spectral entropy: how flat the power spectrum is, tone to noise.
"""SpectralEntropy — how flat the power spectrum is, from antropy.
A pure tone is near 0 and white noise near 1, so this reads as "how noise-like". The last axis is
time and is consumed; every axis before it survives. antropy vectorizes this one itself.
"""
import antropy
import numpy as np
import goofi
class SpectralEntropy(goofi.Node):
"""Spectral entropy: how flat the power spectrum is, tone to noise."""
TAGS = ["analysis"]
INPUTS = {"data": goofi.InputSlot(goofi.DataType.ARRAY, required=True)}
OUTPUTS = {"entropy": goofi.DataType.ARRAY}
PARAMS = {
"spectral": {
"method": goofi.StringParam(
"welch", ["welch", "fft"], doc="Welch averages sub-windows; fft takes the whole frame at once."
),
"normalize": goofi.BoolParam(True, doc="Scale to 0..1 against a flat spectrum."),
}
}
def process(self, data):
p = self.params.spectral
# Without a stamped rate the bins are cycles per sample. That shifts every frequency by the
# same factor, and the measure only reads the SHAPE of the spectrum, so it is unharmed.
sfreq = data.meta.get("sfreq") or 1.0
return antropy.spectral_entropy(
np.asarray(data.data, dtype=np.float64),
sf=sfreq,
method=p.method,
normalize=p.normalize,
axis=-1,
).astype(np.float32)This reference describes goofi 3.1.0(537cd394), generated from a running instance on 2026-09-06.