From 1c9acb2fd9216dee7f6a564b1e648f80e9f75bad Mon Sep 17 00:00:00 2001 From: Bliblank Date: Sun, 11 Jan 2026 23:00:44 -0600 Subject: [PATCH] polyphony checkpoint --- src/KeyboardController.cpp | 1 + src/synth/Envelope.h | 2 + src/synth/Synth.cpp | 115 ++++++++++--------------------------- src/synth/Synth.h | 41 ++++--------- src/synth/Voice.cpp | 111 ++++++++++++++++++++++++++++++++++- src/synth/Voice.h | 72 ++++++++++++++++++++++- 6 files changed, 223 insertions(+), 119 deletions(-) diff --git a/src/KeyboardController.cpp b/src/KeyboardController.cpp index 60e0926..5319d24 100644 --- a/src/KeyboardController.cpp +++ b/src/KeyboardController.cpp @@ -7,6 +7,7 @@ KeyboardController::KeyboardController(NoteQueue& queue) : queue_(queue) { // TODO: also configurable via a yml keymap_ = { + { Qt::Key_Shift, 47 }, // B 2 { Qt::Key_Z, 48 }, // C 3 { Qt::Key_S, 49 }, // C# { Qt::Key_X, 50 }, // D diff --git a/src/synth/Envelope.h b/src/synth/Envelope.h index c0ea207..db45337 100644 --- a/src/synth/Envelope.h +++ b/src/synth/Envelope.h @@ -28,6 +28,8 @@ public: void setRelease(float seconds) { release_ = std::max(seconds, 0.001f); } // values close to zero introduce that popping sound on noteOn/noteOffs + State state() { return state_; }; + // note events void noteOn(); void noteOff(); diff --git a/src/synth/Synth.cpp b/src/synth/Synth.cpp index f4febfe..55cb0bc 100644 --- a/src/synth/Synth.cpp +++ b/src/synth/Synth.cpp @@ -25,48 +25,41 @@ void Synth::updateParams() { void Synth::setSampleRate(uint32_t sampleRate) { sampleRate_ = sampleRate; - filter_.setSampleRate(static_cast(sampleRate)); + + for(Voice& v : voices_) { + v.setSampleRate(static_cast(sampleRate)); + } } inline float Synth::getParam(ParamId id) { return params_[static_cast(id)].current; } -inline float Synth::noteToFrequency(uint8_t note) { - return SYNTH_PITCH_STANDARD * pow(2.0f, static_cast(note - SYNTH_MIDI_HOME) / static_cast(SYNTH_NOTES_PER_OCTAVE)); -} - void Synth::handleNoteEvent(const NoteEvent& event) { if(event.type == NoteEventType::NoteOn) { - // add note to activeNotes list - if (std::find(heldNotes_.begin(), heldNotes_.end(), event.note) == heldNotes_.end()) { - heldNotes_.push_back(event.note); - gainEnvelope_.noteOn(); - cutoffEnvelope_.noteOn(); - resonanceEnvelope_.noteOn(); - // TODO: envelopes in an array so we can loop over them + + // TODO: find quietest voice and assign a note to it instead of just the first inactive one + // find inactive voice and start it with the given note + for(Voice& v : voices_) { + if(!v.isActive()) { + v.noteOn(event.note, event.velocity); + break; + } } + + } else { - // remove note from activeNotes list - auto it = std::find(heldNotes_.begin(), heldNotes_.end(), event.note); - if (it != heldNotes_.end()) { - heldNotes_.erase(it); + + // find voice associated with note event and end it + for(Voice& v : voices_) { + if(v.isActive() && v.note() == event.note) { + v.noteOff(); + break; + } } + } - updateCurrentNote(); -} - -void Synth::updateCurrentNote() { - if(heldNotes_.empty()) { - gainEnvelope_.noteOff(); // TODO: move somewhere else when polyphony - cutoffEnvelope_.noteOff(); - resonanceEnvelope_.noteOff(); - return; - } - - uint8_t note = heldNotes_.back(); - frequency_ = noteToFrequency(note); } void Synth::process(float* out, uint32_t nFrames, uint32_t sampleRate) { @@ -82,63 +75,14 @@ void Synth::process(float* out, uint32_t nFrames, uint32_t sampleRate) { // updates internal buffered parameters for smoothing for(auto& p : params_) p.update(); // TODO: profile this - // process all envelopes - // should be easy enough if all the envelopes are in an array to loop over them - gainEnvelope_.set(getParam(ParamId::Osc1VolumeEnvA), getParam(ParamId::Osc1VolumeEnvD), getParam(ParamId::Osc1VolumeEnvS), getParam(ParamId::Osc1VolumeEnvR)); - cutoffEnvelope_.set(getParam(ParamId::FilterCutoffEnvA), getParam(ParamId::FilterCutoffEnvD), getParam(ParamId::FilterCutoffEnvS), getParam(ParamId::FilterCutoffEnvR)); - resonanceEnvelope_.set(getParam(ParamId::FilterResonanceEnvA), getParam(ParamId::FilterResonanceEnvD), getParam(ParamId::FilterResonanceEnvS), getParam(ParamId::FilterResonanceEnvR)); - float gainEnv = gainEnvelope_.process(); - float cutoffEnv = cutoffEnvelope_.process(); - float resonanceEnv = resonanceEnvelope_.process(); - // TODO: envelope is shared between all notes so this sequence involves a note change but only one envelope attack: - // NOTE_A_ON > NOTE_B_ON > NOTE_A_OFF and note B starts playing part-way through note A's envelope - - // skip if no active notes - if(!gainEnvelope_.isActive()) { - out[2*i] = 0.0f; - out[2*i+1] = 0.0f; - scope_->push(0.0f); - continue; - // TODO: should I have a write() function ? - // maybe we change the synth.process into just returning a single float and the write can be in audioEngine - } + // assemble float array of parameters so that its easier for voices to retrieve - // TODO: make pitchOffset variable for each oscillator (maybe three values like octave, semitone offset, and pitch offset in cents) - float pitchOffset = 1.0f; - float phaseInc = pitchOffset * 2.0f * M_PI * frequency_ / static_cast(sampleRate); - - float gain = gainEnv * getParam(ParamId::Osc1VolumeDepth); - - // sample generation - // TODO: wavetables - // TODO: wavetables should be scaled by their RMS for equal loudness (prelim standard = 0.707) - float sineSample = std::sin(phase_); - float squareSample = (phase_ >= M_PI) ? 0.707f : -0.707f; - float sawSample = ((phase_ / M_PI) - 1.0f) / 0.577f * 0.707f; - // switch statement will be replaced with an array index for our array of wavetables - switch (static_cast(std::round(getParam(ParamId::Osc1WaveSelector1)))) { - case 0: - sampleOut = sineSample * gain; - break; - case 1: - sampleOut = squareSample * gain; - break; - case 2: - sampleOut = sawSample * gain; - break; - case 3: - // TODO: no triable wave yet :( - sampleOut = sineSample * gain; - break; - default: // unreachable - break; + // foreach voice, process... + float mix = 0.0f; + for(Voice& v : voices_) { + mix += v.process(¶ms_[0].current, triggered); } - // filter sample - float cutoffFreq = cutoffEnv * pow(2.0f, getParam(ParamId::FilterCutoffDepth)) * frequency_; - filter_.setParams(Filter::Type::BiquadLowpass, cutoffFreq, resonanceEnv * getParam(ParamId::FilterResonanceDepth)); - sampleOut = filter_.biquadProcess(sampleOut); - // write to buffer out[2*i] = sampleOut; // left out[2*i+1] = sampleOut; // right @@ -148,8 +92,9 @@ void Synth::process(float* out, uint32_t nFrames, uint32_t sampleRate) { scope_->push(sampleOut); // visualization tap } - // sampling business - phase_ += phaseInc; + // triggering business + // TODO: get trigger info from voice (lowest frequency voice) + float phase_ = 0.0f; if (phase_ > 2.0f * M_PI) { phase_ -= 2.0f * M_PI; if(!triggered) { diff --git a/src/synth/Synth.h b/src/synth/Synth.h index 5d14b24..6e9bb33 100644 --- a/src/synth/Synth.h +++ b/src/synth/Synth.h @@ -6,18 +6,11 @@ #include "Envelope.h" #include "ScopeBuffer.h" #include "Filter.h" +#include "Voice.h" #include #include -struct SmoothedParam { - float current = 0.0f; - float target = 0.0f; - float gain = 0.001f; - - inline void update() { current += gain * (target - current); } -}; - class Synth { public: @@ -42,35 +35,21 @@ private: // small getter that abstracts all the static casts and such inline float getParam(ParamId); - // for calculating frequency based on midi note id - inline float noteToFrequency(uint8_t note); - - // finds the active voice - void updateCurrentNote(); + Voice* findFreeVoice(); + Voice* findVoiceByNote(uint8_t note); const ParameterStore& paramStore_; // smoothed params creates a buffer in case the thread controlling paramStore gets blocked std::array params_; + + std::vector heldNotes_; + + // voices + static constexpr int MAX_VOICES = 12; + std::array voices_; uint32_t sampleRate_; - + // for the scope ScopeBuffer* scope_ = nullptr; - // TODO: might make this a fixed array where index=midi-note and the value=velocity - // so non-zero elements are the ones currently being played - std::vector heldNotes_; - - // here's where the actual sound generation happens - // TODO: put this in an oscillator class - float frequency_ = 220.0f; - float phase_ = 0.0f; - - // envelopes !! - Envelope gainEnvelope_; - Envelope cutoffEnvelope_; - Envelope resonanceEnvelope_; - - // filters, just one for now - Filter filter_; - }; diff --git a/src/synth/Voice.cpp b/src/synth/Voice.cpp index 5957268..2fb4851 100644 --- a/src/synth/Voice.cpp +++ b/src/synth/Voice.cpp @@ -1,4 +1,113 @@ #include "Voice.h" +#include -// placeholder +Voice::Voice(std::array* params) : params_(params) { + +} + +void Voice::setSampleRate(float sampleRate) { + sampleRate_ = sampleRate; + + // foreach envelope... + gainEnvelope_.setSampleRate(sampleRate); + cutoffEnvelope_.setSampleRate(sampleRate); + resonanceEnvelope_.setSampleRate(sampleRate); + + // foreach filter... + filter1_.setSampleRate(sampleRate); + filter2_.setSampleRate(sampleRate); + + // then foreach oscillator + //osc1_.setSampleRate(sampleRate); +} + +inline float Voice::noteToFrequency(uint8_t note) { + return SYNTH_PITCH_STANDARD * pow(2.0f, static_cast(note - SYNTH_MIDI_HOME) / static_cast(SYNTH_NOTES_PER_OCTAVE)); +} + +void Voice::noteOn(int midiNote, float velocity) { + note_ = midiNote; + velocity_ = velocity; + frequency_ = noteToFrequency(midiNote); + active_ = true; + + // TODO: for each envelope ... + gainEnvelope_.noteOn(); + cutoffEnvelope_.noteOn(); + resonanceEnvelope_.noteOn(); +} + +void Voice::noteOff() { + // again, foreach + gainEnvelope_.noteOff(); + cutoffEnvelope_.noteOff(); + resonanceEnvelope_.noteOff(); +} + +bool Voice::isActive() { + return active_; +} + +float Voice::process(float* params, bool& scopeTrigger) { + + // process all envelopes + // should be easy enough if all the envelopes are in an array to loop over them + gainEnvelope_.set(getParam(ParamId::Osc1VolumeEnvA), getParam(ParamId::Osc1VolumeEnvD), getParam(ParamId::Osc1VolumeEnvS), getParam(ParamId::Osc1VolumeEnvR)); + cutoffEnvelope_.set(getParam(ParamId::FilterCutoffEnvA), getParam(ParamId::FilterCutoffEnvD), getParam(ParamId::FilterCutoffEnvS), getParam(ParamId::FilterCutoffEnvR)); + resonanceEnvelope_.set(getParam(ParamId::FilterResonanceEnvA), getParam(ParamId::FilterResonanceEnvD), getParam(ParamId::FilterResonanceEnvS), getParam(ParamId::FilterResonanceEnvR)); + + // skip if no active notes + if(!gainEnvelope_.isActive()) { + + return 0.0f; + } + + float gainEnv = gainEnvelope_.process(); + float cutoffEnv = cutoffEnvelope_.process(); + float resonanceEnv = resonanceEnvelope_.process(); + // TODO: envelope is shared between all notes so this sequence involves a note change but only one envelope attack: + // NOTE_A_ON > NOTE_B_ON > NOTE_A_OFF and note B starts playing part-way through note A's envelope + + // TODO: make pitchOffset variable for each oscillator (maybe three values like octave, semitone offset, and pitch offset in cents) + float pitchOffset = 1.0f; + float phaseInc = pitchOffset * 2.0f * M_PI * frequency_ / static_cast(sampleRate); + + float gain = gainEnv * getParam(ParamId::Osc1VolumeDepth); + float sampleOut = 0.0f; + + // sample generation + // TODO: move this into the oscillator class + // TODO: wavetables + // TODO: wavetables should be scaled by their RMS for equal loudness (prelim standard = 0.707) + float sineSample = std::sin(phase_); + float squareSample = (phase_ >= M_PI) ? 0.707f : -0.707f; + float sawSample = ((phase_ / M_PI) - 1.0f) / 0.577f * 0.707f; + // switch statement will be replaced with an array index for our array of wavetables + switch (static_cast(std::round(getParam(ParamId::Osc1WaveSelector1)))) { + case 0: + sampleOut = sineSample * gain; + break; + case 1: + sampleOut = squareSample * gain; + break; + case 2: + sampleOut = sawSample * gain; + break; + case 3: + // TODO: no triable wave yet :( + sampleOut = sineSample * gain; + break; + default: // unreachable + break; + } + + // filter sample + float cutoffFreq = cutoffEnv * pow(2.0f, getParam(ParamId::FilterCutoffDepth)) * frequency_; + filter1_.setParams(Filter::Type::BiquadLowpass, cutoffFreq, resonanceEnv * getParam(ParamId::FilterResonanceDepth)); + filter2_.setParams(Filter::Type::BiquadLowpass, cutoffFreq, resonanceEnv * getParam(ParamId::FilterResonanceDepth)); + sampleOut = filter1_.biquadProcess(sampleOut); + sampleOut = filter2_.biquadProcess(sampleOut); + + return sampleOut; +} \ No newline at end of file diff --git a/src/synth/Voice.h b/src/synth/Voice.h index 84440e1..78b9ec2 100644 --- a/src/synth/Voice.h +++ b/src/synth/Voice.h @@ -1,4 +1,72 @@ -# pragma once +#pragma once -// placeholder +#include "Oscillator.h" +#include "Envelope.h" +#include "Filter.h" +#include "ParameterStore.h" + +#ifndef M_PI // I hate my stupid chungus life + #define M_PI 3.14159265358979323846 +#endif + +// TODO: you get it, also in a yml config +#define SYNTH_PITCH_STANDARD 440.0f // frequency of home pitch +#define SYNTH_MIDI_HOME 69 // midi note index of home pitch +#define SYNTH_NOTES_PER_OCTAVE 12 + +struct SmoothedParam { + float current = 0.0f; + float target = 0.0f; + float gain = 0.001f; + + inline void update() { current += gain * (target - current); } +}; + +class Voice { +public: + + Voice(std::array* params); + ~Voice() = default; + + void setSampleRate(float sampleRate); + + void noteOn(int midiNote, float velocity); + void noteOff(); + + bool isActive(); + + float process(float* params, bool& scopeTrigger); + + uint8_t note() { return note_; } + +private: + + float sampleRate_ = 44100.0f; + + inline float noteToFrequency(uint8_t note); + + uint8_t note_ = 0; + float velocity_ = 1.0f; + bool active_ = false; + + // here's where the actual sound generation happens + // TODO: put this in an oscillator class + float frequency_ = 220.0f; + float phase_ = 0.0f; + //Oscillator osc_; // example + + // envelopes !! + // TODO: foreach envelope in vector envelopes_ + Envelope gainEnvelope_; + Envelope cutoffEnvelope_; + Envelope resonanceEnvelope_; + + // filters + Filter filter1_; + Filter filter2_; + + // paramstore pointer + std::array* params_; + +};