Code review? (Ramp)

To my amazement I actually managed to get my first C++ code to compile without errors and the thing even does something. Simply ramping up a voltage with cosine, linear and exponential interpolation. Is it ok to put some code up here (~120 lines) with some questions for comment?

2 Likes

I donā€™t see why not.

Ok then, here we go. Code below.

Questions:

  • I donā€™t understand the pulseGenerator.

first there is
endPulseGen.trigger(1e-3f); then

endPulse = endPulseGen.process(1/args.sampleTime);
outputs[END_OUTPUT].setVoltage(endPulse ? 10.f : 0.f);

this works, but

endPulse = endPulseGen.process(args.sampleTime);

does not work, it works as a gate. Voltages goes up but does not come down?

  • How to keep the state of things? Three states is doable, more would be complex.

  • Do I have to set variables and output voltages every cycle? For example in the last ā€˜elseā€™ they are there to get the voltage to 0 when a cable is unplugged.

  • Thereā€™s a lot of (nested) ā€˜ifā€™ making it complex to keep track of things. Is there a way out?

  • Creating more ramps (8) would be a matter of adding them in a loop and turn some of the variables in arrays?

  • Is the interpolation function a candidate for simd? Just for learning and maybe for an other plugin that needs more uhmpf.

  • Is there a way to put more than one cable on an triggered input? For example for looping the end signal to start?

  • A lead on how to create a knob that starts at 0, has a mid position value of 1 and a end value of 10 would be welcome.

#include "plugin.hpp"
#include <cmath>

struct Ramp : Module {
	enum ParamIds {
		VFROM_PARAM,
		VTO_PARAM,
		TIME_PARAM,
		INTERP_PARAM,
		NUM_PARAMS
	};
	enum InputIds {
		START_INPUT,
		STOP_INPUT,
		NUM_INPUTS
	};
	enum OutputIds {
		END_OUTPUT,
		VOUT_OUTPUT,
		NUM_OUTPUTS
	};
	enum LightIds {
		NUM_LIGHTS
	};

	Ramp() {
		config(NUM_PARAMS, NUM_INPUTS, NUM_OUTPUTS, NUM_LIGHTS);
		configParam(VFROM_PARAM, 0.f, 10.f, 0.f, "Voltage from");
		configParam(VTO_PARAM, 0.f, 10.f, 0.f, "Voltage to");
		configParam(TIME_PARAM, 0.f, 1200.f, 0.f, "time", "s");
		configParam(INTERP_PARAM, 0.f, 10.f, 0.f, "interpolate");
	}

	/** Interpolates over gs, ge and rescales to ts, te.
	gc: global current
	//gs: global start omitted as gs is always 0
	ge: global end
	ts: target start
	te: target end
	im: interpolation method,
	    im = 0     - cosine interpolation
	    0> im <1   - exponential (ease in)
	    im = 1     - linear
	    1> im <10  - exponential (ease out)
	    im = 10    - step
	*/
	inline float interpolate(float gc, float ge, float ts, float te, float im) {
	  float v;
	  if (im == 10.f) {(gc == ge) ? v = te : v = ts;}  // step
	  else {
	    float x = gc / ge;
	    if (im != 0) { 
	      (im == 1.f) ? v = ts + (te - ts) * x : v = ts + (te - ts) * pow(x, im);
	    }
	    else {                             				// cosine
	      float f = (1 - cos(x * M_PI)) * 0.5;
	      v = ts * (1 - f) + te * f;
	    }  
	  }
	  return v;
	}

	float gc = 0;
	bool running = false;
	bool finished = false;
	bool stopped = true;
	bool endPulse = false;
	dsp::SchmittTrigger startTrigger;
	dsp::SchmittTrigger stopTrigger;
	dsp::PulseGenerator endPulseGen;

	void process(const ProcessArgs& args) override {
		if (inputs[START_INPUT].isConnected() && outputs[VOUT_OUTPUT].isConnected() == true) {
			if (running == true) {
				gc += args.sampleTime;				
				if (gc < params[TIME_PARAM].getValue()) {
					outputs[VOUT_OUTPUT].setVoltage(
						interpolate(
							gc, 
							params[TIME_PARAM].getValue(), 
							params[VFROM_PARAM].getValue(), 
							params[VTO_PARAM].getValue(), 
							params[INTERP_PARAM].getValue()
						)
					);
				}
				else {
					running = false;
					finished = true;
					stopped = false;
					endPulseGen.trigger(1e-3f);
				}
			}
			else if (finished == true) {
				endPulse = endPulseGen.process(1/args.sampleTime);
				outputs[END_OUTPUT].setVoltage(endPulse ? 10.f : 0.f);
				outputs[VOUT_OUTPUT].setVoltage(params[VTO_PARAM].getValue());
			}
			if (startTrigger.process(inputs[START_INPUT].getVoltage()) == true) {
				gc = 0;
				running = true;
				finished = false;
				stopped = false;
			}
			if (inputs[STOP_INPUT].isConnected() && stopTrigger.process(inputs[STOP_INPUT].getVoltage()) == true) {
				gc = 0;
				running = false;
				finished = false;
				stopped = true;
				outputs[VOUT_OUTPUT].setVoltage(0.f);
			}
		}
		else {
			outputs[VOUT_OUTPUT].setVoltage(0.f);
			gc = 0;
		}
	}
};

/* --- snip silver screw and black knob from hardware department --- */

  • Pass args.sampletime to the pulse generator not 1 / sampletime. sampletime = 1/samplerate.

  • To keep states you need variables. There is no other way arround it. But maybe you can eliminate the stoped variable and use !running intead. The ! operator makes true estatements false and false true.

  • I also donā€™t like nested if. Sometimes to keep it less tabbed I do things like:

if (inputnotconected || !running) { return; }

  • Yes, 8 arrays is what you need. You could use objects if it becomes too complex to keep track of everything. But the array thing usually is better for performance.

  • I donā€™t know about simd.

  • Inputs can only have one cable connected to it.

  • For the knob what I do is have a parameter from 0 to 1. And a array{0,1,10} and use linearinterpolation(array, param*arraylen).

1 Like

Input cables only except 1 connection unless poly where it can have up to 16 channels on one cable, any poly merger could sum mono cables.

Calling sin every process call will make your code 10x slower than it needs to be. Rack has a sin aproximation built in.

1 Like

You could tidy a bit by assigning the interpolate variables out of scope.

float time = params[TIME_PARAM].getValue(), 
      voltFrom = params[VFROM_PARAM].getValue(),
      voltTo = params[VTO_PARAM].getValue(), 
      interp = params[INTERP_PARAM].getValue();

outputs[VOUT_OUTPUT].setVoltage(interpolate(gc, time, voltFrom, voltTo, interp);

Not sure if there is another way around not using nested statements.

@lomasmodules Iā€™ll look into the 1/sampleTime again tomorrow and investigate the ! operator. 8 arrays are working now after many crashes and vague errors :slight_smile: lerp the knob is what Iā€™ll try.

@Coirt Ah, polyphony, thatā€™s why it sometimes works and then in an other module not. Would it be useful to add polyphony to a ramp? Regarding tidying up, somehow I always end up with ā€œblack pagesā€ of code :wink:

@Squinky The cos and pow and thinking about other blending functions like logistic-sigmoid is why I looked at simd. Iā€™ll look into the approixmated one. Currently it does .75us with all 8 ones running on an 8 year old AMD athlon under win10.

Thank yā€™all, Cheers

Well if you wanted combine inputs you could use polyphony to sum, but there might be some conflict if it was full poly. Probably just better to have it full poly rather than 2 channel poly. If it was full poly then the extra in port would probably be a must from a UX stand point, unless someone would read a manual they may not realise that function exists on the module.

It is useful to have poly but with regards to params you canā€™t have different settings across all channels from controlled by 1 knob, no choice but to be the setting across all poly channels.

Regarding inputs, decided to keep it monophonic and with just one input.

The matter with 1/sampleTime was a bug in my logic. I connected the end signal to the stop input, then the pulse generator never times out. Manually pulling the signal down on stop works fine.

Where can I find the faster cos approximation?

pow has been implemented so far in dsp/approx.hpp

the dsp/approx.hpp seems only do pow(2,x), not pow(x,y)?

Edit: moved the code to https://github.com/Moaneschien/4rack Will have to do some work on the knobs now.

Thanks,

There is a simd cos approximation in /include/simd/sse_mathfun.h

I donā€™t know if there is a single float version.

Iā€™ve always used an interpolating lookup for all fancy functions. Itā€™s not as fast as some of the approximations, but it does work for any function, and is always fast enough. If you remember your high school math itā€™s easy to do.

just adding using simd::float_4; made it ~30% faster as well on ideling and all interpolations. Couldnā€™t see no difference wit simd::cos, then again I only have the engine cpu meter to observe,

Thanks, Iā€™ll investigate more,

Ingo

Cpu meters are good enough for gross things. If the trig function isnā€™t a critical path, then you donā€™t need to worry.

the difference youā€™ll notice is when youā€™re running multiple parallel operations. executing simd::cos(1.0f) essentially runs four parallel instances of cos(1.0f). if youā€™re doing math on four in parallel, youā€™ll see a much better savings:

float_4 v;
v[0] = 1.0f;
v[1] = 0.9f;
v[2] = 0.8f;
v[3] = 0.7f;
v = simd::cos(v);

note that accessing the individual float values is still a bit expensive, but if you load up float_4 values and do the math with float_4 as much as possible, youā€™ll see a significant gain: especially when executing 4 paths in parallel.

if you have the Synthetic FX (non-free), you can see this in action by using Curds or Whey, where up to 64 Biquad operations are occurring for every step (16x polyphony and a customized Biquad making 4 parallel operations).

it makes quite an improvement if done correctly.

Itā€™s not just simd. I replaced the scalar trig functions in the AS mixer with scalar lookup tables and it was 8 times faster! Every plugin Iā€™ve improved was significantly slowed down by trig functions. Befaco Even VCO, the old Fundamental VCO-1. sin and cos are just really slow, at the resolution mandated by IEEE math spec.

Insightfull,

Thanks.

1 Like

Just been poking in your code & docs a bit. Only calculating every 4 samples is a nice one Iā€™ll try.