Time-varying feedback delay network

hey,
im trying to build a “time-varying” Feedback Delay Network (FDN).
For that to work i would like to change the delay weights of the feedback matrix by modulating the angle of the matrix with a control signal (LFO or Envelope) to rotate it by that angle in real time.
This idea of modulating the delay weights of the feedback matrix is loosely based on the “ZIGZAG” max4live device and results in a very interesting “swooshing” effect.
Ive made a rough sketch to create a rotatable feedback matrix by taking the kronecker product of the 2x2 givens rotation and a 4x4 householder reflection.

How can i now use a control signal to modulate the angle of the feedback matrix?
any other ideas ? thanks :slight_smile:

(
var s, c, angle;

// modulation of angle inside SynthDef with LFO?
angle = 0; 

s = angle.sin;
c = angle.cos;

~givens2x2 = [
	[c, s.neg],
	[s,  c]
];

~householder4x4 = [
    [-0.5, 0.5, 0.5, 0.5],
    [ 0.5,-0.5, 0.5, 0.5],
    [ 0.5, 0.5,-0.5, 0.5],
    [ 0.5, 0.5, 0.5,-0.5],
];

~kronecker = { |a, b|
	a.collect { |x|
		x.collect { |y| b * y }.reduce('+++')
	}.reduce('++')
};

~circulant_fb_matrix = ~kronecker.(~givens2x2, ~householder4x4);
)

i already have this “non-time varying” FDN based on a hadamard 8x8 feedback matrix + reflector working and would like to implement the modulation of the delay weights here. You can have a listen below:

(
var matrix;

matrix = [
	[ 1,  1,  1,  1,  1,  1,  1,  1 ],
	[ 1, -1,  1, -1,  1, -1,  1, -1 ],
	[ 1,  1, -1, -1,  1,  1, -1, -1 ],
	[ 1, -1, -1,  1,  1, -1, -1,  1 ],
	[ 1,  1,  1,  1, -1, -1, -1, -1 ],
	[ 1, -1,  1, -1, -1,  1, -1,  1 ],
	[ 1,  1, -1, -1, -1, -1,  1,  1 ],
	[ 1, -1, -1,  1, -1,  1,  1, -1 ],
] * ( sqrt(2).reciprocal * sqrt(2).reciprocal * sqrt(2).reciprocal );

SynthDef(\jot_stereo, {
	
	var ffreq = \ffreq.kr(16000);
	var fq = \fq.kr(0.5);
	var feedback = \feedback.kr(-3);
	var sampleRate = 44100;
	var sig, inSig, localOut;
	var reverbTime, decayCoef, delTimesSec, delTimes, order;
	var decayScale = \decayScale.kr(0.05);
	
	order = matrix.size;
	
	delTimes = order.collect({|i| rrand(1000, 4599).nextPrime });
	
	( delTimes / sampleRate ).debug(\delTimes_);
	( delTimes.sum / sampleRate ).debug(\delTimes_sum_);
	
	inSig = In.ar(\in.kr(0), order);
	inSig.collect { |it| it.source}.debug(\input);
	
	sig = inSig + LocalIn.ar(order);
	
	// multiplying signals by matrix
	sig = matrix.collect({|it i| matrix[i].collect({|item j| item * sig[j] }).sum });
	
	delTimesSec = delTimes / sampleRate;
	reverbTime = \reverbTime.kr(1).lag(2);
	decayCoef = 0.001.pow(delTimesSec * decayScale / reverbTime);
	
	localOut = order.collect({|i|
		DelayN.ar(sig[i], delTimesSec[i], delTimesSec[i] * decayScale - ControlDur.ir);
	});
	
	localOut = order.collect({|i|
		HighShelf.ar(localOut[i], ffreq, fq, feedback) * decayCoef[i];
	});
	
	LocalOut.ar(localOut);
	
	sig = localOut.size.div(2).collect({|i|
		i = i * 2;
		[
			localOut[i] * -1,
			localOut[i + 1]
		]
	}).sum;
	
	sig = HighShelf.ar(sig, ffreq, fq, feedback.neg);
	
	sig = sig * sqrt(reverbTime).reciprocal;
	
	sig = LeakDC.ar(sig);
	//sig = SafetyLimiter.ar(sig);
	Out.ar(\out.kr(0), sig);
}).add;

SynthDef(\reflector, {
	var numReflcs = 5;
	var delays, delayPans, reflections, pannedReflections;
	var sig, inSig;
	
	inSig = In.ar(\in.kr(0));
	
	delays = Array.fill( numReflcs, { |i| Rand(0.01, 0.025) });
	
	delays = delays * \scaleDelays.kr(1);
	
	delayPans = Array.fill(numReflcs, { |i|
		( \reflPan.kr(0) + ( \spread.kr(1) * [ -1.0, 1.0 ].at(i.mod(2)) ) ).clip2(1)
	});
	
	sig = HPF.ar(inSig, \hpfRefl.kr(110));
	
	reflections = Array.fill( numReflcs,
		{|i|
			DelayN.ar(
				OnePole.ar(sig, \lpfRefl.kr(0.9) * Rand(0.8,1)) *  Rand(-1,1),
				0.2,
				delays.at(i)
			)
		}
	);
	
	reflections.do { |it i|
		var dt0 = Rand( 0.001, 0.01 );
		var dt1 = Rand( 0.001, 0.01 );
		reflections[i] = AllpassN.ar(reflections[i], dt0, dt0, Rand(0.1,0.4));
		reflections[i] = AllpassN.ar(reflections[i], dt1, dt1, Rand(0.1,0.4));
	};
	
	pannedReflections = Array.fill(numReflcs,
		{|i|
			Pan2.ar(reflections.at(i), delayPans.at(i))
		}
	);
	
	Out.ar(\out.kr(0), pannedReflections.sum * \amp.kr(0.5));
	Out.ar(\fxOut.kr(2), reflections);
}).add;

SynthDef(\chirp, {
	var sig, env;
	env =  EnvGen.ar(Env.perc(0.001, 0.2), doneAction: Done.freeSelf);
	sig = Saw.ar(XLine.kr(100, 1000, 0.1));
	sig = sig * env;
	sig = Pan2.ar(sig, \pan.kr(0), \amp.kr(0.5));
	Out.ar(\out.kr(0), sig);
}).add;
)


(
~makeBusses = {
	~bus = Dictionary.new;
	~bus.add(\reflector -> Array.fill(3, { Bus.audio(s, 1) }));
	~bus.add(\tank -> Bus.audio(s, 16));
};
~makeBusses.();
)

(
~mainGrp = Group.new;
~reflectorGrp = Group.after(~mainGrp);
~tankGrp = Group.after(~reflectorGrp);

{
	Pdef(\chirp,
		Pbind(
			\instrument, \chirp,
			\freq, 440,
			\dur, 1,
			\out, ~bus[\reflector][0],
			\group, ~mainGrp,
		);
	).play;

	~reflector = Synth(\reflector,
		[
			\in, ~bus[\reflector],
			\lpfRefl, 0.4,
			\hpfRefl, 210,
			\reflPan, 0,
			\spread, 1,
			\scaleDelays, 0.05,

			\amp, 0.2,
			\out, 0,
			\fxOut, ~bus[\tank],
		],
		~reflectorGrp
	);

	~tank = Synth(\jot_stereo,
		[
			\in, ~bus[\tank],
			\ffreq, 16000,
			\fq, 0.8,
			\feedback, -15,
			\reverbTime, 1,
			\decayScale, 0.05,
			\out, 0,
		],
		~tankGrp
	);

}.fork
)

Interesting idea! I would be great to hear it working fully!

I guess the problem is that some math methods are not wrapped to UGens, therefore they are only calculated once in the SynthDef. This case is similar to the difference between using .rrand and Rand.

You may need to guarantee that .sin and .cos (used to calculate angle) and all your subsequent methods are doing real time calculations. For these two specifically, you can use a server side alternative: SinOsc modulating the phase argument to achieve a angle displacement. But you may guarantee all the other methods used.

hey thanks for your interest :slight_smile:

after looking at this example for a FDN Basic Feedback Delay Network SynthDef once more which calculates the feedback matrix from the eigenvalues and rotates it inside the SynthDef.
I thought maybe you could calculate some rotated matrices with different angles and then crossfade between them with a LFO / Envelope.
But atm i dont really know where to start.

EDIT:
in the max4live device its done like this:

“varying delay weights”

And ideas on this ? Thanks :slight_smile:

The short answer is – you just use a control signal to modulate the angle.

The key here is that (almost) all math operators in SC are implemented for numbers and patterns and UGens.

Float.findRespondingMethodFor('cos')  // SimpleNumber:cos
Pseries.findRespondingMethodFor('cos')  // AbstractFunction:cos
Phasor.findRespondingMethodFor('cos')  // AbstractFunction:cos

cos(Phasor.ar)
-> an UnaryOpUGen

AbstractFunction:cos “composes” the operation into a new object that represents the compound calculation. This has always been supported! You wouldn’t get confused about aUGen.neg – there’s no good reason why SC should say neg is OK in the server but sin / cos is not.

So if cos(aUGen) returns a math-operator UGen, then you can try it and verify for yourself that it returns the expected result.

// it indeed plots a cosine
{ cos(Phasor.ar(0, 440 * SampleDur.ir, 0, 1) * 2pi) }.plot

I haven’t tried it with your code but I expect it should be simpler than you thought.

hjh

thank you very much.
when i havent made a mistake i got it now.
here is a simplified FDN with the rotation capability.
playing around with the room size, rotation and modulating the delayTimes is already a lot of fun.
One could also use a higher order matrix together with an Array of shorter delaytimes to increase the modal density for a less metallic sound or use the puckette4x4 instead of the householder4x4 which also sounds a bit less metallic. But i like things metallic :slight_smile:

(
~rotatableMatrix = { |angle|
	var matrix, householder4x4, givens2x2, kronecker;
	var s, c;
	
	s = angle.sin;
	c = angle.cos;
	
	givens2x2 = [
		[ c, s.neg ],
		[ s,  c ]
	];
		
	householder4x4 = [
		[ -0.5,  0.5,  0.5,  0.5 ],
		[  0.5, -0.5,  0.5,  0.5 ],
		[  0.5,  0.5, -0.5,  0.5 ],
		[  0.5,  0.5,  0.5, -0.5 ],
	];

	kronecker = { |a, b|
		a.collect { |x|
			x.collect { |y| b * y }.reduce('+++')
		}.reduce('++')
	};
	
	matrix = kronecker.(givens2x2, householder4x4);
};
)

(
var matrix;

matrix = { |trig, rotateFreq, rotateAmount|
	var angle, rotate;
	rotate = SinOsc.ar(rotateFreq).linlin(-1, 1, 0, rotateAmount);
	angle = Phasor.ar(trig, rotate * SampleDur.ir, 0, 1) * 2pi; 
	~rotatableMatrix.(angle);
};

{
	var trig = \trig.tr(1);
	var size, delTimes, delTimesSec, order;
	var sig, inSig, modEnv, gainEnv;
	
	var delMod = SinOsc.ar(2).linlin(-1, 1, 1, 4);
	
	gainEnv = EnvGen.ar(Env.perc(0.001, 1), trig, doneAction: Done.freeSelf);
	modEnv = EnvGen.ar(Env([0,1,0], [0.125, 0.5], [-8.0, -4.0]), trig);
	
	inSig = Saw.ar(XLine.kr(100, 1000, 0.1)) * gainEnv;
	sig = Mix(inSig);

	order = 8;
	size = \size.kr(0.15) + modEnv.linlin(0, 1, 0, \sizeEnvAmount.kr(0.65));
	
	delTimes = order.collect({|i| rrand(1000, 4599).nextPrime });
	delTimesSec = (delTimes * delMod) / 44100;

	sig = sig + LocalIn.ar(order);
	sig = DelayC.ar(sig, 0.5, delTimesSec * size - ControlDur.ir);
	sig = sig * \feedback.kr(0.9);
	sig = OnePole.ar(sig, \coef.kr(0.3));
	
	sig = sig * matrix.(trig, \rotateFreq.kr(0.1), \rotateAmount.kr(55)).flop;
	sig = sig.sum;
	LocalOut.ar(sig);
	(inSig + Splay.ar(sig)) * 0.5;
}.play(fadeTime: 0);
)
1 Like