From 3e94d64f4a24dd46e8777659fbfb43f28421ea8b Mon Sep 17 00:00:00 2001 From: Bliblank Date: Mon, 22 Jun 2026 21:31:53 -0500 Subject: [PATCH] dynamic string tuning --- CMakeLists.txt | 1 + src/synth/Filter.cpp | 86 +++++++++++++++++++++++++++ src/synth/Filter.hpp | 46 ++++++++++++++ src/synth/Instruments/PianoString.cpp | 72 ++++++++++++---------- src/synth/Instruments/PianoString.hpp | 19 +++--- src/synth/Synth.cpp | 9 ++- src/synth/Synth.hpp | 2 + 7 files changed, 191 insertions(+), 44 deletions(-) create mode 100644 src/synth/Filter.cpp create mode 100644 src/synth/Filter.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index f9a04fe..93423d3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -55,6 +55,7 @@ add_library(sonobulus_core STATIC src/synth/Scope.cpp src/synth/Synth.cpp src/synth/Voice.cpp + src/synth/Filter.cpp src/synth/Instrument.cpp src/synth/Instruments/PianoString.cpp ) diff --git a/src/synth/Filter.cpp b/src/synth/Filter.cpp new file mode 100644 index 0000000..d3440ba --- /dev/null +++ b/src/synth/Filter.cpp @@ -0,0 +1,86 @@ + +#include "Filter.hpp" + +#include +#include + +void Filter::setSampleRate(float sampleRate) { + sampleRate_ = sampleRate; + calculateCoefficients(); +} + +// recalculate filter based on params +void Filter::setParams(Type type, float frequency, float q) { + type_ = type; + frequency_ = std::min(frequency, sampleRate_ / 2.0f * 0.999f); + q_ = q; + calculateCoefficients(); +} + +// update current state and output filtered value +float Filter::biquadProcess(float in) { + + // calculate filtered sample + float out = b0_ * in + z1_; + + // update states + z1_ = b1_ * in - a1_ * out + z2_; + z2_ = b2_ * in - a2_ * out; + + return out; +} + +// internal control system emulation +void Filter::calculateCoefficients() { + + if(q_ < 0.001f) q_ = 0.001f; + + float omega = 2.0f * pi * frequency_ / sampleRate_; + float sinOmega = std::sin(omega); + float cosOmega = std::cos(omega); + float alpha = sinOmega / (2.0f * q_); + + float b0, b1, b2, a0, a1, a2; + + switch (type_) { + case Type::BiquadLowpass: + b0 = (1.0f - cosOmega) * 0.5f; + b1 = 1.0f - cosOmega; + b2 = (1.0f - cosOmega) * 0.5f; + a0 = 1.0f + alpha; + a1 = -2.0f * cosOmega; + a2 = 1.0f - alpha; + break; + + case Type::BiquadHighpass: + b0 = (1.0f + cosOmega) * 0.5f; + b1 = -(1.0f + cosOmega); + b2 = (1.0f + cosOmega) * 0.5f; + a0 = 1.0f + alpha; + a1 = -2.0f * cosOmega; + a2 = 1.0f - alpha; + break; + + case Type::BiquadBandpass: + b0 = sinOmega * 0.5f; + b1 = 0.0f; + b2 = -sinOmega * 0.5f; + a0 = 1.0f + alpha; + a1 = -2.0f * cosOmega; + a2 = 1.0f - alpha; + break; + } + + // values need to be normalized + b0_ = b0 / a0; + b1_ = b1 / a0; + b2_ = b2 / a0; + a1_ = a1 / a0; + a2_ = a2 / a0; + +} + +void Filter::resetSate() { + z1_ = 0.0f; + z2_ = 0.0f; +} diff --git a/src/synth/Filter.hpp b/src/synth/Filter.hpp new file mode 100644 index 0000000..fa28a0f --- /dev/null +++ b/src/synth/Filter.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include + +class Filter { +public: + + enum class Type : uint16_t { + BiquadLowpass, + BiquadNotch, + BiquadBandpass, + BiquadHighpass + }; + + Filter() = default; + ~Filter() = default; + + void setSampleRate(float sampleRate); + void setParams(Type type, float frequency, float q); + float biquadProcess(float in); + void resetSate(); + + // TODO: add more filter types here + // high pass + // band pass + // notch + // https://webaudio.github.io/Audio-EQ-Cookbook/audio-eq-cookbook.html + // instead of making different calculate functions, have an enum which specifies the filter type + // one public calculate which the enum is passed through and a private calculate for the specific types, switch statement to choose + +private: + + void calculateCoefficients(); + + Type type_ = Type::BiquadLowpass; + float sampleRate_ = 44100.0f; + + float frequency_ = 6000.0f; + float q_ = 0.707f; + static constexpr float pi = 3.14159265358979323846; + + // biquad filter structure + float a1_, a2_, b0_, b1_, b2_; + float z1_, z2_; + +}; \ No newline at end of file diff --git a/src/synth/Instruments/PianoString.cpp b/src/synth/Instruments/PianoString.cpp index 28371ce..a6d3694 100644 --- a/src/synth/Instruments/PianoString.cpp +++ b/src/synth/Instruments/PianoString.cpp @@ -13,6 +13,12 @@ PianoString::PianoString(ConfigService* config, LoggerService* logger) : Instrum void PianoString::noteOn(float frequency, float velocity) { logger_->log("Piano", LogFlag::Debug, "Note On"); + active_ = true; + frequency_ = frequency; + + damping_ = 0.5f; + rms_ = 5.0f; + recalculateConstants(); // resize the state vectors so that they are stable at the specified frequency stringY_current_.resize(segmentCount_ + 1); @@ -36,48 +42,31 @@ void PianoString::noteOn(float frequency, float velocity) { stringY_current_[i] = stringY_current_[i] + dt_ * v0; } - damping_ = 0.5f; - rms_ = 0.5f; - - // recalculate based on change in damping - a1_ = 2.0f - 2.0f * damping_ * dt_; - a2_ = 2.0f * damping_ * dt_ - 1.0f; } void PianoString::noteOff() { logger_->log("Piano", LogFlag::Debug, "Note Off"); damping_ = 10.0f; - // recalculate based on change in damping - a1_ = 2.0f - 2.0f * damping_ * dt_; - a2_ = 2.0f * damping_ * dt_ - 1.0f; + recalculateConstants(); } bool PianoString::isActive() { - return (std::abs(rms_) > 0.001f); + if(active_ && (std::abs(rms_) < 0.0000001f)) { + active_ = false; + + // reset state to stationary + for(size_t i = 0; i < segmentCount_ + 1; i++) { + stringY_current_[i] = 0.0f; + stringY_previous_[i] = 0.0f; + stringY_next_[i] = 0.0f; + } + + } + return active_; } float PianoString::process(bool& scopeTrigger) { - /* - for n in range(2, N-2): - # stiff wave equation - y_xx = y_current[n+1] - 2*y_current[n] + y_current[n-1] - y_xxxx = y_current[n-2] - 4*y_current[n-1] + 6*y_current[n] - 4*y_current[n+1] + y_current[n+2] - - y_next[n] = a1 * y_current[n] + a2 * y_last[n] + r2 * y_xx - s2 * y_xxxx - - y_next[0] = 0 - y_next[1] = 0 - y_next[N-1] = 0 - y_next[N-2] = 0 - - # y_sample[i] = math.tanh(y_next[n_sample]) - y_sample[i] = y_next[n_sample] - - y_last = y_current.copy() - y_current = y_next.copy() - */ - // simulate over string for(size_t i = 2; i < segmentCount_ - 2; i++) { float y_xx = stringY_current_[i-1] - 2.0f*stringY_current_[i] + stringY_current_[i+1]; @@ -92,10 +81,27 @@ float PianoString::process(bool& scopeTrigger) { stringY_previous_ = stringY_current_; stringY_current_ = stringY_next_; - float sampleOut = stringY_next_[static_cast(samplePosition_*stringLength_*segmentCount_)]; + float sampleOut = stringY_next_[static_cast(samplePosition_*L_*segmentCount_)]; rms_ = 0.99f * rms_ + 0.01f * sampleOut*sampleOut; - return sampleOut; + return 5.0f * sampleOut; -} \ No newline at end of file +} + +void PianoString::recalculateConstants() { + + L_ = stringLength_ * std::pow(1.005f, -frequency_ + 20.0f) + 0.5f; + + waveVelocity_ = 1.0f * frequency_ * L_; + + dx_ = L_ / static_cast(segmentCount_); + dt_ = 1.0f / sampleRate_; + + r1_ = waveVelocity_ * dt_/dx_; + r2_ = std::pow(waveVelocity_ * dt_/dx_, 2.0f); + s1_ = stiffness_ * dt_/std::pow(dx_, 2.0f); + s2_ = std::pow(stiffness_ * dt_/std::pow(dx_, 2.0f), 2.0f); + a1_ = 2.0f - 2.0f * damping_ * dt_; + a2_ = 2.0f * damping_ * dt_ - 1.0f; +} diff --git a/src/synth/Instruments/PianoString.hpp b/src/synth/Instruments/PianoString.hpp index f693ee6..966d203 100644 --- a/src/synth/Instruments/PianoString.hpp +++ b/src/synth/Instruments/PianoString.hpp @@ -22,22 +22,26 @@ public: private: + void recalculateConstants(); + // states + float frequency_ = 0.0f; std::vector stringY_current_; std::vector stringY_previous_; std::vector stringY_next_; std::vector stringX_; + bool active_ = false; // constants // string parameters - size_t segmentCount_ = 30; + size_t segmentCount_ = 50; static constexpr float rho_ = 8000.0f; // density, steel, kg/m^3 static constexpr float radius_ = 0.001f; // meters static constexpr float stringTension_ = 1200.0f; // string tension, N - static constexpr float stiffness_ = 0.001f; // stiffness coefficient + static constexpr float stiffness_ = 0.000f; // stiffness coefficient float damping_ = 0.5f; // damping coefficient - static constexpr float stringLength_ = 1.0f; // length of string + static constexpr float stringLength_ = 5.0f; // length of string at lowest frequency (20 hz) static constexpr float strikePosition_ = 0.2f; // x of impulse location static constexpr float impulseWidth_ = 0.02f; // x of impulse width static constexpr float impulseVelocity_ = 10000.0f; // x/t of impulse magnitude @@ -45,13 +49,12 @@ private: float crossSectionalArea_ = pi * std::pow(radius_, 2.0f); // string cross sectional area, assuming circular float mu_ = crossSectionalArea_ * rho_; // linear mass density float waveVelocity_ = std::sqrt(stringTension_ / mu_); // transverse wave velocity + float L_ = 1.0f; // length of string at a given frequency - // eventually we'll have to dynamically tune our string according to the note that comes in - // an alternative is a fully built piano and then it calls voices under the instrument instead of how we do it currently - float f0_ = waveVelocity_ / (2.0f * stringLength_); // fundamental frequency of a non-stiff string - float f1_ = f0_ * std::sqrt(1.0f + stiffness_); // fundamental frequency of the stiff string + // f0_ = waveVelocity_ / (2.0f * stringLength_); // fundamental frequency of a non-stiff string + // f1_ = f0_ * std::sqrt(1.0f + stiffness_); // fundamental frequency of the stiff string - float dx_ = stringLength_ / static_cast(segmentCount_); + float dx_ = L_ / static_cast(segmentCount_); float dt_ = 1.0f / sampleRate_; // derived constants diff --git a/src/synth/Synth.cpp b/src/synth/Synth.cpp index 5707c77..12074bf 100644 --- a/src/synth/Synth.cpp +++ b/src/synth/Synth.cpp @@ -6,6 +6,9 @@ Synth::Synth(ConfigService* config, LoggerService* logger, ScopeBuffer* scope, N voices_.fill(Voice(config_, logger_)); + filter_.setSampleRate(44100.0f); + filter_.setParams(Filter::Type::BiquadLowpass, 1000.0f, 0.707f); + } void Synth::handleNoteEvent(const NoteEvent& event) { @@ -56,13 +59,13 @@ void Synth::process(float* out, size_t nFrames) { float mix = 0.0f; for(size_t j = 0; j < voices_.size(); j++) { bool temp = false; - //if(!voices_[j].isActive()) continue; + if(!voices_[j].isActive()) continue; mix += voices_[j].process(temp); if(j == lowestVoice) triggered = temp; } mix = tanh(mix/4.0f); // prevent clipping - sampleOut = mix; + sampleOut = filter_.biquadProcess(mix); out[2*i] = sampleOut; out[2*i+1] = sampleOut; @@ -96,4 +99,4 @@ Voice* Synth::findVoiceByNote(uint8_t note) { } } return nullptr; -} \ No newline at end of file +} diff --git a/src/synth/Synth.hpp b/src/synth/Synth.hpp index 7fdeb74..bcea397 100644 --- a/src/synth/Synth.hpp +++ b/src/synth/Synth.hpp @@ -6,6 +6,7 @@ #include "NoteQueue.hpp" #include "Voice.hpp" #include "Scope.hpp" +#include "Filter.hpp" #include @@ -25,6 +26,7 @@ private: Voice* findVoiceByNote(uint8_t note); std::vector sustainedNotes_; + Filter filter_; // voices static constexpr size_t MAX_VOICES = 32;