How would I change this C++ delay to work more like this Reaktor one?

DSP, Plugin and Host development discussion.
Post Reply New Topic
RELATED
PRODUCTS

Post

I am currently using a C++ audio delay effect from Maximilian. It works well, but it lacks some basic wet/dry and pre/post 0-1 crossfade variables which I would like.

Here is a model of what I want to do, from the Reaktor Library:

Image

Here is the C++ code for the Maximilian delay:

Code: Select all

    class maxiDelayline {
    	double frequency;
    	int phase;
    	double startphase;
    	double endphase;
    	double output;
    	double memory[88200];
    
    public:
    	maxiDelayline();
    	double dl(double input, int size, double feedback);
    	double dl(double input, int size, double feedback, int position);
    
    private:
    	//This used to be important for dealing with multichannel playback
    	float chandiv = 1;
    };
    
    double maxiDelayline::dl(double input, int size, double feedback) {
    	if (phase >= size) {
    		phase = 0;
    	}
    	output = memory[phase];
    	memory[phase] = (memory[phase] * feedback) + (input)*chandiv;
    	phase += 1;
    	return(output);
    
    }
How would I modify that C++ code to add 'wetDry and 'prePost' multipliers in the same way as the Reaktor example?

I am trying but I am not yet experienced enough with programming DSP to be able to figure it out. Thanks.

Post

I don't know Reaktor, but from looking at your screenshot it seems to do this:
  • There are separate Dry and Wet parameters with which the user can control the amount of each path in the final output signal.
  • There's a Pre/Post parameter that lets the user mix the output of the delay without feedback (0) with the output of the delay after the feedback has been added in (1).
Untested, but here goes...

Code: Select all

double maxiDelayline::dl (double input, int size, double feedback, double prePostMix, double dryMix, double wetMix)
{
  if (phase >= size) { phase = 0; }
  
  double pre = memory[phase];     /* Current step in delay buffer = output of Delay block */
  double post = pre * feedback;   /* Current delay output with feedback = ouput of Fbk block */
  
  /* Mix of both pre- and post-feedback delay paths = output of XFade (lin) block */
  double prePost = ((1.0 - prePostMix) * pre) + (prePostMix * post);
  
  double dry = dryMix * input;     /* Scaled dry input signal */
  double wet = wetMix * prePost;   /* Scaled wet delay signal */
  
  ++phase;
  
  /* Blend of (dry) & (pre & post) signal paths */
  return dry + wet;
}
Confucamus.

Post

Rockatansky wrote: Sat Nov 03, 2018 6:56 pm I don't know Reaktor, but from looking at your screenshot it seems to do this:
  • There are separate Dry and Wet parameters with which the user can control the amount of each path in the final output signal.
  • There's a Pre/Post parameter that lets the user mix the output of the delay without feedback (0) with the output of the delay after the feedback has been added in (1).
Untested, but here goes...
Thanks Max! That was very helpful. It didn't actually put out any delay just as you wrote it for a reason I think I can explain in a second, but I combined what you wrote with the existing format of how the loop was written and it does output now.

Code: Select all

double maxiDelayline::dl(double input, int size, double feedback) {
	if (phase >= size) {
		phase = 0;
	}
	
	preOut = memory[phase];     // Current step in delay buffer = output of Delay block 
	postOut = preOut * feedback;   // Current delay output with feedback = ouput of Fbk block 
	prePostOut = ((1.0 - prePostMixer) * preOut) + (prePostMixer * postOut);   // Mix of both pre- and post-feedback delay paths = output of XFade (lin) block 

	memory[phase] = postOut + input; // feedback back into the start of the delay unit for next cycle

	dryOut = (1.0 - dryWetMixer) * input;     //Scaled dry input signal
	wetOut = dryWetMixer * prePostOut;   //Scaled wet delay signal 

	output = dryOut + wetOut; // Blend of (dry) & (pre & post) signal paths 
	
	phase += 1;
	return(output);
	
}
It's kind of funny but I think it needs to output memory[phase] on the first cycle, then before the end of each next cycle, something must be put back into memory[phase] to get ready for the next cycle that follows.

Seems to be working normally now. Please correct me though if you can see I got something wrong.
Last edited by mikejm on Sun Nov 04, 2018 3:24 am, edited 1 time in total.

Post

Simple follow up question - does the amount of memory set by the delay matter and how so? Ie. By default it's designed for memory of 88200. I thought maybe this would mean it can only do 2 seconds of delay. But it's calculating the delay each sample, ie. so at most it needs 2 samples worth of memory, no? I tried upping that to 192000. I notice no difference.

Can anyone clarify what the significance of the memset here is and what it should be set to ideally? And whether that should be sample rate dependent?

Thanks. I am learning and this is all helpful

Edit: Oh I see. the memset dictates the greatest delay possible. If I have it set to 96000 on 48 khz sample rate, I can get away with 2 seconds delay. But if I try for 3 or 4 seconds, the ensemble crashes. Duly noted.
Last edited by mikejm on Sun Nov 04, 2018 4:55 am, edited 1 time in total.

Post

mikejm wrote: Sun Nov 04, 2018 2:58 am It didn't actually put out any delay
Haha, oh wow... what an oversight... obviously, the calculated output needs to be written into the delay buffer, otherwise there's nothing to mix into the dry sound. :dog:

In the original code, this happens here:

Code: Select all

output = memory[phase]; /* Copies the current buffer value into the output variable */
memory[phase] = (memory[phase] * feedback) + (input)*chandiv; /* Processes and writes back to current buffer value */
In my code, the processed value was stored in a variable (prePost), but never written back into the buffer at the current position (memory[phase]). Yeah, couldn't have worked without that... told ya, "untested". :)
Confucamus.

Post

Rockatansky wrote: Sun Nov 04, 2018 4:50 am
mikejm wrote: Sun Nov 04, 2018 2:58 am It didn't actually put out any delay
Haha, oh wow... what an oversight... obviously, the calculated output needs to be written into the delay buffer, otherwise there's nothing to mix into the dry sound. :dog:

In the original code, this happens here:

Code: Select all

output = memory[phase]; /* Copies the current buffer value into the output variable */
memory[phase] = (memory[phase] * feedback) + (input)*chandiv; /* Processes and writes back to current buffer value */
In my code, the processed value was stored in a variable (prePost), but never written back into the buffer at the current position (memory[phase]). Yeah, couldn't have worked without that... told ya, "untested". :)
lol. No worries man. Thanks. Couldn't have figured it out at all without everything you posted. Didn't know where to start or what was safe to change. Appreciated.

Post

mikejm wrote: Sun Nov 04, 2018 3:23 am Can anyone clarify what the significance of the memset here is and what it should be set to ideally? And whether that should be sample rate dependent?
Looks to me like that code just assumes a samplerate of 44.1 kHz and a maximum delay time of 2 seconds, hence 88200 samples buffer.

A common way to move through a delay buffer would be to set the size of the array depending on the amount of samples required, e.g. 22050 at 44.1 samplerate if the delay time should be 500ms long, and then for every input sample shift that array by one step, always inserting the newest value at the first position. Also, when changing the delay time, the buffer array would probably have to be moved to another memory location in order to be extended. For an array with nearly 90000 memory addresses, even if consecutive, that can become quite slow.

Code: Select all

Empty:    [0][_][_][_][_][_][_][_] /* all zeros */
Sample 1: [1][0][_][_][_][_][_][_] /* shift/cycle buffer +1 right, write into index 0 */
Sample 2: [2][1][0][_][_][_][_][_] /* shift/cycle buffer +1 right, write into index 0 */
Sample 3: [3][2][1][0][_][_][_][_] /* shift/cycle buffer +1 right, write into index 0 */
etc.
So it seems what that Maximilian code does is, it just reserves a fixed amount of memory, and defines the delay length on the go by the int size argument in the processing method call. The size is just the number of delay buffer samples to fill/use, i.e. the length of the resulting delay. But it shouldn't be larger than the amount of memory reserved, or you'll run into trouble. (So ensure size < length of memory[])

The phase variable is like an iterator, a step counter, a "this is where I'm currently at" offset. It gets increased +1 every processing step, and if it becomes larger than the maximum size of the delay line (memory[]), it just gets reset to 0. So it always works with the buffered sample that's the correct delay time away.

In my mind, this is a bit like moving your feet in order to walk on a street, rather than pulling the street under your feet in order to get the feet to walk.

Code: Select all

Empty:    [0][_][_][_][_][_][_][_] /* all zeros */
Sample 1: [1][0][_][_][_][_][_][_] /* write into 0+0 */
Sample 2: [1][2][0][_][_][_][_][_] /* write into 0+1 */
Sample 3: [1][2][3][0][_][_][_][_] /* write into 0+2 */
etc.
As I understand it, yes, that code should behave differently at variable samplerates, since 88200 samples are a different period of time at 44.1 kHz and at 96 kHz. You could adjust the int size value somewhere according to the samplerate, but that would drastically lower the maximum delay time at lower samplerates, or it would require a larger memory[] footprint to result in the same maximum delay time at all sample rates.

For example, to support 2 seconds of delay at samplerates up to 192 kHz, the memory[] would have to hold 384.000 samples. Per channel. If the project only runs at 44.1 kHz, that memory would still have to be reserved, but 302.000 memory addresses would remain unused and just waste. And now say you want to have a filter in the delay and/or feedback path, so you want to oversample... :)

Sure, you could dynamically reallocate the memory[] every time the samplerate changes, at least with an std::vector you could, but that could lead to ugly pops from the buffer emptying suddenly. (Which you would have to do, to assure that no un-zeroed old values are still stored at the new memory location, which could create irrational signals.)
Confucamus.

Post

Rockatansky wrote: Sun Nov 04, 2018 5:48 am
mikejm wrote: Sun Nov 04, 2018 3:23 am Can anyone clarify what the significance of the memset here is and what it should be set to ideally? And whether that should be sample rate dependent?
Looks to me like that code just assumes a samplerate of 44.1 kHz and a maximum delay time of 2 seconds, hence 88200 samples buffer.

A common way to move through a delay buffer would be to set the size of the array depending on the amount of samples required, e.g. 22050 at 44.1 samplerate if the delay time should be 500ms long, and then for every input sample shift that array by one step, always inserting the newest value at the first position. Also, when changing the delay time, the buffer array would probably have to be moved to another memory location in order to be extended. For an array with nearly 90000 memory addresses, even if consecutive, that can become quite slow.

Code: Select all

Empty:    [0][_][_][_][_][_][_][_] /* all zeros */
Sample 1: [1][0][_][_][_][_][_][_] /* shift/cycle buffer +1 right, write into index 0 */
Sample 2: [2][1][0][_][_][_][_][_] /* shift/cycle buffer +1 right, write into index 0 */
Sample 3: [3][2][1][0][_][_][_][_] /* shift/cycle buffer +1 right, write into index 0 */
etc.
So it seems what that Maximilian code does is, it just reserves a fixed amount of memory, and defines the delay length on the go by the int size argument in the processing method call. The size is just the number of delay buffer samples to fill/use, i.e. the length of the resulting delay. But it shouldn't be larger than the amount of memory reserved, or you'll run into trouble. (So ensure size < length of memory[])

The phase variable is like an iterator, a step counter, a "this is where I'm currently at" offset. It gets increased +1 every processing step, and if it becomes larger than the maximum size of the delay line (memory[]), it just gets reset to 0. So it always works with the buffered sample that's the correct delay time away.

In my mind, this is a bit like moving your feet in order to walk on a street, rather than pulling the street under your feet in order to get the feet to walk.

Code: Select all

Empty:    [0][_][_][_][_][_][_][_] /* all zeros */
Sample 1: [1][0][_][_][_][_][_][_] /* write into 0+0 */
Sample 2: [1][2][0][_][_][_][_][_] /* write into 0+1 */
Sample 3: [1][2][3][0][_][_][_][_] /* write into 0+2 */
etc.
As I understand it, yes, that code should behave differently at variable samplerates, since 88200 samples are a different period of time at 44.1 kHz and at 96 kHz. You could adjust the int size value somewhere according to the samplerate, but that would drastically lower the maximum delay time at lower samplerates, or it would require a larger memory[] footprint to result in the same maximum delay time at all sample rates.

For example, to support 2 seconds of delay at samplerates up to 192 kHz, the memory[] would have to hold 384.000 samples. Per channel. If the project only runs at 44.1 kHz, that memory would still have to be reserved, but 302.000 memory addresses would remain unused and just waste. And now say you want to have a filter in the delay and/or feedback path, so you want to oversample... :)

Sure, you could dynamically reallocate the memory[] every time the samplerate changes, at least with an std::vector you could, but that could lead to ugly pops from the buffer emptying suddenly. (Which you would have to do, to assure that no un-zeroed old values are still stored at the new memory location, which could create irrational signals.)
Yeah that makes sense. I already replaced the input from "size" to "seconds":

Code: Select all

double maxiDelayline::delay(double input, double seconds, double feedback, double prePostMixer, double dryWetMixer) {

	size = seconds * sampleRate; 

	if (phase >= size) { // i believe this means if more time has passed than the time for a delay, then it resets
		phase = 0;
	}
But yes, the amount of time that provides is going to be still dependent on sample rate. This is mostly for my own use so I can just accommodate my intended sample rates and leave it at that. I'd like a more ideal solution though.

I tried setting the memory amount as a variable of 2 * sampleRate (ie. 2 seconds), but it won't let me:
settime.PNG
Says I can't use the sampleRate variable as it's not a true constant/static. I suppose this is what you meant about something to do with vectors?

I've figured out the best following protection:

Code: Select all

double maxiDelayline::delay(double input, double seconds, double feedback, double prePostMixer, double dryWetMixer) {

	size = seconds * sampleRate; 
	size = jmin(size, 99600.0);

	if (phase >= size) { // i believe this means if more time has passed than the time for a delay, then it resets
		phase = 0;
	}
I'm using a Juce function jmin() to force it to not exceed the size it actually has available so even if the sample rate changes or I have my knobs set wrong, I won't crash the project. It will just max out on its delay.

I'm more or less satisfied with this. But I do want to make it as efficient and automatically scalable as possible as I will be implementing hundreds of simplified versions of them at a time during modal synthesis. Like you said no point taking up more memory by using a bigger buffer than needed as well.

Any thoughts on solutions or how other delays tackle this that I could steal/adapt?

No worries if not. Like I said, I'm just happy enough I've got it working and it seems pretty CPU cheap to run.
You do not have the required permissions to view the files attached to this post.

Post

The double sampleRate is not a constant because it's unknown/unset at the time of your assignment calculation, so you can't use it to initialize the memorySize if it doesn't have a value.

I've given up manual memory management, so I would just use a vector.
Unoptimized and incomplete pseudo code:

Code: Select all

double maxiDelayline::delay (...)
{
  if (vector.size() != samplerate * seconds * oversamplingX)
  {
    vector.resize(samplerate * seconds * oversamplingX);
  }
  ...
}
I don't know whether or not the vector.resize() method does its own optimization, like "if new size == old size then don't reallocate anything". If it does, one could probably get rid of that conditional if block entirely and just call resize() all the time.

The samplerate doesn't usually change every couple of samples, and if the delay time is as short as 2 seconds, I think it's OK to have a short glitch when switching (from buffer at old samplerate outputting into host at new samplerate). So I don't think it's necessary to do that conditional check per sample, meaning you could put it into a separate function and call it from the prepareToPlay (or whatever it's called) when that is triggered, e.g. on host playback start or samplerate change, or when the delay time changes. Alternatively, if that's too unreliable (because of potential host quirks), you could do it once at the beginning of each processBlock(), that should lead to even shorter glitches, since sample blocks will usually contain only a fraction of a second worth of samples.

So the vector size is updated whenever the host samplerate or delay time change. And you can always figure out the current length of the delay buffer by calling the vector's .size() method, meaning you no longer have to pass the int size argument into the maxiDelayline::delay() call.
Confucamus.

Post

Thanks Max. I've got someone giving me a bit of C++/Juce tutoring once a week and I'm gonna tackle switching it to a vector like you're suggesting with them next week.

Post Reply

Return to “DSP and Plugin Development”