Implementing "All pass reverberator" by M.R. Schroeder

Ive been trying to implement a reverberator described in M.R. Schroeders paper “Natural sounding artificial reverberation”, the one in particular described in this image.


Here is my source code.

(
f = {
	arg in=SinOsc.ar()*Env.perc().kr, gain = 0.7, delTime=0.1;
	var local, localout, out;
	
	out = in * (-1 * gain);
	
	local = LocalIn.ar(1) + in;
	
	local = DelayN.ar(local,0.2,delTime);
	
	localout = local * (1-(gain**2));
	
	local = local * gain;
	
	LocalOut.ar(local);

	out = out + localout;
	
	out
	
};

{
	var temp = f.value(), del = 0.1;
	4.do{
		del = del / (3*rrand(0.98,1.02));
		temp = f.value(temp, 0.7, del);
	};
	temp!2
}.play;
		
)

The problem is that when i play this, the first half second it sounds alright, but then it goes through the roof. Since the echo density of this reverberator is quite high, it seems quite logical that this might happen. But im sure that this was not the intention of Schroeder. So what am i doing wrong?

I found two problems here. The bigger one is a fundamental limitation of LocalIn/LocalOut in SuperCollider, which is that you can have only one pair per SynthDef. This strategy is fine for learning but you can only have one allpass unless you use multiple channels and do some trickery.

Luckily SuperCollider already has Schroeder allpass UGens: see AllpassN, AllpassL, AllpassC. You don’t specify the gain parameter directly, but there are formulas given in the help files for how to derive the gain.

In this implementation, you also need to subtract ControlDur.ir from DelayN’s delay time, otherwise the delay time has an additional block (usually 64 samples) added by the feedback loop. This doesn’t matter much for reverbs but it’s good to make things block size agnostic. This is only necessary for LocalIn/LocalOut, using the Allpass* UGens you just specify the delay time directly.

Using only one allpass I hear a decay using your code:

(
f = { |in, gain = 0.7, delTime=0.1|
	var correctedDelay;
	var local, localout, out;
	out = in * (-1 * gain);
	local = LocalIn.ar(1) + in;
	correctedDelay = delTime - ControlDur.ir;
	local = DelayN.ar(local,correctedDelay,correctedDelay);
	localout = local * (1-(gain**2));
	local = local * gain;
	LocalOut.ar(local);
	out = out + localout;
	out
};

{
	var sig;
	sig = f.value(SinOsc.ar * Env.perc.ar, 0.7, 50e-3);
	sig = sig * -5.dbamp;
	sig ! 2
}.play;
)

Thank you for the constructive points. Im still a beginner so bear with me please hehe. So to circumvent the local bus problem, i could also just define a synthdef for each allpass and serialize them by connecting them with global busses?
Also just a side question. Are these the same as allpass filters or are these two different things, because as ive understood from previous reading, is that allpass filters contain fft’s and change the phase relationships between frequencies, and this schroeder allpass delay line doesnt seem to do that.

So I believe this is what you want.

(
~schroeder_stage = {
	|input, decayTime, delay|
	var g = 0.001 ** (delay / decayTime.abs) * decayTime.sign;
	var d = AllpassN.ar(input, delay, delay, decayTime) * (1-(g*g));
	(input * g.neg) + d
};


x = {
	var sig = {
		var dur = 0.2;
		var env = Env.perc(0.01, dur - 0.01).kr();
		var freq = XLine.ar(20, 20000, dur);
		SinOsc.ar(freq) * env;
	}.();
		
	var chain_rev = { |prev, spec| ~schroeder_stage.performWithEnvir(\value, (input: prev) ++ spec) };
	var stages = [
		(decayTime: 1.2, delay: 0.024568),
		(decayTime: 3.2, delay: 0.04343),
		(decayTime: 3.2, delay: 0.0153622)
	];
	var reverb = stages.inject(sig, chain_rev );
	
	reverb * -10.dbamp
}.play
)

The problem with your code is that you have tried to use LocalOut to delay by less that one block size, which is impossible. Likewise, @nathan’s solution will not work either as…

will be negative (for small values of delTime which is probably what you want to do here), thereby requiring time travel.

The solution is to (either write this in C++) or use the inbuilt AllpassN filter.

The difference with my solution and the diagram you posted above is that you must specify the gain factor as a decay time. This isn’t too much of a problem and I think its actually more musical to do so.

To add another stage to the reverb just extend here…

	var stages = [
		(decayTime: 1.2, delay: 0.024568),
		(decayTime: 3.2, delay: 0.04343),
		(decayTime: 3.2, delay: 0.0153622),
		
		(decayTime: ... some decay time ..., delay: ... some delay ...),
		
		... repeat `(decayTime: n, delay: m),` as needed ...
		
	];

This isn’t true, there are two types of filter FIR (using fft) and IIR, AllpassN uses IIR. The design you posted is analogue so does neither.

AllpassN does this…

s(t) = x(t) + k * s(t - D)
y(t) = -k * s(t) + s(t - D)

… see the docs for the full explanation.

This is true for all filters, unless you want latency and ringing.

The weird thing is that when allpass filters are explained outside the supercollider context, they dont say anything about a decaying output of impulses. How they explain it is that allpass filters dont do anything except change the phase relationships between frequencies. So thats why it makes me think they might be two different things with a similar name. Just an example of many other videos where they explain allpass filters:

Phase manipulation is time manipulation at such a short time frame. If you delay by half a wave length you have phase shifted by pi.

Yes but then youve shifted the whole signal, you didnt change phase relations between partial frequencies, which is what allpass filters supposedly do, as explained in all the videos. Thats why i think those seem to be different than the Allpass UGens explained in the supercollider help files. They are called allpass delay lines and seem to do something different.

The wavelength is different for different frequencies. So the amount of phase you have shifted will be different for each frequency.
Here’s a better link Allpass Filter: All You Need To Know | WolfSound
and i’ll quote from it…

An allpass filter is a filter with a unity gain across all frequencies . This means that no frequency passing through that filter will be boosted or attenuated. It introduces, however, a frequency-dependent delay.

1 Like

Just a follow up, you get better results if you make multiple stages of these, running in series and in parallel…

~schroeder_stage = {
	|input, decayTime, delay|
	var g = 0.001 ** (delay / decayTime.abs) * decayTime.sign;
	var d = AllpassN.ar(input, delay + 0.001, delay, decayTime) * (1-(g*g));
	(input * g.neg) + d;
};
x = {
	/*
	var sig = {
	   var dur = 0.5;
	   var env = Env.perc(0.01, dur - 0.01).kr();
	   var freq = XLine.ar(20, 20000, dur);
	   SinOsc.ar(freq) * env;
	}.();
	*/
	var sig = SoundIn.ar(0);
	
	var rev = { |input, spec| ~schroeder_stage.performWithEnvir(\value, (input: input) ++ spec) };
	var rev_chain_specs = {|init, specs| specs.inject(init, rev) };
	var rev_parallel_specs = {|src, specs| specs.collect({ |s| rev.(src, s) }).sum };
	
	var early_ref_specs =[
		[
			(delay: 0.07492866556197,   decayTime: 3.0),
			(delay: 0.032358517247485,  decayTime: 3.5),
			(delay: 0.010717190412475,  decayTime: 3.3333),
			(delay: 0.0038905153245831, decayTime: 1.75),
		],
		[
			(delay: 0.14346342, decayTime: 2.1),
			(delay: 0.08234565, decayTime: 2.1),
			(delay: 0.02042475, decayTime: 1.3),
			(delay: 0.08905151, decayTime: 0.75)
		]
	];
		
	var long_stages = [
		(delay: 0.27492866556197,   decayTime: 2.0),
		(delay: 0.21358517247485,   decayTime: 2.5),
		(delay: 0.110717190412475,  decayTime: 2.3333),
		(delay: 0.1038905153245831, decayTime: 2.75),
	];
	
	var early_ref = early_ref_specs.collect({ |spec| 
		OnePole.ar(rev_chain_specs.(sig, spec), 0.8)
	}).sum;
	
	var long_ref = rev_parallel_specs.(early_ref, long_stages);
	
	var reverb = early_ref.blend(long_ref, 0.8);
	
	reverb!2 * -20.dbamp
}.play

It is also usual to have a low pass on the early or it can sound too metalic.

Thank you very much for all the explanations, im gonna work on it

Allpass filters are confusing, and it took me a while to wrap my head around them also. Although it’s not totally accurate, you should think of allpass filters as frequency-dependent delays. The formal term is “group delay.”

Schroeder allpass filters (aka allpass delay lines) are valid allpass filters, but there are other kinds of allpass filters too.

The ones you see in signal processing textbooks are usually low-order allpass filters, which have heritage in analog circuits. The digital versions of low-order allpass filters have block diagrams that mostly use just single-sample delays, not the long delay lines found in the Schroeder allpasses.

Low-order allpass filters have small group delay and a very subtle sound. In fact, you may not be able to hear a difference between the filtered vs. unfiltered signals. Their musical applications include phaser pedals (where the allpassed signal is added back to the original, creating notches) and digital waveguide synthesis (where the allpass is placed in a tight feedback loop and processes the signal repeatedly).

(SC has an undocumented 2nd-order allpass filter under the name APF. I think it is broken, however. I’ve never gotten it to work.)

Schroeder allpass filters have a much higher group delay and a distinctive sound. Depending on the parameters, they can add echoes, add subtle metallic coloration, and smear transients. In a feedback loop, their effect is even more dramatic. It’s unintuitive, but they are still frequency-dependent delays.

1 Like

As an aside, allpass filters of various kinds and orders can be used to build other filters, e.g., lowpass / highpass, shelving, peaking, notching, combing.

The classic paper on the topic is:

P. A. Regalia, S. K. Mitra and P. P. Vaidyanathan, “The digital all-pass filter: a versatile signal processing building block,” in Proceedings of the IEEE, vol. 76, no. 1, pp. 19-37, Jan. 1988, doi: 10.1109/5.3286.

3 Likes

I am trying to achieve a similar process, but it is not a reverberator. Unfortunately can’t make it happen:

I would like to create a delay with feedback in which some of the copies would be excluded (negative and positive copies summed) and some doubled (positive summed) etc.

However, when trying to do this with LocalIn/LocalOut the position of the copies never match properly, despite of the - ControlDur.ir correction. I also tried setting s.options.blockSize = 1 without any good. Were is the error here?

(
{
    var source, local;

    //source = Impulse.kr(0.1) + DelayN.kr(Impulse.kr(0.1/2).neg,0.2,0.2);
    //source = Impulse.ar(0.1) + DelayN.ar(Impulse.ar(0.1/2).neg,0.4,0.2-ControlDur.ir);
    //source = Impulse.kr(0.1) + DelayN.kr(Impulse.kr(0.1/2).neg,0.4,0.2-ControlDur.ir);
    source = Impulse.kr(0.1) + DelayN.kr(Impulse.kr(0.1/2).neg,0.4,0.2-ControlRate.ir.reciprocal);
	//source = Impulse.kr(0.1) + DelayN.kr(Impulse.kr(0.1/2).neg,0.4,0.2);
    //source = Impulse.ar(0.1) + DelayN.ar(Impulse.ar(0.1/2).neg,0.4,0.2-ControlRate.ir.reciprocal);
   
    local = LocalIn.ar(1) + source;
    local = DelayN.ar(local, 1.0, 0.1-ControlRate.ir.reciprocal);

    LocalOut.ar(local * 0.8);

    local!2
//}.plot(1.0);
}.play;
)