DSP Feedback - phaser isn't sounding great

I’m trying to make a basic four stage phaser UGen, I can’t quite get it sounding good. I’m not sure if the issue is:

  1. the Allpass implementation
  2. the iteration of the allpass filter in the next() function
  3. the coefficient modulation
  4. something else

Here’s the github repo, code below. I would be grateful for any and all feedback - both DSP and C++/Plugin writing related!

Cheers,
Jordan

// hpp file
// PluginJWPhaser.hpp
// Jordan White (jordanwhitede@gmail.com)

#pragma once

#include "SC_PlugIn.hpp"

namespace JWPhaser {

    class AllpassFilter {
    public:
        AllpassFilter() : prevInput(0.0f), prevOutput(0.0f) {}

        float process(float input, float coeff) {

            // all pass formula, check again
            float output = (coeff * input) + prevInput - (coeff * prevOutput);

            // update previous input & output;
            prevInput = input;
            prevOutput = output;

            return output;
        }

    private:
        float prevInput;
        float prevOutput;
    };

class JWPhaser : public SCUnit {
public:
    JWPhaser();

    // Destructor
     ~JWPhaser();

private:
    // Calc function
    void next(int nSamples);
    void clear(int nSamples);
    // Member variables
    float lfoPhase;
    float feedbackSignal;
    float bufSize;
    float* feedbackBuffer;
    int writePhase;
    int mask;

    AllpassFilter apf[4];
};

} // namespace JWPhaser

// cpp file

// PluginJWPhaser.cpp
// Jordan White (jordanwhitede@gmail.com)

#include "SC_PlugIn.hpp"
#include "JWPhaser.hpp"
#include <cmath>

static InterfaceTable* ft;

namespace JWPhaser {



JWPhaser::JWPhaser() {
    mCalcFunc = make_calc_function<JWPhaser, &JWPhaser::next>();
    feedbackSignal = 0.0f;
    writePhase = 0;
    lfoPhase = 0.0f;
    bufSize = NEXTPOWEROFTWO((float) sampleRate() * 0.01f); // 10 ms delay

    // declare Buffer
    feedbackBuffer = (float *) RTAlloc(mWorld, bufSize * sizeof(float));
    mask = bufSize - 1;

    if (feedbackBuffer == nullptr) {
        mCalcFunc = make_calc_function<JWPhaser, &JWPhaser::clear>();

        clear(1);

        if (mWorld -> mVerbosity > -2) {
                Print("Failed to allocate memory for JWPhaser.\n");
        }
        return;
    }

    memset(feedbackBuffer, 0, bufSize * sizeof(float));
    next(1);
}

void JWPhaser::clear(int inNumSamples) {
        ClearUnitOutputs(this, inNumSamples);

}

JWPhaser::~JWPhaser(){
        RTFree(mWorld, feedbackBuffer);
}

void JWPhaser::next(int nSamples) {

    // Audio rate input
    const float* input = in(0);

    // control rate params
    const float rate = in0(1);

    float depth = in0(2);

    const float mix = in0(3);

    const float feedback = in0(4);

    // Output buffer
    float* output = out(0);

    if (depth < 0.01) {
        depth = 0.01;
    }

    if (depth > 0.99) {
        depth = 0.99;
    }

    float baseCoeff = 0.01; // i have not found a good value for this
    float modRange = depth; // or this - originally depth was multiplied by some number to keep it under control, but that's not an issue atm

    int delayInSamples = 0.01f * sampleRate();

    // phaser function
    // input + feedback. 4 Allpasses in series, feedback from here.
    for (int i = 0; i < nSamples; ++i) {

        // get feedback signal
        int readPhase = writePhase - delayInSamples;
        if (readPhase < 0) readPhase += bufSize;
        feedbackSignal = feedbackBuffer[readPhase & mask];

        // lfo here to calculate coeff modulation
        float lfoValue = sinf(lfoPhase);
        lfoPhase += 2.0f * M_PI * rate / sampleRate(); // update phase
        //if (lfoPhase > 2.0f * M_PI) lfoPhase -= 2.0f * M_PI; // wrap phase at 2pi
        lfoPhase = fmod(lfoPhase, 2.0f * M_PI); // wrap phase at 2pi

        // calculate modulated coeff
        float modulatedCoeff = baseCoeff + (lfoValue * modRange);
        modulatedCoeff = std::clamp(modulatedCoeff, 0.01f, 0.99f);

        // copy signal and mix with feedback
        const float sig = input[i] + feedbackSignal;

        // experimental - learning how to iterate, hopefully it works
        //float allpassed[4];
        float allpassed = sig;
       // AllpassFilter apf[4];
        for (int j = 0; j < 4; ++j) {
        allpassed = apf[j].process(allpassed, modulatedCoeff);
        //allpassed[1] = apf1.process(allpassed[0], modulatedCoeff);
        //allpassed[2] = apf2.process(allpassed[1], modulatedCoeff);
        //allpassed[3] = apf3.process(allpassed[2], modulatedCoeff);
    }

        float sum = (input[i] * (1.0f - mix)) + (allpassed * mix);
        output[i] = zapgremlins(sum);

        // write signal to buffer, then update writePhase
        feedbackBuffer[writePhase] = allpassed * feedback;
        writePhase = (writePhase + 1) & mask;

    }
}

} // namespace JWPhaser

PluginLoad(JWPhaserUGens) {
    // Plugin magic
    ft = inTable;
    registerUnit<JWPhaser::JWPhaser>(ft, "JWPhaser", false);
}

Haven’t tried it out, but I think the way you use the coefficient is unlikely to work. It’s not simply a “frequency” inpup which one would modulate directly, but rather you need to calculate the coefficent from the corner frequency, according to some function that one usually looks up somewhere. So if that function is c, then modulation would work something like c(freq + mod), not (as I think you did) mod + c.
I’m not terribly competent with dsp stuff but I’ve just reviewed basic filters a few weeks ago, and I also like phasers so I did some messing around with allpasses, too.
As a collection of formulas, this one is good: Audio EQ Cookbook; it’s what SC’s BEQsuite uses.
As for the math, I’ve found the relevant chapters in Pirkle’s “DSP in cpp” book helpful and “accessible”… but what’s accessible always depends on where one is at.(Pirkle’s allpass formulas seem to be broken, though, at least for the edition I have).

1 Like

Thats one possible formula for a first order allpass (from the GO book):

(input + (prevInput * coef)) * coef.neg + prevInput;

you can derive its coefficients from frequency like this:

radiansPerSample = freq.abs * 2pi / samplerate;
sine = sin(radiansPerSample);
cosine = cos(radiansPerSample);
coef = (sine - 1) / cosine;
1 Like

Thanks to you both. I was able to get it working by modulating the frequency and calculating the coeff from there:

 // scale lfo to [0, 1]
        float scaledLFOValue = 0.5 * (1 + lfoValue);
        // exponential mapping
        float expLFOValue = minFreq * powf(maxFreq / minFreq, scaledLFOValue);
        expLFOValue = expLFOValue * depth; // adding depth control
        // convert freq to coeff
        float tanValue = tanf(M_PI * expLFOValue / sampleRate());
        float coeff = (tanValue - 1.0f) / (tanValue + 1.0f);

It’s now working quite nicely and I’ve updated the git repo. Feel free to give it a go and let me know what you think. There is also an argument for the number of stages, for now I’ve limited it to be between 2 and 8. So my next question would be - if you change the number of stages while a synth is running, the phaser seems to “start again” - anyone have any ideas how to make it crossfade from one to the other?

EDIT: seems like this would be computationally pretty expensive for what i assume would be quite a marginal gain…but if anyone wants to correct me on this please feel free

@dietcv - I tried out that formula but it didn’t work initially and I didn’t spend too much time on it. Is it possible it should be
(input + (prevInput * coef)) * coef.neg + prevOutput;

The formula as you had it at the beginning is correct for a first-order allpass.
For all passes of order n, there’s this neat rule that the i-th feedback coefficient is the m-i th feedworward coefficient, so the transfer function will always look like

a_0 + a_1*z^-1 + a_2*z^-2 + ... + a_m*z-^m
--------------------------------------------------------
a_m + a_{m-1}*z^-1 + a_{m-2}*z^-2 + ... + a_0*z^-m

and also, the first feedback coefficient (a_0 here) is always 1 for these, so for your one pole, that gets you

1 + a_1 * z^-1
------------------
a_1 + 1 * z^-1

And for the DE, that gets you:

y[n] =  a_1*x[n] + x[n-1] -  a_1*y[n-1]

where a_1 is your coeff, y[n] is out, y[n-1] is prevOut, x[n] is in ; which you can rewrite as

out = (in - prevout) * coeff + prevIn

which is what you had at the beginning…


As for the changing no of stages while synth is running, I don’t think that will ever really work smoothly. It’s also not so important, because afaik all that additional cascaded stages will do is add more phase shift. (I.e., two first-order apfs in series would be the same amount of phase shift as one second order apf.) What you might be able to do is build the thing with second order filters, which have a “Q” parameter, and cascade a bunch of them; then just increasing the Q on all of them simultaneously (which can be done more or less smoothly) should get you roughly (or exactly?) the same outcome as if you were continuously increasing the number of stages.

sorry for the confusion @jordanwhitede @girthrub. Ive made a typo.

Ive looked at the EQ cookbook once more and the go biquad allpass, the cofficients for the biquad allpass (2nd-order) should be correct like this (b2 collapses to 1)

(
var biquadAllpassCoeffs = { |freq = 440, q = 0.5|

	var omega = freq.abs * 2pi / s.sampleRate;
	var alpha = (0.5 * sin(omega)) / q.abs;

	var a0 = 1 + alpha;

	var b0 = (1 - alpha) / a0;
	var b1 = (-2 * cos(omega)) / a0;
	var b2 = 1;

	var a1 = (-2 * cos(omega)) / a0;
	var a2 = (1 - alpha) / a0;

	(
		b0: b0,
		b1: b1,
		b2: b2,

		a1: a1,
		a2: a2
	);

};

biquadAllpassCoeffs.(440, 0.5);
)

and the direct form looks like this:

I think you could then just put them in series with the same coefficients, dependent on freq and Q like @girthrub suggested.

thxthx. I’ll do a 2nd order version soon. not sure if anyone is that interested but then again maybe :slight_smile:

i think it would be nice to exclude the LFO from the device itself, so you could use an abitrary LFO inside SC to modulate the frequency.

1 Like

I think that the Number of Filters don’t have to be modulatable. Would be Fine if you select those with synthdef Evaluation. Whats probably more interesting is to put some non-linearity into the Feedback path.

Currently looking at some papers on allpass applications:
allpass filter chain with audio-rate coefficient modulation
spectral distortion using second-order allpassfilters

1 Like

It’s working ok now. Up to 8 2nd Order APFs with a q parameter, I haven’t tested this much yet.

Instead of a rate argument, there’s now a freq argument, so you can BYO LFO and freq range.

I also included three potential different nonlinearities in the feedback path - cubic, tanh and wavefolding similar to Fold.ar. It can generate some pretty cool sounds but please be careful with volume, I haven’t yet worked out how to get the cubic distortion to behave itself. If you (or anyone) wants to test it out I’d love that.

I haven’t added proper sloping yet so I’m not sure if modulating certain parameters might be an issue but my first tests haven’t found anything.

1 Like

This shows the advantage of using 2nd-order allpass filters for a phaser to control the distribution of notches https://ccrma.stanford.edu/files/papers/stanm21.pdf

This one is also awesome, with alot of other papers cited on that topic:
https://www.researchgate.net/publication/286994878_Spectral_Delay_Filters

1 Like

Havent tested yet, will do during the Next days. Maybe Settle on one specific Type of non-linearity. Maybe have a Look here: Complex Nonlinearities Episode 4: Nonlinear Biquad Filters | by Jatin Chowdhury | Medium the 2nd-Order allpass has the same biquad Architecture.

Here is one additional allpass with non-linearity ive found (havent studied that in detail):

which is the core of this device https://www.youtube.com/watch?v=hk1Q8T9C-kA

I’m wary of using too much from Jatin, since his ChowPhase is also an 8 stage phaser with a nonlinearity in the feedback path…getting close to plagiarism here :smiley:

I need to get better at understanding max patches but the idea of putting an allpass with non-linearity in the feedback path is pretty funny, might try it out the next few days.