Custom Reverb
This tutorial walks you through creating a custom reverb algorithm for the Amplitude engine. You will build a Comb Reverb — a single feedback comb filter per channel — and learn how to register it so the Reverb pipeline node can use it.
Architecture Overview¶
In Amplitude, reverb is handled by the Reverb system. To create a custom reverb, you need two classes:
- Reverb — Defines the algorithm's name and acts as a factory for creating instances.
- ReverbInstance — Holds the runtime state (delay lines, filters) and performs the actual processing.
classDiagram
class Reverb {
+CreateInstance() shared_ptr~ReverbInstance~
}
class ReverbInstance {
+Initialize(sampleRate)
+SetRoomParameters(roomSize, absorption)
+Process(in, out, frames, sampleRate)
+Reset()
}
Reverb --> ReverbInstance : creates The Reverb pipeline node creates one ReverbInstance per layer, and keeps it updated with the room the sound is currently in: whenever that room changes, the node derives a normalized roomSize and absorption from the room's volume, surface area, and wall materials, and passes them to SetRoomParameters(). Your algorithm decides what those two numbers mean for its own parameters.
Step 1: Define the Reverb Classes¶
Create a header file for your custom reverb:
// CombReverb.h
#pragma once
#include <SparkyStudios/Audio/Amplitude/Amplitude.h>
#include <vector>
using namespace SparkyStudios::Audio::Amplitude;
class CombReverbInstance final : public ReverbInstance
{
public:
explicit CombReverbInstance(Reverb* parent);
~CombReverbInstance() override = default;
void Initialize(AmUInt32 sampleRate) override;
void Reset() override;
void SetRoomParameters(AmReal32 roomSize, AmReal32 absorption) override;
void Process(const AudioBuffer& in, AudioBuffer& out, AmUInt64 frames, AmUInt32 sampleRate) override;
private:
static constexpr AmReal32 kMaxDelayMs = 80.0f;
std::vector<std::vector<AmAudioSample>> _delay; // One ring buffer per channel.
AmUInt32 _writePos;
AmUInt32 _delaySamples; // Current delay length, derived from room size.
AmReal32 _feedback; // Derived from absorption.
};
class CombReverb final : public Reverb
{
public:
CombReverb()
: Reverb("Comb")
{}
std::shared_ptr<ReverbInstance> CreateInstance() override
{
return ampoolshared(eMemoryPoolKind_Filtering, CombReverbInstance, this);
}
};
Step 2: Implement the Reverb¶
// CombReverb.cpp
#include "CombReverb.h"
#include <algorithm>
CombReverbInstance::CombReverbInstance(Reverb* parent)
: ReverbInstance(parent)
, _writePos(0)
, _delaySamples(0)
, _feedback(0.0f)
{}
void CombReverbInstance::Initialize(AmUInt32 sampleRate)
{
// Size every channel's delay line for the longest room we'll ever report, so Process()
// never needs to allocate.
const auto maxDelaySamples = static_cast<AmUInt32>(static_cast<AmReal32>(sampleRate) * kMaxDelayMs / 1000.0f);
_delay.assign(kAmMaxLayoutChannelCount, std::vector<AmAudioSample>(maxDelaySamples, 0.0f));
_delaySamples = maxDelaySamples / 2; // A sensible default until SetRoomParameters() is called.
_feedback = 0.5f;
}
void CombReverbInstance::Reset()
{
for (auto& channel : _delay)
std::fill(channel.begin(), channel.end(), 0.0f);
_writePos = 0;
}
void CombReverbInstance::SetRoomParameters(AmReal32 roomSize, AmReal32 absorption)
{
const auto maxDelaySamples = static_cast<AmUInt32>(_delay.empty() ? 0 : _delay[0].size());
_delaySamples = static_cast<AmUInt32>(AM_CLAMP(roomSize, 0.0f, 1.0f) * static_cast<AmReal32>(maxDelaySamples));
// More absorption means the room swallows more energy per bounce, so less feeds back.
_feedback = 0.85f * (1.0f - AM_CLAMP(absorption, 0.0f, 1.0f));
}
void CombReverbInstance::Process(const AudioBuffer& in, AudioBuffer& out, AmUInt64 frames, AmUInt32 sampleRate)
{
const AmUInt16 channels = in.GetChannelCount();
const auto bufferSize = static_cast<AmUInt32>(_delay[0].size());
const AmUInt32 delaySamples = AM_MAX(_delaySamples, 1u);
for (AmUInt64 i = 0; i < frames; ++i)
{
const AmUInt32 readPos = (_writePos + bufferSize - delaySamples) % bufferSize;
for (AmUInt16 ch = 0; ch < channels; ++ch)
{
std::vector<AmAudioSample>& delayLine = _delay[ch];
const AmAudioSample wet = delayLine[readPos];
out[ch][i] = in[ch][i] + wet;
delayLine[_writePos] = in[ch][i] + wet * _feedback;
}
_writePos = (_writePos + 1) % bufferSize;
}
}
One write position for every channel
_writePos and readPos advance once per frame, shared by every channel: all channels are the same number of frames long and move through their delay lines in lockstep. Only the delay line contents differ per channel.
Step 3: Register the Reverb¶
Before initializing the engine, register your reverb:
#include "CombReverb.h"
int main(int argc, char* argv[])
{
// ... initialize memory manager, file system, etc.
// Register the custom reverb
Reverb::Register(std::make_shared<CombReverb>());
// Register built-in extensions and initialize the engine
Engine::RegisterDefaultExtensions();
amEngine->Initialize(AM_OS_STRING("pc.config.amconfig"));
}
Registration order
Reverbs must be registered before amEngine->Initialize() is called. Once the engine is initialized, the reverb registry is locked.
Step 4: Drive It From a Pipeline Node¶
The built-in Reverb pipeline node (and its Freeverb/Dattorro/RoomReverb siblings) is an internal class, not part of the public API, so it can't be reused directly with a different algorithm name. To make your reverb selectable from a pipeline asset, write a small custom pipeline node that looks your algorithm up by name and drives it — the same Reverb::Find() / ReverbInstance calls the built-in node makes internally:
// CombReverbNode.h
#pragma once
#include <SparkyStudios/Audio/Amplitude/Amplitude.h>
using namespace SparkyStudios::Audio::Amplitude;
class CombReverbNodeInstance final : public ProcessorNodeInstance
{
public:
CombReverbNodeInstance()
: _reverb(Reverb::Find("Comb")->CreateInstance())
, _lastRoomId(0)
{}
void Configure(AmUInt64 frameCount, AmUInt16 channelCount) override
{
ProcessorNodeInstance::Configure(frameCount, channelCount);
_reverb->Initialize(amEngine->GetMixer()->GetSampleRate());
}
void Reset() override { _reverb->Reset(); }
protected:
const AudioBuffer* Process(const AudioBuffer* input) override
{
if (input == nullptr)
return nullptr;
UpdateRoomParameters();
_output = *input;
_reverb->Process(*input, _output, input->GetFrameCount(), amEngine->GetMixer()->GetSampleRate());
return &_output;
}
private:
void UpdateRoomParameters()
{
const Room room = GetLayer()->GetRoom();
if (!room.Valid() || room.GetId() == _lastRoomId)
return;
_lastRoomId = room.GetId();
AmReal32 maxSurface = 0.0f;
for (AmUInt32 i = 0; i < kAmRoomSurfaceCount; ++i)
maxSurface = AM_MAX(maxSurface, room.GetSurfaceArea(static_cast<eRoomWall>(i)));
const AmReal32 roomSize = maxSurface > 0.0f ? room.GetVolume() / (maxSurface * std::sqrt(maxSurface)) : 0.0f;
AmReal32 absorption = 0.0f;
for (AmUInt32 i = 0; i < kAmRoomSurfaceCount; ++i)
{
const RoomWallMaterial& material = room.GetWallMaterial(static_cast<eRoomWall>(i));
for (AmReal32 coefficient : material.m_absorptionCoefficients)
absorption += coefficient;
}
absorption /= static_cast<AmReal32>(kAmRoomSurfaceCount * 9);
_reverb->SetRoomParameters(roomSize, absorption);
}
std::shared_ptr<ReverbInstance> _reverb;
AmRoomID _lastRoomId;
};
class CombReverbNode final : public Node
{
public:
CombReverbNode()
: Node("Comb")
{}
std::shared_ptr<NodeInstance> CreateInstance() const override
{
return ampoolshared(eMemoryPoolKind_Amplimix, CombReverbNodeInstance);
}
};
Register the node alongside the reverb algorithm, before amEngine->Initialize():
Reverb::Register(std::make_shared<CombReverb>());
Node::Register(std::make_shared<CombReverbNode>());
This is a simplified room mapping
Averaging every wall's absorption bands into one number, as UpdateRoomParameters() does above, is simpler than the weighted, frequency-aware mapping the built-in reverb nodes use — tune it to whatever your own algorithm actually responds well to.
Room Parameters¶
SetRoomParameters(roomSize, absorption) is called whenever the sound's room changes, including when it first gets one. Both values are normalized:
roomSizecomes from the room's volume relative to its largest wall's surface area — bigger, more cube-like rooms report a larger value.absorptionis the average absorption coefficient across the room's walls, from their wall materials.
Map these however suits your algorithm — the comb filter above uses roomSize for delay length and absorption for feedback (and so, decay time), which is a reasonable starting point but not the only valid mapping.
Thread Safety¶
Like pipeline nodes and resamplers, Process() runs on the audio thread. It must be lock-free, allocation-free, and deterministic. Initialize() runs on the game thread and may allocate — do all your sizing there, as CombReverbInstance does.
Next Steps¶
- Review the Built-in Pipeline Nodes Reference for how
ReverbNodedispatches to a reverb algorithm. - Learn how Rooms shape the parameters your reverb receives.
- Explore the built-in
Freeverb,Dattorro, andRoomReverbimplementations in the SDK source for production-grade examples.