Cross-Platform Stand-Alone Signal Generator - "sig_gen"

C · SDL2 · Real-Time DSP · Low-Level Audio Systems

TL;DR

  • Minimal real-time signal generator written in pure C
  • Multiplicative frequency ramping and linear amplitude ramping
  • 4x oversampled with PloyBLEP/BLAMP bandlimiting and 256-tap FIR decimation
  • Gaussian white noise (Box–Muller) and filtered pink noise
  • Embedded font compiled directly into the binary
  • Statically linked libraries and zero external runtime assets

Introduction

This project began as a practical tool.

While building other audio projects, I wanted a lightweight, self-contained signal source to test analysis tools, device routing, and waveform behavior without relying on a DAW or external plugin host. Rather than use an existing generator, I implemented one from scratch in C using SDL2 for windowing and audio.

The result is a minimal but real-time-safe signal generator capable of producing deterministic waveforms and statistically modeled noise, with smooth parameter transitions, low-alias output, and no external runtime dependencies.

Project Goals

  • Maintain strict real-time safety in the audio callback
  • Avoid clicks and discontinuities during live parameter changes
  • Keep the executable self-contained (no asset dependencies)
  • Provide both stepped and direct numeric parameter entry
  • Keep the UI intentionally minimal and utility-focused
  • Produce a clean, alias-free spectrum comparable to a hardware reference generator

System Architecture

The program is split into two clear domains:


        UI Thread (SDL Event Loop)
                ↓
        Shared Parameter State (freq, amp, waveform)
                ↓
        Audio Callback (Per-Sample DSP)
        

All audio generation occurs inside the SDL audio callback. The UI thread writes target parameter values which the audio callback reads and interpolates toward on a per-sample basis.

Real-Time Audio Design

Frequency and amplitude changes use fixed-length ramps rather than continuous exponential smoothing. When a new target is set, a step count and per-sample increment or ratio are calculated once.

Frequency uses a multiplicative ramp:


        freq_ratio = pow(target / current, 1.0 / steps)
        current_frequency *= freq_ratio  // per sample
        

This produces perceptually even glides across the frequency range, since pitch perception is logarithmic. A linear frequency ramp would sound fast at low frequencies and slow at high ones.

Amplitude uses a linear ramp:


        amp_increment = (target - current) / steps
        current_amplitude += amp_increment  // per sample
        

Critically, when no ramp is active the smoothing math is skipped entirely. The phase increment is cached and only recalculated when frequency is actually changing, avoiding unnecessary per-sample computation during steady-state output.

Waveform changes are handled in two stages:

  1. Ramp amplitude smoothly to zero
  2. Switch waveform type
  3. Ramp back to target amplitude

This avoids transient spikes caused by discontinuous phase or waveform shape changes.

The audio device is opened at the system's native sample rate and channel count using SDL_GetAudioDeviceSpec, avoiding unnecessary resampling and ensuring phase-accurate output at all frequencies. The audio callback performs no heap allocations and no dynamic memory operations. All state is initialized up front.

Waveform & Noise Generation

Deterministic oscillators (sine, square, sawtooth, triangle) use a counter-based phase model:


        phase = phase_offset + (sample_counter * frequency) / internal_sample_rate
        phase -= floor(phase)  // keep in [0, 1)
        

Phase is derived fresh each sample from an integer counter rather than accumulated by repeated addition. This eliminates floating point drift that would otherwise cause the effective frequency to shift over time and produce visible instability on a spectrum analyser. When frequency changes, the current phase is captured into phase_offset and the counter resets to zero, maintaining phase continuity with no discontinuity at the transition point.

White noise is generated using a xorshift32 pseudo-random number generator and the Box–Muller transform to convert uniform distribution into Gaussian. This produces statistically correct Gaussian white noise rather than uniform noise.

Pink noise is generated using a multi-stage filtered noise model approximating a 1/f spectral distribution. The filter stages maintain internal state to produce proper low-frequency energy distribution rather than simple EQ'd white noise. Output is clamped to [-1, 1] to prevent clipping from occasional filter state accumulation.

Anti-Aliasing

A naive digital square or sawtooth wave has theoretically infinite harmonics. At any finite sample rate, harmonics above Nyquist cannot be represented and fold back into the audible spectrum as aliasing artefacts. Producing a clean spectrum required addressing this at multiple levels.

PolyBLEP and PolyBLAMP are applied at the waveform generation stage. PolyBLEP corrects the hard step discontinuities in square and sawtooth waves by applying a polynomial correction around each transition point, reducing the amplitude of high harmonics before they reach the filter. PolyBLAMP applies the equivalent correction for slope discontinuities in the triangle wave.

4x oversampling runs the signal engine internally at four times the output sample rate. This pushes the internal Nyquist to four times the output Nyquist, giving the anti-aliasing filter far more headroom to attenuate content before it can fold back.

A 256-tap windowed sinc FIR filter with a Blackman window is applied before decimation back to the output rate. The filter is designed with its -3dB point at 20kHz against a 96kHz internal Nyquist, giving a 76kHz transition band. This produces over 80dB of attenuation at the output Nyquist frequency. This is sufficient to suppress aliasing fold-back below the noise floor on most spectrum analysers, and shows minimal fold back on the rest. My EQing and analysis tools I have made generally go to -96 dB, which will show some of this foldback, just as they will on several other professional synths.

The FIR uses a double-buffer delay line rather than a circular buffer, writing each sample at two positions so the convolution always reads a contiguous block of memory. This allows the compiler to auto-vectorise the dot product loop with SIMD instructions, reducing the effective cost of the 256-tap convolution significantly.

Since the output is point-decimated (every 4th sample is kept), the full FIR dot product only needs to be computed once per output sample. The intermediate oversampled samples still advance the filter state but skip the expensive convolution, saving 75% of the FIR compute cost.

UI & Embedded Assets

The interface is intentionally minimal:

  • Frequency and amplitude fields (click-to-edit)
  • Keyboard stepping controls
  • Waveform switching via number keys

The font is compiled directly into the binary using a binary include step. This removes runtime font dependencies and keeps the executable self-contained.

Results From Profiling

After adding in what I assumed to be extremely expensive operations to handle the anti-aliasing, I took to profiling to look into CPU and Memory use.

Memory usage was straightforward. SDL and its statically linked dependencies account for the vast majority at around 75MB, with the actual signal generation code contributing a negligible fraction of the remaining 30MB of private process memory.

Profiling the CPU usage and doing anything possible to get Clang to vectorize loops led to a final build that cost 7% of a single core on an AMD Ryzen 3 7320U (a low-end, 4-core laptop processor). The optimizations brought up in aliasing in particular, were built in this process of sticking to the initial goal of this project: a cheap, minimal signal generator.

What I Learned

  • Designing for real-time safety at the C level
  • Managing thread boundaries without higher-level abstractions
  • Practical DSP smoothing techniques outside plugin frameworks
  • Multiplicative vs linear ramping and when each is appropriate
  • Statistical noise modeling vs random generation
  • Strategies for phase accuracy: accumulation vs counter-based
  • PolyBLEP and PolyBLAMP bandlimiting for alias reduction at the source
  • FIR filter design using windowed sinc with a Blackman window
  • Oversampling and decimation as an anti-aliasing strategy
  • Vectorisation-friendly data structures — double buffer vs circular buffer
  • CPU profiling with perf: interpreting IPC, frontend stalls, and branch misses
  • SDL audio device lifecycle and callback behavior
  • Cross-platform static linking with CMake and vcpkg

Summary

The Signal Generator is a focused real-time audio utility built entirely in C. It demonstrates practical DSP implementation, thread-safe audio design, and low-level system control without the abstractions of plugin frameworks or game engines.

While visually minimal, the project prioritizes correctness, predictability, and signal integrity. Most importantly, it offers a utility that I wanted to have in a portable, cross-platform, and stand-alone binary.

Software and Source Code: