Nasty pop when modulating curve argument of lincurve()

Hi all,

I recently stumbled onto some code that produces a very loud, gross pop, and I’m struggling to understand why. I’m using LFSaw to modulate the curve parameter of a lincurve, which is applied to LFTri. The code is included here, but please, don’t run it without muting your system volume first!

s.boot;

//dangerous code, use caution:

(
{
	var sig, mod;
	mod = LFSaw.kr(1).bipolar(7);
	sig = LFTri.ar(250);
	sig = sig.lincurve(-1, 1, -1, 1, mod);
	sig = sig * 0.2!2;
}.play(fadeTime:0);
)

I found that changing LFSaw.kr to LFSaw.ar makes the pop go away (there’s still a slight pop, but it’s to be expected because it naturally creates a discontinuity in the waveform as the curve value jumps from +7 to -7). Reducing the output range of LFSaw.kr lowers the intensity of the pop. Here is a waveform image of the pop::

I was even more confused by a related example. In the first example that follows, there’s no pop. But if I nudge the iphase of LFSaw by 0.0000001, then WHAM there’s a pop at the beginning. This might be dependent on sample rate and block size, so this code should also be considered dangerous.

(
{
	//this example seems fine
	var sig, mod;
	mod = LFSaw.kr(1, 1.0328117).range(0,7);
	sig = LFTri.ar(250);
	sig = sig.lincurve(-1, 1, -1, 1, mod);
	sig = sig * 0.2!2;
}.play(fadeTime:0);
)

(
{
	//this one produces a large pop, but only once at the beginning
	var sig, mod;
	mod = LFSaw.kr(1, 1.0328118).range(0,7); // <- slightly different phase
	sig = LFTri.ar(250);
	sig = sig.lincurve(-1, 1, -1, 1, mod);
	sig = sig * 0.2!2;
}.play(fadeTime:0);
)

I assumed modulating the curve value of lincurve in a signal processing context was a generally safe thing to do, and there is a similar example in the UGen help file. In fairness, it does say

// modulate the curve. Unlike with numbers and CurveSpec, the curve absolute value
// should not be much smaller than 0.5.

but it doesn’t explain why. What’s going on here?

Eli

At the moment nothing of this looks wrong or surprising to me. First, I’d rather regard ar, in a modulation context like this, especially when checking things, you want exact calculation.

But also with kr the last two variants sound the same to me (tested on 3.9.3 and 3.11.2).

One guess: there was an update of lincurve with 3.9. Are you running an older SC version? If so, you can try this variant

// this provides the newer version of lincurve for older SC versions with DX UGens
// Credits to Tim Blechmann and Julian Rohrhuber


+ UGen {
	lincurve_3_9 { arg inMin = 0, inMax = 1, outMin = 0, outMax = 1, curve = -4, clip = \minmax;
		var grow, a, b, scaled, curvedResult;
		if (curve.isNumber and: { abs(curve) < 0.125 }) {
			^this.linlin(inMin, inMax, outMin, outMax, clip)
		};
		grow = exp(curve);
		a = outMax - outMin / (1.0 - grow);
		b = outMin + a;
		scaled = (this.prune(inMin, inMax, clip) - inMin) / (inMax - inMin);

		curvedResult = b - (a * pow(grow, scaled));

		if (curve.rate == \scalar) {
			^curvedResult
		} {
			^Select.perform(this.methodSelectorForRate, abs(curve) >= 0.125, [
				this.linlin(inMin, inMax, outMin, outMax, clip),
				curvedResult
			])
		}
	}
}

+ AbstractFunction {
	lincurve_3_9 { arg inMin = 0, inMax = 1, outMin = 0, outMax = 1, curve = -4, clip = \minmax;
		^this.composeNAryOp('lincurve_3_9', [inMin, inMax, outMin, outMax, curve, clip])
	}
}

+ SequenceableCollection {
	lincurve_3_9 { arg ... args; ^this.multiChannelPerform('lincurve_3_9', *args) }
}

+ SimpleNumber {
	lincurve_3_9 { arg inMin = 0, inMax = 1, outMin = 0, outMax = 1, curve = -4, clip = \minmax;
		var grow, a, b, scaled;
		switch(clip,
			\minmax, {
				if (this <= inMin, { ^outMin });
				if (this >= inMax, { ^outMax });
			},
			\min, {
				if (this <= inMin, { ^outMin });
			},
			\max, {
				if (this >= inMax, { ^outMax });
			}
		);
		if (abs(curve) < 0.001) {
			// If the value should be clipped, it has already been clipped (above).
			// If we got this far, then linlin does not need to do any clipping.
			// Inlining the formula here makes it even faster.
			^(this-inMin)/(inMax-inMin) * (outMax-outMin) + outMin;
		};

		grow = exp(curve);
		a = outMax - outMin / (1.0 - grow);
		b = outMin + a;
		scaled = (this - inMin) / (inMax - inMin);

		^b - (a * pow(grow, scaled));
	}
}

FWIW, I’ve described the math of lincurve here:

Adding to Daniel’s comment (and not deleting the stuff I wrote before seeing his post):

I believe the solution here is to modulate curve at the same rate as the output.

lincurve is unstable for curve values very close to zero. If you look at the curve formula, first it calculates a base for exponentiation g = exp(curve). Then it calculates a coefficient as (max - min) / (1.0 - g). If curve is close to 0, then g is close to 1, and then (1.0 - g) is close to 0, and the coefficient is close to infinity = dangerous.

UGen:lincurve correctly uses a Select UGen as a conditional: if the curve value is too small for single precision, it’s supposed to replace the curve formula’s result with a simple linear mapping.

When the signal being curved is audio rate but the curve modulator is control rate, it looks to me like the decision whether to use the curve or a linear formula is being made only once for that control block. (Select_next_a() in fact does not interpolate a kr input for the index, so I think my guess is correct.)

But if the curve is changing, then it’s entirely possible that it might begin with a value such that abs(curve) >= 0.125 and then, in the middle of the control block, change to be less than. In that case, the blown-up value would not be suppressed = POP.

AFAICS there would be a couple of ways to fix it:

  • Easy: In UGen:lincurve Select (class library hook), throw an error if the curve index rate doesn’t match the output rate. (This may break existing code that is sloppy about rates.)

  • Harder but better: Or, in all the Select-style UGens (C++), implement next_ak methods with linear interpolation.

hjh

Thanks Daniel and James — very interesting. Appreciate the detailed explanations and link to the linexp/lincurve remarks. I’m using SC 3.11.2.

On further study, it seems like the discontinuity in LFSaw is a major part of why this the pop occurs. The curve parameter ramps up to 7, and scsynth is happily selecting the lincurve option, and then suddenly, in the middle of a control block, the value jumps down to zero. The frequency and phase might be specified such that the jump-to-zero perfectly aligns with the start of a control block, but that’s sort of unlikely.

I was able to simulate the same problem with a modified LFTri modulator. As it appears below, it works fine. If the two mod lines are uncommented, it pops because of a sudden high-to-low jump in the signal. If uncommented and LFTri is changed to run at the audio rate, then the problem goes away.

Again, — this is dangerous code! Don’t run it without first muting your system volume:

(
{
	var sig, mod;
	mod = LFTri.kr(1).range(0, 7);
	//mod = mod - 0.1;
	//mod = mod.wrap(0, 7);
	sig = LFTri.ar(250);
	sig = sig.lincurve(-1, 1, -1, 1, mod);
	sig = sig * 0.2!2;
}.play(fadeTime:0);
)

Thanks again for helping me understand the issue.

Eli

I kinda think that SC should throw an error in all of these cases.

It’s odd (i.e. wrong) that Select checks the rate of the array members but not the rate of the index input.

Probably this dangerous case should be disallowed.

hjh