Introduction into C++ (Part 3)
C++ OOP Mastery: The Power of Composition (Has-A)
In software architecture, there are two primary ways to connect objects: Inheritance (Is-A) and Composition (Has-A). While beginners often try to solve everything through inheritance ("An Oscillator is an Envelope"), professionals prefer composition: "A Synthesizer has an Envelope."
1. Why Composition?
Think of our synthesizer as a modular rack. Each module (Oscillator, Envelope) is an independent class. The SynthEngine acts
as the chassis where these modules are plugged in.
Modularity: We can update the Envelope logic without touching a single line of the Oscillator code.
State Separation: The Envelope manages its own complex states (Attack, Decay, Sustain, Release). The Engine doesn’t care how the volume is calculated; it only cares about the result.
Reusability: We could easily add a second or third Envelope for different parameters later because the class is a self-contained unit.
2. The Signal Chain
Through composition, we build a "Signal Chain." Even in this basic version, data flows in a specific order:
Oscillator → Generates the raw, continuous audio samples.
Envelope → Acts as a "Gate," multiplying those samples by a volume level (0.0 to 1.0) based on how long a key has been held.
3. Full Reference Code (Basic Composition)
Below is the complete integrated code demonstrating the relationship between the Engine, the polymorphic Oscillator, and the ADSR Envelope.
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
#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
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
#include <SDL2/SDL.h>
#include <iostream>
#include <vector>
#include "Oscillator.h"
#include "Envelope.h"
// Shared visualization buffer for the oscilloscope
std::vector<float> visualBuffer(2048, 0.0f);
class SynthEngine {
public:
// COMPOSITION: The Engine "has" these components
Oscillator* osc; // Pointer used for polymorphism (Inheritance)
Envelope env; // Direct instance (Composition)
SynthEngine() : env(44100.0) {
// Initialize with a default Square Wave
osc = new SquareOsc(440.0, 0.2, 44100.0);
}
~SynthEngine() {
delete osc;
}
// AUDIO CALLBACK: The Real-Time Heartbeat
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++) {
// Step 1: Generate Raw Sound (Oscillator)
float sample = 0.0f;
if (engine->osc != nullptr) {
sample = engine->osc->getNextSample();
}
// Step 2: Shape Volume (Envelope / ADSR)
// The engine "asks" its composed member for the current amplitude
float currentVolume = engine->env.getNextAmplitude();
float finalSample = sample * currentVolume;
buffer[i] = finalSample;
// Step 3: Record for Visualization
if (i < (int)visualBuffer.size()) {
visualBuffer[i] = finalSample;
}
}
}
};
int main(int argc, char* argv[]) {
if (SDL_Init(SDL_INIT_AUDIO | SDL_INIT_VIDEO) < 0) return 1;
SDL_Window* window = SDL_CreateWindow("C++ Synth: Composition Basics",
SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, 800, 400, SDL_WINDOW_SHOWN);
SDL_Renderer* renderer = SDL_CreateRenderer(window, -1, SDL_RENDERER_ACCELERATED);
SynthEngine engine;
SDL_AudioSpec ds;
ds.freq = 44100;
ds.format = AUDIO_F32SYS;
ds.channels = 1;
ds.samples = 2048;
ds.callback = SynthEngine::AudioCallback;
ds.userdata = &engine;
SDL_OpenAudio(&ds, NULL);
SDL_PauseAudio(0);
std::cout << "Controls:\n1-4: Change Waveform\nSPACE: Play Note (ADSR)\nESC: Exit" << std::endl;
bool running = true;
SDL_Event e;
while (running) {
while (SDL_PollEvent(&e)) {
if (e.type == SDL_QUIT) running = false;
if (e.type == SDL_KEYDOWN) {
// Polymorphic Swap (Inheritance)
Oscillator* nextOsc = nullptr;
switch (e.key.keysym.sym) {
case SDLK_1: nextOsc = new SquareOsc(440.0, 0.2, 44100.0); break;
case SDLK_2: nextOsc = new SawOsc(440.0, 0.2, 44100.0); break;
case SDLK_3: nextOsc = new SineOsc(440.0, 0.2, 44100.0); break;
case SDLK_4: nextOsc = new TriangleOsc(440.0, 0.2, 44100.0); break;
case SDLK_ESCAPE: running = false; break;
}
if (nextOsc != nullptr) {
Oscillator* oldOsc = engine.osc;
engine.osc = nullptr; // Safety nullify for audio thread
delete oldOsc;
engine.osc = nextOsc;
}
}
}
// Triggering the Envelope via Spacebar
const Uint8* state = SDL_GetKeyboardState(NULL);
if (state[SDL_SCANCODE_SPACE]) {
// Trigger Attack if we aren't already playing or releasing
if (engine.env.getState() == OFF || engine.env.getState() == RELEASE)
engine.env.triggerOn();
} else {
// Trigger Release if the key is lifted
if (engine.env.getState() != OFF && engine.env.getState() != RELEASE)
engine.env.triggerOff();
}
// Oscilloscope Rendering
SDL_SetRenderDrawColor(renderer, 10, 10, 15, 255);
SDL_RenderClear(renderer);
SDL_SetRenderDrawColor(renderer, 0, 255, 150, 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);
}
SDL_CloseAudio();
SDL_DestroyRenderer(renderer);
SDL_DestroyWindow(window);
SDL_Quit();
return 0;
}
We compile the file with a simple
1
g++ -o synth main.cpp Oscillator.cpp `pkg-config --cflags --libs sdl2`
and can execute the program:
1
./synth
Now, with the envelope implemented it really sounds like a true Synthesizer!!!
4. Conclusion
By using Composition, we’ve built a system that is easy to extend. The SynthEngine manages the high-level logic and
the hardware interface, while the Envelope manages the mathematical intricacies of volume shaping.
This clean separation of concerns is what makes professional C++ applications robust. In the next chapter, we will add a Voltage Controlled Filter (VCF) to this chain using this exact same compositional approach!