As the title suggests, I’ve been trying to build a reverb. of course, first (and nearly only), I came across Schroeder. I then got to build first and second order allpass filters of sorta unknown quality, as well as a simpleish comb filter delay. Those look like this:
//single ringbuffer allpass(first order) w/ a 'lowest' cutoff of S samples before it loops back on itself in time
template <typename T = float, size_t S = 2048>
struct AllPass1st {
std::atomic<size_t> ind;
T Buf[S]; //main buffer
T blok[8]; //small block for smoothing when time is changed
std::atomic<size_t> blkhd;
T Foffset; //fractional sample offset (delay/frequency)
T Gcoeff; //gain
void reset() {
std::memset(Buf, 0.f, sizeof(T) * S);
ind = 0;
blkhd = 0;
}
AllPass1st() {
this->reset();
}
//run every once in a while(every 8 samples atm)
void setCoeffs(T freq, T gain, float samplerate) {
this->Gcoeff = gain;
this->Foffset = FreqToSampleF(freq, samplerate); //just converts Hz to samples
}
//run every sample
inline T process(T in) {
size_t idx = wraparound((this->ind % S) - (int)this->Foffset, S);
T del = this->Buf[idx];
this->blok[blkhd % 8] = del;
//lerp 4 output from 2 samples in the past to smooth time changes
T out = cubicLerp(this->blok, (blkhd - 2) % 8, this->Foffset, 8);
this->Buf[this->ind % S] = in - (out * this->Gcoeff); //negative feedback to current moment
out += (in * this->Gcoeff); //feed forward by gain
++this->ind;
++this->blkhd;
return out;
}
};
template <typename T = float>
//a sorta kinda biqaud allpass
struct AllPass2nd {
/*
wh = current sample out of 3 in buffer
X[n] = dry input buffer
Y[n] = final output
d = break frequency coefficient
c = bandwidth coefficient
a & b = polynomial coefficients
*/
int wh = 0;
T X[3] = { 0, 0, 0 };
T Y[3] = { 0, 0, 0 };
T d = 0.2;
T c = 0.2;
T a[3] = { 0, 0, 0 };
T b[3] = { 0, 0, 0 };
void reset() {
//std::memset(V, (T)0, sizeof(T) * 3);
std::memset(this->X, (T)0, sizeof(T) * 3);
std::memset(this->Y, (T)0, sizeof(T) * 3);
std::memset(this->a, (T)0, sizeof(T) * 3);
std::memset(this->b, (T)0, sizeof(T) * 3);
wh = 0;
}
AllPass2nd() {
this->reset();
}
//equations found at TheWolfSound
//cutoff and band given in Hz
void setCoeffs(T cutoff, T band, T samplerate) {
T tang = tanapp((_PI * band) / samplerate); //tanapp is andrewkay approx of tan
this->c = (tang - 1.f) / (tang + 1.f);
this->d = -cos((_PI * cutoff) / samplerate); //should use another approx here
this->a[0] = -this->c;
this->a[1] = this->d * (1.f - this->c);
this->a[2] = 1.f;
this->b[0] = 0;
this->b[1] = this->a[1];
this->b[2] = this->a[0];
}
//wraparound treats the array as a circular buffer, fixing negative number issues of simple %
//i should maybe instead structure this like vcvs biquad process
inline T process(T in) {
this->X[wh] = in;
this->Y[wh] = (this->a[0] * this->X[this->wh]) + (this->a[1] * this->X[wraparound(this->wh - 1, 3)]) + (this->a[2] * this->X[wraparound(this->wh - 2, 3)])
- (this->b[1] * this->Y[wraparound(this->wh - 1, 3)]) - (this->b[2] * this->Y[wraparound(this->wh - 2, 3)]);
auto hh = this->wh;
++this->wh;
this->wh %= 3;
return this->Y[hh];
}
};
//this is technically like a doubleringbuffer in a way
template <typename T = float, size_t S = 2048>
struct DelayComb {
std::atomic<size_t> wh;
T Buf[S * 2];
T blok[8];
std::atomic<size_t> blkhd;
void reset() {
std::memset(this->Buf, (T)0, sizeof(T) * S * 2);
this->wh = 0;
this->blkhd = 0;
std::memset(this->blok, (T)0, sizeof(T) * 8);
}
DelayComb() {
this->reset();
}
inline T process(T in, T offset, T FB) {
size_t idx = wraparound((this->wh % S) - (int)offset, S);
this->blok[this->blkhd % 8] = this->Buf[idx]; // do that same cubic lerping thing
T out = cubicLerp(this->blok, (this->blkhd - 2) % 8, offset, 8);
this->Buf[this->wh % S] = in - (out * FB); //negative feedback
++this->wh;
++this->blkhd;
return out;
}
};
I then route those like the pictures say, serial allpasses followed by parallel combs, with the inclusion of a mixing matrix shown in a picture in that video about designing the erbe verb (the first thing i tried was building that picture directly but it did NOT work well) inside the feedback loop for the comb delays. that ends up like this:
template <typename T = float, size_t S = 44100, size_t D = 2048>
struct SchroederLane {
Filter::AllPass1st<T, D> AP1[2];
Filter::AllPass2nd<T> AP2[2];
Filter::DelayComb<T, S> Combs[4];
T combFreq[4]; //expects samples
T baseAP[4] = { 1137, 1627, 2347, 3113 }; //in Hz, these have been init as many things, not the issue here
T baseComb[4] = { 687, 801, 1053, 1251 }; //in samples, same as above
T sprdmlt[4] = { 0.23, 0.47, 0.73, 0.97 }; //multiply global changes to combfreqs by these to spread from each other
T diffFeeds[4]; //feedback group to be matrix mixed
T globalDecay;
void clear() {
for (int c = 0; c < 2; ++c) {
AP1[c].reset();
AP2[c].reset();
}
for (int c = 0; c < 4; ++c) {
Combs[c].reset();
}
}
SchroederLane() {
this->clear();
}
//run when you update parameter values
//material and cohesion just change allpass parameters atm, but i hope to involve filters later
void setQualities(T room, T decay, T material, T cohesion, float samplerate) {
globalDecay = decay;
T matFreq = VoltToFreq(material, 0.f, 128.f);
T precut = matFreq / samplerate;
for (int q = 0; q < 2; ++q) {
T ap1Freq = baseAP[q] - (cohesion * sprdmlt[q]);
AP1[q].setCoeffs(ap1Freq, globalDecay * 0.2f + 0.1f, samplerate);
AP2[q].setCoeffs(baseAP[q + 2] + matFreq, cohesion, samplerate);
}
for (int c = 0; c < 4; ++c) {
combFreq[c] = baseComb[c] + (MeterToSample(room * 0.2f, samplerate) * sprdmlt[c]);
}
}
//dry input here, if you give this part feedback it may go crazy
T Smear(T in) {
//serial process allpasses
T ap1 = AP1[0].process(in);
T ap2 = AP2[0].process(ap1);
T ap3 = AP1[1].process(ap2);
T ap4 = AP2[1].process(ap3);
T sme = ap4;
return sme;
}
//give it the output from smear
T Diffuse(T in) {
//combs themselves get almost no feedback, as that is provided via the shuffling matrix
T combs[4] = { 0, 0, 0, 0 };
for (int p = 0; p < 4; ++p) {
combs[p] = Combs[p].process((in + diffFeeds[p]), combFreq[p], (T)0.01);
}
//shuffling also outputs a mixdown, how convenient
T diffMix = (ShuffleMix4Lane(combs, globalDecay, diffFeeds));
return diffMix;
}
};
//the mixing matrix looks like this
template <typename T = float>
T ShuffleMix4Lane(T* lanes, T FB, T* out = nullptr) {
//rescale decay/feedback, 0-0.3 doesnt let enough thru, over 0.5 is too much
T dec = lerp(0.35f, 0.5f, 0.f, 1.f, FB);
T p1 = lanes[0] - lanes[1];
T p2 = lanes[0] + lanes[1];
T p3 = lanes[2] - lanes[3];
T p4 = lanes[2] + lanes[3];
T n1 = (p1 - p3) * dec;
T n2 = (p1 + p3) * dec;
T n3 = (p2 - p4) * dec;
T n4 = (p2 + p4) * dec;
T mix = ((n1 + n2 + n3 + n4) * 0.5);
if (out) {
out[0] = n1;
out[1] = n3;
out[2] = n2;
out[3] = n4;
}
return mix;
}
these are objectively simple building blocks and a straightforward implementation, but just one of these reverberators is costing 3% CPU (for comparison all of Plateau runs at about 4%). There seems to be very little information that i can find that goes in depth on the guts of a reverb algorithm, and the plugins here whos code i can actually look at are written at a level that i simply cannot yet understand, and honestly dont sound much better than what this already puts out.