faustlibraries icon indicating copy to clipboard operation
faustlibraries copied to clipboard

add fi.svf.morph

Open DBraun opened this issue 1 year ago • 35 comments

@oleg-nesterov

I've added a new fi.svf.morph function that allows seamless blending between LP, BP, and HP via a new blend parameter. The implementation uses the new ef.mixLinearClamp function to blend the weights. All other svf modes are backwards compatible.

Faust IDE Demo.

DBraun avatar Oct 13 '23 20:10 DBraun

All other svf modes are backwards compatible.

Nice ! Have you checked that previously existing modes still generate the same code ?

sletz avatar Oct 13 '23 20:10 sletz

I didn't check C++ for example but I did listen to the other modes and check the diagrams.

DBraun avatar Oct 13 '23 20:10 DBraun

I guess pattern matching should to the job and generated C++ should be the same, but always better be sure (-;

sletz avatar Oct 13 '23 20:10 sletz

Ok I checked the C++ generated from the previous svf.lp versus the new, and they're the same.

DBraun avatar Oct 13 '23 20:10 DBraun

@oleg-nesterov

I've added a new fi.svf.morph function that allows seamless blending between LP, BP, and HP via a new blend parameter. The implementation uses the new ef.mixLinearClamp function to blend the weights. All other svf modes are backwards compatible.

Faust IDE Demo.

If you notice, the filter curves go a bit up and down during the mix due to the non-aligned phases. You see that more clearly if you feed the filter a sine at the same frequency as the CF; you will see that the peaks are not constant.

We can improve the mix a bit easily: the LP and HP are 180deg out of phase, so you can invert one of them for perfect phase alignment; the BP is trickier and it'd require a Hilbert filter to align the whole frequency range, which I find overkill, but we can at least align the frequency at CF with a one-pole allpass. image image

 onePoleTPT(cf, x) = loop ~ _ : ! , si.bus(3)
    with {
        g = tan(cf * ma.PI * ma.T);
        G = g / (1.0 + g);
        loop(s) = v + lp , lp , hp , ap
            with {
                v = (x - s) * G;
                lp = v + s;
                hp = x - lp;
                ap = lp - hp;
            };
    };
AP1(cf, x) = onePoleTPT(cf, x) : ! , ! , _;

Dario

dariosanfilippo avatar Oct 14 '23 02:10 dariosanfilippo

@dariosanfilippo Thanks for double checking it. Should I take the outputs of onePoleTPT and blend between lp, (ap-lp-hp), hp with the mixLinearClamp thing I did in the PR? So if blend is 0 you get lp, if blend is 1 you get (ap-lp-hp) and if blend is 2 you get hp?

DBraun avatar Oct 14 '23 03:10 DBraun

@dariosanfilippo Thanks for double checking it. Should I take the outputs of onePoleTPT and blend between lp, (ap-lp-hp), hp with the mixLinearClamp thing I did in the PR? So if blend is 0 you get lp, if blend is 1 you get (ap-lp-hp) and if blend is 2 you get hp?

Hi, David.

I was referring to correcting the phases of the SVF outputs that you mix together. So we invert the sign of the SVF HP output to make its phase match that of the SVF LP perfectly, and then we shift the phase of the SVF BP by 90deg at CF with a one-pole allpass to make it match that of the other filters. You could also seamlessly morph back from HP to LP, circularly, without getting a notch in your magnitude response along the way. See this example code here.

dariosanfilippo avatar Oct 14 '23 10:10 dariosanfilippo

I tried to reply via email, but it doesn't seem to work. Let me copy-and-paste my emails here.

On 10/13, David Braun wrote:

@oleg-nesterov

Hi, thanks for letting me know,

I've added a new fi.svf.morph function that allows seamless blending between LP, BP, and HP via a new blend parameter.

...

https://github.com/grame-cncm/faustlibraries/pull/166.patch https://github.com/grame-cncm/faustlibraries/pull/166.diff

Hmm. Perhaps I missed something, but I don't think you need to change fi.svf.

If I read this code and the new mixLinearClamp() correctly you can just use

    morph(f,q,b) = _ <: ef.mixLinearClamp(3,1, b, (fi.svf.lp(f,q), fi.svf.bp(f,q), fi.svf.hp(f,q)));

and this should generate more or less the same code.

faust is clever enough, it should factor out the common code in lp/bp/hp.

Oleg.

oleg-nesterov avatar Oct 14 '23 13:10 oleg-nesterov

On 10/14, Oleg Nesterov wrote:

If I read this code and the new mixLinearClamp() correctly you can just use

morph(f,q,b) = _ <: ef.mixLinearClamp(3,1, b, (fi.svf.lp(f,q), fi.svf.bp(f,q), fi.svf.hp(f,q)));

and this should generate more or less the same code.

Let me check this. FYI, I had to add

ro = library("routes.lib");
aa = library("aanl.lib");

into misceffects.lib, otherwise ef.mixLinearClamp() doesn't compile.

Test-case:

import("stdfaust.lib");

m1(f,q,b) = _ <: ef.mixLinearClamp(3,1, b, (fi.svf.lp(f,q), fi.svf.bp(f,q), fi.svf.hp(f,q)));

m2(f,q,b) = svf_new.morph(f,q,b);

F = 5000; Q = .9; B = 2.1; // random values

process = 1-1' <: m1(F,Q,B), m2(F,Q,B);

// this is fi.svf with
// https://patch-diff.githubusercontent.com/raw/grame-cncm/faustlibraries/pull/166.diff
// applied
svf_new = environment {

	// Internal implementation
	svf(T,F,Q,G,B) = tick ~ (_,_) : !,!,si.dot(3, mix(T))
	with {
		tick(ic1eq, ic2eq, v0) =
			2*v1 - ic1eq,
			2*v2 - ic2eq,
			v0, v1, v2
		with {
			v1 = ic1eq + g *(v0-ic2eq) : /(1 + g*(g+k));
			v2 = ic2eq + g * v1;
		};

		A = pow(10.0, G/40.0);

		g = tan(F * ma.PI/ma.SR) : case {
			(7) => /(sqrt(A));
			(8) => *(sqrt(A));
			(t) => _;
		} (T);

		k = case {
			(6) => 1/(Q*A);
			(t) => 1/Q;
		} (T);

		mix = case {
			(0) => 0, 0, 1;
			(1) => 0, 1, 0;
			(2) => 1, -k, -1;
			(3) => 1, -k, 0;
			(4) => 1, -k, -2;
			(5) => 1, -2*k, 0;
			(6) => 1, k*(A*A-1), 0;
			(7) => 1, k*(A-1), A*A-1;
			(8) => A*A, k*(1-A)*A, 1-A*A;
			// blend among the weights of LP, BP, HP:
			(9) => ef.mixLinearClamp(3, 3, B, (mix(0), mix(1), mix(2)));
		};
	};

	// External API
	lp(f,q)      = svf(0, f, q, 0, 0);
	bp(f,q)      = svf(1, f, q, 0, 0);
	hp(f,q)      = svf(2, f, q, 0, 0);
	notch(f,q)   = svf(3, f, q, 0, 0);
	peak(f,q)    = svf(4, f, q, 0, 0);
	ap(f,q)      = svf(5, f, q, 0, 0);
	bell(f,q,g)  = svf(6, f, q, g, 0);
	ls(f,q,g)    = svf(7, f, q, g, 0);
	hs(f,q,g)    = svf(8, f, q, 0, 0);
	morph(f,q,b) = svf(9, f, q, 0, b);
};

C++ code:

void instanceConstants(int sample_rate)
{
	fSampleRate = sample_rate;
	fConst0 = std::tan(15707.963f / std::min<float>(1.92e+05f, std::max<float>(1.0f, float(fSampleRate))));
	float fConst1 = fConst0 * (fConst0 + 1.1111112f) + 1.0f;
	fConst2 = 2.0f / fConst1;
	fConst3 = fConst0 / fConst1;
	fConst4 = 1.0f / fConst1;
}

void compute(int count, FAUSTFLOAT** inputs, FAUSTFLOAT** outputs)
{
	FAUSTFLOAT* output0 = outputs[0];
	FAUSTFLOAT* output1 = outputs[1];
	for (int i0 = 0; i0 < count; i0 = i0 + 1) {
		iVec0[0] = 1;
		int iTemp0 = 1 - iVec0[1];
		float fTemp1 = float(iTemp0);
		float fTemp2 = fRec0[1] + fConst0 * (fTemp1 - fRec1[1]);
		fRec0[0] = fConst2 * fTemp2 - fRec0[1];
		float fTemp3 = fRec1[1] + fConst3 * fTemp2;
		fRec1[0] = 2.0f * fTemp3 - fRec1[1];
		float fRec2 = fConst4 * fTemp2;
		float fRec3 = fTemp3;
		float fTemp4 = fTemp1 - (fRec3 + 1.1111112f * fRec2);
		output0[i0] = FAUSTFLOAT(fTemp4);
		output1[i0] = FAUSTFLOAT(fTemp4);
		iVec0[1] = iVec0[0];
		fRec0[1] = fRec0[0];
		fRec1[1] = fRec1[0];
	}
}

See? at least in this case m1() and m2() generate the same code, faust simply outputs fTemp4 twice.

So no, I don't think this patch makes sense...

Oleg.

oleg-nesterov avatar Oct 14 '23 13:10 oleg-nesterov

@oleg-nesterov @dariosanfilippo Thank you both. I've pushed another update which I think properly incorporates Dario's suggestion while doing very little modification to the existing svf codebase.

DBraun avatar Oct 14 '23 19:10 DBraun

Woops! I fixed the frequency issue now. Would you mind typing up a doc blurb for onePoleTPT and AP1. Under what section would they go in filters.lib?

DBraun avatar Oct 14 '23 23:10 DBraun

I still can't understand why do you want to insert morph() into the "svf" environment, to me this makes no sense. You can just add the new svf_morph() function which uses svf.lp/bp/hp.

And what exactly this morph() should do? to me it looks like a very special case of mixing of lp/bp/hp. I don't even really understand the purpose of phase synchronization. I mean, I don't understand in what sense it makes the result "better".

OK. Just for example. We know that

    fi.svf.lp(f,q) + fi.svf.bp(f,q)/q + fi.svf.hp(f,q) == _
                     // normalized bp

so perhaps we can use this as another basis for mixing.

Can you explain why is the new svf.morph() "better" than

    morph(f,q,b) = _ <: ef.mixLinearClamp(3,1, b, (fi.svf.lp(f,q), fi.svf.bp(f,q)/q, fi.svf.hp(f,q)));

and in what sense ?

I'd say that no one is "better", they just differ. And neither version should live in the svf environment.

Oleg.

oleg-nesterov avatar Oct 15 '23 12:10 oleg-nesterov

I'd agree that morph doesn't need to be in the svf environment and that it could just be another function in the filters lib.

The phase alignment makes the resulting mix better because you'd have a consistent peak throughout the whole mixing coefficient, equal to the Q setting, which is also the peak at CF for the individual LP/BP/HP filters.

dariosanfilippo avatar Oct 15 '23 12:10 dariosanfilippo

The reason I want this function is to emulate the filter at 11:00-12:00 in this video https://youtu.be/ouWu89G5tGI?si=1Y_ITwyWWw-9U1Ve

I’m not certain that it’s SVF, but upon looking at the SVF environment source this seemed like an efficient way to do it. I would also be interested in the same LP-BP-HP morphing thing with other filters.

I’d be ok with the name svf_morph but I figured the svf prefix already exists, and this function uses svf behind the scenes, so why not just give it the same prefix.

DBraun avatar Oct 15 '23 13:10 DBraun

The phase alignment makes the resulting mix better because you'd have a consistent peak throughout the whole mixing coefficient,

I guess you mean the same peak at CF. Yes, I understand.

But why is it so important if you use morph() as audio effect?

But this doesn't matter. Sorry if I was not clear, I didn't even try to argue with the design of this new filter.

My only point is that whatever it does I don't think it should live in the svf environment, and you seem to agree.

svf is the collection of "standard" tools with the clear semantics, imo morph() doesn't fall into this category.

oleg-nesterov avatar Oct 15 '23 13:10 oleg-nesterov

I’m not certain that it’s SVF, but upon looking at the SVF environment source this seemed like an efficient way to do it. I would also be interested in the same LP-BP-HP morphing thing with other filters.

Those look more like 4-pole ladder filters to me.

dariosanfilippo avatar Oct 15 '23 13:10 dariosanfilippo

Speaking of svf... Sorry for being offtopic, but what do you think about this trivial change?

--- a/filters.lib
+++ b/filters.lib
@@ -2655,6 +2655,7 @@ declare svf copyright "Copyright (C) 2020 Oleg Nesterov <[email protected]>";
 declare svf license "MIT-style STK-4.3 license";
 
 svf = environment {
+	_tan = tan;
 
	// Internal implementation
	svf(T,F,Q,G) = tick ~ (_,_) : !,!,si.dot(3, mix)
@@ -2670,7 +2671,7 @@ svf = environment {
 
		A = pow(10.0, G/40.0);
 
-		g = tan(F * ma.PI/ma.SR) : case {
+		g = _tan(F * ma.PI/ma.SR) : case {
			(7) => /(sqrt(A));
			(8) => *(sqrt(A));
			(t) => _;

This way you you can do

my_fast_tan = ...;

svf = fi.svf[_tan = my_fast_tan;];

process = svf.xxx(heavily_modulated_cf, q);

oleg-nesterov avatar Oct 15 '23 14:10 oleg-nesterov

The -fm option is a way to possibly plug faster version of math functions: -fm <file> --fast-math <file> use optimized versions of mathematical functions implemented in <file>, use 'faust/dsp/fastmath.cpp' when file is 'def', assume functions are defined in the architecture file when file is 'arch'.

@oleg-nesterov way seems quite "hackish" to me.

sletz avatar Oct 15 '23 15:10 sletz

The -fm option is a way to possibly plug faster version of math functions:

Yes I know. But this affects all users of tan().

While this trivial change a) doesn't change the default behavior, and b) allows to easily use multiple versions of "fast tan" with svf. Not to mention you can write fast_tan() in faust or use ffunction().

@oleg-nesterov way seems quite "hackish" to me.

OK. lets forget it.

Oleg.

oleg-nesterov avatar Oct 15 '23 16:10 oleg-nesterov

But why is it so important if you use morph() as audio effect?

I wouldn't say that constant peak is a vital feature to have. My thinking was that, in some cases, with high resonances, you might hear loudness variations when moving/modulating the mixing coefficient.

Other than that, it generally feels more correct as the derived morph filter behaves more like the elementary building blocks and it's very little overhead as g is already computed for the SVF and we wouldn't need another one for the AP1.

However, I wonder if Faust understands that tan(2piF/SR) and tan(2piF*T) are the same thing.

dariosanfilippo avatar Oct 15 '23 16:10 dariosanfilippo

But why is it so important if you use morph() as audio effect?

I wouldn't say that constant peak is a vital feature to have. My thinking was that, in some cases, with high resonances, you might hear loudness variations when moving/modulating the mixing coefficient.

Perhaps. I simply do not know.

But let me repeat this once again: sorry for confusion, I didn't try to argue with the filter's design!

However, I wonder if Faust understands that tan(2piF/SR) and tan(2piF*T) are the same thing.

I don't think I can answer, but could you spell please? I mean... example?

oleg-nesterov avatar Oct 15 '23 16:10 oleg-nesterov

It does seem to optimise; the following:

import("stdfaust.lib");
process = _ <: tan((2.0 * ma.PI * _) / ma.SR) , tan(2.0 * ma.PI * _ * ma.T);

results in this code:

	virtual void compute(int count, FAUSTFLOAT** RESTRICT inputs, FAUSTFLOAT** RESTRICT outputs) {
		FAUSTFLOAT* input0 = inputs[0];
		FAUSTFLOAT* output0 = outputs[0];
		FAUSTFLOAT* output1 = outputs[1];
		for (int i0 = 0; i0 < count; i0 = i0 + 1) {
			float fTemp0 = std::tan(fConst0 * float(input0[i0]));
			output0[i0] = FAUSTFLOAT(fTemp0);
			output1[i0] = FAUSTFLOAT(fTemp0);
		}
	}

Looks right, correct?

dariosanfilippo avatar Oct 15 '23 17:10 dariosanfilippo

It does seem to optimise; the following:

process = _ <: tan((2.0 * ma.PI * _) / ma.SR) , tan(2.0 * ma.PI * _ * ma.T);

Ah. I think I can answer but may be Stephane will correct me ;)

I think you can forget about tan() in this case. Consider

    process = _ <:  2.0 * ma.PI * _ / ma.SR , 2.0 * ma.PI * _ * ma.T;

C++ code:

    void instanceConstants(int sample_rate)
    {
            fSampleRate = sample_rate;
            fConst0 = 6.2831855f / std::min<float>(1.92e+05f, std::max<float>(1.0f, float(fSampleRate)));
    }

    void compute(int count, FAUSTFLOAT** inputs, FAUSTFLOAT** outputs)
    {
            FAUSTFLOAT* input0 = inputs[0];
            FAUSTFLOAT* output0 = outputs[0];
            FAUSTFLOAT* output1 = outputs[1];
            for (int i0 = 0; i0 < count; i0 = i0 + 1) {
                    float fTemp0 = fConst0 * float(input0[i0]);
                    output0[i0] = FAUSTFLOAT(fTemp0);
                    output1[i0] = FAUSTFLOAT(fTemp0);
            }
    }

simplification()->normalizeAddTerm() does a good job (even if it is not perfect).

And. if you have

    process = tan_or_whatever(expr1), tan_or_whatever(expr2);

and faust can deduce that expr1 and expr2 are "equal" you can expect the same optimization.

Oleg.

oleg-nesterov avatar Oct 15 '23 17:10 oleg-nesterov

So what is the conclusion ? Should morph lives inside the svf environment ? Or be built outside ?

sletz avatar Oct 17 '23 08:10 sletz

Let me remind what fi.svf is. It is the collection of the standard 2nd order filters implemented via TPT/ZDF analog modelling.

For example, IIRC vi.svf.bp(f,q) is "equivalent" to fi.resonbp(f,q,1) in that they have the same impulse response, modulo floating point errors.

But. fi.svf works better under modulation, better than even tf2snp. See https://sourceforge.net/p/faudiostream/mailman/message/37097988/

And it is more numerically stable, see https://sourceforge.net/p/faudiostream/mailman/message/37182089/ and the whole thread.

What does morph() have to do with standard filters? It doesn't. That is why I think it should be built outside.

There are other tools in filters.lib that are built on top of svf.xxx(), they do not live in inside the svf environment. Why the new morph() should?

oleg-nesterov avatar Oct 17 '23 10:10 oleg-nesterov

Sorry for noise, I'd like to add to my previous comment...

Of course, svf has another advantage. If you use, say, svf.lp then you have svf.bp/hp/more almost for free, the "main" tick() code will be shared.

And this is what makes it a good choice for the new morph() tool.

oleg-nesterov avatar Oct 17 '23 11:10 oleg-nesterov

OK this makes sense, then since the generated code should be the same, @DBraun better code it separately ?

sletz avatar Oct 17 '23 11:10 sletz

@oleg-nesterov Speaking of SVFs in general, Simper's design is remarkable: it requires 1 MUL less than Zavalishin's one, and it's stable for CFs from 0 to Nyquist. Did I count correctly that, assuming CF and Q time-variant, you could implement yours in 4 MULs, 2 DIVs, and 9 ADDs? I'm doing a trade of 2v1 and 2v2 for v1+v1 and v2+v2, as I'd assume that they'd be computed faster if we "const" v1 and v2 for fast access.

This is Zavalishin's SVF; the LP will have a flat response only up to about 99.9% of Nyquist, and it will blow up at CF=Nyquist:

SVF(Q, CF, x) = feedback ~ si.bus(2) : (! , ! , _ , _ , _)
    with {
        g = tan(CF * ma.PI * ma.T); // 1 MUL; PI*T is a const
        R2 = 1.0 / Q;
        gPlusR2 = g + R2;
        denHP = 1.0 + g * gPlusR2;
        feedback(s0, s1) = u0 , u1 , LP , HP , BP
            with {
                HP = (x - gPlusR2 * s0 - s1) / denHP;
                v0 = g * HP;
                BP = s0 + v0;
                v1 = g * BP;
                LP = s1 + v1;
                u0 = v0 + BP;
                u1 = v1 + LP;
            };
    }; MULs: 5; DIVs: 2; ADDs: 8

Faust will convert my attempt at defining signals as sums of the same vars into a multiplication of the vars by "2". I haven't looked at the assembly and I'm not sure what the C++ compiler goes for in the end. If I take the LP2 output only, this is the C++, which still seems suboptimal unless I counted FLOPS wrong:

	virtual void compute(int count, FAUSTFLOAT** RESTRICT inputs, FAUSTFLOAT** RESTRICT outputs) {
		FAUSTFLOAT* input0 = inputs[0];
		FAUSTFLOAT* input1 = inputs[1];
		FAUSTFLOAT* input2 = inputs[2];
		FAUSTFLOAT* output0 = outputs[0];
		for (int i0 = 0; i0 < count; i0 = i0 + 1) {
			float fTemp0 = std::tan(fConst0 * float(input1[i0])); // g ––– 1 MUL, which is fine since PI*T is a const
			float fTemp1 = 1.0f / float(input0[i0]) + fTemp0; // gPlusR2; why the float casting?
			float fTemp2 = float(input2[i0]) - (fTemp1 * fRec0[1] + fRec1[1]); // numerator of HP
			float fTemp3 = fTemp0 * fTemp1 + 1.0f; // denHP
			float fTemp4 = fTemp0 * fTemp2 / fTemp3; // v0: g * numHP / denHP
			fRec0[0] = fRec0[1] + 2.0f * fTemp4; // u0
			float fTemp5 = fRec0[1] + fTemp4; // s0 + v0 = BP
			float fTemp6 = fTemp0 * fTemp5; // v1
			fRec1[0] = fRec1[1] + 2.0f * fTemp6; s1 + 2v1 = u1
			float fRec2 = fRec1[1] + fTemp6; // LP
			output0[i0] = FAUSTFLOAT(fRec2);
			fRec0[1] = fRec0[0];
			fRec1[1] = fRec1[0];
		}
	} // MULs: 7; ADDs: 8; DIVs: 2

dariosanfilippo avatar Oct 17 '23 22:10 dariosanfilippo

Dario, et al, this is going off-topic and I am a bit tired of the web interface ;)

I hope you won't mind if I reply on faudiostream-users.

oleg-nesterov avatar Oct 18 '23 10:10 oleg-nesterov

Sure, no problem.

dariosanfilippo avatar Oct 18 '23 10:10 dariosanfilippo