SPANDAN (स्पंदन)

Advanced Real-Time Polyphonic Synthesizer

By Prashant Singh & Parth Srivastava

Chapter 8: The Quantum Canvas – Bandlimited Wavetable Synthesis, Wave Morphing, Preset State Management, and Production VST3/AU/Standalone Deployment

📋 Table of Contents


1. Executive Overview & Theoretical Objectives

In Chapters 1 through 7 of the SPANDAN (स्पंदन) Cookbook, we designed a comprehensive software synthesis engine:

  1. Chapter 1: Numerically Controlled Oscillators (NCO) using discrete-time phase accumulation.
  2. Chapter 2: Analog-modeled exponential ADSR envelope generators using Moog-Ussachevsky RC state-machine math.
  3. Chapter 3: Dual-oscillator linear superposition, cents-based detuning, Vadim Zavalishin 2-pole Topology-Preserving Transform (TPT) Zero-Delay Feedback State Variable Filters (SVF), and real-time zero-crossing oscilloscopes.
  4. Chapter 4: Polyphonic voice allocation, thread-safe voice stealing, and John Chowning Frequency Modulation (FM/PM) synthesis.
  5. Chapter 5: Real-Time Radix-2 Fast Fourier Transform ($\mathcal{O}(N \log_2 N)$) spectral analytics, Hann windowing, and Lock-Free Single-Producer Single-Consumer (SPSC) ring buffers.
  6. Chapter 6: Low-Frequency Oscillators (LFOs) and dynamic 32-channel modulation matrix routing.
  7. Chapter 7: Non-linear harmonic distortion, hyperbolic tangent ($ anh$) saturation waveshaping, and ping-pong delay audio effects.

In this final chapter—Chapter 8: The Quantum Canvas—we advance the core synthesis pipeline from naive trigonometric generation to Bandlimited Wavetable Synthesis, Wave Morphing, Thread-Safe Preset State Management (juce::ValueTree), and Production VST3 / AU / Standalone Deployment.

Key Architectural Goals:

  1. Aliasing Mitigation: Eliminate Gibbs phenomenon and Nyquist foldover distortion ($f > rac{f_s}{2}$) during high-pitch note rendering using pre-calculated multi-octave bandlimited wavetables and Lanczos sigma factors.
  2. Dynamic Wave Morphing: Execute 2D surface interpolation across multi-table banks (e.g., morphing seamlessly from a pure sine wave to a harsh metallic wavetable) modulated by envelopes and LFOs.
  3. State Management & Preset Persistence: Implement a thread-safe, lock-free preset management system leveraging juce::AudioProcessorValueTreeState (APVTS) and XML/ValueTree state serialization.
  4. Production Build & Cross-Platform Deployment: Compile the DSP engine natively into VST3, Audio Unit (AU), and Standalone executables using MSVC (Visual Studio 2026), LLVM, and Projucer 9.

2. Bandlimited Wavetable Synthesis & Aliasing Mitigation

2.1 Discrete Fourier Table Generation & Lanczos Sigma Correction

Naive mathematical generation of sharp-edged waveforms (such as Sawtooth or Square) generates an infinite series of harmonics according to Fourier expansion: \(x_{ ext{saw}}(t) = rac{2}{\pi} \sum_{n=1}^{\infty} rac{(-1)^{n+1}}{n} \sin(2\pi n f_0 t)\)

When digitized at sample rate $f_s = 44.1 ext{ kHz}$, any harmonic frequency component $n \cdot f_0$ exceeding the Nyquist limit $f_{ ext{Nyquist}} = rac{f_s}{2} = 22.05 ext{ kHz}$ folds back into the audible spectrum as unharmonic, harsh aliasing noise.

To solve this without CPU-heavy real-time trigonometric evaluation, SPANDAN constructs a database of bandlimited wavetables for discrete pitch octaves. Each table in the database contains exactly $N = 2048$ samples containing only harmonics up to $M = \lfloor rac{f_s}{2 f_0} floor$.

To suppress Gibbs phenomenon oscillations (ripple artifacts near waveform discontinuities), Lanczos $\sigma$-factor attenuation is applied to each harmonic $n$: \(\sigma_n = ext{sinc}\left( rac{n \pi}{M} ight) = rac{\sin\left( rac{n \pi}{M} ight)}{ rac{n \pi}{M}}\)

The bandlimited wavetable sample at index $k \in [0, N-1]$ is pre-computed as: \(x[k] = rac{2}{\pi} \sum_{n=1}^{M} \sigma_n \cdot rac{(-1)^{n+1}}{n} \sin\left( rac{2\pi n k}{N} ight)\)

      Harmonic Spectrum & Bandlimiting
      
 Amplitude
   ^
1.0|  |  
   |  |  |  
   |  |  |  |  
   |  |  |  |  |  (Lanczos Attenuation Envelope)
0.0+--------------------------> Frequency
   0  f0 2f0 3f0 4f0 ... f_Nyquist (cutoff)

2.2 Table-Lookup Phase Accumulation & Fractional Interpolation

During audio rendering, a 32-bit floating-point normalized phase accumulator $\phi \in [0.0, 1.0)$ traverses the $N$-point table. The exact fractional read location $i_{ ext{float}}$ is: \(i_{ ext{float}} = N \cdot \phi\)

The integer read index $i = \lfloor i_{ ext{float}} floor$ and fractional offset $lpha = i_{ ext{float}} - i$ are extracted. To prevent audio distortion, linear interpolation is performed between table indices $i$ and $(i + 1) mod N$: \(y[n] = (1 - lpha) \cdot T[i] + lpha \cdot T[(i + 1) ext{ mask}]\) where $ ext{mask} = N - 1 = 2047$ (bitwise AND wrap-around for power-of-two tables).


3. Wave Morphing & Dynamic Wavetable Interpolation

3.1 Dual-Table Crossfading & Modulation Networks

Commercial synthesizers like Serum and Vital allow producers to morph across a 2D array of $M_{ ext{bank}}$ wavetable slices. In SPANDAN, a normalized Morph Index $M_{ ext{idx}} \in [0.0, 1.0]$ selects the lower table index $T_A$ and upper table index $T_B$: \(ext{Position} = M_{ ext{idx}} \cdot (M_{ ext{bank}} - 1)\) \(T_A = \lfloor ext{Position} floor, \quad T_B = \min(T_A + 1, M_{ ext{bank}} - 1)\) \(\mu = ext{Position} - T_A\)

Using equal-power crossfading (preserving constant RMS loudness during morphing): \(G_A = \cos\left( rac{\pi}{2} \cdot \mu ight), \quad G_B = \sin\left( rac{\pi}{2} \cdot \mu ight)\) \(y_{ ext{morph}}[n] = G_A \cdot y_{T_A}[n] + G_B \cdot y_{T_B}[n]\)

          Wave Morphing Pipeline
          
 Morph Control [0..1] ---> [ Position Calculator ]
                                  |
                   +--------------+--------------+
                   |                             |
             Table Slice A                 Table Slice B
             [Read & Interp]               [Read & Interp]
                   |                             |
                   +------------> (x) <----------+
                               Equal-Power
                                Crossfade
                                    |
                             Morph Output

3.2 Phase Distortion & Morphing Index Calculation

The morph index $M_{ ext{idx}}$ can be modulated in real-time by LFO1, AuxEG, or MIDI Velocity through the Modulation Matrix: \(M_{ ext{idx}}(t) = ext{clamp}\left(M_{ ext{base}} + \sum_{k} ext{Depth}_k \cdot ext{ModSource}_k(t), \, 0.0, \, 1.0 ight)\)

In addition, phase distortion (non-linear phase warping) modifies the phase accumulator $\phi$ prior to table lookup: \(\phi_{ ext{distorted}} = egin{cases} rac{\phi}{2 \cdot D}, & 0 \le \phi < D \ 0.5 + rac{\phi - D}{2 \cdot (1 - D)}, & D \le \phi < 1.0 \end{cases}\) where $D \in (0.0, 1.0)$ controls asymmetric waveform bending.


4. Real-Time State Management & Preset Serialization

4.1 JUCE AudioProcessorValueTreeState (APVTS) Architecture

To manage dozens of synthesis parameters (Oscillator waveforms, Filter cutoffs, ADSR rates, LFO speeds, Matrix routings) without parameter desynchronization between the GUI thread and Audio thread, SPANDAN uses JUCE’s juce::AudioProcessorValueTreeState (APVTS).

The APVTS maintains a thread-safe tree of std::atomic<float>* parameter pointers:

// Thread-safe parameter retrieval on Audio Callback:
float cutoff = *cutoffParamAtomic;
float attack = *attackParamAtomic;

4.2 Thread-Safe XML/ValueTree Preset Serialization

Presets are saved and loaded as hierarchical XML documents or JSON structures. Because file I/O involves blocking system calls, preset loading occurs on the Message/GUI thread.

           Preset Management Dataflow
           
 [ Disk XML File ] <---> [ ValueTree State ] (Message Thread)
                                 |
                         Copy ValueTree State
                                 |
                                 v
                     [ Atomic APVTS Tree ]
                                 |
                           Atomic Pointer
                                 |
                                 v
                     [ Audio Callback Thread ] (Real-Time Safe)

5. Production Build Pipeline & Deployment Architecture

5.1 Real-Time Thread Safety & SIMD Vectorization (alignas(64))

To guarantee sub-3ms hardware callback deadlines ($N=128 ext{ samples}$ at $44.1 ext{ kHz}$), SPANDAN enforces strict compiler flags and memory layout:

  1. Memory Alignment: Voice structures and audio buffers are 64-byte aligned (alignas(64)) to match CPU cache lines and enable SIMD (AVX2 / NEON) vectorization.
  2. noexcept Specification: All DSP methods (processSample(), renderNextBlock()) are declared noexcept to allow compilers (MSVC / LLVM) to omit exception handling code blocks.
  3. constexpr Pre-computation: Sine/Cosine lookup tables, Lanczos factors, and filter coefficient constants are pre-computed at compile time.

5.2 VST3, AU, and Standalone Multi-Platform Build Pipeline

Project SPANDAN is structured via Projucer 9 and CMake to compile into three production targets:

  1. VST3 Plugin: Standard Steinberg VST3 binary compiled using MSVC (Visual Studio 2026) for Windows and LLVM for macOS/Linux.
  2. Audio Unit (AU): Native Apple macOS audio processing component compiled via Xcode.
  3. Standalone Executable: Independent desktop binary featuring native audio driver interfaces (ASIO, WASAPI, CoreAudio) and direct WAV file exporting via juce::AudioFormatWriter.
               Compilation & Build Pipeline
               
  [ Core C++ DSP Engine ] + [ JUCE 9 Hardware Abstraction ]
                             |
             +---------------+---------------+
             |                               |
    [ MSVC / Visual Studio 2026 ]     [ Xcode / LLVM ]
             |                               |
     +-------+-------+               +-------+-------+
     |               |               |               |
   .vst3         Standalone        .vst3            .component (AU)
 (Windows)       (.exe)           (macOS)          (macOS)

6. Complete C++ Reference Implementation

6.1 WavetableOscillator.h

/*
==============================================================================
    WavetableOscillator.h
    Project SPANDAN (स्पंदन) - Chapter 8 Reference Code
    Bandlimited Wavetable Synthesis & Wave Morphing Engine
==============================================================================
*/

#pragma once

#include <JuceHeader.h>
#include <array>
#include <vector>
#include <cmath>

class WavetableOscillator
{
public:
    static constexpr size_t kTableSize = 2048;
    static constexpr size_t kTableMask = kTableSize - 1;
    static constexpr size_t kNumTablesPerBank = 8;

    WavetableOscillator() noexcept;
    ~WavetableOscillator() noexcept = default;

    void prepareToPlay(double sampleRate) noexcept;
    void setFrequency(float frequencyInHz) noexcept;
    void setMorphPosition(float positionNormalized) noexcept;
    void setPhaseDistortion(float distortionNormalized) noexcept;
    void resetPhase() noexcept;

    float processSample() noexcept;

private:
    void generateBandlimitedTables() noexcept;
    void updatePhaseIncrement() noexcept;

    double currentSampleRate { 44100.0 };
    double targetFrequency { 440.0 };
    double currentPhase { 0.0 };
    double phaseIncrement { 0.0 };

    float morphPosition { 0.0f };
    float phaseDistortion { 0.5f };

    // 64-byte aligned multi-table storage for AVX2 cache line efficiency
    alignas(64) std::array<std::array<float, kTableSize>, kNumTablesPerBank> wavetableBank;
};

6.2 WavetableOscillator.cpp

/*
==============================================================================
    WavetableOscillator.cpp
    Project SPANDAN (स्पंदन) - Chapter 8 Reference Code
==============================================================================
*/

#include "WavetableOscillator.h"

WavetableOscillator::WavetableOscillator() noexcept
{
    generateBandlimitedTables();
}

void WavetableOscillator::prepareToPlay(double sampleRate) noexcept
{
    currentSampleRate = (sampleRate > 0.0) ? sampleRate : 44100.0;
    resetPhase();
    updatePhaseIncrement();
}

void WavetableOscillator::setFrequency(float frequencyInHz) noexcept
{
    float boundedFreq = std::clamp(frequencyInHz, 10.0f, 20000.0f);
    if (targetFrequency != static_cast<double>(boundedFreq))
    {
        targetFrequency = static_cast<double>(boundedFreq);
        updatePhaseIncrement();
    }
}

void WavetableOscillator::setMorphPosition(float positionNormalized) noexcept
{
    morphPosition = std::clamp(positionNormalized, 0.0f, 1.0f);
}

void WavetableOscillator::setPhaseDistortion(float distortionNormalized) noexcept
{
    phaseDistortion = std::clamp(distortionNormalized, 0.01f, 0.99f);
}

void WavetableOscillator::resetPhase() noexcept
{
    currentPhase = 0.0;
}

void WavetableOscillator::updatePhaseIncrement() noexcept
{
    phaseIncrement = targetFrequency / currentSampleRate;
}

void WavetableOscillator::generateBandlimitedTables() noexcept
{
    // Pre-calculate 8 bandlimited wavetable slices (Sine to Sawtooth to Square)
    for (size_t tableIdx = 0; tableIdx < kNumTablesPerBank; ++tableIdx)
    {
        float morphFactor = static_cast<float>(tableIdx) / static_cast<float>(kNumTablesPerBank - 1);
        int maxHarmonics = 64; // Bandlimited harmonic count

        for (size_t k = 0; k < kTableSize; ++k)
        {
            double sampleAcc = 0.0;
            double phaseAngle = (2.0 * juce::MathConstants<double>::pi * static_cast<double>(k)) / static_cast<double>(kTableSize);

            for (int n = 1; n <= maxHarmonics; ++n)
            {
                // Lanczos sigma factor correction
                double sigma = std::sin((n * juce::MathConstants<double>::pi) / maxHarmonics) / ((n * juce::MathConstants<double>::pi) / maxHarmonics);
                double harmonicAmp = (1.0 - morphFactor) * (n == 1 ? 1.0 : 0.0) + 
                                     morphFactor * ((n % 2 == 1) ? (1.0 / n) : 0.0); // Sine to Square transition

                sampleAcc += sigma * harmonicAmp * std::sin(n * phaseAngle);
            }
            wavetableBank[tableIdx][k] = static_cast<float>(sampleAcc);
        }
    }
}

float WavetableOscillator::processSample() noexcept
{
    // 1. Phase Distortion
    double readPhase = currentPhase;
    if (phaseDistortion != 0.5f)
    {
        if (readPhase < phaseDistortion)
            readPhase = readPhase / (2.0 * phaseDistortion);
        else
            readPhase = 0.5 + (readPhase - phaseDistortion) / (2.0 * (1.0 - phaseDistortion));
    }

    // 2. Table Read Coordinates
    double tablePosFloat = readPhase * static_cast<double>(kTableSize);
    size_t indexA = static_cast<size_t>(tablePosFloat) & kTableMask;
    size_t indexB = (indexA + 1) & kTableMask;
    float alpha = static_cast<float>(tablePosFloat - std::floor(tablePosFloat));

    // 3. Morph Bank Coordinates
    float bankPos = morphPosition * static_cast<float>(kNumTablesPerBank - 1);
    size_t sliceA = static_cast<size_t>(bankPos);
    size_t sliceB = std::min(sliceA + 1, kNumTablesPerBank - 1);
    float morphFrac = bankPos - static_cast<float>(sliceA);

    // Equal-power crossfade weights
    float weightA = std::cos(0.5f * juce::MathConstants<float>::pi * morphFrac);
    float weightB = std::sin(0.5f * juce::MathConstants<float>::pi * morphFrac);

    // 4. Dual-Slice Linear Interpolation
    float sampleSliceA = (1.0f - alpha) * wavetableBank[sliceA][indexA] + alpha * wavetableBank[sliceA][indexB];
    float sampleSliceB = (1.0f - alpha) * wavetableBank[sliceB][indexA] + alpha * wavetableBank[sliceB][indexB];

    float finalSample = weightA * sampleSliceA + weightB * sampleSliceB;

    // 5. Accumulate Phase
    currentPhase += phaseIncrement;
    while (currentPhase >= 1.0)
        currentPhase -= 1.0;

    return finalSample;
}

6.3 PresetManager.h

/*
==============================================================================
    PresetManager.h
    Project SPANDAN (स्पंदन) - Thread-Safe Preset Manager
==============================================================================
*/

#pragma once

#include <JuceHeader.h>

class PresetManager
{
public:
    PresetManager(juce::AudioProcessorValueTreeState& apvtsToManage)
        : apvts(apvtsToManage)
    {}

    void savePreset(const juce::File& presetFile)
    {
        auto state = apvts.copyState();
        std::unique_ptr<juce::XmlElement> xml(state.createXml());
        xml->writeTo(presetFile);
    }

    void loadPreset(const juce::File& presetFile)
    {
        if (!presetFile.existsAsFile())
            return;

        std::unique_ptr<juce::XmlElement> xml(juce::XmlDocument::parse(presetFile));
        if (xml != nullptr && xml->hasTagName(apvts.state.getType()))
        {
            apvts.replaceState(juce::ValueTree::fromXml(*xml));
        }
    }

private:
    juce::AudioProcessorValueTreeState& apvts;
};

7. References & Academic Citations

  1. Chowning, J. M., “The Synthesis of Complex Audio Spectra by Means of Frequency Modulation,” Journal of the Audio Engineering Society, 21(7), pp. 526–534, 1973 [188, 194, 259, 273].
  2. Pekonen, J., “Filter-Based Oscillator Algorithms for Virtual Analog Synthesis,” Doctoral Dissertation, Aalto University, Helsinki, Finland, 2014 [84, 199, 260].
  3. Zavalishin, V., “The Art of VA Filter Design,” Native Instruments, Rev. 2.1, 2018 [197, 260].
  4. Pirkle, J. R., “Designing Software Synthesizer Plug-Ins in C++ with Audio DSP,” 2nd Edition, Routledge / Focal Press, 2021 [5, 6, 402].
  5. Stroustrup, B., “The C++ Programming Language,” 4th Edition, Addison-Wesley, 2013 [279, 280, 693].
  6. Meyers, S., “Effective Modern C++: 42 Specific Ways to Improve Your Use of C++11 and C++14,” O’Reilly Media, 2014 [32, 33, 555].
  7. Bencina, R., “Real-Time Audio Programming 101: Time Waits for Nothing,” 2011 [117, 126].
Stay Updated
💻 GitHub Repo 🔗 Prashant's LinkedIn 🔗 Parth's LinkedIn