From Stateless to Stateful: Filters & Composition

In previous parts, we built an oscillator. It was "stateless"—you give it a phase, it gives you a value. It doesn’t care what happened a microsecond ago. But to make music sound "warm" or "organic," we need objects that have a memory.

1. DSP General: The Concept of State

In Digital Signal Processing (DSP), a filter is stateful. To calculate the current output, a filter needs to know the previous output.

Mathematically, a simple One-Pole Low Pass Filter looks like this:

\[y[n] = y[n-1] + a \cdot (x[n] - y[n-1])\]

  • x[n] is your current raw sample (the input).

  • y[n-1] is the sample we calculated just before this one (the State).

  • a is the coefficient (determined by your Cutoff frequency).

Because the object "remembers" y[n-1], we call it stateful. In C++, this means our Filter class must store that value as a private member variable that persists between calls to process().

2. Architecture: Composition (Has-A)

To manage these stateful modules, we use Composition. While beginners often try to solve everything through inheritance, professionals prefer composition: "A Synthesizer has an Envelope and a Filter."

  • Modularity: We can swap the Filter implementation without touching the Oscillator.

  • Encapsulation: The SynthEngine doesn’t need to know the math of the filter; it just hands the filter a sample and gets a shaped sample back.

3. The Signal Chain

Data flows through our composed objects in a specific order:

  1. Oscillator → Generates the raw, harmonically rich "buzz."

  2. Filter (VCF) → Subtracts frequencies (the stateful part).

  3. Envelope (ADSR) → Shapes the final volume.

4. Full Reference Code

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
#ifndef ENVELOPE_H
#define ENVELOPE_H

enum EnvelopeState { OFF, ATTACK, DECAY, SUSTAIN, RELEASE };

class Envelope {
private:
    double amplitude = 0.0;
    double attackTime = 0.05;  // 50ms fade in
    double decayTime = 0.1;   // 100ms settle
    double sustainLevel = 0.7; // Hold at 70% volume
    double releaseTime = 0.3;  // 300ms fade out
    double sampleRate;
    EnvelopeState state = OFF;

public:
    Envelope(double sr) : sampleRate(sr) {}

    void triggerOn() { state = ATTACK; }
    void triggerOff() { state = RELEASE; }

    float getNextAmplitude() {
        switch (state) {
            case ATTACK:
                amplitude += 1.0 / (attackTime * sampleRate);
                if (amplitude >= 1.0) {
                    amplitude = 1.0;
                    state = DECAY;
                }
                break;
            case DECAY:
                amplitude -= (1.0 - sustainLevel) / (decayTime * sampleRate);
                if (amplitude <= sustainLevel) {
                    amplitude = sustainLevel;
                    state = SUSTAIN;
                }
                break;
            case SUSTAIN:
                amplitude = sustainLevel;
                break;
            case RELEASE:
                amplitude -= sustainLevel / (releaseTime * sampleRate);
                if (amplitude <= 0.0) {
                    amplitude = 0.0;
                    state = OFF;
                }
                break;
            case OFF:
                amplitude = 0.0;
                break;
        }
        return (float)amplitude;
    }

    EnvelopeState getState() const { return state; }
};

#endif
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#ifndef FILTER_H
#define FILTER_H

class LowPassFilter {
private:
    float lastSample = 0.0f;
    float cutoff = 0.1f; // Range 0.0 to 1.0

public:
    // Simple smoothing formula:
    // y[n] = y[n-1] + cutoff * (x[n] - y[n-1])
    float process(float input) {
        float output = lastSample + cutoff * (input - lastSample);
        lastSample = output;
        return output;
    }

    void setCutoff(float newCutoff) {
        if (newCutoff > 1.0f) cutoff = 1.0f;
        else if (newCutoff < 0.0f) cutoff = 0.0f;
        else cutoff = newCutoff;
    }
};

#endif
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#ifndef OSCILLATOR_H
#define OSCILLATOR_H

#include <cmath>

class Oscillator {
protected:
    double frequency, amplitude, phase, sampleRate;

public:
    // Nur Deklarationen (Semikolon statt geschweifter Klammern)
    Oscillator(double freq, double amp, double sr);
    virtual ~Oscillator() {}

    virtual float getNextSample() = 0;
    void setFrequency(double freq);
};

// Unterklassen können hier im Header bleiben, wenn sie kurz sind
class SquareOsc : public Oscillator {
public:
    using Oscillator::Oscillator;
    float getNextSample() override; // Definition kommt in die .cpp
};

class SawOsc : public Oscillator {
public:
    using Oscillator::Oscillator;
    float getNextSample() override; // Definition kommt in die .cpp
};

class SineOsc : public Oscillator {
public:
    using Oscillator::Oscillator;
    float getNextSample() override; // Definition kommt in die .cpp
};

class TriangleOsc : public Oscillator {
public:
    using Oscillator::Oscillator;
    float getNextSample() override; // Definition kommt in die .cpp
};

#endif
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include "Oscillator.h"

Oscillator::Oscillator(double freq, double amp, double sr)
    : frequency(freq), amplitude(amp), phase(0.0), sampleRate(sr) {}

void Oscillator::setFrequency(double freq) {
    frequency = freq;
}

float SquareOsc::getNextSample() {
    float sample = (sin(phase) > 0) ? 1.0f : -1.0f;
    phase += (2.0 * M_PI * frequency) / sampleRate;
    if (phase > 2.0 * M_PI) phase -= 2.0 * M_PI;
    return sample * (float)amplitude;
}

float SawOsc::getNextSample() {
    float sample = (float)(2.0 * (phase / (2.0 * M_PI)) - 1.0);
    phase += (2.0 * M_PI * frequency) / sampleRate;
    if (phase > 2.0 * M_PI) phase -= 2.0 * M_PI;
    return sample * (float)amplitude;
}

float SineOsc::getNextSample() {
    float sample = (float)sin(phase);
    phase += (2.0 * M_PI * frequency) / sampleRate;
    if (phase > 2.0 * M_PI) phase -= 2.0 * M_PI;
    return sample * (float)amplitude;
}

float TriangleOsc::getNextSample() {
    float sample = (float)((2.0 / M_PI) * asin(sin(phase)));
    phase += (2.0 * M_PI * frequency) / sampleRate;
    if (phase > 2.0 * M_PI) phase -= 2.0 * M_PI;
    return sample * (float)amplitude;
}
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
#include <SDL2/SDL.h>
#include <iostream>
#include <vector>
#include <string>
#include <algorithm>

// Include your custom headers
#include "Oscillator.h"
#include "Envelope.h"
#include "Filter.h"

// Shared buffer for the oscilloscope visualization
std::vector<float> visualBuffer(2048, 0.0f);

// --- THE SYNTH ENGINE ---
class SynthEngine {
public:
    Oscillator* osc;
    Envelope env;
    LowPassFilter filter;

    SynthEngine() : env(44100.0) {
        osc = new SawOsc(440.0, 0.2, 44100.0);
        filter.setCutoff(0.5f);
    }

    ~SynthEngine() {
        delete osc;
    }

    static void AudioCallback(void* userdata, Uint8* stream, int len) {
        SynthEngine* engine = static_cast<SynthEngine*>(userdata);
        float* buffer = reinterpret_cast<float*>(stream);
        int length = len / sizeof(float);

        for (int i = 0; i < length; i++) {
            float sample = 0.0f;
            if (engine->osc != nullptr) {
                sample = engine->osc->getNextSample();
            }

            // Signal Chain: Osc -> Filter -> Envelope
            sample = engine->filter.process(sample);
            float currentAmp = engine->env.getNextAmplitude();
            float finalSample = sample * currentAmp;

            buffer[i] = finalSample;

            if (i < (int)visualBuffer.size()) {
                visualBuffer[i] = finalSample;
            }
        }
    }
};

int main(int argc, char* argv[]) {
    // 1. System Init (Audio & Video only)
    if (SDL_Init(SDL_INIT_AUDIO | SDL_INIT_VIDEO) < 0) return 1;

    SDL_Window* window = SDL_CreateWindow("C++ PolySynth (No Fonts)",
        SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, 800, 400, SDL_WINDOW_SHOWN);
    SDL_Renderer* renderer = SDL_CreateRenderer(window, -1, SDL_RENDERER_ACCELERATED);

    // 2. Engine State
    SynthEngine engine;
    float currentCutoff = 0.5f;

    // 3. Audio Config
    SDL_AudioSpec ds;
    ds.freq = 44100;
    ds.format = AUDIO_F32SYS;
    ds.channels = 1;
    ds.samples = 2048;
    ds.callback = SynthEngine::AudioCallback;
    ds.userdata = &engine;

    if (SDL_OpenAudio(&ds, NULL) < 0) return 1;
    SDL_PauseAudio(0);

    // 4. Main Loop
    bool running = true;
    SDL_Event e;
    while (running) {
        while (SDL_PollEvent(&e)) {
            if (e.type == SDL_QUIT) running = false;
            if (e.type == SDL_KEYDOWN) {
                Oscillator* nextOsc = nullptr;
                float f = 440.0, a = 0.2, r = 44100.0;

                switch (e.key.keysym.sym) {
                    case SDLK_1: nextOsc = new SineOsc(f, a, r);     break;
                    case SDLK_2: nextOsc = new SquareOsc(f, a, r);   break;
                    case SDLK_3: nextOsc = new SawOsc(f, a, r);      break;
                    case SDLK_4: nextOsc = new TriangleOsc(f, a, r); break;

                    case SDLK_UP:
                        currentCutoff = std::min(1.0f, currentCutoff + 0.05f);
                        engine.filter.setCutoff(currentCutoff);
                        break;
                    case SDLK_DOWN:
                        currentCutoff = std::max(0.01f, currentCutoff - 0.05f);
                        engine.filter.setCutoff(currentCutoff);
                        break;
                    case SDLK_ESCAPE: running = false; break;
                }

                if (nextOsc) {
                    Oscillator* old = engine.osc;
                    engine.osc = nextOsc;
                    delete old;
                }
            }
        }

        const Uint8* state = SDL_GetKeyboardState(NULL);
        if (state[SDL_SCANCODE_SPACE]) engine.env.triggerOn();
        else engine.env.triggerOff();

        // 5. Rendering (Oscilloscope only)
        SDL_SetRenderDrawColor(renderer, 20, 20, 25, 255);
        SDL_RenderClear(renderer);

        SDL_SetRenderDrawColor(renderer, 50, 255, 200, 255);
        for (int x = 0; x < 799; x++) {
            int y1 = 200 - (int)(visualBuffer[x] * 150);
            int y2 = 200 - (int)(visualBuffer[x+1] * 150);
            SDL_RenderDrawLine(renderer, x, y1, x + 1, y2);
        }

        SDL_RenderPresent(renderer);
        SDL_Delay(16);
    }

    // 6. Cleanup
    SDL_CloseAudio();
    SDL_DestroyRenderer(renderer);
    SDL_DestroyWindow(window);
    SDL_Quit();
    return 0;
}

5. Visual Debugging: Seeing the State

Finally, how do we know our stateful system is working? Since we can’t "see" sound, we use Visual Debugging.

By integrating SDL_ttf, we can display the internal currentCutoff value on screen. In C++, this requires careful Resource Management. We create a surface and texture every frame to reflect the changing state, and we must destroy them immediately to prevent memory leaks.

If the number on screen changes but the sound does not, you know the bug is in your DSP logic (y[n] calculation) rather than your Control logic (keyboard input).

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
#include <SDL2/SDL.h>
#include <SDL2/SDL_ttf.h>
#include <iostream>
#include <vector>
#include <string>
#include <algorithm>

// Include your custom headers
#include "Oscillator.h"
#include "Envelope.h"
#include "Filter.h"

// Shared buffer for the oscilloscope visualization
std::vector<float> visualBuffer(2048, 0.0f);

// --- THE SYNTH ENGINE ---
// Demonstrating COMPOSITION: The Engine "has" an Osc, an Envelope, and a Filter.
class SynthEngine {
public:
    Oscillator* osc;
    Envelope env;
    LowPassFilter filter;

    SynthEngine() : env(44100.0) {
        // Start with a Sawtooth wave for a rich harmonic sound
        osc = new SawOsc(440.0, 0.2, 44100.0);
        filter.setCutoff(0.5f);
    }

    ~SynthEngine() {
        delete osc;
    }

    // Real-time Audio Callback
    static void AudioCallback(void* userdata, Uint8* stream, int len) {
        SynthEngine* engine = static_cast<SynthEngine*>(userdata);
        float* buffer = reinterpret_cast<float*>(stream);
        int length = len / sizeof(float);

        for (int i = 0; i < length; i++) {
            float sample = 0.0f;

            // 1. Generate Raw Signal
            if (engine->osc != nullptr) {
                sample = engine->osc->getNextSample();
            }

            // 2. Process through Filter (VCF)
            sample = engine->filter.process(sample);

            // 3. Apply Amplitude Envelope (ADSR)
            float currentAmp = engine->env.getNextAmplitude();
            float finalSample = sample * currentAmp;

            buffer[i] = finalSample;

            // Update visualizer
            if (i < (int)visualBuffer.size()) {
                visualBuffer[i] = finalSample;
            }
        }
    }
};

int main(int argc, char* argv[]) {
    // 1. System Init
    if (SDL_Init(SDL_INIT_AUDIO | SDL_INIT_VIDEO) < 0) return 1;
    if (TTF_Init() < 0) return 1;

    SDL_Window* window = SDL_CreateWindow("C++ PolySynth v1.0",
        SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, 800, 400, SDL_WINDOW_SHOWN);
    SDL_Renderer* renderer = SDL_CreateRenderer(window, -1, SDL_RENDERER_ACCELERATED);

    // 2. Resource Loading
    TTF_Font* font = TTF_OpenFont("arial.ttf", 22);
    if (!font) std::cerr << "Font missing! Place arial.ttf in the project folder." << std::endl;

    // 3. Engine State
    SynthEngine engine;
    float currentCutoff = 0.5f;
    std::string currentWaveName = "Sawtooth";

    // 4. Audio Config
    SDL_AudioSpec ds;
    ds.freq = 44100;
    ds.format = AUDIO_F32SYS;
    ds.channels = 1;
    ds.samples = 2048;
    ds.callback = SynthEngine::AudioCallback;
    ds.userdata = &engine;

    if (SDL_OpenAudio(&ds, NULL) < 0) return 1;
    SDL_PauseAudio(0);

    // 5. Main Loop
    bool running = true;
    SDL_Event e;
    while (running) {
        while (SDL_PollEvent(&e)) {
            if (e.type == SDL_QUIT) running = false;
            if (e.type == SDL_KEYDOWN) {
                Oscillator* nextOsc = nullptr;
                float f = 440.0, a = 0.2, r = 44100.0;

                switch (e.key.keysym.sym) {
                    case SDLK_1: nextOsc = new SineOsc(f, a, r);     currentWaveName = "Sine";     break;
                    case SDLK_2: nextOsc = new SquareOsc(f, a, r);   currentWaveName = "Square";   break;
                    case SDLK_3: nextOsc = new SawOsc(f, a, r);      currentWaveName = "Sawtooth"; break;
                    case SDLK_4: nextOsc = new TriangleOsc(f, a, r); currentWaveName = "Triangle"; break;

                    case SDLK_UP:
                        currentCutoff = std::min(1.0f, currentCutoff + 0.05f);
                        engine.filter.setCutoff(currentCutoff);
                        break;
                    case SDLK_DOWN:
                        currentCutoff = std::max(0.01f, currentCutoff - 0.05f);
                        engine.filter.setCutoff(currentCutoff);
                        break;
                    case SDLK_ESCAPE: running = false; break;
                }

                // The Pointer Swap (Polymorphism)
                if (nextOsc) {
                    Oscillator* old = engine.osc;
                    engine.osc = nextOsc;
                    delete old;
                }
            }
        }

        // Spacebar for ADSR
        const Uint8* state = SDL_GetKeyboardState(NULL);
        if (state[SDL_SCANCODE_SPACE]) engine.env.triggerOn();
        else engine.env.triggerOff();

        // 6. Rendering
        SDL_SetRenderDrawColor(renderer, 20, 20, 25, 255);
        SDL_RenderClear(renderer);

        // Oscilloscope
        SDL_SetRenderDrawColor(renderer, 50, 255, 200, 255);
        for (int x = 0; x < 799; x++) {
            int y1 = 200 - (int)(visualBuffer[x] * 150);
            int y2 = 200 - (int)(visualBuffer[x+1] * 150);
            SDL_RenderDrawLine(renderer, x, y1, x + 1, y2);
        }

        // HUD Text
        if (font) {
            std::string uiText = "Wave: " + currentWaveName + " | Cutoff: " + std::to_string((int)(currentCutoff * 100)) + "%";
            SDL_Color color = { 255, 255, 255, 255 };
            SDL_Surface* s = TTF_RenderText_Blended(font, uiText.c_str(), color);
            SDL_Texture* t = SDL_CreateTextureFromSurface(renderer, s);
            int tw, th;
            SDL_QueryTexture(t, NULL, NULL, &tw, &th);
            SDL_Rect dst = { 20, 20, tw, th };
            SDL_RenderCopy(renderer, t, NULL, &dst);
            SDL_FreeSurface(s);
            SDL_DestroyTexture(t);
        }

        SDL_RenderPresent(renderer);
        SDL_Delay(16);
    }

    // 7. Cleanup
    if (font) TTF_CloseFont(font);
    TTF_Quit();
    SDL_CloseAudio();
    SDL_DestroyRenderer(renderer);
    SDL_DestroyWindow(window);
    SDL_Quit();
    return 0;
}