The filter design of sfizz is made after the models listed in SFZ specification. This design is compared against other established sampler software for verification.
Filter reference:
For comparison, one can implement a SFZ using a *noise
generator input, and observe the spectrum of the output.
For an equivalence to this noise generator, one can compile a white noise generator of 1/4 amplitude.
import("stdfaust.lib");
process = no.noise : *(0.25);
SFZ Example
<region>
sample=*noise
lokey=0
hikey=127
cutoff=1000.0
fil_type=lpf_2p
fil_gain=0.0
resonance=10.0
The filters of 2, 4 or 6 poles kind are provided as biquad filters. A biquad is an IIR filter of the second order. 4-pole and 6-pole are obtained by cascading.
The transfer function of the generic biquad is:
H(z) = (b0 + b1*z^-1 + b2 * z^-2) / (a0 + a1 * z^-1 + a2 * z^-2)
From this formula, the response is computed by substituting z
for exp(2*pi*j*fc/fs)
.
The polar form of the result are the filter's amplitude and phase response.
When H(z)
is substituted with Y(z) / X(z)
, and then this identity applied to pass from z-domain to time domain:
(A*X(z)*z^-N)
→ (A*x[n-N])
It's the implementable Direct Form I equation:
y[n] = (1/a0) * (b0*x[n] + b1*x[n-1] + b2*x[n-2] - a1*y[n-1] - a2*y[n-2])
Then, a0
is factored into the equation to save an instruction in the computation:
y[n] = (b0/a0)*x[n] + (b1/a0)*x[n-1] + (b2/a0)*x[n-2] - (a1/a0)*y[n-1] - (a2/a0)*y[n-2]
It's the normalized form which is needed to use this filter in faust function fi.iir
.
The most frequently used biquad kind is RBJ, named after initials of the author.
It has a set of formulas for calculating coefficient for various kinds of responses.
We select this filter for our implementation, because it matches near perfectly in the comparisons.
It's available in faust in maxmsp.lib
.
Only in late or developer versions, this library was relicensed permissively, so instead it's copied in our source tree with the appropriate license mention. (as rbj.lib
)
Relevant discussions and PR:
The implementation of the filters is subject to following prerequisites:
- have controls which map to the documented opcodes
- have ability to modulate frequency and resonance and fast rate
- have stability, particularly in presence of important modulation
We have a source sfz_filters.dsp
written in faust language, an entry point to the filters.
Faust is a specialized programming language which is able to generated optimized DSP code, in some aspects which are not sometimes possible in a non-specialized language.
For instance, Faust is aware of some notions of variability, and adapt code generation and optimization accordingly. It can be defined like this:
- constant: a value which never varies
- a-rate: a value which updates at the audio rate, once per frame
- k-rate: a value which updates at the control rate, once per
process
call
When you have a filter which can modulate dynamically its response, there is potentially expensive computation involved. For instance, formulas of RBJ biquads involve trigonometry.
To make modulation efficient as much as possible, this computation must be avoided at a-rate, but rather be made at k-rate.
For this reason, we define out filter controls as parameters, as in the sliders of a hypothetical UI, and not signals. It's how to implement k-rate update. (even if no UI, faust calls it "sliders")
cutoff = hslider("[01] Cutoff [unit:Hz] [scale:log]", 440.0, 50.0, 10000.0, 1.0);
Q = vslider("[02] Resonance [unit:dB]", 0.0, 0.0, 40.0, 0.1) : ba.db2linear;
Our filter corresponding to each type has a naming convention starting with "sfz".
As an example, the 2-pole lowpass is the function sfzLpf2p
. It is 1-in, 1-out.
When filter functions are implemented, we generate all with a script since there exist a high quantity of them.
See scripts/generate_filters.sh
.
The output is a file of .cxx
extension for each. It's a special extension because it's not listed in CMake sources, rather all filters are included by a single implementation file. It permits also to inline them into their caller.
The following faust options are used which are notable:
-pn
: sets the name of the function to generate fromsfz_filters.dsp
, in this case the name of our filter function as noted in the convention above-cn
: sets the class name of generated C++-double
: indicates to perform internal computations in double precision, which is found to be necessary for numeric stability-inpl
: allow to process the buffers in place; from what I can tell, it doesn't have performance impact.
If you want to use a faust
Docker image, you have to update the scripts to replace the line that calls faust $FAUSTARGS ...
with docker run -v $PWD:/faust faust:latest $FAUSTARGS ...
.
On 2020-02-20, the version of faust
used was 2.20.2
-- alot of 2 and 0 but it's not on purpose...
When the filter is modulated, we need a form of smoothing to apply, such that a modulation never applies a too brutal change which produces a discontinuity, destabilizing the filter.
The smoothing function is a single-pole lowpass filter. Defined in sfz_filters.dsp
, it's the function smoothCoefs
.
Handling this problem, there is one special consideration to consider:
- if smoothing is applied to parameters Fc and Q, they will be varying at a-rate, and also the filter computation will also become a-rate, regardless that Fc and Q controls are k-rate, killing efficiency.
- the solution is to apply smoothing to the individual coefficients
bN
andaN
; since biquads are normalized, hence eliminates coefficienta0
, there is generally need for 5 smooth filters (b0
,b1
,b2
,a1
,a2
). In some RBJ filter types, there are some coefficients which are constant or equal to another, in this case faust will be able to eliminate and simplify as needed, so it needs less smooth filters than general case.
Note that in Faust, the smoothing starts from for all parameters and as such it can cause a strange transient effect at the beginning of the filter.
To handle this in the least hacky way, there is a boolean value in the Faust filters that deactivate the smoothing.
The idea is to deactivate the smoothing, process a single empty sample with the proper parameter values, and then reactivate the smoothing.
The parameters are now set to their initial values and processing can continue with the original smoothing function.
In the wrapper, the prepare
function call does this.
Technically, the smoothing is a 1-pole IIR filter with a time constant tau
set at 1ms.
Relevant discussions and PR:
There exists a filter wrapper class written for multi-mode. This is found in SfzFilters.h
.
This is what is special regarding this wrapper:
- the type of filter is selected through a choice of enumerated constants;
- all filter objects are instantiated in this, but only one used based on value of type;
- when there is a change of type, the memory of the new filter is cleared to zero, to avoid any glitches; theoretically a type change may cause discontinuity, but not in SFZ, since filter type is always fixed.
- when the filter is not subject to modulations, the function call
process
is optimal. - when is is modulated, invoke
processModulated
. In this case, the filter updates will occur at a fixed intervalN
frames, to limit the frequency of updates. It's a latency-performance compromise.N
is defined askFilterControlInterval
in the fileSfzFilterDefs.h
.
cutoff
: it's the opcodefilN_cutoff
(Hz)q
: it's the opcodefilN_resonance
(dB)pksh
: it's the opcodefilN_gain
(dB)