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.

nodes_signal/smooth.py
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.

ConstantShape
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>.
PRODUCERTrue 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.

node-bundles/complexity/spectral_entropy.py
"""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.