Creating new effects
This guide walks you through building a new audio effect for Ongenet — something that takes incoming audio and changes it, like a delay, filter, distortion or compressor. It is written for people comfortable with C# but new to audio programming (DSP), and it explains the audio ideas as it goes.
Skim creating-instruments.md first — it introduces the core concepts (samples, frames, blocks, the real-time audio thread) that this guide builds on. The big difference: an instrument produces sound from notes, while an effect transforms sound that is already there.
Everything here lives in Ongenet.Core (Audio/Effects, Audio/Dsp,
Audio/Parameters) — plain net10.0, no UI, no native code.
The shortest path: copy a small existing effect (
DelayEffect.csorUtilityEffect.cs), rename it, change the DSP inProcess, and add one line to the registry.
1. The mental model for effects
An effect edits the buffer in place. Where an instrument adds its sound into the buffer, an effect is handed a buffer that already contains audio and rewrites it. Read each sample, compute a new value, write it back to the same slot. There is no separate input and output buffer.
The format is the same as everywhere else. 32-bit float samples, interleaved by channel
([L, R, L, R, …]), at the engine's sample rate. For a buffer of length N and channels channels,
frames = N / channels, and channel c of frame f is at buffer[f * channels + c].
Effects are stateful and long-lived. One instance lives on a track for the whole session. That matters because many effects remember the past — a reverb tail keeps ringing after the input stops, a delay echoes what came before. You keep that memory (delay lines, filter state) in fields.
The audio thread is sacred — same rules as instruments. Don't allocate, lock, or do slow work in
Process. Allocate buffers up front (in Prepare).
Where effects sit in the signal chain. Effects run as inserts: the audio flows through them in order. There are actually two insert points — a pre chain on each instrument slot, and a post chain on the track (and on group/master buses). The signal path is covered in audio-engine.md; for writing an effect you just need to know you get a buffer and must transform it.
2. The contract: IAudioEffect
Every effect implements this one interface:
public interface IAudioEffect
{
/// <summary>Display name.</summary>
string Name { get; }
/// <summary>Stable registry type id, used to recreate this effect when loading a project.</summary>
string TypeId { get; }
/// <summary>
/// Whether the effect processes audio. When false the engine bypasses it (the signal passes
/// through untouched) without removing it from the chain.
/// </summary>
bool Enabled { get; set; }
/// <summary>Editable parameters, rendered generically by the effects panel.</summary>
IReadOnlyList<Parameter> Parameters { get; }
/// <summary>Called with the engine format before processing (and on format change).</summary>
void Prepare(AudioFormat format);
/// <summary>Processes <paramref name="buffer"/> (interleaved) in place.</summary>
void Process(Span<float> buffer);
/// <summary>Creates a fresh copy with the same parameters (for track duplication).</summary>
IAudioEffect Clone();
}
What each member is for:
Name— what shows in the UI (e.g."Delay").TypeId— a short, stable string id (e.g."delay"). Saved into project files; never change it once shipped. (Tip: declarepublic const string TypeId = "delay";and then implement the interface member explicitly:string IAudioEffect.TypeId => TypeId;.)Enabled— whenfalse, the engine skipsProcessentirely (true bypass). You don't have to check it yourself.Parameters— your knobs (see §5).Prepare(format)— called before processing and whenever the device format changes. This is where you learn the sample rate/channel count and allocate any state buffers. There is no separateReset()— re-initialise your DSP state here.Process(buffer)— the heart of the effect: transform the interleaved buffer in place.Clone()— return a new instance carrying the same parameter values (used when a track is duplicated).
3. Worked example: a gain/utility effect (the simplest case)
UtilityEffect just applies gain, pan, mono
fold-down and phase invert. It is stateless — each output sample depends only on the matching input
sample — so it's the cleanest place to see the shape of an effect:
public sealed class UtilityEffect : IAudioEffect
{
public const string TypeId = "utility";
string IAudioEffect.TypeId => TypeId;
public string Name => "Utility";
public bool Enabled { get; set; } = true;
public double GainDb { get; set; } // backing fields = the single source of truth
public double Pan { get; set; }
public bool Mono { get; set; }
public bool InvertPhase { get; set; }
private int _channels = 2;
private IReadOnlyList<Parameter>? _parameters;
public IReadOnlyList<Parameter> Parameters => _parameters ??= new Parameter[]
{
new FloatParameter("Gain", -24.0, 24.0, () => GainDb, v => GainDb = v, "0.#", "dB"),
new FloatParameter("Pan", -1.0, 1.0, () => Pan, v => Pan = v, "0.##"),
new BoolParameter("Mono", () => Mono, v => Mono = v),
new BoolParameter("Invert Phase", () => InvertPhase, v => InvertPhase = v)
};
public void Prepare(AudioFormat format) =>
_channels = format.Channels < 1 ? 1 : format.Channels;
public void Process(Span<float> buffer)
{
var gain = (float)AudioMath.Db2Lin(GainDb); // dB → linear multiplier
var frames = buffer.Length / _channels;
for (var f = 0; f < frames; f++)
for (var c = 0; c < _channels; c++)
buffer[f * _channels + c] *= gain; // edit in place
}
public IAudioEffect Clone() => new UtilityEffect
{
Enabled = Enabled, GainDb = GainDb, Pan = Pan, Mono = Mono, InvertPhase = InvertPhase
};
}
Notice the pattern that repeats in every effect:
- Backing properties hold the state (
GainDb,Pan, …). They are the single source of truth. Parameterswraps those properties with get/set delegates and is built lazily (??=).Prepareonly caches what it needs (here, the channel count).Processloops frames × channels and rewrites the buffer; it reads parameters straight from the properties — no allocation, no locks.
4. Worked example: a delay (an effect with memory)
A delay (echo) needs to remember past audio so it can play it back later. The tool for that is a
DelayLine — a ring buffer. Here is the complete
DelayEffect:
public void Prepare(AudioFormat format)
{
_sampleRate = format.SampleRate > 0 ? format.SampleRate : 44100.0;
_channels = format.Channels < 1 ? 1 : format.Channels;
_size = (int)(MaxDelaySeconds * _sampleRate) + 4;
_lines = new DelayLine[_channels];
for (var c = 0; c < _channels; c++) { _lines[c] = new DelayLine(); _lines[c].Resize(_size); }
}
public IAudioEffect Clone() => new DelayEffect
{
Enabled = Enabled, TimeMs = TimeMs, Feedback = Feedback, Mix = Mix
};
public void Process(Span<float> buffer)
{
if (_lines.Length == 0) return;
var channels = _channels;
var delay = Math.Clamp((int)(TimeMs / 1000.0 * _sampleRate), 1, _size - 1);
var fb = (float)Math.Clamp(Feedback, 0, 0.95);
var mix = (float)Math.Clamp(Mix, 0, 1);
var frames = buffer.Length / channels;
for (var frame = 0; frame < frames; frame++)
{
var i = frame * channels;
for (var c = 0; c < channels; c++)
{
var dry = buffer[i + c];
var delayed = _lines[c].ReadInt(delay);
buffer[i + c] = dry * (1 - mix) + delayed * mix;
_lines[c].Write(dry + delayed * fb);
}
}
}
The important lessons:
- Allocate state in
Prepare, notProcess. The delay lines are sized once for the maximum delay time. BecausePrepareis also called on format change, the buffers are always correct for the current sample rate. (This doubles as "reset" — there is no separate reset method.) - Read parameters once per block into locals (
delay,fb,mix) and clamp them defensively. - The classic delay recipe, per sample: read the delayed sample, mix dry + wet to the output, then
write
dry + delayed * feedbackback into the line so echoes repeat and decay. - "dry" vs "wet" is universal effect vocabulary: dry = the untouched input, wet = the processed
signal. A
Mixknob blends between them.
5. Parameters
Parameters are identical to the instrument system — see creating-instruments.md §5. In short, they are thin wrappers around your own fields, so the field stays the single source of truth and the audio thread reads it directly:
| Type | Use for | Constructor sketch |
|---|---|---|
FloatParameter |
a continuous knob/slider | new FloatParameter("Time", 1, 2000, () => TimeMs, v => TimeMs = v, "0", "ms", skew: 2.0) |
BoolParameter |
an on/off switch | new BoolParameter("Mono", () => Mono, v => Mono = v) |
ChoiceParameter |
a dropdown | new ChoiceParameter("Mode", names, () => idx, i => idx = i) |
skew bends a knob's response (use > 1 for time/frequency so small values are easier to dial);
unit is a display suffix ("dB", "ms", "Hz", "%"); format is a .NET number format.
Automation and MIDI-learn come for free the moment you expose a parameter — the engine writes the
backing field before each block; you write no extra code.
Build the list lazily and read the backing property directly in Process:
private IReadOnlyList<Parameter>? _parameters;
public IReadOnlyList<Parameter> Parameters => _parameters ??= new Parameter[]
{
new FloatParameter("Amount", 0.0, 1.0, () => Amount, v => Amount = v)
};
6. The DSP building blocks
Before writing your own filter or saturator, reach for Audio/Dsp. These
are the shared, audio-thread-safe primitives effects are built from:
| Building block | What it does | Key methods |
|---|---|---|
DelayLine |
Ring buffer — remembers the last N samples (echo, chorus, flanger, comb). | Resize, Clear, ReadInt, ReadFrac, Write |
Biquad + BiquadCoefficients |
The workhorse filter (low/high/band-pass, peaking EQ, shelves). | BiquadCoefficients.Compute(mode, freq, q, sr); Biquad.Process(in coeffs, x) |
OnePole |
Cheap one-pole filter; also ideal for smoothing a parameter to avoid zipper noise. | SetLowpass, SetSmoothTime, ProcessLP/HP, Reset |
EnvelopeFollower |
Tracks the loudness of a signal over time (the brain of compressors/gates). | SetTimes(attackMs, releaseMs, sr), Process(rectified), Value |
Lfo |
A slow oscillator to modulate things (chorus/tremolo/phaser sweeps). | SetRate, Reset, Next, Value(phaseOffset) |
WaveShaper / DistortionStack |
Non-linear shaping — drive, saturation, distortion. | WaveShaper.Shape(x, type, drive, bias) |
FilterBank |
Splits a signal into frequency bands (vocoder, multiband). | Configure(bands, minHz, maxHz, sr), Process(input, bandsOut) |
CaptureBuffer |
A larger ring buffer for capturing/replaying audio (stutter, granular). | Write, ReadAbs, Snapshot |
PitchShifter / PitchDetector |
Shift or detect pitch (auto-tune, harmonisers). | Configure, SetRatio, Process / Push, Detect |
CombFilter, TapeStopProcessor, SpectrumScope |
Comb resonator, tape-stop effect, analyser tap. | see each file |
AudioMath |
dB↔linear, clamp, soft-clip, lerp, equal-power pan. | Db2Lin, Lin2Db, Clamp, SoftClip, Lerp, PanGains |
A typical filtered-effect skeleton wires these together: in Prepare, compute coefficients and reset
the filter; in Process, run each sample through Biquad.Process.
7. All the built-in effects (study targets)
Every one of these is a real, readable implementation in
Audio/Effects:
| Category | Effects | Good example of… |
|---|---|---|
| EQ & Filter | EqEffect, FilterEffect |
biquad filtering, a custom inspector UI |
| Dynamics | CompressorEffect, LimiterEffect, GateEffect, SidechainEffect |
envelope followers, parameter smoothing, sidechain |
| Modulation | ChorusEffect, PhaserEffect, FlangerEffect, TremoloEffect, StutteroEffect |
LFOs + delay lines; tempo-sync + MIDI (Stuttero) |
| Delay & Reverb | DelayEffect, ReverbEffect |
ring buffers, multi-line reverb |
| Distortion | DistortionEffect, BitcrusherEffect |
waveshaping, sample-rate/bit reduction |
| Pitch | VocoderEffect, AutoTuneEffect |
filter banks, pitch detection, sidechain carrier |
| Utility | StereoWidthEffect, UtilityEffect |
mid/side, simple gain/pan |
For a simple effect, read UtilityEffect and DelayEffect. For the full toolbox in action, read
StutteroEffect (tempo + MIDI + custom state) and VocoderEffect (sidechain + filter bank).
8. Advanced seams: tempo, sidechain, MIDI, custom state
Effects opt into extra capabilities by implementing small marker interfaces in addition to
IAudioEffect. The engine detects them and feeds them what they need.
Tempo & transport — IContextualEffect
To sync to the project tempo or know where the playhead is, implement IContextualEffect. The engine
calls SetContext right before every Process, handing you an EffectContext:
public interface IContextualEffect
{
void SetContext(EffectContext context);
}
public sealed class EffectContext
{
public AudioFormat Format { get; set; }
public double Bpm { get; set; } = 120.0; // project tempo
public double PlayheadBeats { get; set; } // position at the START of this block, in quarter-note beats
public bool Playing { get; set; }
public ISidechainBus Sidechain { get; set; } // see below
}
Typical use — convert beats to samples for a tempo-synced rate:
private EffectContext? _ctx;
public void SetContext(EffectContext context) => _ctx = context;
private double SamplesPerBeat()
{
var bpm = _ctx?.Bpm ?? 120.0;
if (bpm <= 0) bpm = 120.0;
return _sampleRate * 60.0 / bpm; // e.g. a 1/4-note delay = this many samples
}
Sidechain (reading another track) — ISourceTrackEffect + IContextualEffect
A sidechain lets your effect read another track's audio — the classic "duck the bass when the
kick hits", or a vocoder using a synth as its carrier. Implement ISourceTrackEffect to expose which
track to read:
public interface ISourceTrackEffect
{
Guid? SourceTrackId { get; set; } // the UI lets the user pick this
}
Then, in Process, request and read that track from the sidechain bus (available via
EffectContext.Sidechain):
if (_ctx is not null && SourceTrackId is { } id)
{
_ctx.Sidechain.Request(id); // ask for it (once per block)
var src = _ctx.Sidechain.Read(id, out var srcChannels); // read its post-FX audio
// ... use src as the sidechain/carrier signal ...
}
The bus is audio-thread-only and lock-free. If the source track happens to render after you, you get
its previous block (one block of latency) — fine for control signals. See
SidechainBus.cs,
SidechainEffect and
VocoderEffect.
MIDI into an effect — IMidiAwareEffect
Some effects respond to MIDI notes (e.g. Stuttero triggers stutters from keys). Implement
IMidiAwareEffect:
public interface IMidiAwareEffect
{
void HandleMidi(in MidiMessage message);
void AllNotesOff();
}
Thread safety matters here: MIDI can arrive from a different thread than Process. The standard,
safe pattern is to push messages into a MidiEventFifo and drain them at the start of Process:
private readonly MidiEventFifo _fifo = new();
private readonly List<MidiMessage> _drain = new();
public void HandleMidi(in MidiMessage message) => _fifo.Push(message); // any thread
public void Process(Span<float> buffer)
{
_fifo.Drain(_drain); // audio thread: pull queued messages
foreach (var m in _drain) { /* react to notes/CC */ }
// ... audio processing ...
}
MidiMessage decodes the raw bytes for you: message.Note, message.Velocity (0..1),
message.Controller, message.Value, message.PitchBend14, etc.
Extra saved state — IProjectStatefulComponent
If your effect has state that isn't a simple Parameter (an EQ's variable list of bands, a chosen
source-track GUID, Stuttero's drawn gestures), implement IProjectStatefulComponent to read/write it
to the project file:
public interface IProjectStatefulComponent
{
void WriteProjectState(OngenWriter writer);
void ReadProjectState(OngenReader reader);
}
(Plain parameter values are saved automatically; you only need this for the extras.)
Spectrum analyser display — ISpectrumSource
Implement ISpectrumSource (and feed a SpectrumScope from Process) if you want the inspector to
draw a live spectrum, as EqEffect and FilterEffect do.
9. Registering your effect
The app finds effects through the
EffectRegistry. Add one line to the _builtIn
list and you're done — the library, the "add effect" menu, and project loading all read from it:
// EffectRegistry._builtIn (abbreviated — see the file for the full ~90 entries)
private readonly List<EffectInfo> _builtIn = new()
{
new EffectInfo(EqEffect.TypeId, "EQ", () => new EqEffect(), CatEqFilter),
new EffectInfo(CompressorEffect.TypeId, "Compressor", () => new CompressorEffect(), CatDynamics),
new EffectInfo(PeakLimiterEffect.TypeId, "Peak Limiter", () => new PeakLimiterEffect(), CatMastering),
// … Mastering, Modulation, Delay & Reverb, Distortion, Pitch, Utility, Visualizer, Spectral, …
};
Your line, e.g.:
new EffectInfo(MyEffect.TypeId, "My Effect", () => new MyEffect(), CatModulation),
The category constant (CatDynamics, CatModulation, …) chooses the group it appears under. The
registry is a DI singleton (registered in
ServiceCollectionExtensions), so
nothing else needs wiring. Most effects use the generic parameter inspector automatically; if you want
a bespoke UI (like the EQ's graph), you'd add a dedicated view model in the app's effect-chain view —
not required for a normal effect.
10. A copy-paste starting template
using System;
using System.Collections.Generic;
using Ongenet.Core.Audio.Parameters;
namespace Ongenet.Core.Audio.Effects;
public sealed class MyEffect : IAudioEffect
{
public const string TypeId = "myeffect";
string IAudioEffect.TypeId => TypeId;
public string Name => "My Effect";
public bool Enabled { get; set; } = true;
public double Amount { get; set; } = 0.5;
private int _channels = 2;
private double _sampleRate = 44100.0;
private IReadOnlyList<Parameter>? _parameters;
public IReadOnlyList<Parameter> Parameters => _parameters ??= new Parameter[]
{
new FloatParameter("Amount", 0.0, 1.0, () => Amount, v => Amount = v)
};
public void Prepare(AudioFormat format)
{
_sampleRate = format.SampleRate > 0 ? format.SampleRate : 44100.0;
_channels = format.Channels < 1 ? 1 : format.Channels;
// allocate / reset any DSP state here (delay lines, filters, followers)
}
public void Process(Span<float> buffer)
{
var channels = _channels;
var frames = buffer.Length / channels;
var amount = (float)Amount; // read parameters once per block
for (var f = 0; f < frames; f++)
for (var c = 0; c < channels; c++)
buffer[f * channels + c] *= amount; // your DSP here, in place
}
public IAudioEffect Clone() => new MyEffect { Enabled = Enabled, Amount = Amount };
}
Then register it in EffectRegistry._builtIn:
new EffectInfo(MyEffect.TypeId, "My Effect", () => new MyEffect(), CatUtility),
11. Build your own — step by step
- Create the class in
Ongenet.Core/Audio/Effects/MyEffect.csimplementingIAudioEffect. - Add a stable id (
public const string TypeId = "myeffect";+string IAudioEffect.TypeId => TypeId;). - Add backing properties for your knobs and a lazy
Parameterslist wrapping them. - In
Prepare: cacheformat.SampleRate/format.Channels; allocate & reset DSP state. - In
Process: loop frames × channels, read parameters from the properties, transform the buffer in place, no allocations. - Implement
Clone()copying every parameter field. - Register with one line in
EffectRegistry._builtIn. - Add seams as needed:
IContextualEffect(tempo),ISourceTrackEffect+IContextualEffect(sidechain),IMidiAwareEffect(MIDI),IProjectStatefulComponent(extra saved state),ISpectrumSource(analyser). - Run
dotnet run --project Ongenet.Desktop, add a track, drop your effect on it, and listen.
The cardinal rules (clean-audio checklist)
- Edit in place in
Process(buffer[i] = …). - Never allocate in
Process— pre-allocate everything inPrepare. - Re-init state in
Prepare(there is no separateReset); it's called on format change. - Use the real sample rate from
format, not a hard-coded 44,100. - Clamp parameters defensively before using them.
- Smooth fast-changing parameters (e.g. with
OnePole) to avoid clicks/zipper noise. - Keep
TypeIdstable forever — it lives in saved projects. - Make MIDI handling thread-safe with a
MidiEventFifodrained insideProcess.
Where to go next
- creating-instruments.md — the sibling guide for instruments.
- audio-engine.md — how the engine calls
Process, the full signal flow (instrument pre-FX → track FX → buses → master), and real-time safety in depth. - main-window-layout.md — where effects and their inspectors live in the UI.