But I figured, if we use 2-sample PolyBLEPs then buffering becomes a trivial one sample delay thing (that we still do here, but no ring-buffers or anything), and the BLEP data is no longer an issue. The exact same concepts works, we just need less "dirty details" that make writing tutorials more painful. I'll probably give up and provide the "dirty details" in a later post anyway, but .. yeah ..
This should be beginner friendly, almost "your first oscillator" level.
However: this is still a "real" BLEP oscillator: you just replace the "one sample delay" that's held in two variables, with a proper ring-buffer, and you can use longer BLEPs without any additional trouble.. in fact it's a simplified and cleaned up version of code that uses polyphase FIR BLEPs.
For this example.. I support constant frequency, and constant mix between saw/pulse, but really these could come from buffer just as well. The PWM does come from a buffer, since I added a fix for more robust PWM, so it kinda made sense. The "broken" version is left as an #ifdef just so you can observe how it appears to works about 99% of time, but then occasionally with certain types of modulation there can be glitches.
Anyway, first we need the BLEPs, and here we use two-sample polynomial filter.
These appear to be what are most commonly suggested:
Code: Select all
// The first sample BLEP
static inline float poly3blep0(float t)
{
// these are just sanity checks
// correct code doesn't need them
if(t < 0) return 0;
if(t > 1) return 1;
float t2 = t*t;
return t * t2 - 0.5f * t2 * t2;
}
// And second sample as wrapper, optimize if you want.
static inline float poly3blep1(float t)
{
return -poly3blep0(1-t);
}
How I derived it (if you actually care): Build C1 continuous impulse from two cubic "smooth steps" which on their own looks like p(t)=3*t^2-2*t^3 and map t : [0,1] -> [0,1] in a smooth way. Then integrate the poly to P(t)=t^3-0.5*t^4 which gives the "BLEP for the first sample" and then the second sample is 1-P(1-t) for symmetry, except we remove the trivial step, so just -P(1-t), which makes sense because.. well, it works out that way in math, it works and doesn't sound totally horrid.
Now we can go on with the basic oscillator, which needs a bit of state. So I wrapped it inside the minimum struct that makes it possible to test this thing, then copy-pasted it here.
I wrote the code for this (it's fresh), but I tested it and fixed the bugs.
It's only the COMMENTS that make it long, and those are rest of this tutorial.
I suggest copying to an editor that does syntax highlight.
Code: Select all
struct Oscillator {
// these are internal state, not parameters
float phase;
float blepDelay;
float widthDelay;
int pulseStage;
// convenience constructor
Oscillator() { reset(); }
// the initialization code
void reset()
{
// set current phase to 0
phase = 0;
// blep delay "buffer" to zero
blepDelay = 0;
// previous pulseWidth to any valid value
widthDelay = 0.5f;
// only 2 stages here, set to first
pulseStage = 0;
}
// the freq should be normalized realFreq/samplerate
// the mix parameter is 0 for saw, 1 for pulse
// the pwm is audio-rate buffer, since that needs treatment
// rest are pretty trivial to turn into audio-rate sources
void render(float freq, float mix, float * pwm,
float * bufOut, int nsamples)
{
for(int i = 0; i < nsamples; ++i)
{
// the BLEP latency is 1 sample, so first
// take the delayed part from previous sample
float out = blepDelay;
// then reset the delay so we can build into it
blepDelay = 0;
// then proceed like a trivial oscillator
phase += freq;
// load and sanity-check the new PWM
// ugly things would happen outside range
float pulseWidth = pwm[i];
if(pulseWidth > 1) pulseWidth = 1;
if(pulseWidth < 0) pulseWidth = 0;
// Then replace the reset logic: loop until we
// can't find the next discontinuity, so during
// one sample we can process any number of them!
while(true)
{
// Now in order of the stages of the wave-form
// check for discontinuity during this sample.
// First is the "pulse-width" transition.
if(pulseStage == 0)
{
// if we didn't hit the pulse-width yet
// break out of the while(true) loop
if(phase < pulseWidth) break;
#ifdef NO_PWM_FIX
// otherwise solve transition: when during
// this sample did we hit the pw-border..
// t = (1-x) from: phase + (x-1)*freq = pw
float t = (phase - pulseWidth) / freq;
#else
// the above version is fine when pw is constant
// and it's what we use for the reset part, but
// for pw modulation, t could end up outside [0,1]
// and that will sound pretty ugly, so use lerp:
// phase + (x-1)*freq = (1-x)*pwOld + x*pw
// and again t = (1 - x), and hopefully..
float t = (phase - pulseWidth)
/ (widthDelay - pulseWidth + freq);
#endif
// so then scale by pulse mix
// and add the first sample to output
out += mix * poly3blep0(t);
// and second sample to delay
blepDelay += mix * poly3blep1(t);
// and then we proceed to next stage
pulseStage = 1;
}
// then whether or not we did transition, if stage
// is at this point 1, we process the final reset
if(pulseStage == 1)
{
// not ready to reset yet?
if(phase < 1) break;
// otherwise same as the pw, except threshold 1
float t = (phase - 1) / freq;
// and negative transition.. normally you would
// calculate step-sizes for all mixed waves, but
// here both saw and pulse go from 1 to 0.. so
// it's always the same transition size!
out -= poly3blep0(t);
blepDelay -= poly3blep1(t);
// and then we do a reset (just one!)
pulseStage = 0;
phase -= 1;
}
// and if we are here, then there are possibly
// more transitions to process, so keep going
}
// When the loop breaks (and it'll always break)
// we have collected all the various BLEPs into our
// output and delay buffer, so add the trivial wave
// into the buffer, so it's properly delayed
//
// note: using pulseStage instead of pw-comparison
// avoids inconsistencies from numerical inaccuracy
blepDelay += (1-mix)*phase
+ mix*(pulseStage ? 1.f : 0.f);
// and output is just what we collected, but
// let's make it range -1 to 1 instead
bufOut[i] += (2*out - 1);
// and store pulse-width delay for the lerp
widthDelay = pulseWidth;
}
}
}; // struct Oscillator
The reason it's structure this way, is that it makes it a lot easier to understand and alot easier to edit and extend. It's also the structure that actually works, when you want to do something fancier than basic saws and pulse.
And the exact same render-method, with comments removed.. so it's easier to see the structure:
Code: Select all
void render(float freq, float mix, float * pwm,
float * bufOut, int nsamples)
{
for(int i = 0; i < nsamples; ++i)
{
float out = blepDelay;
blepDelay = 0;
phase += freq;
float pulseWidth = pwm[i];
if(pulseWidth > 1) pulseWidth = 1;
if(pulseWidth < 0) pulseWidth = 0;
while(true)
{
if(pulseStage == 0)
{
if(phase < pulseWidth) break;
#ifdef NO_PWM_FIX
float t = (phase - pulseWidth) / freq;
#else
float t = (phase - pulseWidth)
/ (widthDelay - pulseWidth + freq);
#endif
out += mix * poly3blep0(t);
blepDelay += mix * poly3blep1(t);
pulseStage = 1;
}
if(pulseStage == 1)
{
if(phase < 1) break;
float t = (phase - 1) / freq;
out -= poly3blep0(t);
blepDelay -= poly3blep1(t);
pulseStage = 0;
phase -= 1;
}
}
blepDelay += (1-mix)*phase
+ mix*(pulseStage ? 1.f : 0.f);
bufOut[i] += (2*out - 1);
widthDelay = pulseWidth;
}
}
Note that most of the time the loop does nothing: a few integer comparisons, one floating point comparison, and then break out. We only do extra work when there are discontinuities on the current sample, otherwise it's identical to naive!
If you really want to, you can use this with the same terms that I used for the filter that got more popular that it should... but really it's more of a clean starting point for something fancier (just like that filter was supposed to be).