Skip to content

Commit a8114f4

Browse files
Camilo Landauclaude
authored andcommitted
Adds Necessary Delay: BBD delay into spring reverb
Bucket-brigade style delay with vibrato modulation and progressive darkening, feeding into a Schroeder spring reverb. Infinite hold via footswitch. Dry signal never touches the spring. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ba83bde commit a8114f4

3 files changed

Lines changed: 295 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ A collection of custom effects for the [Polyend Endless](https://polyend.com/end
88
|--------|-------------|
99
| [Tremvolope](effects/tremvolope/) | Envelope-controlled stereo tremolo. Dynamics shape the rate, depth, and stereo spread — quiet signals bloom into swirling modulation, or loud attacks drive aggressive throb. |
1010
| [Preamp](effects/preamp/) | Transparent preamp and clean boost. Soft saturation adds warmth and compression without coloring your tone. Tilt EQ goes from round to airy in a single knob. |
11+
| [Necessary Delay](effects/necessary-delay/) | BBD-style delay into spring reverb. Vibrato-wobbled, progressively darkening repeats dissolve into dripping, metallic ambience. Infinite hold for drones and layering. |
1112

1213
## How to Use
1314

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
#include "Patch.h"
2+
3+
#include <cmath>
4+
5+
// BBD-style delay into spring reverb.
6+
// Dry signal is never reverbed — only the delay trails hit the spring.
7+
//
8+
// Left knob: Time (30 ms to 3000 ms)
9+
// Mid knob: Mix (dry/wet)
10+
// Right knob: Repeats (feedback amount)
11+
// Footswitch press: bypass
12+
// Footswitch hold: toggle infinite repeats (LED turns red)
13+
14+
constexpr float kPi = 3.14159265f;
15+
constexpr float kTwoPi = 2.0f * kPi;
16+
17+
// Delay
18+
constexpr int kMaxDelaySamples = 144000; // 3000 ms at 48 kHz
19+
constexpr int kMinDelaySamples = 1440; // 30 ms
20+
21+
// BBD vibrato modulation
22+
constexpr float kVibratoRate = 2.5f; // Hz
23+
constexpr float kVibratoDepth = 40.0f; // samples (~0.8 ms)
24+
constexpr float kBbdDamping = 0.35f; // LP coefficient per repeat (~3 kHz)
25+
26+
// Spring reverb — allpass diffusers (series)
27+
constexpr int kAllpassLengths[] = {556, 441, 341, 225};
28+
constexpr int kNumAllpasses = 4;
29+
constexpr float kAllpassCoeff = 0.5f;
30+
31+
// Spring reverb — comb filters (parallel)
32+
constexpr int kCombLengths[] = {1687, 1601, 2053, 2251};
33+
constexpr int kNumCombs = 4;
34+
constexpr float kCombDecay = 0.7f;
35+
constexpr float kCombDamping = 0.3f;
36+
37+
class PatchImpl : public Patch
38+
{
39+
public:
40+
void init() override
41+
{
42+
delayWritePos = 0;
43+
lfoPhase = 0.0f;
44+
bbdLpState = 0.0f;
45+
46+
for (int i = 0; i < kNumCombs; ++i)
47+
{
48+
combPos[i] = 0;
49+
combLpState[i] = 0.0f;
50+
}
51+
for (int i = 0; i < kNumAllpasses; ++i)
52+
allpassPos[i] = 0;
53+
}
54+
55+
void setWorkingBuffer(std::span<float, kWorkingBufferSize> buffer) override
56+
{
57+
// Partition the working buffer into delay line + reverb buffers
58+
delayLine = buffer.data();
59+
int offset = kMaxDelaySamples;
60+
61+
for (int i = 0; i < kNumCombs; ++i)
62+
{
63+
combLines[i] = buffer.data() + offset;
64+
offset += kCombLengths[i];
65+
}
66+
for (int i = 0; i < kNumAllpasses; ++i)
67+
{
68+
allpassLines[i] = buffer.data() + offset;
69+
offset += kAllpassLengths[i];
70+
}
71+
72+
// Zero all buffers
73+
for (int i = 0; i < offset; ++i)
74+
buffer[i] = 0.0f;
75+
}
76+
77+
void processAudio(std::span<float> audioBufferLeft,
78+
std::span<float> audioBufferRight) override
79+
{
80+
if (!active)
81+
return;
82+
83+
const int delaySamples = kMinDelaySamples +
84+
static_cast<int>(timeParam * static_cast<float>(kMaxDelaySamples - kMinDelaySamples));
85+
const float feedback = infiniteHold ? 0.99f : repeatsParam * 0.9f;
86+
const float wet = mixParam;
87+
const float dry = 1.0f - mixParam;
88+
89+
for (auto leftIt = audioBufferLeft.begin(), rightIt = audioBufferRight.begin();
90+
leftIt != audioBufferLeft.end();
91+
++leftIt, ++rightIt)
92+
{
93+
float input = (*leftIt + *rightIt) * 0.5f;
94+
95+
// --- Vibrato LFO ---
96+
lfoPhase += kVibratoRate / static_cast<float>(kSampleRate);
97+
if (lfoPhase >= 1.0f)
98+
lfoPhase -= 1.0f;
99+
float mod = std::sin(kTwoPi * lfoPhase) * kVibratoDepth;
100+
101+
// --- Read from delay with vibrato + linear interpolation ---
102+
float readPosF = static_cast<float>(delayWritePos)
103+
- static_cast<float>(delaySamples) + mod;
104+
while (readPosF < 0.0f)
105+
readPosF += static_cast<float>(kMaxDelaySamples);
106+
107+
int readIdx = static_cast<int>(readPosF);
108+
float frac = readPosF - static_cast<float>(readIdx);
109+
int idx0 = readIdx % kMaxDelaySamples;
110+
int idx1 = (readIdx + 1) % kMaxDelaySamples;
111+
112+
float delayed = delayLine[idx0] * (1.0f - frac) + delayLine[idx1] * frac;
113+
114+
// --- BBD low-pass (darkens with each repeat) ---
115+
bbdLpState += kBbdDamping * (delayed - bbdLpState);
116+
delayed = bbdLpState;
117+
118+
// --- Write to delay (input + feedback with soft limit) ---
119+
delayLine[delayWritePos] = std::tanh(input + delayed * feedback);
120+
delayWritePos = (delayWritePos + 1) % kMaxDelaySamples;
121+
122+
// --- Spring reverb on delay output only ---
123+
float wetSignal = processSpring(delayed);
124+
125+
// --- Mix (dry signal never hits the spring) ---
126+
*leftIt = *leftIt * dry + wetSignal * wet;
127+
*rightIt = *rightIt * dry + wetSignal * wet;
128+
}
129+
}
130+
131+
ParameterMetadata getParameterMetadata(int paramIdx) override
132+
{
133+
switch (paramIdx)
134+
{
135+
case 0: return { 0.0f, 1.0f, 0.3f }; // Time
136+
case 1: return { 0.0f, 1.0f, 0.5f }; // Mix
137+
case 2: return { 0.0f, 1.0f, 0.4f }; // Repeats
138+
default: return { 0.0f, 1.0f, 0.5f };
139+
}
140+
}
141+
142+
Color getStateLedColor() override
143+
{
144+
if (!active)
145+
return Color::kDimBlue;
146+
return infiniteHold ? Color::kRed : Color::kLightBlueColor;
147+
}
148+
149+
void setParamValue(int idx, float value) override
150+
{
151+
switch (idx)
152+
{
153+
case 0: timeParam = value; break;
154+
case 1: mixParam = value; break;
155+
case 2: repeatsParam = value; break;
156+
}
157+
}
158+
159+
void handleAction(int actionIdx) override
160+
{
161+
if (actionIdx == 0)
162+
active = !active;
163+
else if (actionIdx == 1)
164+
infiniteHold = !infiniteHold;
165+
}
166+
167+
private:
168+
float processSpring(float input)
169+
{
170+
// Allpass diffusion chain (spring dispersion)
171+
float signal = input;
172+
for (int i = 0; i < kNumAllpasses; ++i)
173+
{
174+
float* buf = allpassLines[i];
175+
int len = kAllpassLengths[i];
176+
int& pos = allpassPos[i];
177+
178+
float delayed = buf[pos];
179+
float temp = signal + kAllpassCoeff * delayed;
180+
buf[pos] = temp;
181+
signal = delayed - kAllpassCoeff * temp;
182+
pos = (pos + 1) % len;
183+
}
184+
185+
// Parallel comb filters with damped feedback (resonant tail)
186+
float combSum = 0.0f;
187+
for (int i = 0; i < kNumCombs; ++i)
188+
{
189+
float* buf = combLines[i];
190+
int len = kCombLengths[i];
191+
int& pos = combPos[i];
192+
float& lp = combLpState[i];
193+
194+
float delayed = buf[pos];
195+
lp += kCombDamping * (delayed - lp);
196+
buf[pos] = signal + kCombDecay * lp;
197+
combSum += delayed;
198+
pos = (pos + 1) % len;
199+
}
200+
201+
return combSum * 0.25f;
202+
}
203+
204+
// Working buffer partitions
205+
float* delayLine = nullptr;
206+
float* combLines[kNumCombs] = {};
207+
float* allpassLines[kNumAllpasses] = {};
208+
209+
// Delay state
210+
int delayWritePos = 0;
211+
float lfoPhase = 0.0f;
212+
float bbdLpState = 0.0f;
213+
214+
// Spring reverb state
215+
int combPos[kNumCombs] = {};
216+
float combLpState[kNumCombs] = {};
217+
int allpassPos[kNumAllpasses] = {};
218+
219+
// Parameters
220+
float timeParam = 0.3f;
221+
float mixParam = 0.5f;
222+
float repeatsParam = 0.4f;
223+
224+
// State
225+
bool active = true;
226+
bool infiniteHold = false;
227+
};
228+
229+
static PatchImpl patch;
230+
231+
Patch* Patch::getInstance()
232+
{
233+
return &patch;
234+
}

effects/necessary-delay/README.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# Necessary Delay
2+
3+
**A bucket-brigade delay into spring reverb for the Polyend Endless.**
4+
5+
Your dry signal stays clean. The repeats don't. Necessary Delay runs a BBD-style analog delay — complete with vibrato wobble and progressive high-frequency rolloff — into a spring reverb that only the delay trails ever touch. The result is a delay that starts articulate and dissolves into dripping, metallic ambience as the repeats fade.
6+
7+
The vibrato modulation gives each repeat a slight pitch drift, the way a real bucket-brigade chip would. The one-pole filter in the feedback path darkens every pass, so early repeats are clear and present while later ones smear into warm fog. Then the spring catches all of that and adds its own resonant, boingy tail — diffused through allpass filters for that characteristic drip, sustained by damped comb filters for the decay.
8+
9+
Hold the footswitch and the repeats freeze into infinite sustain, layering new playing on top of a living, breathing loop.
10+
11+
## Controls
12+
13+
| Knob | Parameter | Description |
14+
|------|-----------|-------------|
15+
| Left | **Time** | Delay time, 30 ms to 3 seconds. Short for slapback, long for sprawling ambient repeats. |
16+
| Mid | **Mix** | Dry/wet blend. Full CCW is dry, full CW is wet. The dry signal is always clean — the spring reverb only colors the delay trails. |
17+
| Right | **Repeats** | Feedback amount. Low for a single echo, high for cascading, darkening trails that wash into the spring. |
18+
19+
**Footswitch press**: Bypass on/off (LED light blue = active, dim blue = bypassed).
20+
21+
**Footswitch hold**: Toggle infinite repeats. The delay loop sustains indefinitely — play over it, layer textures, build drones. LED turns red so you know it's locked.
22+
23+
## How It Works
24+
25+
The signal chain is intentionally asymmetric:
26+
27+
```
28+
Dry signal ──────────────────────────────────────→ output
29+
30+
Input → BBD delay → vibrato mod → spring reverb → wet mix
31+
↑ |
32+
└── feedback ──┘
33+
(LP filtered, soft limited)
34+
```
35+
36+
- **BBD delay**: Mono delay line with linear-interpolated readout. A 2.5 Hz sine LFO wobbles the read position by ~0.8 ms for that analog pitch drift.
37+
- **Feedback path**: Each repeat passes through a one-pole low-pass (~3 kHz) that progressively darkens the sound. A `tanh` soft limiter prevents runaway, especially during infinite hold.
38+
- **Spring reverb**: 4 cascaded Schroeder allpass filters diffuse the signal (modeling spring dispersion), then 4 parallel comb filters with damped feedback create the resonant tail. Only the delay output feeds the spring — your dry tone stays untouched.
39+
40+
## Sweet Spots
41+
42+
- **Analog slapback**: Time at 8 o'clock (~100 ms), Repeats at 9 o'clock, Mix at 10 o'clock. Quick, warm slap with a hint of spring splash.
43+
- **Tape-echo wash**: Time at noon (~1.5 s), Repeats at 1 o'clock, Mix at noon. Repeats darken and dissolve into reverb. Classic ambient delay.
44+
- **Drip machine**: Time at 10 o'clock (~500 ms), Repeats at 2 o'clock, Mix at 1 o'clock. Spring-drenched repeats that cascade and drip.
45+
- **Infinite drone**: Any time setting, hold footswitch. Layer notes and chords over a frozen, evolving delay loop. The spring reverb smears everything into a lush pad.
46+
- **Lo-fi ghost**: Time at 3 o'clock (~2.5 s), Repeats high, Mix at 9 o'clock. Long, dark, barely-there repeats that haunt the background.
47+
48+
## Build
49+
50+
Requires the [GNU Arm Embedded Toolchain](https://developer.arm.com/Tools%20and%20Software/GNU%20Toolchain).
51+
52+
```
53+
make
54+
```
55+
56+
Copy the resulting `.endl` file from `build/` to your Endless via USB.
57+
58+
## License
59+
60+
MIT — see [LICENSE](../../LICENSE).

0 commit comments

Comments
 (0)