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
// 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)
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)
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
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
// 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:
#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
| Library | Purpose |
|---|---|
| JUCE | Full audio app / plugin framework (VST3/AU/AAX) |
| RtAudio | Cross-platform raw audio I/O, no GUI |
| PortAudio | Cross-platform audio I/O, C API |
| libsndfile | Audio file I/O (WAV, AIFF, FLAC, etc.) |
| FFTW | Fast Fourier Transform — gold standard |
| KFR | Modern C++14 DSP library, SIMD-optimized |
| Maximilian | Audio DSP algorithms, educational |
| dr_libs | Single-header WAV/MP3/FLAC decoder |
| miniaudio | Single-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.
#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();
}# 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.
#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
#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
#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;
}# Link FFTW3
g++ -O3 -std=c++20 dsp.cpp -o dsp -lfftw3 -lmAudio 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
#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)
#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.
#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
}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.
| Format | License | GUI toolkit | Hosts |
|---|---|---|---|
| LV2 | ISC (open) | Any (X11, Cairo, OpenGL…) | Ardour, Carla, Reaper |
| VST3 | GPLv3 / Steinberg SDK | VSTGUI or custom | Nearly all DAWs |
| CLAP | MIT (open) | Any | Bitwig, Reaper, MultitrackStudio |
CLAP minimal skeleton
#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
#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
// 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
# 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| Strategy | When to use |
|---|---|
| Buffer comparison (offline) | Unit tests for DSP algorithms |
| Impulse response check | Filter frequency response |
| RMS / peak level check | Gain staging, clipping detection |
| Golden-file diff | Regression — compare output WAV to reference |
| Virtual ALSA / null sink | Integration tests in CI without hardware |