Skip to content

Custom Resampler

This tutorial walks you through creating a custom resampler for the Amplitude engine. You will build a Linear Resampler — a simple sample rate converter using linear interpolation — and learn how to register it so codecs and the mixer can use it for runtime sample rate conversion.

Architecture Overview

In Amplitude, resampling is handled by the Resampler system. To create a custom resampler, you need two classes:

  1. Resampler — Defines the resampler name and acts as a factory for creating instances.
  2. ResamplerInstance — Holds the runtime state and performs the actual sample rate conversion.
classDiagram
    class Resampler {
        +CreateInstance() shared_ptr~ResamplerInstance~
    }

    class ResamplerInstance {
        +Initialize(channelCount, sampleRateIn, sampleRateOut)
        +Process(in, inputFrames, out, outputFrames) bool
        +SetRatio(inputPerOutput)
        +SetRatioRamp(inputPerOutputStart, inputPerOutputEnd, outputFrames)
        +IsConversionExact(sampleRateIn, sampleRateOut) bool
        +GetSampleRateIn() AmUInt32
        +GetSampleRateOut() AmUInt32
        +GetChannelCount() AmUInt16
        +GetInputFramesNeeded(outputFrameCount) AmUInt64
        +GetLatency() AmUInt64
        +Reset()
        +PrimeHistory(input, frames)
        +Clear()
    }

    Resampler --> ResamplerInstance : creates

At runtime, the engine creates a ResamplerInstance whenever a sound's sample rate does not match the output device rate, or when an encoder requests resampling. Process() is output-driven: the caller sets outputFrames to how many frames it wants, and the call reports back how many input frames it actually consumed and how many output frames it actually produced — never more than what the available input supports.

Step 1: Define the Resampler Classes

Create a header file for your custom resampler:

// LinearResampler.h

#pragma once

#include <SparkyStudios/Audio/Amplitude/Amplitude.h>

using namespace SparkyStudios::Audio::Amplitude;

class LinearResamplerInstance final : public ResamplerInstance
{
public:
    LinearResamplerInstance();
    ~LinearResamplerInstance() override = default;

    void Initialize(AmUInt16 channelCount, AmUInt32 sampleRateIn, AmUInt32 sampleRateOut) override;
    bool Process(const AudioBuffer& input, AmUInt64& inputFrames, AudioBuffer& output, AmUInt64& outputFrames) override;
    void SetRatio(AmReal64 inputPerOutput) override;

    [[nodiscard]] AmUInt32 GetSampleRateIn() const override { return _sampleRateIn; }
    [[nodiscard]] AmUInt32 GetSampleRateOut() const override { return _sampleRateOut; }
    [[nodiscard]] AmUInt16 GetChannelCount() const override { return _channelCount; }
    [[nodiscard]] AmUInt64 GetInputFramesNeeded(AmUInt64 outputFrameCount) const override;
    [[nodiscard]] AmUInt64 GetLatency() const override { return 0; }

    void Reset() override { _readPos = 0.0; }
    void Clear() override {}

private:
    AmUInt16 _channelCount;
    AmUInt32 _sampleRateIn;
    AmUInt32 _sampleRateOut;
    AmReal64 _ratio;   // Input frames consumed per output frame.
    AmReal64 _readPos; // Fractional read position in the input, carried across Process() calls.
};

class LinearResampler final : public Resampler
{
public:
    LinearResampler()
        : Resampler("Linear")
    {}

    std::shared_ptr<ResamplerInstance> CreateInstance() override
    {
        return ampoolshared(eMemoryPoolKind_Engine, LinearResamplerInstance);
    }
};

Step 2: Implement the Resampler

// LinearResampler.cpp

#include "LinearResampler.h"

LinearResamplerInstance::LinearResamplerInstance()
    : _channelCount(0)
    , _sampleRateIn(48000)
    , _sampleRateOut(48000)
    , _ratio(1.0)
    , _readPos(0.0)
{}

void LinearResamplerInstance::Initialize(AmUInt16 channelCount, AmUInt32 sampleRateIn, AmUInt32 sampleRateOut)
{
    _channelCount = channelCount;
    _sampleRateIn = sampleRateIn;
    _sampleRateOut = sampleRateOut;
    _ratio = static_cast<AmReal64>(sampleRateIn) / static_cast<AmReal64>(sampleRateOut);
}

void LinearResamplerInstance::SetRatio(AmReal64 inputPerOutput)
{
    // Non-finite or non-positive ratios are treated as 1, per the base class contract.
    _ratio = (inputPerOutput > 0.0 && std::isfinite(inputPerOutput)) ? inputPerOutput : 1.0;
}

AmUInt64 LinearResamplerInstance::GetInputFramesNeeded(AmUInt64 outputFrameCount) const
{
    if (outputFrameCount == 0)
        return 0;

    // The last requested output frame reads input at `_readPos + (outputFrameCount - 1) * _ratio`,
    // and linear interpolation needs the sample one frame past that.
    const AmReal64 lastReadPos = _readPos + static_cast<AmReal64>(outputFrameCount - 1) * _ratio;
    return static_cast<AmUInt64>(lastReadPos) + 2;
}

bool LinearResamplerInstance::Process(
    const AudioBuffer& input, AmUInt64& inputFrames,
    AudioBuffer& output, AmUInt64& outputFrames)
{
    const AmUInt64 wanted = outputFrames;
    AmUInt64 produced = 0;

    for (; produced < wanted; ++produced)
    {
        const AmUInt64 idx = static_cast<AmUInt64>(_readPos);
        if (idx + 1 >= inputFrames)
            break; // Not enough input left for another interpolated frame.

        const AmReal64 frac = _readPos - static_cast<AmReal64>(idx);

        for (AmUInt16 ch = 0; ch < _channelCount; ++ch)
        {
            const AmAudioSample* src = input[ch].begin();
            const AmAudioSample s0 = src[idx];
            const AmAudioSample s1 = src[idx + 1];
            output[ch].begin()[produced] = static_cast<AmAudioSample>(s0 + frac * (s1 - s0));
        }

        _readPos += _ratio;
    }

    // Report the whole input frames consumed and keep the fractional phase for the next call.
    const AmUInt64 consumed = AM_MIN(static_cast<AmUInt64>(_readPos), inputFrames);
    _readPos -= static_cast<AmReal64>(consumed);

    inputFrames = consumed;
    outputFrames = produced;
    return true;
}

Latency and rate changes

Rate changes go through SetRatio() / SetRatioRamp() — continuous and phase-preserving, so the read position never jumps. GetLatency() reports a single value, in input frames, relative to the input. Built-in resamplers centre their kernel on the output time and report 0; their read-ahead is already counted by GetInputFramesNeeded(). A plain linear interpolator has no such lookahead beyond the next input frame, so 0 is accurate here too.

Step 3: Register the Resampler

Before initializing the engine, register your resampler:

#include "LinearResampler.h"

int main(int argc, char* argv[])
{
    // ... initialize memory manager, file system, etc.

    // Register the custom resampler
    Resampler::Register(std::make_shared<LinearResampler>());

    // Register other extensions and initialize the engine
    Engine::RegisterDefaultExtensions();
    amEngine->Initialize(AM_OS_STRING("pc.config.amconfig"));
}

Registration order

Resamplers must be registered before amEngine->Initialize() is called. Once the engine is initialized, the resampler registry is locked.

How Resampling is Triggered

Amplitude automatically uses a resampler when:

  1. A sound file's sample rate differs from the output device rate.
  2. The AudioConverter DSP node needs to convert between formats.
  3. The amac tool requests resampling during encoding.

The engine selects the resampler by the name set in mixer.resampler (empty, or an unregistered name, falls back to the default resampler). Register your resampler under its own name and set mixer.resampler to that name to use it everywhere, or look it up yourself with Resampler::Find()/Resampler::Construct() to use it only in specific code paths (a custom codec, for instance).

Ratio Ramps

A sound whose playback speed moves during a block — pitch, Doppler, a runtime playback-rate change — publishes a ratio ramp rather than a single step, through SetRatioRamp(inputPerOutputStart, inputPerOutputEnd, outputFrames). Holding one constant ratio for a whole block makes the read position a staircase, which is audible as a click at every block boundary while the pitch glides.

SetRatioRamp() has a default implementation that collapses the ramp to its mean and calls SetRatio(): the stream advances correctly, but the read position keeps the staircase. Override it to vary the ratio per output frame instead, for example by linearly interpolating _ratio across the outputFrames of the next Process() call. The sum of the input consumed over the ramp must still match that of the mean ratio, so callers can size buffers and map positions from the mean regardless of which way you get there.

Exact Conversions

IsConversionExact(sampleRateIn, sampleRateOut) reports whether a given rate pair converts with no approximation of the ratio, so tools and asset pipelines can warn about rates that will be approximated. The base class default always returns true. A resampler whose ratio is a AmReal64 — like the LinearResamplerInstance above — can represent any pair of positive rates to float precision, so the inherited default is accurate and does not need to be overridden. Override it to return false for algorithms that snap to a fixed set of ratios, such as a polyphase resampler with a limited filter bank.

Priming History on a Seek

When a stream restarts mid-signal — a seek, or a start at an offset — the engine calls Reset() and then PrimeHistory(input, frames) with the samples that precede the new position, before the next Process() call. A resampler with real filter history (a FIR kernel, a delay line) uses this to seed that history so the first outputs read real signal on their left instead of zeros, avoiding the ringing of an abrupt onset. The base class default ignores the given frames, which is correct for LinearResamplerInstance: it has no history beyond the next input sample, so there's nothing to prime.

Built-in Resamplers

Amplitude ships one built-in resampler class, BandlimitedResampler, registered under five names that select different quality presets (from cheapest to best):

Name Preset Best For
linear Linear interpolation Cheapest; quick previews, tests
cubic Cubic interpolation Low cost with better quality than linear
sinc Band-limited sinc High quality, moderate cost
sinc_best Band-limited sinc, best settings Highest quality; used as default
default Same as sinc_best Used whenever mixer.resampler is empty or names an unregistered resampler

Next Steps