Extending Audity
Audity’s node library is not a closed set. You can add your own DSP nodes and
your own audio sources from your project or your own package, using only the
public extension API - no engine edits. This page shows the shape of both. The
package comes with worked examples in the Custom Nodes sample (import it from the Package Manager - a Bitcrusher node, a
Poly Blip node, and a Sine Tone generator) that you can read alongside it.
A custom node is two classes
Section titled “A custom node is two classes”A node is a definition (what it does on the audio thread) plus an authoring class (how it looks in the editor).
1. The definition
Section titled “1. The definition”Subclass AudityNodeDef, mark it [AudityNode] and [BurstCompile], declare
its pins, and provide a Burst-compiled exec function.
[BurstCompile][AudityNode]public sealed class BitcrusherDef : AudityNodeDef{ public override bool PropagatesChannels => true;
public override void GetInputPins(int variadicCount, List<AudityPinSpec> pins) { pins.Add(new AudityPinSpec("in", AudityPinType.Audio)); pins.Add(new AudityPinSpec("bits", AudityPinType.Float, 8f)); // 8f is the unconnected fallback pins.Add(new AudityPinSpec("rate", AudityPinType.Float, 1f)); pins.Add(new AudityPinSpec("mix", AudityPinType.Float, 1f)); }
public override void GetOutputPins(int variadicCount, List<AudityPinSpec> pins) => pins.Add(new AudityPinSpec("out", AudityPinType.Audio));
// Per-instance DSP state is a struct you declare: the engine allocates it per // node (zeroed at instance start) and your exec reaches it as a typed ref. struct CrusherState { public float phase; public ChannelFloats held; // one float per channel, up to the max layout }
public override int StateBytes => UnsafeUtility.SizeOf<CrusherState>();
public override AudityNodeExec Exec => Execute;
[BurstCompile] [AOT.MonoPInvokeCallback(typeof(AudityNodeExec))] static void Execute(ref AudityExecContext context, ref AudityNodeDesc node) { var dst = context.AudioOut(node, 0, out int channels); var src = context.AudioIn(node, 0, out _); if (src.IsEmpty) { dst.Clear(); return; }
// Read float pins through the clamping overload - a wire can carry anything. float bits = context.ReadFloat(node, 1, 1f, 24f); float mix = context.ReadFloat(node, 3, 0f, 1f);
ref var state = ref context.State<CrusherState>(node); // ... write dst[...] from src[...] and state ... }}The exec function is the whole DSP. It works through AudityExecContext, and
this is the same surface every built-in node is written on - a Filter, a Delay,
or the ADSR uses nothing your node cannot use:
AudioIn(node, pin, out channels)/AudioOut(node, pin, out channels)- the interleaved audio buffers as spans.ReadFloat(node, pin)andReadFloat(node, pin, min, max)- a control pin’s value (use the clamping overload; wires carry arbitrary values).WriteFloat(node, pin, value)- set a Float output;ReadVec/WriteVec/OutWidthfor vector-width pins.ReadRamp(node, pin, current, out start, out end)- the engine-standard zipper for click-free block-rate gain moves.GatherTriggers(node, pin, offsets)/EmitTrigger(node, pin, frameOffset)- sample-accurate trigger input and output within the block.State<T>(node)- your own per-instance state struct, sized by the def’sStateBytesoverride, zeroed at instance start. (UserState(node)is the same memory as a raw float span, 16 floats by default.)DspRegion(node)- long per-channel DSP memory (delay lines, reverb tanks), pre-sized at configure time from the def’sDspSecondsPerChanneloverride.Random- the instance’s deterministic random stream, shared in executor order: the same graph with the same seed replays the same values. Draw only from exec, never speculatively.SampleRate,LocalFrame,SecondsToFrames(seconds),context.frames- the engine clock and block length.HoldTail(tailFrames)/HoldFinish()- keep a finite graph from finishing while your effect’s tail is still audible or your envelope/scheduler is still active; checkStoppingeach block and cancel any future triggers you have scheduled.NodeParam(node, index)/NodeParams(node)- the node’s baked parameter blob (its layout is your def’s own contract).
Because the function is Burst-compiled and marked
[AOT.MonoPInvokeCallback(typeof(AudityNodeExec))], keep it to unmanaged code:
spans, math, and your state struct, no managed allocations.
2. The authoring class
Section titled “2. The authoring class”Subclass AudityKindNode, mark it [AudityNodeInfo(...)], and expose the
inspector fields and inline pin defaults. Its Kind binds to the definition
through AudityNodeRegistry.KindOf<TDef>().
[Serializable][AudityNodeInfo("Bitcrusher", "Effect", CreatePath = "Effects/Bitcrusher", Order = 100)]public sealed class BitcrusherNode : AudityKindNode{ [Tooltip("Quantization depth in bits (fewer = harsher).")] [Range(1f, 24f)] public float bits = 8f; [Range(0f, 1f)] public float rate = 1f; [Range(0f, 1f)] public float mix = 1f;
public override AudityNodeKindId Kind => AudityNodeRegistry.KindOf<BitcrusherDef>();
// Feed the inline field into the matching pin when it is unconnected. public override bool TryGetInlineValue(string portId, out float value) { switch (portId) { case "bits": value = bits; return true; case "rate": value = rate; return true; case "mix": value = mix; return true; default: value = 0f; return false; } }
public override string InlineValueUnit(string portId) => portId == "bits" ? "bit" : null;}[AudityNodeInfo] gives the node its title, category, creation-menu path and
sort order - it appears in the create menu exactly like a built-in node.
3. Assemblies and player builds
Section titled “3. Assemblies and player builds”A node’s definition registers itself the first time a graph using it compiles, and player builds keep every node type your graphs use.
If you use assembly definitions (.asmdef), reference
DarkEffekt.Audity.Runtime so the assembly can see AudityNodeRegistry and
AudityNodeDef, plus Unity.Burst and Unity.Collections for the Burst exec
function. (The sample’s asmdef lists exactly these. Unity.Mathematics is
auto-referenced, so math.* needs no explicit entry. Code under Assets/ with
no .asmdef is in the default Assembly-CSharp and already sees the Audity
types.)
That is the whole contract. Kind ids for external definitions are assigned on first use, so you never touch the built-in node enum or the engine.
Moving or renaming a node class
Section titled “Moving or renaming a node class”A graph stores an authoring node by its namespace, class name and assembly. Move
the class to a different .asmdef, rename it, or change its namespace, and every
graph that used it can no longer resolve it: the node reads back as missing.
Unity’s own attribute is the fix, and it belongs on the class the moment you move it:
using UnityEngine.Scripting.APIUpdating;
[MovedFrom(false, "Old.Namespace", "Old.Assembly", "OldClassName")]public sealed class BitcrusherNode : AudityNode { }Old assets then follow the move on their own. Leave any argument null when that part did not change (moving between assemblies only, for instance, needs the assembly argument alone).
If you ship a node without it and later move the class, nothing is lost: the
node’s settings stay in the buyer’s asset, and Audity’s graph editor reports the
type as missing and names the package it was recorded against. Adding
[MovedFrom] in a later version still repairs those assets when they are next
opened. What loses data is a buyer deleting the node to clear the error, so the
message says so.
A custom audio source
Section titled “A custom audio source”Anywhere a Sample entry takes a source, it accepts any SAP IAudioGenerator,
not only an AudioClip. Implement one to produce audio procedurally (a
synthesized tone, a text-to-speech stream, a network source).
An IAudioGenerator is a managed factory (a ScriptableObject) plus two
unmanaged structs:
CreateInstance(...)allocates the instance and reports capabilities (isFinite,isRealtime,length).- An
IRealtimestruct fills the audio buffer on the audio thread (Process(...)). - An
IControlstruct declares the output format at configure time (Configure(...)) and handles control messages.
Both structs are copied into unmanaged memory, so they hold no managed fields.
The included SineToneGenerator sample is a complete, minimal implementation to
copy from; it also shows the standalone path (assign the generator directly to an
AudioSource.generator).
Where custom content fits
Section titled “Where custom content fits”- A custom node joins the graph library and is wired like any node.
- A custom generator is content: it goes in a Sample entry or Sample Set and
is played by a Wave Player, or is assigned straight to an
AudioSource.
Both are pure additions on the public API. Neither requires forking Audity, and both survive package updates.