Strategies for Makeup Gain inside of Synths (or actually: Different Ways to Scale Values)

I’m working on a little square wave LFO that has the ability to round off toward a sine wave using one of the lag methods/classes. When introducing more lag, however, the amplitude of the signal drops off, and I really struggled with finding an elegant makeup gain solution for this operation.

I was hoping to structure the makeup gain amount to be based on the rounding off (I’m using “shape” as my arg name). So something like: sig = sig.lag2(shape) * shape.lincurve(0, 1, 1, 7, 5.5).

The thing that’s giving the the hard time is that the additional gain required isn’t linear or along one continuous curve of values.

So basically, at certain shape values, given a specific curve amount, the gain is correct, at others, it’s too little or too much. I think in this case two different conjoined curves could have done it. But I got to the point of either stacking additional methods onto the lincurve (such as softRound) or considering how to make the curve value itself, in lincurve scale relative to the current value of my shape argument, before deciding that seemed a bit over the top.

I was also thinking about making an array with correct gain values, and using shape to index into it, but I was unable to use an arg in place of an Array's index inside of a SynthDef (I’m not looking to dynamically populate the array, nor set the amount of entries, simply get the right value based on the index). I haven’t spent time investigating making that work yet (is Select the right way here, or can I just use a plain old Array?)

I’ve now currently settled on using the Normalizer UGen, and that seems to be working for this module since the added delay for its lookahead isn’t a problem for a continually running LFO, but it’s not always going to be ideal.

I’m curious about other strategies or ways to finesse what I’ve tried might be using in this cases. What are ways that you scale values which don’t neatly conform to a curve?

Thanks!

1 Like

There is very likely a better solution, but here is mine. (As an LFO it is tricky, but above a certain frequency you can probably just use a Low Pass Filter)

You can convert the square wave to a triangle wave using slew and then shape that in the direction of a sine wave (it is not perfect) with a cubed root:

({
	var freq = 440;
	var square = LFPulse.ar(freq)*2-1;
	var tri = Slew.ar(square, freq*4, freq*4);
	var sin = (tri>0).linlin(0,1,-1,1)*(tri.abs**(1/4));

	var delay = (freq/SampleRate.ir/16);

	[DelayC.ar(square, delay, delay), tri, sin]

}.plot
)

this can be interpolated by interpolating the slew and the signal warping. Perhaps this math could be simplified.

({
	var freq = 10;
	var square = LFPulse.ar(freq)*2-1;
	
	//interpolation with a logorithmic curve
	var interp = (MouseX.kr(0,1)**(1/16)).poll;
	
	//slew is controlled by the interpolation value
	var slewRate = interp.linlin(0,1,SampleRate.ir, freq*4);
	
	var tri = Slew.ar(square, slewRate, slewRate);
	
	//sinification is also controlled by the interpolation value
	var sig = (tri>0).linlin(0,1,-1,1)*(tri.abs**(1/interp.linlin(0,1,1,4)));

	SinOsc.ar(200+(sig*200))*0.1
}.play
)

Would love to know another solution.

Sam

For a fun opposite solution, starting with a SinOsc and clipping it to make the square wave makes a smoother transition. You just can’t do PWM with this.

({
	var freq = 10;
	var sig = SinOsc.ar(freq);
	
	var interp = ( MouseX.kr(1,0)**8)*50+1;
	
	interp.poll;
	
	sig = (sig*interp).clip(-1,1)*0.5+0.5;
	
	SinOsc.ar(200+(sig*1000))*0.1
}.play
)

Yes.

For small arrays, this should be fine. Every value in the array gets coded into the SynthDef’s table of constants, so if the array contains thousands of values, this approach might not scale up to it. In that case, you could load a larger array into a Buffer and BufRd it.

Perhaps phase distortion with bilin might help here?

hjh

Sam is right, a low pass filter is THE way to go here, instead of lag.

But if you like the lag implementation, then likely your problem is that you are allowing so much lag that the square wave can never get to full height. You should cap the lag time at half the period (1 / frequency). If you are changing the width, then you need to cap the lag time even shorter.

So your lag should be capped at min(lag, (0.5 - (0.5 - width).abs) / freq) - this should limit your lag time to something that will be smoother without smoothing all the way down to nubbins.

Have you tried the Ease Quark? It’s not a direct solution to your problem, but could be a more precise way of getting the LFO shapes you want.

Quarks.install("Ease"); // then recompile class library

(
// some Ease examples:
{
    var rate = 2;
    var lfo = LFTri.ar(rate).unipolar;
    // Ease classes need unipolar input (values between 0 and 1)
    [
        EaseInOutQuad.ar(lfo),
        EaseInOutAtan.ar(lfo),
        EaseInOutSine.ar(lfo),
        EaseInOutCirc.ar(lfo)
    ]
}.plot(1);
)
1 Like

At very low frequencies, LPF acts funny:

(
{
	var impulseFreq = 3;
	var lfo = LFPulse.ar(impulseFreq);
	var osc;
	var freq = MouseX.kr(0,1).linexp(0,1,impulseFreq,20000).poll;
	lfo = LPF.ar(lfo, freq);
	osc = SinOsc.ar(200+(lfo*200));

	osc*0.1
}.play
)

It’s funny you should mention this because the pulse is based on your example in a different thread: Pulse width modulation using only sines - #2 by fmiramar.

Here’s a slightly simplified version of what I have. It’s two oscillators out of phase with each other, with the “secondary” osc having controls for phase shifting and frequency offset relative to the primary.

The waveshaping is pretty simple in mine, and Normalize is working quite nicely, with seemingly the same computational overhead as the other value scaling I was messing with before.

(
a = {
	var spin = \spin.kr, phase = \phase.kr.mod(2pi), width = \width.kr(0.5).clip(0.0001, 0.9999),
	shape = \shape.kr, amp = \amp.kr(0.2);
	
	// freq & FM inputs
	var freq = \freq.kr(200) + (\fm.ar([0, 0]) * \fmDepth.kr(1));
	
	var sig = [
		(
			(SinOsc.ar(freq).asin * 0.5pi.reciprocal + width.linlin(0, 1, -1, 1))
			> 0 // comparator for square converter
		).madd(2, -1), // normalize
		(
			(SinOsc.ar(freq + spin, phase).asin * 0.5pi.reciprocal + width.linlin(0, 1, 1, -1)) // width scaling reversed due to neg
			> 0
		).madd(2, -1).neg // normalize & invert channel
	].flat;
	
	// for smoothing wave edge: 0.0 - 1
	sig = Lag2.ar(sig, shape.lincurve(0, 1, 0, 0.015, 2));
	
	sig = Normalizer.ar(sig, 1.0, 0.001);
	// sig = sig * shape.lincurve(0, 1, 1, 7, 5.5); // old make-up gain 
	sig = sig * amp;
	
	// main signal for stereo FM is osc1 L (FM L chan) + osc2 R (FM R chan)
	// outsMapping: osc1 L + osc2 R, osc1 R + osc2 L, osc1 L + osc2 L, osc1 R + osc2 R
	[sig[0], sig[3]]
}.play
)

Only issue with the oscillator as is at the moment I think is that it aliases in sort of a strange way when it’s fully squared and the frequency is set to, for example 204.2Hz. I don’t necessarily mind that though, and this’ll mostly be used as an LFO.

Though the shape itself is less important, and I like the siney-tri-ey-sawy thing happening in my example, but I’ll see if I can implement your “morphing” example from above here though, cause yeah, somewhere in there I’ve already got sine waves!

I’ll look into this too. In this case it might work nicely as well, because it’s also 4 or 5 different gain levels that I need, so the array can be real simple.

And I have played with Ease a little bit, but not enough yet to fully remember it while I dig deep into stuff. So also maybe it’d be a way to create the makeup gain curve as well, rather than Normalize.

There are many ways to skin a cat. I came up with this one this morning. Works with pwm and sine.

(
b = Buffer.alloc(s, 512, 1);
b.sine1(1, true, true, true);
)

(
{
	var pwm, sin, interp;
	var width = 0.5;
	var phasor = Phasor.ar(0, MouseY.kr(100, 1000)/SampleRate.ir)-width;
	phasor = (phasor.bilin(0, width.neg, 1-width, 0, -1, 1));
	pwm = (phasor<0).madd(2,-1);

	sin = Osc.ar(b, 0, phasor*pi).neg;

	//plotting the warped phasor, sin, pwm, and then 2 interpolated signals
	[phasor, sin, pwm, pwm*0.25+(sin*(1-0.25)), pwm*0.75+(sin*(1-0.75))]
}.plot(minval:-1, maxval:1))

(
{
	var pwm, sin, interp, sigInterp;
	var width = MouseX.kr;
	var freq = 3;
	var phasor = Phasor.ar(0, freq/SampleRate.ir)-width;
	phasor = (phasor.bilin(0, width.neg, 1-width, 0, -1, 1));
	pwm = (phasor<0).madd(2,-1);

	sin = Osc.ar(b, 0, phasor*pi).neg;

	interp = MouseY.kr(0,1);

	sigInterp = pwm*interp+(sin*(1-interp));

	SinOsc.ar(200+(sigInterp.madd(0.5, 0.5)*1000))*0.1
}.play
)
2 Likes

have you checked out https://github.com/required-field/squinewave/releases ?

Squine SuperCollider Ugen - sine-square-saw-pulse morphing oscillator with hardsync.

Whoa. That’s a beauty!

Sam

Yep, thank you! I’m using that oscillator elsewhere. It’s lovely. It doesn’t allow you to modify the phase after initialization though, that’s why I’ve been using SinOsc.

This is a very nice oscillator Sam! Thank you for sharing.

PWM on a sine wave is awesome, and the interpolation between wave shapes is interesting. It’s very different to the filtering approach I’ve been using and it’s nice to not have to pass through a triangle.

the cheapest way without pulse width or hard sync could be a crossfade

(
{
	var shape = [0, 0.25, 0.5, 0.75, 1];
	var sig = [LFTri.ar(), SinOsc.ar(), LFPulse.ar().range(-1, 1)];
	LinSelectX.ar(shape * (sig.size-1), sig);
}.plot();
)

hey, is there a reason for using Osc.ar instead of sin(phase * pi).neg for example ive also tried to use BufRd instead with cubic interpolation or straightforward SinOsc.ar(0, phase * pi).neg they all sound different. Does make interpolation a difference here?