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
)

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