Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add fi.svf.morph #166

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open

add fi.svf.morph #166

wants to merge 6 commits into from

Conversation

DBraun
Copy link
Contributor

@DBraun DBraun commented Oct 13, 2023

@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.

@sletz
Copy link
Member

sletz commented Oct 13, 2023

All other svf modes are backwards compatible.

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

@DBraun
Copy link
Contributor Author

DBraun commented Oct 13, 2023

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

@sletz
Copy link
Member

sletz commented Oct 13, 2023

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

@DBraun
Copy link
Contributor Author

DBraun commented Oct 13, 2023

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

@DBraun DBraun marked this pull request as ready for review October 13, 2023 20:51
@dariosanfilippo
Copy link
Contributor

dariosanfilippo commented Oct 14, 2023

@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

@DBraun
Copy link
Contributor Author

DBraun commented Oct 14, 2023

@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?

@dariosanfilippo
Copy link
Contributor

@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.

@oleg-nesterov
Copy link
Contributor

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
Copy link
Contributor

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.

@DBraun
Copy link
Contributor Author

DBraun commented Oct 14, 2023

@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.

Copy link
Contributor

@dariosanfilippo dariosanfilippo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@DBraun I think that the lpc, bpc, hpc, and AP1 should all take f as argument instead of the hard-coded 1000, which was just for testing purposes. Ideally, I'd put the one-pole multi-mode and derived AP1 filters outside of the svf scope and just available in the fi. env. For the time-invariant case, the LP and HP outputs of the onePoleTPT should be equivalent to the first-order Butterworth filters in the libraries.

@DBraun
Copy link
Contributor Author

DBraun commented Oct 14, 2023

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?

@oleg-nesterov
Copy link
Contributor

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.

@dariosanfilippo
Copy link
Contributor

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.

@DBraun
Copy link
Contributor Author

DBraun commented Oct 15, 2023

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.

@oleg-nesterov
Copy link
Contributor

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.

@dariosanfilippo
Copy link
Contributor

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.

@oleg-nesterov
Copy link
Contributor

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);

@sletz
Copy link
Member

sletz commented Oct 15, 2023

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.

@oleg-nesterov
Copy link
Contributor

oleg-nesterov commented Oct 15, 2023

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.

@dariosanfilippo
Copy link
Contributor

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.

@oleg-nesterov
Copy link
Contributor

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?

@dariosanfilippo
Copy link
Contributor

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?

@oleg-nesterov
Copy link
Contributor

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.

@sletz
Copy link
Member

sletz commented Oct 17, 2023

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

@oleg-nesterov
Copy link
Contributor

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
Copy link
Contributor

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.

@sletz
Copy link
Member

sletz commented Oct 17, 2023

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

@dariosanfilippo
Copy link
Contributor

@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

@oleg-nesterov
Copy link
Contributor

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.

@dariosanfilippo
Copy link
Contributor

Sure, no problem.

@DBraun
Copy link
Contributor Author

DBraun commented Oct 18, 2023

The code is now outside the svf environment and the function is called fi.svf_morph. I think this is ok based on the discussion. Also, onePoleTPT has been added under a new TPT section. @dariosanfilippo if you have the time and interest, I think an implementation of the morphing ladder filter you mentioned would be a great addition.

@oleg-nesterov
Copy link
Contributor

Also, onePoleTPT has been added under a new TPT section.

Cough ;) I'm afraid I have already made too much noise here, but I can't resist.

This section is not new. IIUC both svf and the new onePoleTPT fall into this
TPT/ZDF/whatever-you-name-it category.

And not only "These are useful when the frequency is modulating quickly", they
are more numerically robust even without modulation. See the 2nd link in
#166 (comment)
iow the response is closer to the theoretical.

Sorry for nitpicking.

@sletz
Copy link
Member

sletz commented Oct 20, 2023

So ? what is the next step?

@oleg-nesterov
Copy link
Contributor

With the last commit we have

svf = environment {
	...
};

...

//==================Topology-preserving Transform Filters======================

...

onePoleTPT(cf, x) = ...

and to me this looks confusing/misleading a bit.

svf and onePoleTPT are the same thing in that they use the same
techniques, just the order differs.

We can even rewrite onePoleTPT() using Andrew Simper's method of
integration (also used in svf),

xxx(cf) = tick ~ _ : !, _,_,_
with {
	g = tan(cf * ma.PI / ma.SR);

	tick(s, in) = 2*lp - s, lp, hp, ap
	with {
		lp = (g * in + s) / (1 + g);
		hp = in - lp;
		ap = lp - hp;
	};
};

I have no idea which version is "beter", may be Dario can take a look,
but mathematically they are equivalent, I've even verified that transfer
functions are the same and match that of analog 1/(1+s) LP after BLT.

And... whatever implementation we use, I'd suggest to make it environment,
say,

xxx = environment {
	xxx(cf) = tick ~ _ : !, _,_,_
	with {
		g = tan(cf * ma.PI / ma.SR);

		tick(s, in) = 2*lp - s, lp, hp, ap
		with {
			lp = (g * in + s) / (1 + g);
			hp = in - lp;
			ap = lp - hp;
		};
	};

	lp = xxx : _,!,!;
	hp = xxx : !,_,!;
	ap = xxx : !,!,_;
};

this makes it more symmetrical with svf, and to me it would be more
convenient to use

xxx.lp(f);

than

xxx(f) : _,!,!_;

But of course I won't insist, I leave this to David and Dario.

Oleg.

@dariosanfilippo
Copy link
Contributor

dariosanfilippo commented Oct 20, 2023

@DBraun I'm afraid that I can't work on the ladder filters at the moment as it's busy at work (when has it not been busy? I know.). Though if it's useful for you, there's a four-pole ladder LP implementation in the vaeffects lib:

moogLadder(normFreq, Q, x) = loop ~ si.bus(4) : (!,!,!,!,_)
.

@oleg-nesterov Both Zavalishin's and Simper's one-pole appear to be stable for CFs from 0 to Nyquist and they seem to require the same operations, so I'm happy to use Simper's for continuity and have them in an environment as you suggested.

Cheers,
Dario

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants