Parametric EQ (ZDF SVF/TPT) built with C++20 and JUCE

DSP, Plugin and Host development discussion.
RELATED
PRODUCTS

Post

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

Post

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!

Post

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!

Post

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.
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.

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.
My website: rs-met.com, My presences on: YouTube, GitHub, Facebook

Post

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).
Last edited by Music Engineer on Sat May 02, 2026 5:07 pm, edited 1 time in total.
My website: rs-met.com, My presences on: YouTube, GitHub, Facebook

Post

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.

Post

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.
Yeah - there's often a tension between wanting to write clean code and wanting to write efficient code.
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.
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:

viewtopic.php?t=615626
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.
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've now added static_cast to avoid implicit conversions ...
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.
Thank you for the insights and the link to your library! It's very inspiring.
Thanks!

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));
}
My website: rs-met.com, My presences on: YouTube, GitHub, Facebook

Post

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.
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.

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.

Post

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.

Post

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.

Post

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

Post

Congrats on the release!

Post

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?
My website: rs-met.com, My presences on: YouTube, GitHub, Facebook

Post

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?
Mathematically, this "Tilt" is implemented by normalizing the HighShelf so that the center frequency stays at 0dB.
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.

Post

AndrewHullabaloo wrote: Tue Apr 28, 2026 10:39 pm Congrats on the release!
Thanks, I appreciate it!

Post Reply

Return to “DSP and Plugin Development”