SPANDAN (स्पंदन)

Advanced Real-Time Polyphonic Synthesizer

By Prashant Singh & Parth Srivastava

Chapter 6: The Architecture of Dynamic DSP – C++ Polymorphism, Real-Time Audio Constraints, and Lock-Free Object Pools

In Chapters 1 through 5 of the SPANDAN (स्पंदन) Cookbook, we constructed the foundational signal processing and visual engine for our polyphonic synthesizer: the Numerically Controlled Oscillator (NCO) phase engine, Moog-Ussachevsky exponential ADSR envelopes, 2-pole Topology-Preserving Transform (TPT) State Variable Filters, polyphonic voice allocators with algorithmic voice stealing, and real-time Radix-2 FFT spectral visualizers

However, modern software synthesizers and modular audio engines require dynamic module routing—allowing sound designers to reconfigure signal flow on the fly (e.g., placing a filter before or after a distortion effect, chaining multiple oscillators, or inserting custom modulation blocks).

In traditional software development, dynamic module management is implemented via Object-Oriented Polymorphism (using virtual base classes and std::vector<std::unique_ptr<IDspModule>>) with runtime dynamic allocation (new / delete). In real-time digital signal processing, this naïve object-oriented approach breaks down catastrophically.

In this chapter, we explore why dynamic memory allocation is strictly prohibited on the high-priority audio callback thread, how standard C++ dynamic polymorphism impacts processor cache performance, and how Pre-Allocated Object Pools combined with Lock-Free Pointer Graph Routing enable completely safe, zero-latency, runtime module re-routing in C++.


📋 Table of Contents

  1. The Real-Time Audio Callback Thread & The Zero-Allocation Mandate
  2. C++ Polymorphism in Real-Time Systems: Vtables, Cache Locality, and CRTP
  3. The Mechanics of Pre-Allocated Object Pools
  4. Lock-Free Runtime Module Routing & Double-Buffered DSP Graphs
  5. C++ Implementation: Production-Grade ObjectPool & AudioGraphRouter
  6. Memory Allocation Auditing & Benchmarking Protocols
  7. Frequently Asked Interview & Viva Questions
  8. Summary Matrix: Polymorphism Architectural Trade-Offs

1. The Real-Time Audio Callback Thread & The Zero-Allocation Mandate

The Hard Hardware Deadline

When an audio plugin executes inside a Digital Audio Workstation (DAW) or standalone engine, the operating system’s hardware audio driver invokes the audio callback function (processBlock / processAudioFrame) at fixed buffer intervals. At a standard sample rate of $f_s = 44.1\text{ kHz}$ with a buffer block size of $N = 512\text{ samples}$, the audio thread must process all $512$ samples within a strict time frame:

\[\Delta t_{\text{buffer}} = \frac{N}{f_s} = \frac{512}{44100\text{ Hz}} \approx 11.61\text{ milliseconds}\]

If the total processing duration across all voices, oscillators, filters, and effect modules exceeds $11.61\text{ ms}$, the audio hardware buffer runs dry before the hardware sound card can transmit samples to the Digital-to-Analog Converter (DAC). This results in a buffer underrun, producing audible dropouts, clicks, and pops.

+-----------------------------------------------------------------------------------+
|                        OS Audio Callback Thread (processBlock)                   |
|                                                                                   |
|  [Hardware Interrupt] ---> [Read Input] ---> [DSP Math Execution] ---> [Write Out]|
|                                                          |                        |
|                                                          v                        |
|                                        STRICT TIME BUDGET: 11.61 ms               |
+-----------------------------------------------------------------------------------+

Why Dynamic Memory Allocation (new, malloc, std::vector) is Forbidden

In standard desktop software, allocating memory on the system heap using malloc, new, std::vector::push_back, or std::make_shared is considered fast. However, heap allocation in operating systems is non-deterministic:

  1. Free-List Traversal & OS Paging: When malloc or new is invoked, the OS memory manager must scan internal heap data structures (such as bins, free-lists, or buddy allocators) to find a contiguous block of memory. If a new page is required, the OS may trigger a page fault, suspending the calling thread while swapping virtual memory pages.
  2. Global Heap Mutex Locks: Standard C runtime allocators (e.g., ptmalloc, jemalloc) utilize internal thread synchronization locks (mutexes) to prevent multi-threaded memory corruption. If another background process or GUI thread holds the heap lock, the high-priority audio callback thread is forced to sleep until the lock is released.
  3. Non-Deterministic Execution Time: While average allocation times are microsecond-scale, worst-case heap allocation times can spike to tens of milliseconds. In real-time audio, worst-case timing governs system stability.

Therefore, SPANDAN enforces the 4 Golden Rules of Real-Time Audio Programming:


2. C++ Polymorphism in Real-Time Systems: Vtables, Cache Locality, and CRTP

To build modular synthesizers, DSP engineers often define abstract C++ interface classes:

class IDspModule
{
public:
    virtual ~IDspModule() = default;
    virtual void reset(double sampleRate) noexcept = 0;
    virtual float processSample(float input) noexcept = 0;
};

The Cost of Dynamic Polymorphism (Virtual Table Dispatch)

While virtual functions provide clean abstraction, invoking virtual float processSample(float) inside a per-sample audio loop ($44,100$ calls per second per voice) introduces subtle real-time overhead:

  1. Vtable Indirection: Calling a virtual function requires dereferencing the object’s virtual table pointer (vptr), indexing into the virtual function table (vtable), and performing an indirect branch jump to the target function memory address.
  2. Branch Predictor Misses: When iterating over an array of heterogeneous IDspModule* pointers (e.g., Oscillator -> Filter -> Delay), CPU hardware branch predictors struggle to predict indirect call targets, invalidating the CPU instruction pipeline.
  3. Cache Line Thrashing (Pointer Chasing): In standard dynamic allocation, objects derived from IDspModule reside scattered across non-contiguous locations in heap memory. Iterating through a vector of pointers (std::vector<IDspModule*>) causes CPU L1/L2 cache misses, forcing processor stalls while fetching data from slow main RAM.

Static Polymorphism via Curiously Recurring Template Pattern (CRTP)

For fixed DSP chains where module types are known at compile time, static polymorphism via CRTP eliminates vtable indirection entirely while preserving interface constraints:

template <typename Derived>
class DspModuleBase
{
public:
    void reset(double sampleRate) noexcept
    {
        static_cast<Derived*>(this)->resetImpl(sampleRate);
    }

    float processSample(float input) noexcept
    {
        return static_cast<Derived*>(this)->processSampleImpl(input);
    }
};

class BiquadFilter : public DspModuleBase<BiquadFilter>
{
public:
    void resetImpl(double sampleRate) noexcept { /* Reset filter state */ }
    float processSampleImpl(float input) noexcept { /* Filter difference equation */ return input; }
};

CRTP enables full compiler inlining, zero-cost function dispatch, and loop vectorization. However, when users want to dynamically insert, swap, or re-route DSP modules at runtime via the UI, compile-time CRTP cannot reconfigure the processing graph. To reconcile runtime flexibility with real-time audio safety, we combine dynamic virtual interfaces with Pre-Allocated Object Pools.


3. The Mechanics of Pre-Allocated Object Pools

An Object Pool is a creational design pattern that pre-allocates a fixed array of objects in contiguous memory during initialization (prepareToPlay), managing their active/idle lifetimes without ever requesting heap memory from the operating system during runtime.

+-----------------------------------------------------------------------------------+
|                        PRE-ALLOCATED CONTIGUOUS OBJECT POOL                       |
|                                                                                   |
|  +------------------+------------------+------------------+------------------+    |
|  | Module Slot 0    | Module Slot 1    | Module Slot 2    | Module Slot 3    |    |
|  | [Oscillator]     | [StateVarFilter] | [AudioDelay]     | [Unallocated]    |    |
|  | Active: TRUE     | Active: TRUE     | Active: TRUE     | Active: FALSE    |    |
|  +------------------+------------------+------------------+------------------+    |
|                                                                                   |
|  Contiguous Stack/Heap Memory Arena (Zero Allocation During Runtime)               |
+-----------------------------------------------------------------------------------+

Key Architectural Benefits of Object Pools in Audio DSP

  1. Guaranteed $O(1)$ Allocation & Release: Acquiring a module from the pool or returning it to the pool involves updating integer index pointers or bitmasks, executing in deterministic $O(1)$ time.
  2. Optimal L1/L2 CPU Cache Locality: Because pool entries are allocated contiguously in a single block of memory, sequential processing of pooled DSP modules maximizes CPU cache line hits.
  3. Zero Fragmentation: Pre-allocating maximum capacity at setup eliminates memory fragmentation over long DAW production sessions.

4. Lock-Free Runtime Module Routing & Double-Buffered DSP Graphs

Managing dynamic module routing requires transferring configuration updates from the user interface (GUI thread) to the audio rendering callback (processBlock) without using mutexes or causing priority inversion.

Double-Buffered Graph Routing

SPANDAN uses a Double-Buffered Graph Structure:

[ GUI Thread ] ---> (Modifies Pending Graph) ---> [ Swap Atomic Pointer ]
                                                          |
                                                          v
[ Audio Thread ] <--- (Reads Active Graph) <--- [ Active Atomic Pointer ]

When the user finishes re-ordering modules on the graphical patcher, the GUI thread stages the new sequence of module indices in the writeGraph and executes an atomic pointer swap using std::atomic<Graph*>::store with std::memory_order_release. The audio thread reads the active graph via std::memory_order_acquire at the beginning of the next audio block, switching the signal routing instantly without blocking or missing a sample.


5. C++ Implementation: Production-Grade ObjectPool & AudioGraphRouter

Here is the complete, self-contained C++20 implementation of an abstract DSP module interface, a pre-allocated object pool, and a lock-free graph router designed for Project SPANDAN.

#pragma once

#include <iostream>
#include <array>
#include <vector>
#include <atomic>
#include <memory>
#include <cmath>
#include <algorithm>

// ============================================================================
// 1. ABSTRACT DSP MODULE INTERFACE
// ============================================================================
class IDspModule
{
public:
    virtual ~IDspModule() = default;
    
    virtual void prepareToPlay(double sampleRate) noexcept = 0;
    virtual void reset() noexcept = 0;
    virtual float processSample(float input) noexcept = 0;
    virtual const char* getName() const noexcept = 0;
};

// ============================================================================
// 2. CONCRETE DSP MODULE DERIVATIONS
// ============================================================================

// --- Module A: Oscillator Generator ---
class OscillatorModule : public IDspModule
{
public:
    OscillatorModule() noexcept = default;

    void prepareToPlay(double sampleRate) noexcept override
    {
        currentSampleRate = sampleRate;
        phaseIncrement = 440.0f / static_cast<float>(sampleRate);
        reset();
    }

    void reset() noexcept override { phase = 0.0f; }

    float processSample(float input) noexcept override
    {
        // Generate Sine Wave and mix with input
        const float out = std::sin(2.0f * 3.14159265f * phase) + input;
        phase += phaseIncrement;
        if (phase >= 1.0f) phase -= 1.0f;
        return out;
    }

    const char* getName() const noexcept override { return "OscillatorModule"; }

private:
    double currentSampleRate { 44100.0 };
    float phase { 0.0f };
    float phaseIncrement { 0.0f };
};

// --- Module B: State Variable Low-Pass Filter ---
class FilterModule : public IDspModule
{
public:
    FilterModule() noexcept = default;

    void prepareToPlay(double sampleRate) noexcept override
    {
        currentSampleRate = sampleRate;
        reset();
    }

    void reset() noexcept override { s1 = 0.0f; s2 = 0.0f; }

    float processSample(float input) noexcept override
    {
        // Simple 1-pole recursive low-pass smoothing
        const float alpha = 0.1f;
        s1 = s1 + alpha * (input - s1);
        return s1;
    }

    const char* getName() const noexcept override { return "FilterModule"; }

private:
    double currentSampleRate { 44100.0 };
    float s1 { 0.0f };
    float s2 { 0.0f };
};

// --- Module C: Simple Delay Line ---
class DelayModule : public IDspModule
{
public:
    DelayModule() noexcept = default;

    void prepareToPlay(double sampleRate) noexcept override
    {
        buffer.fill(0.0f);
        writeIndex = 0;
    }

    void reset() noexcept override
    {
        buffer.fill(0.0f);
        writeIndex = 0;
    }

    float processSample(float input) noexcept override
    {
        const float delayed = buffer[writeIndex];
        buffer[writeIndex] = input;
        writeIndex = (writeIndex + 1) % buffer.size();
        return input + 0.5f * delayed;
    }

    const char* getName() const noexcept override { return "DelayModule"; }

private:
    std::array<float, 4410> buffer {}; // 100ms fixed buffer
    size_t writeIndex { 0 };
};

// ============================================================================
// 3. PRE-ALLOCATED FIXED-CAPACITY OBJECT POOL
// ============================================================================
template <size_t MaxModules = 16>
class DspModuleObjectPool
{
public:
    DspModuleObjectPool() noexcept
    {
        for (size_t i = 0; i < MaxModules; ++i)
            availableIndices[i] = i;
        availableCount.store(MaxModules, std::memory_order_relaxed);
    }

    // Pre-allocate instances in pool memory arena at setup-time
    template <typename T, typename... Args>
    T* allocateModule(Args&&... args) noexcept
    {
        if (allocatedCount >= MaxModules)
            return nullptr; // Pool capacity exceeded

        // Allocate inside fixed pool array arena
        auto* rawPtr = new (&storage[allocatedCount * sizeof(T)]) T(std::forward<Args>(args)...);
        allocatedCount++;
        return rawPtr;
    }

    void prepareAll(double sampleRate) noexcept
    {
        for (auto* mod : activeModules)
        {
            if (mod) mod->prepareToPlay(sampleRate);
        }
    }

private:
    alignas(16) std::array<uint8_t, MaxModules * 1024> storage {};
    std::array<size_t, MaxModules> availableIndices {};
    std::atomic<size_t> availableCount { MaxModules };
    std::vector<IDspModule*> activeModules;
    size_t allocatedCount { 0 };
};

// ============================================================================
// 4. LOCK-FREE ROUTING GRAPH & AUDIO PROCESSOR ENGINE
// ============================================================================
template <size_t MaxChainLength = 8>
class AudioGraphRouter
{
public:
    struct ChainConfig
    {
        std::array<IDspModule*, MaxChainLength> modules {};
        size_t activeCount { 0 };
    };

    AudioGraphRouter() noexcept
    {
        activeGraphIndex.store(0, std::memory_order_relaxed);
    }

    // Called on GUI / Message thread to stage a new processing order
    bool updateRoutingChain(const std::vector<IDspModule*>& newOrder) noexcept
    {
        if (newOrder.size() > MaxChainLength)
            return false;

        const size_t nextIndex = 1 - activeGraphIndex.load(std::memory_order_relaxed);
        auto& targetChain = graphs[nextIndex];

        targetChain.activeCount = newOrder.size();
        for (size_t i = 0; i < newOrder.size(); ++i)
        {
            targetChain.modules[i] = newOrder[i];
        }

        // Atomic Release Swap: Ensures targetChain is completely written before swapping index
        activeGraphIndex.store(nextIndex, std::memory_order_release);
        return true;
    }

    // Hard Real-Time Audio Callback Thread Function
    float processSample(float input) noexcept
    {
        // Atomic Acquire Read: Ensures thread safe read of the active graph
        const size_t currentIndex = activeGraphIndex.load(std::memory_order_acquire);
        const auto& activeChain = graphs[currentIndex];

        float currentSample = input;
        for (size_t i = 0; i < activeChain.activeCount; ++i)
        {
            if (activeChain.modules[i])
            {
                currentSample = activeChain.modules[i]->processSample(currentSample);
            }
        }
        return currentSample;
    }

private:
    std::array<ChainConfig, 2> graphs {}; // Double-buffered graph configuration
    std::atomic<size_t> activeGraphIndex { 0 };
};

6. Memory Allocation Auditing & Benchmarking Protocols

To verify that SPANDAN’s dynamic module routing engine adheres strictly to real-time audio standards, the following automated profiling tools are executed in CI/CD pipelines:

  1. Valgrind Massif & Heaptrack Auditing: Override standard C malloc and C++ operator new symbols to assert that zero calls occur inside processBlock.
  2. RAII Real-Time Assertions: Insert custom debug assertions inside the process callback that trip an assertion failure if dynamic allocation occurs.
  3. Microsecond Callback Benchmarking: Profile execution latency using OS high-resolution microsecond timers (std::chrono::high_resolution_clock) to guarantee callback completion well below $11.61\text{ ms}$.
// Real-Time Memory Allocation Guard Macro
#ifndef NDEBUG
struct ScopedAllocationGuard
{
    ScopedAllocationGuard() noexcept { allocationAllowed = false; }
    ~ScopedAllocationGuard() noexcept { allocationAllowed = true; }
    static inline thread_local bool allocationAllowed { true };
};
#define ASSERT_REALTIME_SAFE ScopedAllocationGuard guard;
#else
#define ASSERT_REALTIME_SAFE
#endif

7. Summary Matrix: Polymorphism Architectural Trade-Offs

Polymorphism Paradigm Real-Time Safety Memory Allocation Strategy Runtime Flexibility Best Use Case
Dynamic Polymorphism (new/delete) ❌ Unsafe (Causes underruns) Dynamic Heap (malloc/new) High (Unrestricted) Desktop GUI / Non-real-time utilities
Static Polymorphism (CRTP Templates) ✅ Hard Real-Time Safe Stack / Pre-allocated Heap None (Compile-time fixed) Monophonic DSP core, NCOs, Filters
Pooled Dynamic Polymorphism ✅ Hard Real-Time Safe Pre-allocated Object Pool High (Bounded Pool Capacity) Modular FX chains, Voice Allocators
Lock-Free Double-Buffered Graph ✅ Hard Real-Time Safe Atomic Index/Pointer Swaps High (Thread-safe re-routing) Dynamic Plugin Patchers & Audio Engines
Stay Updated
💻 GitHub Repo 🔗 Prashant's LinkedIn 🔗 Parth's LinkedIn