Detecting a decaying signal

How do you determine when a signal starts decaying? Say I have this sound:

{ LFSaw.ar(100) * Env.perc(0.2, 0.2, 1, 0).ar(2) }.plot(0.4);

How do I get one and just one trigger, when then signal starts decaying after 0.2 seconds, or some short time after 0.2 seconds? I tried many different approaches but I can past the local ‘wiggling’ of the amplitude.

If you have a general idea of the max level of your signal and how quickly the dynamics change you can tune something specific to your signal. Making something totally generic is tougher.

But here are some ideas to get you started.
The idea is to get a rough dynamics envelop, then run your thresholding and triggering on that.

(
{ 
	var thresh = 0.3; // threshold you care about crossing
	var onLag = 0.008; // lag upward trigger to safely clear the threshold
	
	// your signal
	var sig	= LFSaw.ar(100) * Env.perc(0.2, 0.2, 1, 0).ar(0, Impulse.ar(2));
	
	// signal smoothers
	var amp = Amplitude.ar(sig, attackTime: 0.001, releaseTime: 0.1);
	var lag = LagUD.ar(sig.abs, lagTimeU: 0.01, lagTimeD: 0.5);
	var avg = MovingAverage.ar(sig.abs*2, numsamp: 0.05*s.sampleRate, maxsamp: 0.2*s.sampleRate);
	var smoothers = [amp, avg, lag]; // collect them to easily run thresholding
	
	// generate a trigger on the smoohted signals
	var trigUp, trigDown, wasUp;
	
	trigUp = smoothers > thresh;
	trigUp = trigUp & DelayN.ar(trigUp, 0.1, onLag); // for a more reliable onset
	
	wasUp = DelayN.ar(trigUp, 0.1, onLag);
	trigDown = SetResetFF.ar(trig: trigUp < 1 & wasUp, reset: trigUp);
	
	[sig] ++ smoothers ++ trigDown // inspect downward trigger
	// [sig] ++ smoothers ++ trigUp // inspect upward trigger
}.plot(1);
)

Here’s the trigger generate don these 3 smoothed envelopes. You’d have to tune it depending on the kind of acceptable latency

Thanks a lot @mike. I will study your code more closely over the next couple of days. Initially I thought some of my problems determining a decaying signal had to do with the type of signal used and that it would be easier to cary out on a smooth sinewave rather than jagged sawtooth, but I get pretty much the same readings when swapping the LFSaw for a SinOsc.

I can’t tell if from looking at the LFSaw plot if the apparent irregularities of the signal stems from visual aliasing or from the actual signal. My initial thought was to try this method, but could’t really get it to work: Measure the peak value of consecutive cycles of signal, maybe something like Peak.ar(sig, sig < 0) and compare this measurement for n cycles, where n hopefully can be a rather small number. For this method to work reliably, the peaks for a decaying signal should steadily drop but as far as I can tell this is not always the case - you can have an overall decaying signal at the same time as the last peak is higher than the previous even for relatively pure signals like a sawtooth. I did however think this would be the case for a pure sinewave, it certainly looks like every peak, once the signal starts decaying af 0.2 seconds is notably lower than the previous.

As mentioned I haven’t really played around with the settings of your code but it seems like a pretty long time to determine that the signal is decaying as the trigger occurs approx. 0.13 seconds after the signal has begun its 0.2 seconds decay.

EDIT:
I tried my initial idea again and got it to work on a pure signal, but needless to say, if you input something more interesting and ‘reallify’, it goes bunkers. So probably not a very useful thing as anything as pure as this most likely only comes up in a situation where you have a handle on the envelope of the signal to begin with.

Here I latch the 3 most recent positive peaks and compare the oldest to the newest.

(
{	
	var sig = LFSaw.ar(100) * Env.perc(0.2, 0.2, 1, 0).ar(0, Impulse.ar(2));
	var trig1 = sig < 0, trig2 = Delay1.ar(trig1), trig3 = Delay1.ar(trig2);
	var peak = Peak.ar(sig, sig < 0);
	var val0 = Latch.ar(peak, trig3);
	var val1 = Latch.ar(val0, trig2);
	var val2 = Latch.ar(val1, trig1);
	[sig, (val2 - val0) > 0]
}.plot(1)
)

I haven’t tried it so take my idea with a grain (or a ton) of salt, but I think you could use an integrator to follow a more global “amplitude envelope” of the sound instead of tracking each and every local variation. (You can think of it as a Lag applied to the signal, which causes it to follow only the general shape of the signal instead of all local variations). There’s a PeakFollower UGen that could be useful here.

Yea that actually works, thanks! I was first trying PeakFollow on the signal itself which again resulted in very jagged curve, however applying the PeakFollow to the amplitude provides much better result, even with a chaotic sound like decaying white noise and short lag times:

(
{	
	// var sig = LFSaw.ar(LFNoise0.kr(2).range(100, 1000)) 
	var sig = WhiteNoise.ar
	* Env.perc(0.2, 0.2, LFNoise0.kr(2).range(0.5, 1), 0).ar(0, Impulse.ar(2));
	var amp = Amplitude.ar(sig, 0.01, 0.1);
	var peak = PeakFollower.ar(amp).lag(0.01);
	[sig, (Delay1.ar(peak) - peak).lag(0.01) > 0]	
}.plot(1.5, separately: true)
)

Hi @Thor_Madsen, sorry i misunderstood your original post and thought you wanted to detect a signal envelope decaying below a given threshold.

Looks like you found a way of finding the point at which a signal starts decaying.

Before I saw your followup, I made this variation, which, similar to yours, uses a peak follower, but uses Peak which doesn’t decay. Paired with Latch and some tigger delaying, you can capture the direction of the envelope fairly well, and define some thresholds to tune.

EDIT: for low frequency noise, I added an HPF and signal smoothing back in so it’s more robust. But for oscillators or other “simple” signals, those may not be needed.

(
{
	var sampleTrig, pk, latch, hpz;
	var trigUp, trigDown, wasUp, offsetTrig, offsetTrigViz;
	
	// rate you measure envelope the change, 
	// should be less than your signal's frequency
	var checkPeakRate = 45;

	// threshold level change for detecting rising or falling signal
	var changeThresh = 1e-7;

	// oscillator
	// var sigFreq = 100;
	// var sig	= LFSaw.ar(sigFreq) * Env.perc(0.2, 0.2, 1, 0).ar(0, Impulse.ar(2));

    // or noise
	var sig	= PinkNoise.ar * Env.perc(0.2, 0.2, 1, 0).ar(0, Impulse.ar(2));	
	// precondition: if the signal has low frequency variation, highpass
	sig = HPF.ar(sig, 100);
	// If the signal is chaotic, smooth it
	sig = MovingAverage.ar(sig.abs*2, numsamp: 0.05*s.sampleRate, maxsamp: 0.2*s.sampleRate);
	
    // decay detection trigger
	sampleTrig = Impulse.ar(checkPeakRate);
	pk = Peak.ar(sig, Delay1.ar(sampleTrig));
	latch = Latch.ar(pk, sampleTrig);
	hpz = latch - Delay1.ar(latch);

	trigUp = hpz > changeThresh;
	trigDown = hpz < changeThresh.neg;
	wasUp = SetResetFF.ar(trigUp, trigDown);
	offsetTrig = Delay1.ar(wasUp) & trigDown;
	offsetTrigViz = Decay.ar(offsetTrig, 0.1);

	[sig, latch, wasUp, offsetTrigViz]
}.plot(1);
)

I included the offsetTrigViz output just to visualize the trigger, otherwise the output trigger impulse can get aliased out of the plot.

Yes, that’s aliasing from Plot.

Thanks @mike, I will test your code on various input sources and as this kind of detection naturally behave very differently depending on the input source it is very useful to have different strategies for different applications.

I am working on an upward expander for live input, electric guitar mostly, and determining if the signal is rising or falling is very useful as I can apply expansion (in a limited range, eg. [-30, -50] dbFS) on decaying signals and thus keeping the attacks untouched.

@mike - I did some more testing of your code and it definitely is better than mine for guitar input, the smoothing is much needed for starters.

Do you think it would improve the the code if the phase of sampleTrig was reset by wasUp or (1 - wasUp) or something like that? And if so, how do you go about resetting the phase of an Impulse.ar/kr?

Hi @Thor_Madsen,

It depends what the problem is… If you’re getting multiple triggers on decay, it could be that the signal needs to be smoothed more, or the high pass frequency needs to be increased to filter out low frequency variation that can cause a messy envelope.

If you’re getting triggers when your signal is very low, you could either gate it, or increase the changeThresh.

There could be smarter ways of using triggers here, but I tried to limit the complexity.

The phase of Impulse can’t be reset in a straightforward way, it’s like an oscillator in that sense. If you want to reset a periodic signal, something like Phasor would work.

I’ve refactored the code to be a bit more clear (sorry I rushed out the last version and wasn’t very good about variable naming and comments)

(
s.waitForBoot {
	{
		var checkPeakTrig, peakVal, peakHold, peakChange;
		var isIncreasing, isDecreasing, wasIncreasing, isDecayingTrig, decayTrigViz;
		
		/* Tuning variables */
		
		// Smoothing window size (sec)
		var smoothWin = 0.05;
		
		// High pass frequency to reduce variation at low frequencies
		// which can make the envelope messy
		var hpFreq = 150;
		
		// Rate at which the peak value is sampled
		// Should be less than your signal's frequency
		var checkPeakRate = 90;
		
		// Threshold level change for detecting rising or falling signal
		var changeThresh = 1e-7;
		
		
		/* Source signals */
		
		// oscillator
		// var sigFreq = 100;
		// var sig	= LFSaw.ar(sigFreq) * Env.perc(0.2, 0.2, 1, 0).ar(0, Impulse.ar(2));
		
		// noise
		var sig	= PinkNoise.ar * Env.perc(0.2, 0.2, 1, 0).ar(0, Impulse.ar(2));
		
		/* Signal smoothing */
		
		sig = HPF.ar(sig, hpFreq); // highpass to reduce low frequency variation
		sig = MovingAverage.ar(    // smooth the signal if it's chaotic
			sig.abs * 2, 		   // rectify and amplify (averaged signal will have less amplitude)
			numsamp: smoothWin * s.sampleRate,
			maxsamp: 0.2 * s.sampleRate);
		
		/* Measure change in the running peak value */
		
		checkPeakTrig = Impulse.ar(checkPeakRate);
		peakVal       = Peak.ar(sig, Delay1.ar(checkPeakTrig));
		peakHold      = Latch.ar(peakVal, checkPeakTrig);
		peakChange    = peakHold - Delay1.ar(peakHold);
		
		/* Generate a single trigger for a decaying peak value */
		
		isIncreasing   = peakChange > changeThresh;
		isDecreasing   = peakChange < changeThresh.neg;
		wasIncreasing  = Delay1.ar(SetResetFF.ar(isIncreasing, isDecreasing));
		isDecayingTrig = wasIncreasing & isDecreasing; // <- your decay trigger
		decayTrigViz   = Decay.ar(isDecayingTrig, 0.1); // to hear trigger: * SinOsc.ar(300);
		
		[sig, peakHold, wasIncreasing, decayTrigViz]
	}.plot(1);
})

I’ve brought all the tunable parameters to the top to make testing easier, and if you have this running as a synth, these are the parameters you can use as arguments for testing in real-time.