Custom Codec
This tutorial walks you through creating a custom audio codec for the Amplitude engine. You will build a Raw PCM codec — a simple reader for uncompressed raw audio data — and learn how to register it as a plugin so the engine can load your files at runtime.
Architecture Overview¶
In Amplitude, codecs are built on top of the Codec system. To create a custom codec, you need three classes:
- Codec — Defines the codec metadata (name) and acts as a factory for creating decoders and encoders.
- Decoder — Reads audio data from a file into an
AudioBuffer. Supports both full loading and streaming. - Encoder — Writes audio data from an
AudioBufferinto a file.
classDiagram
class Codec {
+CreateDecoder() shared_ptr~Decoder~
+CreateEncoder() shared_ptr~Encoder~
+CanHandleFile(file) bool
}
class Decoder {
+Open(file) bool
+Close() bool
+Load(out) AmUInt64
+Stream(out, bufferOffset, seekOffset, length) AmUInt64
+Seek(offset) bool
}
class Encoder {
+Open(file) bool
+Close() bool
+SetFormat(format)
+Write(in, offset, length) AmUInt64
}
Codec --> Decoder : creates
Codec --> Encoder : creates At runtime, the engine uses CanHandleFile() to automatically pick the right codec for each audio file. When a sound is played, the engine creates a Decoder instance to read the data.
Step 1: Define the Decoder¶
Create a header file for your custom codec. The Decoder subclass handles reading audio data from the file system:
// RawPCMCodec.h
#pragma once
#include <SparkyStudios/Audio/Amplitude/Amplitude.h>
using namespace SparkyStudios::Audio::Amplitude;
class RawPCMCodec final : public Codec
{
public:
class RawPCMDecoder final : public Decoder
{
public:
explicit RawPCMDecoder(const Codec* codec)
: Decoder(codec)
, _initialized(false)
{}
bool Open(std::shared_ptr<File> file) override
{
if (!m_codec->CanHandleFile(file))
return false;
_file = file;
// Raw PCM format: 48kHz, stereo, 16-bit signed integer
m_format.SetAll(
48000, // sampleRate
2, // channels
16, // bitsPerSample
_file->Length() / 4, // total frames (4 bytes per frame: 2 channels × 2 bytes)
4, // frame size
eAudioSampleFormat_Int16
);
_initialized = true;
return true;
}
bool Close() override
{
if (!_initialized)
return true;
_file.reset();
m_format = SoundFormat();
_initialized = false;
return true;
}
AmUInt64 Load(AudioBuffer* out) override
{
return Stream(out, 0, 0, m_format.GetFramesCount());
}
AmUInt64 Stream(AudioBuffer* out, AmUInt64 bufferOffset, AmUInt64 seekOffset, AmUInt64 length) override
{
if (!_initialized)
return 0;
Seek(seekOffset);
const AmUInt64 framesToRead = AM_MIN(length, m_format.GetFramesCount() - seekOffset);
const AmUInt64 bytesToRead = framesToRead * m_format.GetFrameSize();
std::vector<AmInt16> pcmData(framesToRead * m_format.GetNumChannels());
_file->Read(reinterpret_cast<AmUInt8Buffer>(pcmData.data()), bytesToRead);
// Convert interleaved int16 to de-interleaved float32 in the AudioBuffer
for (AmUInt16 ch = 0; ch < m_format.GetNumChannels(); ++ch)
{
for (AmUInt64 i = 0; i < framesToRead; ++i)
{
const AmInt16 sample = pcmData[i * m_format.GetNumChannels() + ch];
(*out)[ch][bufferOffset + i] = AmInt16ToReal32(sample);
}
}
return framesToRead;
}
bool Seek(AmUInt64 offset) override
{
if (!_initialized)
return false;
const AmUInt64 byteOffset = offset * m_format.GetFrameSize();
_file->Seek(static_cast<AmInt64>(byteOffset), eFileSeekOrigin_Start);
return true;
}
private:
std::shared_ptr<File> _file;
bool _initialized;
};
class RawPCMEncoder final : public Encoder
{
public:
explicit RawPCMEncoder(const Codec* codec)
: Encoder(codec)
, _initialized(false)
{}
bool Open(std::shared_ptr<File> file) override
{
_file = file;
_initialized = true;
return true;
}
bool Close() override
{
if (!_initialized)
return true;
_file.reset();
_initialized = false;
return true;
}
AmUInt64 Write(AudioBuffer* in, AmUInt64 offset, AmUInt64 length) override
{
if (!_initialized)
return 0;
const AmUInt16 channels = in->GetChannelCount();
std::vector<AmInt16> pcmData(length * channels);
for (AmUInt16 ch = 0; ch < channels; ++ch)
{
for (AmUInt64 i = 0; i < length; ++i)
{
pcmData[i * channels + ch] = AmReal32ToInt16((*in)[ch][offset + i]);
}
}
_file->Write(
reinterpret_cast<AmConstUInt8Buffer>(pcmData.data()),
length * channels * sizeof(AmInt16));
return length;
}
private:
std::shared_ptr<File> _file;
bool _initialized;
};
RawPCMCodec()
: Codec("raw")
{}
~RawPCMCodec() override = default;
std::shared_ptr<Decoder> CreateDecoder() override
{
return ampoolshared(eMemoryPoolKind_Codec, RawPCMDecoder, this);
}
std::shared_ptr<Encoder> CreateEncoder() override
{
return ampoolshared(eMemoryPoolKind_Codec, RawPCMEncoder, this);
}
bool CanHandleFile(std::shared_ptr<File> file) const override
{
if (!file)
return false;
// Accept any file with the .raw extension
const AmString path = file->GetPath();
return path.size() > 4 && path.compare(path.size() - 4, 4, ".raw") == 0;
}
};
Step 2: Register the Codec¶
Before initializing the engine, register your codec:
#include "RawPCMCodec.h"
int main(int argc, char* argv[])
{
// ... initialize memory manager, file system, etc.
// Register the custom codec
Codec::Register(std::make_shared<RawPCMCodec>());
// Now initialize the engine
amEngine->Initialize(AM_OS_STRING("pc.config.amconfig"));
// The engine will automatically use the RawPCMCodec for any .raw files
}
Registration order
Codecs must be registered before amEngine->Initialize() is called. Once the engine is initialized, the codec registry is locked.
Step 3: Create a Sound Asset¶
Create a sound asset JSON file that references your raw audio file:
{
"id": 42,
"name": "ambient_wind",
"path": "sounds/ambient_wind.raw",
"stream": false,
"loop": {
"enabled": true,
"loop_count": 0
}
}
Compile the project with build_project.py and load the bank at runtime. The engine will automatically route .raw files to your custom codec.
Step 4: Streaming vs Loading¶
The Decoder interface supports two modes of operation:
- Load mode: The entire file is read into memory in one call to
Load(). This is ideal for short sound effects. - Stream mode: Small chunks are read on demand via
Stream(). This is required for long music or ambient tracks to keep memory usage low.
When stream: true is set in the sound asset, the engine will call Stream() repeatedly during playback. Your implementation must be thread-safe, as Stream() may be called from the audio thread.
Memory Management¶
Always use Amplitude's pool-aware allocation macros when allocating memory inside your codec:
// Good: uses the Codec memory pool
void* buffer = ampoolmalloc(eMemoryPoolKind_Codec, size);
// Bad: bypasses the engine's memory tracking
void* buffer = malloc(size);
The eMemoryPoolKind_Codec pool is reserved for codec allocations. Using it allows the engine to track memory usage and report statistics.
Thread Safety¶
Both Decoder::Stream() and Encoder::Write() may be called from the audio processing thread. Ensure your implementation does not perform blocking I/O inside these methods. If you need to perform heavy work, do it in Open() or Load(), which are called from the main thread.
Next Steps¶
- Learn how to write custom effects to process audio at runtime.
- Explore the Codec API Reference for the full interface.
- Look at the built-in WAV and MP3 codecs in the SDK source for production-ready examples.