goofi v3.0.0

Writing a node

Drop a file in the patch workspace's nodes_signal/, nodes_audio/ or nodes_graphics/, or straight into a node root, as smooth.py, Smooth.rs or Smooth.wgsl. 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 extension decides the engine: a .py is a signal node and a .wgsl is a graphics one. A .rs names the SDK it is written against, and that is what routes it — the signal plane or the audio one — falling back to the folder it sits in. 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.

A Python file does not choose its tier

The same file runs on either tier, and it does not decide which. goofi imports it once to see what it needs and picks the tier from that. The palette shows the tags a node declares, not the tier it runs on.

goofi-init makes the two Python interpreters goofi runs nodes in, 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 Rust nodes goofi ships are the same kind of file, kept under a bundle, built at goofi's own build time and embedded, so a toolchain is needed to author a Rust node and not to run one. goofi library get <type> hands back any node's source to copy, and every source listing in the node reference comes from it.

Shader nodes

A .wgsl file is a node on the graphics engine, and the extension alone is what says so. It carries no Python and no constants: a /* goofi … */ comment at the top holds the whole manifest as JSON, and the rest of the file is WGSL that runs on the GPU.

The entry point is fn shade(uv: vec2f) -> vec4f, called once per pixel with uv across the frame. Each declared input is in scope under its own name, with samp to sample it, and every param is a field of p, so p.level is the param declared as threshold/level. time, frame and resolution are there too. Those names are reserved, and a shader that redeclares one is refused rather than silently shadowing it.

A graphics node has exactly one output, out, and the engine adds it. A header that lists outputs of its own is refused. What shade returns is that texture.

node-bundles/graphics/Threshold.wgsl
/* goofi
{ "doc": "brightness to black and white\nSoft widens the step into a ramp around the level.",
  "tags": ["image", "transform"],
  "inputs": [{"name": "input", "kind": "TEXTURE"}],
  "params": [
    {"group": "threshold", "name": "level", "kind": "float", "default": 0.5, "min": 0.0, "max": 1.0},
    {"group": "threshold", "name": "soft", "kind": "float", "default": 0.0, "min": 0.0, "max": 0.5} ] }
*/
fn shade(uv: vec2f) -> vec4f {
    let c = textureSample(input, samp, uv);
    let l = dot(c.rgb, vec3f(0.299, 0.587, 0.114));
    // A `smoothstep` with two equal edges divides by zero; a hard step is what soft 0 asks for.
    let soft = max(p.soft, 1e-5);
    let t = smoothstep(p.level - soft, p.level + soft, l);
    return vec4f(vec3f(t), c.a);
}

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
        out = antropy.spectral_entropy(
            np.asarray(data.data, dtype=np.float64),
            sf=sfreq,
            method=p.method,
            normalize=p.normalize,
            axis=-1,
        )
        return np.asarray(out, dtype=np.float32), data.drop_axis(-1)