Custom Effect
This tutorial walks you through creating a custom audio effect for the Amplitude engine. You will build a Tremolo effect — an amplitude modulation that periodically varies the volume of a sound — and learn how to register it as a plugin, define its asset file, and use it at runtime.
Architecture Overview¶
In Amplitude, effects are built on top of the Filter system. To create a custom effect, you need two classes:
- Filter — Defines the filter metadata (name, parameters, value ranges) and acts as a factory for creating instances.
- FilterInstance — Holds per-sound state and performs the actual DSP processing.
classDiagram
class Filter {
+GetParameterCount() AmUInt32
+GetParameterName(index) AmString
+GetParameterType(index) eParameterType
+GetParameterMax(index) AmReal32
+GetParameterMin(index) AmReal32
+CreateInstance() shared_ptr~FilterInstance~
}
class FilterInstance {
+Process(in, out, frames, sampleRate)
+ProcessChannel(in, out, channel, frames, sampleRate)
+ProcessSample(sample, channel, sampleRate) AmAudioSample
+GetParameter(index) AmReal32
+SetParameter(index, value)
}
Filter --> FilterInstance : creates At runtime, the engine creates a FilterInstance for each sound object that uses the effect. This ensures each sound has its own independent processing state.
Step 1: Define the Filter Class¶
Create a header file for your custom filter. The Filter subclass defines the parameter metadata and the factory method:
// TremoloFilter.h
#pragma once
#include <SparkyStudios/Audio/Amplitude/DSP/Filter.h>
namespace MyGame
{
using namespace SparkyStudios::Audio::Amplitude;
class TremoloFilter;
// Forward declaration of the instance class
class TremoloFilterInstance final : public FilterInstance
{
public:
explicit TremoloFilterInstance(TremoloFilter* parent);
~TremoloFilterInstance() override = default;
protected:
AmAudioSample ProcessSample(
AmAudioSample sample, AmUInt16 channel, AmUInt32 sampleRate) override;
private:
AmReal64 m_phase;
};
class TremoloFilter final : public Filter
{
friend class TremoloFilterInstance;
public:
// Parameter indices
enum ATTRIBUTE
{
ATTRIBUTE_WET = 0, // Dry/wet mix (0 = dry, 1 = fully wet)
ATTRIBUTE_RATE, // Modulation rate in Hz
ATTRIBUTE_DEPTH, // Modulation depth (0 = none, 1 = full)
ATTRIBUTE_LAST
};
TremoloFilter();
~TremoloFilter() override = default;
[[nodiscard]] AmUInt32 GetParameterCount() const override;
[[nodiscard]] AmString GetParameterName(AmUInt32 index) const override;
[[nodiscard]] eParameterType GetParameterType(AmUInt32 index) const override;
[[nodiscard]] AmReal32 GetParameterMax(AmUInt32 index) const override;
[[nodiscard]] AmReal32 GetParameterMin(AmUInt32 index) const override;
std::shared_ptr<FilterInstance> CreateInstance() override;
protected:
AmReal32 m_rate;
AmReal32 m_depth;
};
} // namespace MyGame
Key points:
- The constructor must call
Filter("FilterName")with a unique name. This name is used in effect asset files to reference your filter. - Parameter index
0must beATTRIBUTE_WET— the dry/wet mix level. This is a mandatory convention expected by the engine for all filters. - The
ATTRIBUTE_LASTsentinel makes it easy to return the parameter count.
Step 2: Implement the Filter¶
Now implement the filter metadata methods and the factory:
// TremoloFilter.cpp
#include "TremoloFilter.h"
#include <cmath>
namespace MyGame
{
// -------------------------------------------------------------------------
// TremoloFilter (factory + metadata)
// -------------------------------------------------------------------------
TremoloFilter::TremoloFilter()
: Filter("Tremolo") // This name must match the "effect" field in the JSON asset
, m_rate(4.0f)
, m_depth(0.5f)
{}
AmUInt32 TremoloFilter::GetParameterCount() const
{
return ATTRIBUTE_LAST;
}
AmString TremoloFilter::GetParameterName(AmUInt32 index) const
{
if (index >= ATTRIBUTE_LAST)
return "Unknown";
static const AmString names[ATTRIBUTE_LAST] = { "Wet", "Rate", "Depth" };
return names[index];
}
eParameterType TremoloFilter::GetParameterType(AmUInt32 index) const
{
return eParameterType_Float;
}
AmReal32 TremoloFilter::GetParameterMax(AmUInt32 index) const
{
if (index >= ATTRIBUTE_LAST)
return 0.0f;
static const AmReal32 values[ATTRIBUTE_LAST] = {
1.0f, // Wet: 0-1
20.0f, // Rate: 0-20 Hz
1.0f // Depth: 0-1
};
return values[index];
}
AmReal32 TremoloFilter::GetParameterMin(AmUInt32 index) const
{
if (index >= ATTRIBUTE_LAST)
return 0.0f;
static const AmReal32 values[ATTRIBUTE_LAST] = {
0.0f, // Wet
0.1f, // Rate (minimum 0.1 Hz)
0.0f // Depth
};
return values[index];
}
std::shared_ptr<FilterInstance> TremoloFilter::CreateInstance()
{
return ampoolshared(eMemoryPoolKind_Filtering, TremoloFilterInstance, this);
}
// -------------------------------------------------------------------------
// TremoloFilterInstance (per-sound processing)
// -------------------------------------------------------------------------
TremoloFilterInstance::TremoloFilterInstance(TremoloFilter* parent)
: FilterInstance(parent)
, m_phase(0.0)
{
// Initialize the parameter buffer with the parent's default values
Initialize(parent->GetParameterCount());
m_parameters[TremoloFilter::ATTRIBUTE_RATE] = parent->m_rate;
m_parameters[TremoloFilter::ATTRIBUTE_DEPTH] = parent->m_depth;
}
AmAudioSample TremoloFilterInstance::ProcessSample(
AmAudioSample sample, AmUInt16 channel, AmUInt32 sampleRate)
{
const AmReal32 wet = m_parameters[TremoloFilter::ATTRIBUTE_WET];
const AmReal32 rate = m_parameters[TremoloFilter::ATTRIBUTE_RATE];
const AmReal32 depth = m_parameters[TremoloFilter::ATTRIBUTE_DEPTH];
// Compute the modulation: a sine wave oscillating between (1-depth) and 1
const auto modulation =
static_cast<AmReal32>(1.0 - depth * 0.5 * (1.0 - std::sin(2.0 * AM_PI * m_phase)));
// Advance the phase (only on channel 0 to keep channels in sync)
if (channel == 0)
{
m_phase += 1.0 / static_cast<AmReal64>(sampleRate);
if (m_phase >= 1.0 / static_cast<AmReal64>(rate))
m_phase -= 1.0 / static_cast<AmReal64>(rate);
}
// Mix dry and wet signals
const AmReal32 dry = static_cast<AmReal32>(sample);
const AmReal32 wetSample = dry * modulation;
return static_cast<AmAudioSample>(dry * (1.0f - wet) + wetSample * wet);
}
} // namespace MyGame
Processing Levels¶
The FilterInstance class offers three levels of processing granularity. Override the one that best fits your effect:
| Method | When to Use |
|---|---|
ProcessSample() | Simple per-sample effects (our tremolo example) |
ProcessChannel() | Effects that need to see the entire channel buffer at once |
Process() | Effects that need access to all channels simultaneously |
The default Process() implementation calls ProcessChannel() for each channel, and the default ProcessChannel() calls ProcessSample() for each sample. Override only the level you need.
Tip
For performance-critical effects, override Process() or ProcessChannel() directly to process audio in bulk rather than sample-by-sample. This allows you to use SIMD optimizations or batch operations.
Step 3: Register the Filter¶
Register your filter before the engine initializes. Use Engine::RegisterExtension<T>() which creates the instance and adds it to the internal registry:
#include <SparkyStudios/Audio/Amplitude/Amplitude.h>
#include "TremoloFilter.h"
using namespace SparkyStudios::Audio::Amplitude;
// Store the shared pointer so we can unregister later
static std::shared_ptr<MyGame::TremoloFilter> sTremoloPlugin;
int main(int argc, char* argv[])
{
ConsoleLogger logger(true);
Logger::SetLogger(&logger);
MemoryManager::Initialize();
// ... file system setup ...
// Register built-in extensions
Engine::RegisterDefaultExtensions();
// Register your custom filter
sTremoloPlugin = Engine::RegisterExtension<MyGame::TremoloFilter>();
// Initialize the engine
amEngine->Initialize(AM_OS_STRING("pc.config.amconfig"));
// ... load sound banks, run game loop, etc. ...
// Cleanup
amEngine->Deinitialize();
// Unregister your custom filter
Engine::UnregisterExtension(sTremoloPlugin);
Engine::UnregisterDefaultExtensions();
Engine::DestroyInstance();
MemoryManager::Deinitialize();
return 0;
}
Warning
Filters must be registered before calling amEngine->Initialize() and unregistered after calling amEngine->Deinitialize(). The engine locks the filter registry during initialization to prevent modifications while running.
Step 4: Create the Effect Asset¶
Create a JSON file in your project's effects/ directory. The effect field must match the name you passed to the Filter constructor ("Tremolo"):
{
"id": 10,
"name": "tremolo",
"effect": "Tremolo",
"parameters": [
{ "kind": "Static", "value": 1.0 },
{ "kind": "Static", "value": 4.0 },
{ "kind": "Static", "value": 0.5 }
]
}
The parameters array maps to your ATTRIBUTE enum in order:
| Index | Parameter | Value | Description |
|---|---|---|---|
| 0 | Wet | 1.0 | Fully wet (100% effect) |
| 1 | Rate | 4.0 | 4 Hz modulation speed |
| 2 | Depth | 0.5 | 50% modulation depth |
After creating the JSON file, compile your project to generate the binary .amfx asset.
Using RTPC for Dynamic Parameters¶
Parameters can be driven by RTPC values instead of static values. For example, to tie the tremolo depth to a game parameter:
{
"id": 10,
"name": "tremolo",
"effect": "Tremolo",
"parameters": [
{ "kind": "Static", "value": 1.0 },
{ "kind": "Static", "value": 4.0 },
{
"kind": "RTPC",
"rtpc": {
"id": 1,
"curve": {
"parts": [
{
"start": { "x": 0.0, "y": 0.0 },
"end": { "x": 1.0, "y": 1.0 },
"fader": "Linear"
}
]
}
}
}
]
}
This maps the RTPC value (0 to 1) directly to the tremolo depth (0 to 1) using a linear curve. As your game updates the RTPC value at runtime, the tremolo depth changes automatically.
Step 5: Assign the Effect to a Sound Object¶
Reference your effect in a sound object asset by its ID or name. The effect will be applied to the sound during playback through the audio pipeline.
Memory Management¶
When allocating memory inside your filter, use Amplitude's memory pool system:
// Allocate a buffer using the Filtering memory pool
auto* buffer = static_cast<AmReal32*>(
ampoolmalloc(eMemoryPoolKind_Filtering, size * sizeof(AmReal32)));
// Create shared pointers using the pool allocator
auto instance = ampoolshared(eMemoryPoolKind_Filtering, TremoloFilterInstance, this);
// Free pool-allocated memory
ampoolfree(eMemoryPoolKind_Filtering, buffer);
Using the eMemoryPoolKind_Filtering pool ensures your allocations are tracked and reported alongside the engine's internal filter allocations.
Summary¶
Creating a custom effect in Amplitude follows this pattern:
- Subclass
Filter— Define parameter metadata and implementCreateInstance() - Subclass
FilterInstance— Implement the DSP logic inProcessSample(),ProcessChannel(), orProcess() - Register — Call
Engine::RegisterExtension<T>()before engine initialization - Define the asset — Create a JSON file in
effects/with the filter name and parameter values - Build — Compile your project to generate the
.amfxbinary asset
The same pattern applies to all Amplitude plugins. For other plugin types, see:
- Custom Codec — Add support for new audio file formats
- Custom Driver — Implement a custom audio device backend
- Custom Fader — Create custom fade curve algorithms