Parametric EQ (ZDF SVF/TPT) built with C++20 and JUCE
- KVRer
- 19 posts since 25 Mar, 2026
I've been self-studying DSP for a while and recently built a parametric EQ from scratch using C++20 and JUCE.
I've implemented a 2nd order ZDF SVF with TPT structure.
It seems to be working fine for me, but I’m looking for more efficient codes.
I'd appreciate any technical feedback or code reviews.
This is my first time sharing my code with others, and also my first post on KVR.
https://github.com/zalthyrexor/QuasarEQ
I've implemented a 2nd order ZDF SVF with TPT structure.
It seems to be working fine for me, but I’m looking for more efficient codes.
I'd appreciate any technical feedback or code reviews.
This is my first time sharing my code with others, and also my first post on KVR.
https://github.com/zalthyrexor/QuasarEQ
- KVRist
- 191 posts since 31 Oct, 2017
Nice. Code is pretty clean - no comments in there though?
Why are you looking for more efficiency? Have you profiled? On first glance I wouldn't imagine any performance problems...not on today's processors.
You will have noticed that your filters cramp when the frequency gets close to Nyquist. It's a classic issue.
Regarding that, you may find this interesting: viewtopic.php?t=625630&hilit=filter
There's a small MIT licensed source lib there for de-cramped filters. You could almost use it as a drop in replacement.
Other than that, you could add Mid/Side quite easily etc. Maybe even add a simple compression feature for the filter cutoffs to get dynamic EQ compression.
Nice project though!
Why are you looking for more efficiency? Have you profiled? On first glance I wouldn't imagine any performance problems...not on today's processors.
You will have noticed that your filters cramp when the frequency gets close to Nyquist. It's a classic issue.
Regarding that, you may find this interesting: viewtopic.php?t=625630&hilit=filter
There's a small MIT licensed source lib there for de-cramped filters. You could almost use it as a drop in replacement.
Other than that, you could add Mid/Side quite easily etc. Maybe even add a simple compression feature for the filter cutoffs to get dynamic EQ compression.
Nice project though!
- KVRer
- Topic Starter
- 19 posts since 25 Mar, 2026
Yeah, I usually skip comments because I'm aiming to write self-documenting code to keep it as readable as possible.
I'm aware of the Nyquist cramping issue.
Even standard plugins like Ableton's EQ, Serum, or Kilohearts Filter seem to exhibit this behavior, so I've decided to skip it to keep the implementation simple.
Anyway the link you shared is interesting, so I'll keep it for future reference.
I'm planning to implement Mid/Side processing after some refactoring.
Thanks for the feedback, JustinJ!
I'm aware of the Nyquist cramping issue.
Even standard plugins like Ableton's EQ, Serum, or Kilohearts Filter seem to exhibit this behavior, so I've decided to skip it to keep the implementation simple.
Anyway the link you shared is interesting, so I'll keep it for future reference.
I'm planning to implement Mid/Side processing after some refactoring.
Thanks for the feedback, JustinJ!
-
Music Engineer Music Engineer https://www.kvraudio.com/forum/memberlist.php?mode=viewprofile&u=15959
- KVRAF
- 4389 posts since 8 Mar, 2004 from Berlin, Germany
The code and the plugin look very nice. Thank you for making it available! As you are specifically asking for optimization opportunities, one thing that I see is that in ZdfSvf2ndOrder::update_coefficients(), you always compute sqrtA and A even though these values are not needed by all filter types. For example, lowpass, highpass and bandpass do not use them. You could drag the computation of the values into the individual cases where they are actually needed. That would increase the code size a little bit (you would get duplications) but you would save these operations for the LP/HP/BP modes. Whether that is worth it in a presumably static EQ may be debatable - but if you would want to use this filter code in a time-varying context (like in a synth filter) where you might call update_coefficients() per sample and where LP is presumably by far the most common setting, it may pay off some more - especially when taking into account that a call to pow() is actually rather expensive.Zalthyrexor wrote: Wed Mar 25, 2026 8:00 am It seems to be working fine for me, but I’m looking for more efficient codes.
I'd appreciate any technical feedback or code reviews.
By the way - where do the computation formulas there come from? I think, I have never seen a formula for the design of a 2nd order SVF tilt filter before. That's interesting! I may want to add tilt mode to my own SVF implementation, too.
Last edited by Music Engineer on Thu Mar 26, 2026 8:34 am, edited 4 times in total.
-
Music Engineer Music Engineer https://www.kvraudio.com/forum/memberlist.php?mode=viewprofile&u=15959
- KVRAF
- 4389 posts since 8 Mar, 2004 from Berlin, Germany
Ah - and I see another optimization opportunity: You have this struct StereoSvf that consists of two instances of your SVF implementation. One could implement a stereo filter for the price of a single filter using SIMD. The way I do such a thing typically is to templatize my filter class on the data type for the parameters (which in your case would be double) and for the signal type (which in this case would be some wrapper around a SIMD vector of 2 doubles, i.e. __m128d). This is my templatized SVF implementation:
https://github.com/RobinSchmidt/RS-MET/ ... leFilter.h
To get a stereo filter with the same parameters/coefficients for both channels, you would instantiate it as:
rsStateVariableFilter<TwoDoubles, double> myStereoSVF;
where I use "TwoDoubles" as placeholder for the wrapper class around __m128d. I have my own class for this purpose in my library that I call rsFloat64x2. You need the wrapper class mostly to wrap the intrinsic functions for the arithmetic operations (like _mm_add_pd(), _mm_mul_pd(), etc.) into the respective operators such that the generic template code works with this type - and also because noone wants to read code that uses _mm_add_pd(), _mm_mul_pd(), ... instead of +,*,... directly. Maybe JUCE has such a wrapper, too (I didn't really look into that because I have my own) and with C++26 one might also consider using std::simd as well.
Ah wait! Your StereoSvf actually works on float rather than double - but your inner (mono) SVF use double precision. Maybe you should also try avoiding these implicit float/double conversions. If you process everything in single precision, you could even get 4 channels for the price of a single filter using __m128 without the "d" - which is a SIMD vector of 4 single precision floats. ...or you just do everything in double and report to the host that you can process in double precision - which will then hopefully use the double precision audio callback (although that's not guaranteed).
https://github.com/RobinSchmidt/RS-MET/ ... leFilter.h
To get a stereo filter with the same parameters/coefficients for both channels, you would instantiate it as:
rsStateVariableFilter<TwoDoubles, double> myStereoSVF;
where I use "TwoDoubles" as placeholder for the wrapper class around __m128d. I have my own class for this purpose in my library that I call rsFloat64x2. You need the wrapper class mostly to wrap the intrinsic functions for the arithmetic operations (like _mm_add_pd(), _mm_mul_pd(), etc.) into the respective operators such that the generic template code works with this type - and also because noone wants to read code that uses _mm_add_pd(), _mm_mul_pd(), ... instead of +,*,... directly. Maybe JUCE has such a wrapper, too (I didn't really look into that because I have my own) and with C++26 one might also consider using std::simd as well.
Ah wait! Your StereoSvf actually works on float rather than double - but your inner (mono) SVF use double precision. Maybe you should also try avoiding these implicit float/double conversions. If you process everything in single precision, you could even get 4 channels for the price of a single filter using __m128 without the "d" - which is a SIMD vector of 4 single precision floats. ...or you just do everything in double and report to the host that you can process in double precision - which will then hopefully use the double precision audio callback (although that's not guaranteed).
Last edited by Music Engineer on Sat May 02, 2026 5:07 pm, edited 1 time in total.
- KVRer
- Topic Starter
- 19 posts since 25 Mar, 2026
I've updated the code to calculate sqrtA and A only within the specific cases that require them.
I was slightly hesitant about the duplication, but your insight on the potential performance gains helped me decide to go for it.
I developed this tilt implementation myself (as far as I'm aware).
That said, please feel free to incorporate it into your own SVF if you find it useful.
I haven't explored SIMD much yet, so it's a bit outside the scope of this project for now.
I can see how that approach would be efficient, especially for cascaded filters.
I'm aware JUCE has SIMD classes, but I’m trying to minimize external dependencies to keep the code portable.
My choice of double stems from my experience with juce::dsp::IIR::Coefficients.
If I recall correctly, when using float, I noticed the frequency response became unstable or jittery near 20Hz when calling getMagnitudeForFrequency().
Although I haven't strictly measured the SVF's error floor yet, I've stuck with double to be safe.
I've now added static_cast to avoid implicit conversions and am looking into implementing a double-precision audio callback to support native double processing in the DAW.
Thank you for the insights and the link to your library! It's very inspiring.
I was slightly hesitant about the duplication, but your insight on the potential performance gains helped me decide to go for it.
I developed this tilt implementation myself (as far as I'm aware).
That said, please feel free to incorporate it into your own SVF if you find it useful.
I haven't explored SIMD much yet, so it's a bit outside the scope of this project for now.
I can see how that approach would be efficient, especially for cascaded filters.
I'm aware JUCE has SIMD classes, but I’m trying to minimize external dependencies to keep the code portable.
My choice of double stems from my experience with juce::dsp::IIR::Coefficients.
If I recall correctly, when using float, I noticed the frequency response became unstable or jittery near 20Hz when calling getMagnitudeForFrequency().
Although I haven't strictly measured the SVF's error floor yet, I've stuck with double to be safe.
I've now added static_cast to avoid implicit conversions and am looking into implementing a double-precision audio callback to support native double processing in the DAW.
Thank you for the insights and the link to your library! It's very inspiring.
-
Music Engineer Music Engineer https://www.kvraudio.com/forum/memberlist.php?mode=viewprofile&u=15959
- KVRAF
- 4389 posts since 8 Mar, 2004 from Berlin, Germany
Yeah - there's often a tension between wanting to write clean code and wanting to write efficient code.Zalthyrexor wrote: Thu Mar 26, 2026 1:03 pm I've updated the code to calculate sqrtA and A only within the specific cases that require them.
I was slightly hesitant about the duplication, but your insight on the potential performance gains helped me decide to go for it.
Aha - interesting. Thanks. I think, I have to first translate the formula to my structure. It looks like you are forming your final output as weighted sum of input, bandpass and lowpass signals (like in a paper I have seen from Andrew Simper - and your variable names ic1eq, etc. also remind me of that paper - so my guess is that your filter is based on that?) whereas my implementation uses highpass, bandpass and lowpass outputs. We discussed this at some length here:I developed this tilt implementation myself (as far as I'm aware).
That said, please feel free to incorporate it into your own SVF if you find it useful.
viewtopic.php?t=615626
Yeah - I'm also very careful that my DSP library code is self-contained and has no dependency on JUCE. But if you do it with templates like I did, your filter class would not have a dependency on JUCE. It would only use standard C++ features. It's only when you instantiate the template with JUCEs SIMD class that the dependency would kick in - and that would be at a point in your code, where you already do have the JUCE dependency anyway (namely in StereoSvf which sits inside QuasarEQAudioProcessor which is a subclass of juce::AudioProcessor). I'm not trying to persuade you to do it, by the way - just saying. To me, the code looks fine as is. It would be another uglification in the name of performance (and flexibility).I haven't explored SIMD much yet, so it's a bit outside the scope of this project for now.
I can see how that approach would be efficient, especially for cascaded filters.
I'm aware JUCE has SIMD classes, but I’m trying to minimize external dependencies to keep the code portable.
I was more concerned about the performance implication of the conversion itself - not about whether it's implicit or explicit. It's probably not a big deal, though - but you were asking for optimization opportunities so I thought it might be appropriate to mention.I've now added static_cast to avoid implicit conversions ...
Thanks!Thank you for the insights and the link to your library! It's very inspiring.
Meanwhile, I have found another possible point to optimize: As said: Calling pow() is rather expensive and exp() is far cheaper. You could use some sort of dbToAmp function that is just based on calling exp() with precomputed constants. Mine looks like this:
Code: Select all
/** Converts a value in decibels to a raw amplitude value/factor. */
template<class T>
inline T rsDbToAmp(T dB)
{
return exp(dB * T(0.11512925464970228420089957273422));
}
- KVRAF
- 8503 posts since 12 Feb, 2006 from Helsinki, Finland
This "cramping behaviour" is part of why "DAW EQ" at least used to be a somewhat derogative term, because it can make using an EQ more difficult than it needs to be. For mostly static EQs, the increased computational complexity of a better design is usually not a huge deal, but for dynamic filters it certainly adds some cost, so you typically tend to see more "naive" filters where those filters are modulated.Zalthyrexor wrote: Wed Mar 25, 2026 3:18 pm I'm aware of the Nyquist cramping issue.
Even standard plugins like Ableton's EQ, Serum, or Kilohearts Filter seem to exhibit this behavior, so I've decided to skip it to keep the implementation simple.
It's worthwhile to point out though that even the classic "cookbook filters" do a bit of mitigation by trying to adjust the bandwidth of peaking/shelving filters at high cutoffs. The classic RBJ formulation actually goes a bit too far, since it tries to always preserve the bandwidth, which can push the lower band-edge way too low when the higher band-edge approaches Nyquist, but a similar idea can be applied in a more moderate fashion to get something that beats a completely naive design.
Besides all the various fitting methods, oversampling certainly also helps and this is often why you won't bother decramping filters that are going to be oversampled for anti-aliasing reasons anyway.. but for mostly static EQ it's inefficient as it multiplies the processing cost.
As for your actual processing code, it's close enough to optimal that if you need to improve efficiency you should profile and see if there is something else where you're spending more time than expected.
- KVRer
- Topic Starter
- 19 posts since 25 Mar, 2026
To be honest, after 6 years of composing in Cubase and Ableton, I've never really been bothered by the cramping. In fact, when a filter doesn't show any cramping, I find myself suspecting hidden oversampling or some 'magic' that might negatively affect the phase response or CPU usage. For my current goals, I prefer the simplicity and transparency of a naive design.
I’ve experimented with oversampling before, but I found that even at 2x or 4x, it didn't push the upper frequency limits as much as I expected on a logarithmic scale, while the processing cost increased significantly.
I’ve experimented with oversampling before, but I found that even at 2x or 4x, it didn't push the upper frequency limits as much as I expected on a logarithmic scale, while the processing cost increased significantly.
- KVRer
- Topic Starter
- 19 posts since 25 Mar, 2026
Yes, my implementation is indeed based on Andrew Simper's paper.
Initially, I used JUCE's built-in filters, but I wasn't satisfied with the level of artifacts and noise, so I searched for a more robust alternative and found this to be the de-facto standard.
Regarding the templates and SIMD, I now better understand your intention.
To be honest, that level of abstraction is currently a bit beyond my technical reach.
However, I'd love to tackle it in the future, perhaps when I'm building a synthesizer where performance becomes even more critical.
And thank you for the exp() suggestion!
I hadn't thought of replacing pow() with exp() to optimize it.
I've just implemented it, and the code feels much cleaner now.
Initially, I used JUCE's built-in filters, but I wasn't satisfied with the level of artifacts and noise, so I searched for a more robust alternative and found this to be the de-facto standard.
Regarding the templates and SIMD, I now better understand your intention.
To be honest, that level of abstraction is currently a bit beyond my technical reach.
However, I'd love to tackle it in the future, perhaps when I'm building a synthesizer where performance becomes even more critical.
And thank you for the exp() suggestion!
I hadn't thought of replacing pow() with exp() to optimize it.
I've just implemented it, and the code feels much cleaner now.
- KVRer
- Topic Starter
- 19 posts since 25 Mar, 2026
Just wanted to let you know that I've officially released the plugin!
I'll be back here when I have more technical questions.
Thanks again for all the help and feedback!
https://zalthyrexor.itch.io/quasar-eq
https://github.com/zalthyrexor/QuasarEQ
viewtopic.php?t=629380
I'll be back here when I have more technical questions.
Thanks again for all the help and feedback!
https://zalthyrexor.itch.io/quasar-eq
https://github.com/zalthyrexor/QuasarEQ
viewtopic.php?t=629380
-
AndrewHullabaloo AndrewHullabaloo https://www.kvraudio.com/forum/memberlist.php?mode=viewprofile&u=804885
- KVRer
- 8 posts since 27 Apr, 2026
Congrats on the release!
-
Music Engineer Music Engineer https://www.kvraudio.com/forum/memberlist.php?mode=viewprofile&u=15959
- KVRAF
- 4389 posts since 8 Mar, 2004 from Berlin, Germany
Ah! I think, I now understand (from the video) what the "Tilt" mode does. It's just a shelf with a global compensation gain that is half of the shelf's gain but negated, right? ...but is it a low- or high-shelf? Or does that depend on the gain setting - and does that even make a difference or can a high-shelf always be expressed as low-shelf + gain and vice versa?
- KVRer
- Topic Starter
- 19 posts since 25 Mar, 2026
Mathematically, this "Tilt" is implemented by normalizing the HighShelf so that the center frequency stays at 0dB.Music Engineer wrote: Wed Apr 29, 2026 9:51 am Ah! I think, I now understand (from the video) what the "Tilt" mode does. It's just a shelf with a global compensation gain that is half of the shelf's gain but negated, right? ...but is it a low- or high-shelf? Or does that depend on the gain setting - and does that even make a difference or can a high-shelf always be expressed as low-shelf + gain and vice versa?
If you take the HighShelf coefficients m0, m1, m2 and divide them all by sqrt(A) (though this might depend on the specific implementation), you get exactly the coefficients I used for the Tilt mode.
- KVRer
- Topic Starter
- 19 posts since 25 Mar, 2026
Thanks, I appreciate it!
