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
SynthEnginedoesn’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:
Oscillator → Generates the raw, harmonically rich "buzz."
Filter (VCF) → Subtracts frequencies (the stateful part).
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;
}