Skip to content
C++
Domain Track
Difficulty 3/5

C++ for Audio Programming

Audio programming in C++ — real-time audio constraints, JUCE framework, DSP fundamentals, audio callback patterns, MIDI handling, and plugin development.

Real-time audio constraints

Audio programming is among the strictest real-time domains in C++. The audio callback is called at ~10ms intervals — missing it causes audible dropouts.

Never do in an audio callback:

  • Allocate or free memory (new, delete, malloc, free, std::vector::push_back)
  • Acquire a lock (std::mutex::lock) — may block
  • File I/O or network I/O
  • std::cout, printf (may allocate, may lock)
  • C++ exceptions (RTTI overhead)
  • System calls that block

Always:

  • Pre-allocate everything before the callback starts
  • Use lock-free queues for communication between audio and UI threads
  • Keep the callback as short as possible

JUCE framework — the industry standard

JUCE is the de-facto framework for audio application and plugin development.

Minimal JUCE app structure

cpp
// AudioApp.h
#include <JuceHeader.h>

class AudioApp : public juce::JUCEApplication,
                 public juce::AudioIODeviceCallback {
public:
    const juce::String getApplicationName() override { return "MyAudio"; }
    const juce::String getApplicationVersion() override { return "1.0"; }

    void initialise(const juce::String&) override {
        device_manager_.initialiseWithDefaultDevices(0, 2);  // 0 in, 2 out
        device_manager_.addAudioCallback(this);
    }

    void shutdown() override {
        device_manager_.removeAudioCallback(this);
    }

    // AUDIO CALLBACK — real-time thread, strict constraints
    void audioDeviceIOCallbackWithContext(
        const float* const* input,
        int num_input_channels,
        float* const* output,
        int num_output_channels,
        int num_samples,
        const juce::AudioIODeviceCallbackContext& ctx) override
    {
        // Process audio here — num_samples frames
        for (int ch = 0; ch < num_output_channels; ++ch)
            for (int i = 0; i < num_samples; ++i)
                output[ch][i] = process_sample(ch, i);
    }

    void audioDeviceAboutToStart(juce::AudioIODevice* device) override {
        double sample_rate = device->getCurrentSampleRate();
        int buffer_size = device->getCurrentBufferSizeSamples();
        prepare(sample_rate, buffer_size);
    }

    void audioDeviceStopped() override {}

private:
    juce::AudioDeviceManager device_manager_;
    void prepare(double sr, int block_size) { /* allocate DSP state */ }
    float process_sample(int ch, int sample) { return 0.0f; }
};

START_JUCE_APPLICATION(AudioApp)

Audio processor plugin (VST3 / AU / AAX)

cpp
class SynthProcessor : public juce::AudioProcessor {
public:
    SynthProcessor() : AudioProcessor(BusesProperties()
        .withOutput("Output", juce::AudioChannelSet::stereo())) {}

    void prepareToPlay(double sample_rate, int max_block_size) override {
        sr_ = sample_rate;
        // Allocate DSP state
        osc_.prepare({sr_, (juce::uint32)max_block_size, 2});
    }

    void processBlock(juce::AudioBuffer<float>& buffer,
                      juce::MidiBuffer& midi) override
    {
        buffer.clear();  // clear incoming (may have garbage)

        // Process MIDI events
        for (const auto meta : midi) {
            auto msg = meta.getMessage();
            if (msg.isNoteOn())
                osc_.setFrequency(juce::MidiMessage::getMidiNoteInHertz(msg.getNoteNumber()));
        }

        // Process audio
        juce::dsp::AudioBlock<float> block{buffer};
        juce::dsp::ProcessContextReplacing<float> ctx{block};
        osc_.process(ctx);
    }

    // Required metadata
    const juce::String getName() const override { return "MySynth"; }
    bool acceptsMidi() const override { return true; }
    bool producesMidi() const override { return false; }
    double getTailLengthSeconds() const override { return 0.0; }
    int getNumPrograms() override { return 1; }
    int getCurrentProgram() override { return 0; }
    void setCurrentProgram(int) override {}
    const juce::String getProgramName(int) override { return "Default"; }
    void changeProgramName(int, const juce::String&) override {}
    void getStateInformation(juce::MemoryBlock&) override {}
    void setStateInformation(const void*, int) override {}

private:
    double sr_ = 44100.0;
    juce::dsp::Oscillator<float> osc_{[](float x) { return std::sin(x); }};
};

DSP fundamentals

Oscillator (wavetable)

cpp
class WavetableOscillator {
    std::vector<float> table_;
    float phase_ = 0.0f;
    float phase_increment_ = 0.0f;
    float sample_rate_;

public:
    void prepare(float sr, int table_size = 2048) {
        sample_rate_ = sr;
        table_.resize(table_size + 1);
        for (int i = 0; i < table_size; ++i)
            table_[i] = std::sin(2.0f * M_PI * i / table_size);
        table_[table_size] = table_[0];  // wrap
    }

    void set_frequency(float freq) {
        phase_increment_ = freq * table_.size() / sample_rate_;
    }

    float next_sample() {
        int idx = static_cast<int>(phase_);
        float frac = phase_ - idx;
        float sample = table_[idx] + frac * (table_[idx + 1] - table_[idx]);
        phase_ = std::fmod(phase_ + phase_increment_, table_.size() - 1);
        return sample;
    }
};

Biquad filter

cpp
class BiquadFilter {
    // Coefficients
    float b0_, b1_, b2_, a1_, a2_;
    // State (two-sample history)
    float x1_ = 0, x2_ = 0, y1_ = 0, y2_ = 0;

public:
    // Low-pass, 2nd order Butterworth
    void set_lowpass(float freq, float q, float sr) {
        float w0 = 2.0f * M_PI * freq / sr;
        float alpha = std::sin(w0) / (2.0f * q);
        float cos_w0 = std::cos(w0);
        float a0 = 1.0f + alpha;
        b0_ = (1.0f - cos_w0) / (2.0f * a0);
        b1_ = (1.0f - cos_w0) / a0;
        b2_ = b0_;
        a1_ = (-2.0f * cos_w0) / a0;
        a2_ = (1.0f - alpha) / a0;
    }

    float process(float x) {
        float y = b0_ * x + b1_ * x1_ + b2_ * x2_ - a1_ * y1_ - a2_ * y2_;
        x2_ = x1_; x1_ = x;
        y2_ = y1_; y1_ = y;
        return y;
    }
};

Ring buffer for lock-free audio/UI communication

cpp
// SPSC ring buffer — safe between one producer (UI) and one consumer (audio)
template<typename T, std::size_t N>
class SPSCRingBuffer {
    static_assert((N & (N - 1)) == 0, "N must be power of 2");
    std::array<T, N> buf_;
    std::atomic<std::size_t> read_pos_{0};
    std::atomic<std::size_t> write_pos_{0};

public:
    bool push(const T& val) {
        auto w = write_pos_.load(std::memory_order_relaxed);
        auto next = (w + 1) & (N - 1);
        if (next == read_pos_.load(std::memory_order_acquire)) return false;  // full
        buf_[w] = val;
        write_pos_.store(next, std::memory_order_release);
        return true;
    }

    bool pop(T& val) {
        auto r = read_pos_.load(std::memory_order_relaxed);
        if (r == write_pos_.load(std::memory_order_acquire)) return false;  // empty
        val = buf_[r];
        read_pos_.store((r + 1) & (N - 1), std::memory_order_release);
        return true;
    }
};

// Usage: UI thread pushes parameter changes; audio thread reads them
struct ParamChange { int param_id; float value; };
SPSCRingBuffer<ParamChange, 64> param_queue;

// UI thread (non-real-time):
param_queue.push({FILTER_CUTOFF, 1200.0f});

// Audio callback (real-time):
ParamChange change;
while (param_queue.pop(change))
    apply_param(change.param_id, change.value);

JUCE DSP module

JUCE includes a DSP module with common audio building blocks:

cpp
#include <juce_dsp/juce_dsp.h>

// Chain multiple processors
using Chain = juce::dsp::ProcessorChain<
    juce::dsp::Oscillator<float>,
    juce::dsp::IIR::Filter<float>,
    juce::dsp::Reverb,
    juce::dsp::Gain<float>
>;

Chain chain_;

void prepareToPlay(double sr, int blockSize) {
    juce::dsp::ProcessSpec spec{sr, (juce::uint32)blockSize, 2};
    chain_.prepare(spec);

    // Configure oscillator
    chain_.get<0>().initialise([](float x) { return std::sin(x); });
    chain_.get<0>().setFrequency(440.0f);

    // Configure filter (low-pass at 2kHz)
    *chain_.get<1>().state = *juce::dsp::IIR::Coefficients<float>::makeLowPass(sr, 2000.0f);

    // Configure gain
    chain_.get<3>().setGainLinear(0.5f);
}

void processBlock(juce::AudioBuffer<float>& buffer, juce::MidiBuffer&) {
    juce::dsp::AudioBlock<float> block{buffer};
    chain_.process(juce::dsp::ProcessContextReplacing<float>{block});
}

Key libraries

LibraryPurpose
JUCEFull audio app / plugin framework (VST3/AU/AAX)
RtAudioCross-platform raw audio I/O, no GUI
PortAudioCross-platform audio I/O, C API
libsndfileAudio file I/O (WAV, AIFF, FLAC, etc.)
FFTWFast Fourier Transform — gold standard
KFRModern C++14 DSP library, SIMD-optimized
MaximilianAudio DSP algorithms, educational
dr_libsSingle-header WAV/MP3/FLAC decoder
miniaudioSingle-header cross-platform audio

PortAudio Setup and Callback Model

PortAudio is a portable, low-level C library that wraps ALSA, CoreAudio, WASAPI, and ASIO behind a single Pa_OpenStream / callback API — ideal when you need raw audio I/O without a full framework. The callback is called from a high-priority system thread; the same real-time rules as JUCE apply.

cpp
#include <portaudio.h>
#include <cmath>
#include <numbers>

struct SineState {
    double phase         = 0.0;
    double phase_inc     = 0.0;   // set by caller
};

// Callback signature required by PortAudio
static int sine_callback(
    const void* /*input*/,
    void*        output,
    unsigned long frames_per_buffer,
    const PaStreamCallbackTimeInfo* /*time*/,
    PaStreamCallbackFlags /*flags*/,
    void* user_data)
{
    auto* state = static_cast<SineState*>(user_data);
    auto* out   = static_cast<float*>(output);

    for (unsigned long i = 0; i < frames_per_buffer; ++i) {
        float sample = static_cast<float>(std::sin(state->phase));
        *out++ = sample;   // left
        *out++ = sample;   // right (interleaved stereo)
        state->phase += state->phase_inc;
        if (state->phase >= 2.0 * std::numbers::pi)
            state->phase -= 2.0 * std::numbers::pi;
    }
    return paContinue;
}

int main() {
    Pa_Initialize();

    SineState state;
    double sample_rate = 44100.0;
    state.phase_inc = 2.0 * std::numbers::pi * 440.0 / sample_rate;

    PaStream* stream;
    Pa_OpenDefaultStream(
        &stream,
        0,                    // no input channels
        2,                    // stereo output
        paFloat32,            // sample format
        sample_rate,
        256,                  // frames per buffer (~5.8 ms at 44.1 kHz)
        sine_callback,
        &state);

    Pa_StartStream(stream);
    Pa_Sleep(3000);           // play for 3 seconds (non-RT sleep, main thread only)
    Pa_StopStream(stream);
    Pa_CloseStream(stream);
    Pa_Terminate();
}
bash
# Link PortAudio (pkg-config)
g++ -O2 -std=c++20 tone.cpp -o tone $(pkg-config --cflags --libs portaudio-2.0)

JACK Audio — Zero-Copy Port Model

JACK (JACK Audio Connection Kit) targets professional, ultra-low-latency audio on Linux by using a shared-memory graph where each client registers ports and processes audio in a synchronized cycle. Buffers are handed directly between clients — no copies, no kernel round-trips.

cpp
#include <jack/jack.h>
#include <cmath>

jack_port_t* out_port_l;
jack_port_t* out_port_r;
double phase     = 0.0;
double phase_inc = 0.0;

// JACK process callback — called once per cycle by the JACK server
int process(jack_nframes_t nframes, void* /*arg*/) {
    float* left  = static_cast<float*>(jack_port_get_buffer(out_port_l, nframes));
    float* right = static_cast<float*>(jack_port_get_buffer(out_port_r, nframes));

    for (jack_nframes_t i = 0; i < nframes; ++i) {
        float s = static_cast<float>(std::sin(phase));
        left[i] = right[i] = s;
        phase += phase_inc;
        if (phase >= 2.0 * M_PI) phase -= 2.0 * M_PI;
    }
    return 0;
}

int main() {
    jack_client_t* client = jack_client_open("MySynth", JackNullOption, nullptr);
    double sr = jack_get_sample_rate(client);
    phase_inc = 2.0 * M_PI * 440.0 / sr;

    out_port_l = jack_port_register(client, "out_L",
        JACK_DEFAULT_AUDIO_TYPE, JackPortIsOutput, 0);
    out_port_r = jack_port_register(client, "out_R",
        JACK_DEFAULT_AUDIO_TYPE, JackPortIsOutput, 0);

    jack_set_process_callback(client, process, nullptr);
    jack_activate(client);

    // Connect to system playback
    jack_connect(client, "MySynth:out_L", "system:playback_1");
    jack_connect(client, "MySynth:out_R", "system:playback_2");

    // Block main thread; JACK runs the callback on its own thread
    pause();
    jack_client_close(client);
}

DSP Building Blocks — FIR, FFT, Convolution

FIR filters provide linear-phase frequency shaping; for long impulse responses (reverb, room simulation) convolution is done efficiently in the frequency domain using FFT. FFTW3 is the standard library for real-valued and complex FFTs in C++.

FIR Filter

cpp
#include <vector>
#include <numeric>

class FIRFilter {
    std::vector<float> coeffs_;
    std::vector<float> history_;

public:
    explicit FIRFilter(std::vector<float> h)
        : coeffs_(std::move(h)), history_(coeffs_.size(), 0.0f) {}

    float process(float x) {
        // Shift history
        for (int i = static_cast<int>(history_.size()) - 1; i > 0; --i)
            history_[i] = history_[i - 1];
        history_[0] = x;
        // Dot product
        return std::inner_product(coeffs_.begin(), coeffs_.end(),
                                  history_.begin(), 0.0f);
    }
};

FFT with FFTW3

cpp
#include <fftw3.h>
#include <vector>
#include <complex>

// Compute magnitude spectrum of a real signal
std::vector<float> magnitude_spectrum(const std::vector<float>& signal) {
    int N = static_cast<int>(signal.size());
    int bins = N / 2 + 1;

    std::vector<double> in(N);
    std::vector<fftw_complex> out(bins);
    for (int i = 0; i < N; ++i) in[i] = signal[i];

    fftw_plan plan = fftw_plan_dft_r2c_1d(N, in.data(), out.data(), FFTW_ESTIMATE);
    fftw_execute(plan);
    fftw_destroy_plan(plan);

    std::vector<float> mag(bins);
    for (int k = 0; k < bins; ++k)
        mag[k] = static_cast<float>(
            std::sqrt(out[k][0] * out[k][0] + out[k][1] * out[k][1]));
    return mag;
}
bash
# Link FFTW3
g++ -O3 -std=c++20 dsp.cpp -o dsp -lfftw3 -lm

Audio File I/O — libsndfile, dr_wav, minimp3

libsndfile handles lossless formats (WAV, AIFF, FLAC, OGG Vorbis) through a unified C API; for dependency-free decoding in embedded or WASM contexts, the single-header dr_wav and minimp3 are preferred.

libsndfile

cpp
#include <sndfile.h>
#include <vector>

// Read entire WAV file into a float buffer
std::vector<float> load_wav(const char* path, int& sample_rate, int& channels) {
    SF_INFO info{};
    SNDFILE* sf = sf_open(path, SFM_READ, &info);
    if (!sf) throw std::runtime_error(sf_strerror(nullptr));

    sample_rate = info.samplerate;
    channels    = info.channels;
    std::vector<float> buf(info.frames * info.channels);
    sf_readf_float(sf, buf.data(), info.frames);
    sf_close(sf);
    return buf;
}

// Write float data as 24-bit WAV
void save_wav(const char* path, const float* data, int frames,
              int sample_rate, int channels) {
    SF_INFO info{};
    info.samplerate = sample_rate;
    info.channels   = channels;
    info.format     = SF_FORMAT_WAV | SF_FORMAT_PCM_24;
    SNDFILE* sf = sf_open(path, SFM_WRITE, &info);
    sf_writef_float(sf, data, frames);
    sf_close(sf);
}

dr_wav (single-header)

cpp
#define DR_WAV_IMPLEMENTATION
#include "dr_wav.h"

drwav wav;
if (!drwav_init_file(&wav, "audio.wav", nullptr)) return;
std::vector<float> pcm(wav.totalPCMFrameCount * wav.channels);
drwav_read_pcm_frames_f32(&wav, wav.totalPCMFrameCount, pcm.data());
drwav_uninit(&wav);

MIDI Processing — RtMidi

RtMidi provides a cross-platform C++ API for listing, opening, and receiving MIDI ports; incoming messages arrive as std::vector<unsigned char> in a callback or via polling. Status byte 0x90 = note-on, 0x80 = note-off, 0xB0 = control change.

cpp
#include <RtMidi.h>
#include <iostream>

void midi_callback(double /*delta*/, std::vector<unsigned char>* msg, void* /*user*/) {
    if (msg->empty()) return;

    uint8_t status  = (*msg)[0] & 0xF0;   // strip channel nibble
    uint8_t channel = (*msg)[0] & 0x0F;

    if (status == 0x90 && msg->size() >= 3 && (*msg)[2] > 0) {
        uint8_t note     = (*msg)[1];
        uint8_t velocity = (*msg)[2];
        std::cout << "Note ON  ch=" << (int)channel
                  << " note=" << (int)note
                  << " vel="  << (int)velocity << '\n';
    } else if (status == 0x80 || (status == 0x90 && (*msg)[2] == 0)) {
        std::cout << "Note OFF ch=" << (int)channel
                  << " note=" << (int)(*msg)[1] << '\n';
    } else if (status == 0xB0 && msg->size() >= 3) {
        std::cout << "CC  cc=" << (int)(*msg)[1]
                  << " val="   << (int)(*msg)[2] << '\n';
    }
}

int main() {
    RtMidiIn midi_in;
    if (midi_in.getPortCount() == 0) {
        std::cerr << "No MIDI input ports\n";
        return 1;
    }
    midi_in.openPort(0);
    midi_in.setCallback(midi_callback);
    midi_in.ignoreTypes(false, false, false);  // include sysex, timing, sense
    std::cout << "Listening on: " << midi_in.getPortName(0) << '\n';
    std::cin.get();  // block main thread
}
bash
g++ -O2 -std=c++20 midi_listen.cpp -o midi_listen \
    $(pkg-config --cflags --libs rtmidi)

Plugin Formats — LV2, VST3, CLAP

The three dominant open plugin formats differ in licensing, complexity, and host ecosystem; CLAP is the modern choice for new projects, VST3 covers the widest DAW reach, and LV2 is the Linux/free-software standard.

FormatLicenseGUI toolkitHosts
LV2ISC (open)Any (X11, Cairo, OpenGL…)Ardour, Carla, Reaper
VST3GPLv3 / Steinberg SDKVSTGUI or customNearly all DAWs
CLAPMIT (open)AnyBitwig, Reaper, MultitrackStudio

CLAP minimal skeleton

cpp
#include <clap/clap.h>

static const clap_plugin_descriptor_t s_desc = {
    .clap_version = CLAP_VERSION_INIT,
    .id           = "com.example.my-gain",
    .name         = "My Gain",
    .vendor       = "Example",
    .version      = "1.0.0",
    .features     = (const char*[]){ CLAP_PLUGIN_FEATURE_AUDIO_EFFECT, nullptr },
};

struct MyPlugin {
    clap_plugin_t  plugin;
    double         sample_rate = 44100.0;
    float          gain        = 1.0f;
};

static bool plugin_init(const clap_plugin_t* p) { return true; }
static void plugin_destroy(const clap_plugin_t* p) {
    delete reinterpret_cast<const MyPlugin*>(p);
}
static bool plugin_activate(const clap_plugin_t* p, double sr,
                            uint32_t /*min*/, uint32_t /*max*/) {
    reinterpret_cast<MyPlugin*>(
        const_cast<clap_plugin_t*>(p))->sample_rate = sr;
    return true;
}
static void plugin_deactivate(const clap_plugin_t*) {}

// Process: apply gain to all audio outputs
static clap_process_status plugin_process(const clap_plugin_t* p,
                                          const clap_process_t* process) {
    auto* self = reinterpret_cast<MyPlugin*>(const_cast<clap_plugin_t*>(p));
    uint32_t nframes = process->frames_count;
    for (uint32_t ch = 0; ch < process->audio_outputs[0].channel_count; ++ch) {
        float* in  = process->audio_inputs[0].data32[ch];
        float* out = process->audio_outputs[0].data32[ch];
        for (uint32_t i = 0; i < nframes; ++i)
            out[i] = in[i] * self->gain;
    }
    return CLAP_PROCESS_CONTINUE;
}

LV2 uses a Turtle (.ttl) manifest file to declare ports and metadata; the C API then receives an LV2_Handle and a run() callback per audio block. JUCE can target all three formats from a single codebase.


Testing Audio Code

Audio DSP code is testable without a sound card: process buffers offline, compare outputs to known-good references, and measure impulse responses to verify filter behavior. CI pipelines run audio tests headlessly using virtual ALSA devices or simple buffer comparison.

Buffer comparison test

cpp
#include <gtest/gtest.h>
#include <cmath>
#include "biquad_filter.h"

TEST(BiquadFilter, LowPassAttenuatesHighFreq) {
    const double sr    = 44100.0;
    const int    N     = 4096;
    const float  cutoff = 1000.0f;
    const float  q      = 0.707f;

    BiquadFilter filt;
    filt.set_lowpass(cutoff, q, static_cast<float>(sr));

    // Generate a 10 kHz sine (well above cutoff)
    std::vector<float> buf(N);
    for (int i = 0; i < N; ++i)
        buf[i] = std::sin(2.0f * static_cast<float>(M_PI) * 10000.0f * i / sr);

    // Run filter
    for (auto& s : buf) s = filt.process(s);

    // Skip the filter transient (first 512 samples)
    float rms = 0.0f;
    for (int i = 512; i < N; ++i) rms += buf[i] * buf[i];
    rms = std::sqrt(rms / (N - 512));

    // At 10 kHz, a 1 kHz LPF should attenuate by at least 40 dB
    float db = 20.0f * std::log10(rms + 1e-12f);
    EXPECT_LT(db, -40.0f);
}

Impulse response test

cpp
// Feed a unit impulse; the output IS the filter's impulse response
std::vector<float> measure_ir(BiquadFilter& f, int length) {
    std::vector<float> ir(length, 0.0f);
    ir[0] = 1.0f;
    for (auto& s : ir) s = f.process(s);
    return ir;
}

Headless ALSA for CI

bash
# In Docker / CI: create a virtual ALSA loopback
modprobe snd-aloop          # Linux kernel loopback module
# Or use PulseAudio null sink
pulseaudio --start
pactl load-module module-null-sink sink_name=test
PULSE_SINK=test ./my_audio_test
StrategyWhen to use
Buffer comparison (offline)Unit tests for DSP algorithms
Impulse response checkFilter frequency response
RMS / peak level checkGain staging, clipping detection
Golden-file diffRegression — compare output WAV to reference
Virtual ALSA / null sinkIntegration tests in CI without hardware