Von zustandslos zu zustandsbehaftet: Filter & Komposition

In den vorangegangenen Teilen haben wir einen Oszillator gebaut. Er war „zustandslos“ – man gibt ihm eine Phase vor, und er liefert einen Wert. Es ist ihm egal, was vor einer Mikrosekunde passiert ist. Damit Musik jedoch „warm“ oder „organisch“ klingt, brauchen wir Objekte, die über ein Gedächtnis verfügen.

1. DSP allgemein: Das Konzept des Zustands

In der digitalen Signalverarbeitung (DSP) ist ein Filter zustandsbehaftet. Um den aktuellen Ausgangswert zu berechnen, muss ein Filter den vorherigen Ausgangswert kennen.

Mathematisch sieht ein einfacher einpoliger Tiefpassfilter so aus:

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

  • x[n] ist dein aktuelles Roh-Sample (der Eingang).

  • y[n-1] ist der Sample, den wir unmittelbar vor diesem berechnet haben (der Zustand).

  • a ist der Koeffizient (bestimmt durch Ihre Grenzfrequenz).

Da sich das Objekt an y[n-1] „erinnert“, bezeichnen wir es als zustandsbehaftet. In C++ bedeutet dies, dass unsere Filter-Klasse diesen Wert als private Member-Variable speichern muss, die zwischen Aufrufen von process() erhalten bleibt.

2. Architektur: Komposition (Has-A)

Um diese zustandsbehafteten Module zu verwalten, verwenden wir Komposition. Während Anfänger oft versuchen, alles durch Vererbung zu lösen, bevorzugen Profis die Komposition: „Ein Synthesizer hat einen Hüllkurvengenerator und einen Filter.“

  • Modularität: Wir können die Filter-Implementierung austauschen, ohne den Oszillator zu verändern.

  • Kapselung: Die SynthEngine muss die Mathematik des Filters nicht kennen; sie übergibt dem Filter lediglich ein Sample und erhält ein geformtes Sample zurück.

3. Die Signalkette

Daten fließen in einer bestimmten Reihenfolge durch unsere zusammengesetzten Objekte:

  1. Oszillator → Erzeugt den rohen, harmonisch reichen „Summen“.

  2. Filter (VCF) → Subtrahiert Frequenzen (der zustandsbehaftete Teil).

  3. Hüllkurve (ADSR) → Formt die endgültige Lautstärke.

4. Vollständiger Referenzcode

 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. Visuelles Debugging: Den Zustand visualisieren

Und schließlich: Woher wissen wir, dass unser zustandsbehaftetes System funktioniert? Da wir Ton nicht „sehen“ können, nutzen wir visuelles Debugging.

Durch die Integration von SDL_ttf können wir den internen Wert von currentCutoff auf dem Bildschirm anzeigen. In C++ erfordert dies ein sorgfältiges Ressourcenmanagement. Wir erstellen in jedem Frame eine Oberfläche und eine Textur, um den sich ändernden Zustand widerzuspiegeln, und müssen diese sofort wieder löschen, um Speicherlecks zu vermeiden.

Wenn sich die Zahl auf dem Bildschirm ändert, der Klang jedoch nicht, wissen Sie, dass der Fehler in Ihrer DSP-Logik (y[n]-Berechnung) liegt und nicht in Ihrer Steuerungslogik (Tastatureingabe).

  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;
}

Übersetzt mit DeepL.com (kostenlose Version)