C++ help: Why is my custom allpass plugin sounding so bad?

I am writing a simple allpass filter for myself and it’s going fairly well.

I have followed the formula y[n] = (-g * x[n]) + x[n - D] + (g * y[n - D]) from Curtis Roads’ computer music tutorial and I am pretty sure I got it right, but the filter is acting weirdly and so I fear it is something SC specific that I misunderstood. I might have gotten something misunderstood when reading C code and convering it for the “new” .hpp header style.

Here is the code:

// PluginMKAllpass.cpp
// Mads Kjeldgaard (mail@madskjeldgaard.dk)

#include "SC_PlugIn.hpp"
#include "MKAllpass.hpp"

static InterfaceTable* ft;

namespace MKAllpass {

MKAllpass::MKAllpass() {
  mCalcFunc = make_calc_function<MKAllpass, &MKAllpass::next>();

  writephase = 0;

  maxdelay = 1.0; // Seconds
  bufsize = NEXTPOWEROFTWO((float)sampleRate() * maxdelay);

  // Don't forget to free this in the destructor
  delaybuffer = (float *)RTAlloc(mWorld, bufsize * sizeof(float));
  historybuffer = (float *)RTAlloc(mWorld, bufsize * sizeof(float));

  // Used to wrap the phase using bit masking
  mask = bufsize - 1;

  // This check makes sure that RTAlloc succeeded. (It might fail if there's
  // not enough memory.) If you don't do this check properly then YOU CAN
  // CRASH THE SERVER! A lot of ugens in core and sc3-plugins fail to do this.
  // Don't follow their example.
  if (delaybuffer == NULL || historybuffer == NULL) {

    mCalcFunc = make_calc_function<MKAllpass, &MKAllpass::clear>();

    // Clear outputs of the ugen, just in case
    clear(1);

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

    return;
  }

    // Fill the buffer with zeros.
    memset(delaybuffer, 0, bufsize * sizeof(float));
    memset(historybuffer, 0, bufsize * sizeof(float));

    next(1);
}

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

MKAllpass::~MKAllpass() {
  RTFree(mWorld, delaybuffer);
  RTFree(mWorld, historybuffer);
}

void MKAllpass::next(int nSamples) {

  // Ugen ins and outs
  const float *input = in(0);
  float *output = out(0);

  const float feedback = in0(1);
  float delay = in0(2);

  /* Print("feedback: %f\n", feedback); */
  /* Print("delay: %f\n", delay); */
  /* Print("mask: %d\n", mask); */

  // Cap the delay
  if (delay > maxdelay) {
    delay = maxdelay;
  }

  // Calculate delay phase
  int delayPhaseOffset = delay * (float)sampleRate();

  /**
   * Simple allpass filter with a flat long-term frequency response
   * that delays various frequencies with various amounts
   *
   * y[n] = (-g * x[n]) + x[n - D] + (g * y[n - D])
   *
   * The delayed input x[n - D] is represented by a historybuffer
   * The delayed output y[n - D] that is fed back is delayed by a
   * delaybuffer
   *
   */
  for (int i = 0; i < nSamples; ++i) {

    historybuffer[writephase] = input[i];

    // The actual allpass calculation
    const int historyphase = i - delayPhaseOffset;
    const int delayphase = i - delayPhaseOffset;
    const float filtered = ((-1.0 * feedback) * input[i]) +
                           historybuffer[historyphase & mask] +
                           (feedback * delaybuffer[delayphase & mask]);

    // Calculate output
    output[i] = zapgremlins(filtered);

    // Write to buffer
    delaybuffer[writephase] = output[i];

    // the mask wraps the phase to the max delay size in samples:
    writephase = (writephase + 1) & mask;
  }
}

} // namespace MKAllpass

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

You can hear the problem here. a 150hz saw playing in the allpass filter in the left channel and clean in the right channel:

WARNING: It’s loud and horrible.

ah, the problem was my use of indexes. This for loop fixed it where i someplaces had to be replaced with writephase to enable the circular buffer

for (int i = 0; i < nSamples; ++i) {

    historybuffer[writephase] = input[i];

    // The actual allpass calculation
    const int historyphase = writephase - delayPhaseOffset;
    const int delayphase = writephase - delayPhaseOffset;
    const float filtered = ((-1.0 * feedback) * input[i]) +
                           historybuffer[historyphase & mask] +
                           (feedback * delaybuffer[delayphase & mask]);

    // Calculate output
    output[i] = zapgremlins(filtered);

    // Write to buffer
    delaybuffer[writephase] = output[i];

    // the mask wraps the phase to the max delay size in samples:
    writephase = (writephase + 1) & mask;
  }
2 Likes

For comparison you could check with an Fb1 implementation, all the examples need miSCellaneous and are faster with lower blockSize

// (1) varying g, fixed D
// src + filtered src -> phase cancellations

(
{
	var d = 10;
	var g = MouseX.kr(0.01, 0.99).poll;
	var sig = Saw.ar(50, 0.1);
	var allp = Fb1(
		{ |in, out| (g.neg * in[0]) + in[d] + (g * out[d]) },
		sig,
		inDepth: d + 1, outDepth: d + 1,
		blockSize: s.options.blockSize
	);
	sig + allp ! 2
}.play
)


// (2) CPU-cheaper variant for fixed D

(
{
	var d = 10;
	var g = MouseX.kr(0.01, 0.99).poll;
	var sig = Saw.ar(50, 0.1);
	var allp = Fb1(
		{ |in, out| (g.neg * in[0]) + in[1] + (g * out[1]) },
		sig,
		inDepth: [[0, d]], outDepth: [[0, d]],
		blockSize: s.options.blockSize
	);
	sig + allp ! 2
}.play
)


// (3) varying g and D

(
{
	var maxDepth = 20;
	var d = MouseY.kr(1, maxDepth).round.poll;
	var g = MouseX.kr(0.01, 0.99).poll;
	var sig = Saw.ar(50, 0.1);
	var allp = Fb1(
		{ |in, out| (g.neg * in[0]) + Select.kr(d, in) + (g * Select.kr(d, out)) },
		sig,
		inDepth: maxDepth + 1, outDepth: maxDepth + 1,
		blockSize: s.options.blockSize
	);
	sig + allp ! 2
}.play
)

Some things could be adapted:

.) when depth exceeds blockSize, a blockFactor argument must be given

.) For continously varied d take LinSelectX, see (4)

.) d and g can be made ar, therefore they have to be passed via Fb1’s in arg (5)

// (4) contiously varied d, kr

(
{
	var maxDepth = 20;
	var d = SinOsc.kr(0.1).range(1, maxDepth);
	var g = MouseX.kr(0.05, 0.9).poll;
	var sig = Saw.ar(50, 0.1);
	var allp = Fb1(
		{ |in, out| (g.neg * in[0]) + LinSelectX.kr(d, in) + (g * LinSelectX.kr(d, out)) },
		sig,
		inDepth: maxDepth + 1, outDepth: maxDepth + 1,
		blockSize: s.options.blockSize
	);
	sig + allp ! 2
}.play
)

// (5) d and g passed as ar args, probably no sense in most cases as much more costly

(
{
	var maxDepth = 20;
	var d = SinOsc.ar(5).range(1, maxDepth);
	var g = SinOsc.ar(4.7).range(0.01, 0.99);
	var sig = Saw.ar(50, 0.1);
	var allp = Fb1(
		{ |in, out|
			var d = in[0][1];
			var g = in[0][2];
			(g.neg * in[0][0]) + LinSelectX.kr(d, (in.flop)[0]) + (g * LinSelectX.kr(d, out))
		},
		[sig, d, g],
		inDepth: maxDepth + 1, outDepth: maxDepth + 1,
		blockSize: s.options.blockSize
	);
	sig + allp ! 2
}.play
)

As some advice that would have helped me years ago – try not to rely entirely on your ears/Function:plot to ensure correct behavior. I strongly recommend getting yourself a test suite (I like GoogleTest) so you can have programmatic confidence in the mathematical correctness of the C++ code, and to ensure that code changes and refactors don’t break working behavior. Check out this intro article for some philosophical tips if you’re new to that. I’d also be happy to share some tips specific to audio DSP testing.

1 Like

Thanks Nathan, that’s very generous of you. I will create a seperate topic for testing talk as I think it’s super interesting!