Comparator - Wave switching / Wave slicing

I was playing with different waveform strategies I used on analog synths to splice together a couple different waveforms into one new waveform. In an analog synth, a comparator with a voltage reference input is used to generate a trigger signal that drives a switch, often with a flip flop involved.

I put together a basic working example, turning a sine wave into a pseudo-saw that can be phase animated:

(
{
var sig, trig, ref=0.9999, out, pmRate=0.5;
sig = SinOsc.ar(30, SinOsc.kr(pmRate));
trig = (sig >= ref) + (sig <= ref.neg);
out = Select.ar(ToggleFF.ar(trig), [sig, sig.neg]);
[sig, out];
}.plot(duration:0.25);
)

This works just fine, but I’m wondering if there’s a better general approach that someone might recommend?

I do find it interesting that 0.9999 triggers only once at the peak of a sine wave, and doesn’t retrigger. I would have expected an intense burst of short pulses.

This is a great way to make interesting sounding/looking waveforms for sound or modulation sources:

(
{
	var sig, folded1, folded2, foldThresh1=0.5, foldThresh2=0.25, ref=0.9999, trig1, trig2, switchThresh=0.75, out1, out2;
	sig = SinOsc.ar(30);
	trig1 = (sig > switchThresh) + (sig < switchThresh.neg);
	trig2 = ToggleFF.ar((sig >= ref) + (sig <= ref.neg));
	folded1 = Fold.ar(sig, foldThresh1.neg, foldThresh1);
	folded2 = Fold.ar(Fold.ar(sig, (foldThresh2/2).neg, foldThresh2/2), foldThresh2.neg, foldThresh2)*2;
	out1 = Select.ar(trig1, [folded1, sig]);
	out2 = Select.ar(trig2, [out1, folded2]);
	[sig, folded1, folded2, out1, out2];
}.plot(duration:0.1);
)

I’m also wondering if there’s a way to continually log min and max values of a waveform that can be used to dynamically offset and scale it to fit a range. I feel like there must be a way to log and average min and max values, and use those for scaling, but that’s just a feeling I have. On an analog synth, splicing waveforms generally means watching an oscilloscope and tweaking an attenuverter that has a voltage offset to get the waveform to match up or sound right.

For example, in a case like this, the resulting combined waveform is within the 0 to 1 range, and would need to be offset by -0.5, and multiplied by 2 to shift and scale it to the -1 to 1 bipolar range:

(
{
var sig, trig, trigThresh = 0, out;
	sig = SinOsc.ar(30);
	trig = sig >= trigThresh;
	out = Select.ar(trig, [sig.neg, sig]);
	[sig, out, (out-0.5)*2];
}.plot(duration:0.1, minval:-1, maxval:1);
)

Is there a way to scale that without knowing the range, or hard coding the offset and amplification?
Assuming one can grab min and max values, it seems easy to derive offset and scale values. Something like:

offset = ((1-maxVal) + (-1-minVal))/2;
scale = (1-(-1)) / (maxVal - minVal);

Any thoughts anyone has?
Thanks

A trigger is the transition from <= 0 to > 0 (or false to true). To retrigger, it’s necessary for the signal to go false again.

What retriggering rule were you hoping for here?

Cool waveforms btw.

hjh

You could use Normalizer.ar, but it adds a delay:

(
{
var sig, trig, trigThresh = 0, out;
	sig = SinOsc.ar(30);
	trig = sig >= trigThresh;
	out = Select.ar(trig, [sig.neg, sig]);
	// [sig, out, (out-0.5)*2];
	[sig, out, Normalizer.ar(out, 1).madd(2, -1)];
}.plot(duration:0.1, minval:-1, maxval:1);
)

Best,
Paul

That makes sense, thanks. I wasn’t expecting or hoping for any particular behavior, I’m just trying to get by head around the results I’m seeing and hearing compared to the analog gear I’m used to in this kind of approach.

There isn’t a Ugen equivalent for an analog voltage comparator that I’ve missed, is there?

The one thing that confuses me is why I can’t write something like:

trigger = signal > val1 || signal < val2;

it makes sense that I’m forced to mix triggers as a signal, but I think of these as logic operations:

trigger = (signal > val) + (signal < val2);

I feel like there’s a “mode” of writing from a logic standpoint that I’m missing.

I love it when waveforms really dance. Bonus points when they sound good too.

I don’t mind a delay, but doesn’t Normalizer shift the signal based on the max value, but doesn’t scale the signal?

whats cool with digital audio is that you can mess with the phase instead of the amplitude of your signal. I think alot of the stuff you have been showing could be achieved with phase shaping, phase increment distortion or phase modulation, where you could work with normalized phases between 0 and 1 instead of trying to scale your amplitudes :slight_smile:

1 Like

In SC, || and && are for Booleans. There’s no such thing as a Boolean signal – only 0 and nonzero. So you have to treat “pseudo-Booleans” in the server using arithmetic operators, not logical operators.

hjh

1 Like

I like the sound of this. Can you point me to any code or Ugens to play with along those lines?

That makes perfect sense, of course. I guess what makes it confusing is that there trigger inputs to various Ugens that presume a binary input, but, like you say, there’s no binary signal.

There have been these threads:

I also just released a bunch of Unit Shapers with my Grain Utils library and there are more to come.

some phase shaping, phase increment distortion techniques below:

// vps with phase increment distortion

(
var triangle = { |phase, skew|
	Select.ar(phase > skew, [
		phase / skew,
		1 - ((phase - skew) / (1 - skew))
	]);
};


var vps = { |freq, skew, harm|

	var phase = Phasor.ar(DC.ar(0), freq * SampleDur.ir);

	var harm_even = harm.round(2);
	var harm_odd = ((harm + 1).round(2) - 1);

	var pmod = triangle.(phase, skew);

	var sig_even = cos(phase + (pmod * (harm_even - skew)) * 2pi).neg;
	var sig_odd = cos(phase + (pmod * (harm_odd - skew)) * 2pi).neg;

	LinXFade2.ar(sig_even, sig_odd, harm.fold(0, 1) * 2 - 1);
};

{
	var freq = 55;
	var skew = MouseX.kr(0, 1);
	var harm = MouseY.kr(1.0, 10.0);
	var sig = vps.(freq, skew, harm);
	sig = LeakDC.ar(sig);
	sig !2 * 0.1;
}.play;
)

// vps with phase shaping

(
var kink = { |phase, harm, skew|
	phase = phase.linlin(0, 1, skew.neg, 1 - skew);
	phase.bilin(0, skew.neg, 1 - skew, harm, 0, 1);
};

var vps = { |freq, skew, harm|

	var phase = Phasor.ar(DC.ar(0), freq * SampleDur.ir);

	var harm_even = harm.round(2);
	var harm_odd = ((harm + 1).round(2) - 1);

	var phasor_even = kink.(phase, harm_even, skew);
	var phasor_odd = kink.(phase, harm_odd, skew);

	var sig_even = cos(phasor_even * 2pi).neg;
	var sig_odd = cos(phasor_odd * 2pi).neg;

	LinXFade2.ar(sig_even, sig_odd, harm.fold(0, 1) * 2 - 1);
};

{
	var freq = 55;
	var skew = MouseX.kr(0.01, 0.99);
	var harm = MouseY.kr(1.0, 10.0);
	var sig = vps.(freq, skew, harm);
	sig = LeakDC.ar(sig);
	sig !2 * 0.1;
}.play;
)

///////////////////////////////////////////////////////////////

(
var raisedCos = { |phase, index|
	var cosine = cos(phase * 2pi);
	exp(index.abs * (cosine - 1));
};

var singleSideBandPM = { |freq, modRatio, index|
	var carrPhase = Phasor.ar(DC.ar(0), freq * SampleDur.ir);
	var modPhase = Phasor.ar(DC.ar(0), freq * modRatio * SampleDur.ir);
	var raisedCosWindow = raisedCos.(modPhase, index);
	var mod = sin(modPhase * 2pi);
	var carr = sin(carrPhase * 2pi + (mod * index));
	carr * raisedCosWindow;
};

SynthDef(\singleSidebandPM, {

	var freq, modPhase, grainFreqMod, index, sig, phase;

	freq = \freq.kr(110);

	modPhase = Phasor.ar(DC.ar(0), \freqMF.kr(0.3) * SampleDur.ir);
	grainFreqMod = SinOsc.ar(DC.ar(0), modPhase + [0.0, 0.5] * 2pi);

	index = [
		\indexA.kr(8) * (2 ** (grainFreqMod[1] * 3)),
		\indexB.kr(16) * (2 ** (grainFreqMod[0] * 1)),
	];

	sig = singleSideBandPM.(freq, \modRatio.kr(1), index).sum;

	sig = Pan2.ar(sig, \pan.kr(0));

	sig = sig * \amp.kr(-25.dbamp);

	sig = sig * Env.asr(0.001, 1, 0.001).ar(Done.freeSelf, \gate.kr(1));

	sig = LeakDC.ar(sig);
	Out.ar(\out.kr(0), sig);
}).add;
)

(
Routine({

	var ratios = [1.00, 1.04];
	var pan = [-1.0, 1.0];

	s.bind {
		ratios.collect{ |ratio, i|
			var freq = 125;

			Synth(\singleSidebandPM, [

				\freq, freq * ratio,
				\modRatio, 1,

				// amp & outs
				\amp, -25.dbamp,
				\pan, pan[i],
				\out, 0,

			]);
		};

	};

}).play;
)

/////////////////////////////////////////////////////////////////

(
var raisedCos = { |phase, index|
	var cosine = cos(phase * 2pi);
	exp(index.abs * (cosine - 1));
};

var crossfade_formants = { |phase, harm|
	var harmEven = harm.round(2);
	var harmOdd = ((harm + 1).round(2) - 1);
	var sigEven = sin(phase * 2pi * harmEven);
	var sigOdd = sin(phase * 2pi * harmOdd);
	LinXFade2.ar(sigEven, sigOdd, harm.fold(0, 1) * 2 - 1);
};

var modFM = { |freq, harm, index|
	var phase = Phasor.ar(DC.ar(0), freq * SampleDur.ir);
	var raisedCosWindow = raisedCos.(phase, index);
	var formants = crossfade_formants.(phase, harm);
	formants * raisedCosWindow;
};

SynthDef(\formant, {

	var sig, modPhase, grainFreqMod, harmonics;

	modPhase = Phasor.ar(DC.ar(0), \freqMF.kr(0.3) * SampleDur.ir);
	grainFreqMod = SinOsc.ar(DC.ar(0), modPhase + [0.0, 0.5] * 2pi);

	harmonics = [
		\harmA.kr(8) * (2 ** (grainFreqMod[1] * 3)),
		\harmB.kr(16) * (2 ** (grainFreqMod[0] * 1))
	];

	sig = modFM.(\freq.kr(440), harmonics, \index.kr(1)).sum;

	sig = Pan2.ar(sig, \pan.kr(0));

	sig = sig * \amp.kr(-25.dbamp);

	sig = sig * Env.asr(0.001, 1, 0.001).ar(Done.freeSelf, \gate.kr(1));

	sig = LeakDC.ar(sig);
	Out.ar(\out.kr(0), sig);
}).add;
)

(
Routine({

	var ratios = [1.00, 1.04];
	var pan = [-1.0, 1.0];

	s.bind {
		ratios.collect{ |ratio, i|
			var freq = 125;

			Synth(\formant, [

				\freq, freq * ratio,
				\index, 8,

				\amp, -25.dbamp,
				\pan, pan[i],
				\out, 0,

			]);

		};
	};

}).play;
)

/////////////////////////////////////////////////////////////////////////////

(
var raisedCos = { |phase, index|
	var cosine = cos(phase * 2pi);
	exp(index.abs * (cosine - 1));
};

var crossfade_formants = { |phase, harm|
	var harmEven = harm.round(2);
	var harmOdd = ((harm + 1).round(2) - 1);
	var sigEven = sin(phase * 2pi * harmEven);
	var sigOdd = sin(phase * 2pi * harmOdd);
	LinXFade2.ar(sigEven, sigOdd, harm.fold(0, 1) * 2 - 1);
};

var modFM = { |freq, harm, index|
	var phase = Phasor.ar(DC.ar(0), freq * SampleDur.ir);
	var raisedCosWindow = raisedCos.(phase, index);
	var formants = crossfade_formants.(phase, harm);
	formants * raisedCosWindow;
};

SynthDef(\formant, {

	var trig, sustain, gainEnv, harmEnv, harmonics, sig;

	trig = \trig.tr(1);
	sustain = \sustain.kr(1);

	gainEnv = EnvGen.ar(Env(
		[0, 1, 0],
		[\atk.kr(0.01), \rel.kr(0.99)],
		[\atkCurve.kr(45.0), \relCurve.kr(-24.0)]
	), trig, timeScale: sustain);

	harmEnv = EnvGen.ar(Env(
		[0, 1, 0],
		[\fAtk.kr(0.01), \fRel.kr(0.99)],
		[\fAtkCurve.kr(45.0), \fRelCurve.kr(-24.0)]
	), trig, timeScale: sustain);

	harmonics = harmEnv.linlin(0, 1, 1, \harmEnvAmount.kr(50));

	sig = modFM.(\freq.kr(103.826), harmonics, \index.kr(1));

	sig = sig * gainEnv;

	sig = Pan2.ar(sig, \pan.kr(0));

	sig = sig * \amp.kr(-15.dbamp);

	sig = sig * Env.asr(0.001, 1, 0.001).ar(Done.freeSelf, \gate.kr(1));

	sig = LeakDC.ar(sig);
	Out.ar(\out.kr(0), sig);
}).add;
)

(
Pdef(\laser,
	Pmono(\formant,

		\trig, 1,
		\legato, 0.8,
		\dur, 8,

		\atk, 0.005,
		\rel, 0.995,
		\atkCurve, 45.0,
		\relCurve, -24.0,

		\fAtk, 0.001,
		\fRel, 0.999,
		\fAtkCurve, 45.0,
		\fRelCurve, -24.0,

		\time, Pfunc { |ev| ev.use { ~sustain.value } / thisThread.clock.tempo },

		\freq, [44, 54, 60].midicps,

		\harmEnvAmount, 50,
		\index, 2,

		\amp, -15.dbamp,
		\out, 0,
	);
).play;
)

/////////////////////////////////////////////////////////////////////////////

//not so much phase shaping haha :slight_smile: ...

(
SynthDef(\lax, {

	var tFreq = \tFreq.kr(12);
	var trig = Impulse.ar(tFreq);
	var lfo = LFSaw.ar(0.3 * (1 + (LFNoise2.kr(0.3) * 0.5)));

	var sig = SinOsc.ar(lfo.linexp(-1, 1, 100, 8000)) + SinOsc.ar(lfo.linexp(-1, 1, 1000, 4000));
	sig = Pluck.ar(sig, trig, \maxDel.kr(0.1), \freq.kr(50).reciprocal, \decTime.kr(0.1), \coef.kr(0.5));

	sig = Pan2.ar(sig, \pan.kr(0));

	sig = sig * \amp.kr(-15.dbamp);

	sig = Limiter.ar(sig);
	Out.ar(\out.kr(0), sig);
}).add;
)

(
Routine {

	var freqs = [125, 130];
	var pan = [-1, 1];

	s.bind {
		freqs.collect{ |freq, i|

			Synth(\lax, [

				\tFreq, 12,

				\freq, freq,
				\maxDel, 0.1,
				\decTime, 0.2,
				\coef, 0.5,
				\rate, 0.3,

				\amp, -20.dbamp,
				\pan, pan[i],
				\out, 0,

			]);

		};
	};

}.play;
)

My bad - testing more it seems I was wrong about Normalizer; it only scales based on the maximum value. Here, the minimum value doesn’t reach -1:

(
{
var sig, trig, trigThresh = 0, out;
	sig = SinOsc.ar(30);
	trig = sig >= trigThresh;
	out = Select.ar(trig, [sig.neg, sig]) * 0.3 + 0.3;
	[sig, out, Normalizer.ar(out, 1).madd(2, -1)];
}.plot(duration:0.1, minval:-1, maxval:1);
)

Hello, maybe you could try RunningMax and/or RunningMin UGens for this job ?

1 Like

Thank you, and thank you.
I didn’t know those about those Ugens. Just did one quick test, and they appear to be exactly what I’m looking for.

Many thanks.

I definitely need to play with these more to get a handle on these, but right off the bat this does seem to work:

(
{
var sig, trig, trigThresh = 0, out, scaled, max, min, runTrig, offset;
	sig = SinOsc.ar(30);
	trig = sig >= trigThresh;
	out = Select.ar(trig, [sig.neg, sig]);
	runTrig = Impulse.kr(1);
	max = RunningMin.ar(out, runTrig);
	min = RunningMax.ar(out, runTrig);
	offset = abs(max + min)/2;
	scaled = (out - offset) * abs(2/(max - min));
	[sig, out, scaled, offset];
}.plot(duration:0.1, minval:-1, maxval:1);
)

1 Like

Thanks for the help in the thread. I was able to put together a version of what I was aiming to emulate from analog gear, which is switching between two waveforms when they level match, so there aren’t hard edges in the switch. I haven’t tested this extensively, but it seems to work.

( 
{
var sig1, sig2, dly1, dly2, levelX, levelXTrg, hyst=0.001, combined, trigThresh = 0, out, scaled, max, min, runTrig, offset;
	//first osc and the changes
	sig1 = SinOsc.ar(200);
	out = Fold.ar(sig1, -0.75, 0.75);
	out = Fold.ar(out, -0.25, 0.25);
	runTrig = Impulse.kr(1);
	min = RunningMin.ar(out, runTrig);
	max = RunningMax.ar(out, runTrig);
	offset = abs(max + min)/2;
	scaled = (out - offset) * abs(2/(max - min));
	dly1 = DelayN.ar(scaled, 0.001, 1/SampleRate.ir);
	//second osc
	sig2 = SinOsc.ar(345.01);
	//find the crossings between osc 1 & 2
	levelX = ((scaled > (sig2+hyst)) * (dly1 < (sig2+hyst.neg))) + ((scaled < (sig2+hyst.neg)) * (dly1 > (sig2+hyst)));
	levelXTrg = levelX > 0;
	//combine the two
	combined = Select.ar(ToggleFF.ar(levelXTrg), [scaled, sig2]);
	[scaled, sig2, combined];
}.plot(duration:0.03, minval:-1, maxval:1);
)

3 Likes

A small tip – this is probably more efficient as Delay1.ar(scaled).

(Also, as a side note, SynthDef automatically changes sig2+hyst.neg into sig2 - hyst :wink: )

hjh

1 Like