If you want a FeedbackDelay to read its line exactly N frames back, you
can't always get it at 44.1 or 22.05 kHz. The node takes the delay in
milliseconds and turns it back into frames in float32, and for about one
whole frame in eight at 44.1 kHz no float32 millisecond value lands on N.
The read then sits one float32 step off the frame, and the two-tap read
smears every pass of the loop onto the neighbouring frame. The repeats
darken, slowly, at Times that were meant to be lossless.
The cause. audiodsp_feedback_delay_configure computes
frames = value * rate / 1000.0f (src/shared/audiodsp_feedback_delay.c:148
at v0.6.2, 1c89b03) with value the handed delay_ms as a float. For
N = 16 457 at 44.1 kHz, say, no float32 delay_ms within 64 ulps of
N · 1000 / 44 100 makes that product exactly 16 457; the nearest lands
1/512 of a frame short. The error is one float32 step of N: up to 2⁻⁹ of a
frame at 44.1 kHz and 2⁻¹⁰ at 22.05 kHz over the 20 ms – 1 s range. At
48 kHz every whole frame from 20 ms to 1 s lands.
Measured. Whole frames from 20 ms to 1 s with no float32 delay_ms
within 64 ulps that lands: 5 554 of 43 219 at 44.1 kHz, 2 785 of 21 610 at
22.05 kHz, 0 of 47 041 at 48 kHz (probe
a probe that walks every knob position and every whole frame,
output beside it). Through PingPongDelay, whose Time knob is 20–1 000 ms
log on the 0–127 grid, that is 25 of 128 positions at 44.1 kHz and 20 at
22.05 kHz (pingpongdelay_stationB_landing.py). At MIDI 95 (373.175 ms,
16 457 frames), 44.1 kHz, a 20 000 LSB click at Mix 2, Feedback 0 comes
back as 19 961 on frame 16 457 and 39 on frame 16 456, byte-identical on
CPython, MicroPython 1.29 and CircuitPython 10.3 (render_effect.py, fnv
35be320b). At Feedback 0.99 the 60th repeat of a 10 kHz tone burst is
0.86 dB below the Feedback's own decay at 44.1 kHz and 0.98 dB at
22.05 kHz; one knob position over, where the frame lands, it is 0.00 dB.
What would fix it. Either of two, neither costed:
- A
delay_frames option (or delay_samples), taken as a float and used
as the read offset directly. A whole number up to 2²⁴ is exact in
float32, so a class that wants N frames gets N. delay_ms stays, for
everyone who thinks in milliseconds. This is the one we'd pick.
- Round
frames to the nearest whole frame when it is within, say,
2⁻⁸ of one. Cheaper to add, but it quietly moves any fractional delay a
caller really asked for, so we like it less.
A static delay_ms render at 48 kHz would not change under either. Under
(1) nothing that does not pass the new option changes at all.
Who is affected. Every Phase 5 class that lands a static Time on a
whole frame and hands it as milliseconds: PingPongDelay (its docstring
now names the positions), and DigitalDelay and SlapbackDelay, which
carry the same law. At 9e4ea23, rebuilt/digitaldelay.py:66-68 still
says every static Time lands, lossless. rebuilt/slapbackdelay.py:188
already counts the misses in a comment (21 of its 128 positions at
44.1 kHz, 20 at 22.05 kHz), but its docstring at :60 still says that
with Wow at 0 the repeat loses nothing. DigitalDelay's positions are not
counted here.
What the library does meanwhile. PingPongDelay states the positions
and the cost in its docstring and dossier, and two tests pin them: the set
of off-frame positions per rate, and the 19 961 / 39 split at MIDI 95. Both
go red the day the node lands every frame, which is the signal to strike
the exception.
Reopens when: an audiodsp release lets a caller ask for a delay in frames,
or changes how delay_ms becomes frames. Brad's ruling of 2026-09-28 puts
node fixes after Phase 5's classes, in one release.
Status
Found separately by five class runs (SlapbackDelay, PingPongDelay, DigitalDelay, AnalogDelay, MultiTapDelay), each of which now guards or discloses it. Decided 2026-09-28: fixed after effects Phase 5, because the cure adds a public option and reopens every delay class. Each class carries a test that goes red when the node lands exactly.
If you want a
FeedbackDelayto read its line exactly N frames back, youcan't always get it at 44.1 or 22.05 kHz. The node takes the delay in
milliseconds and turns it back into frames in float32, and for about one
whole frame in eight at 44.1 kHz no float32 millisecond value lands on N.
The read then sits one float32 step off the frame, and the two-tap read
smears every pass of the loop onto the neighbouring frame. The repeats
darken, slowly, at Times that were meant to be lossless.
The cause.
audiodsp_feedback_delay_configurecomputesframes = value * rate / 1000.0f(src/shared/audiodsp_feedback_delay.c:148at v0.6.2,
1c89b03) withvaluethe handeddelay_msas a float. ForN = 16 457 at 44.1 kHz, say, no float32
delay_mswithin 64 ulps ofN · 1000 / 44 100 makes that product exactly 16 457; the nearest lands
1/512 of a frame short. The error is one float32 step of N: up to 2⁻⁹ of a
frame at 44.1 kHz and 2⁻¹⁰ at 22.05 kHz over the 20 ms – 1 s range. At
48 kHz every whole frame from 20 ms to 1 s lands.
Measured. Whole frames from 20 ms to 1 s with no float32
delay_mswithin 64 ulps that lands: 5 554 of 43 219 at 44.1 kHz, 2 785 of 21 610 at
22.05 kHz, 0 of 47 041 at 48 kHz (probe
a probe that walks every knob position and every whole frame,
output beside it). Through
PingPongDelay, whose Time knob is 20–1 000 mslog on the 0–127 grid, that is 25 of 128 positions at 44.1 kHz and 20 at
22.05 kHz (
pingpongdelay_stationB_landing.py). At MIDI 95 (373.175 ms,16 457 frames), 44.1 kHz, a 20 000 LSB click at Mix 2, Feedback 0 comes
back as 19 961 on frame 16 457 and 39 on frame 16 456, byte-identical on
CPython, MicroPython 1.29 and CircuitPython 10.3 (
render_effect.py, fnv35be320b). At Feedback 0.99 the 60th repeat of a 10 kHz tone burst is0.86 dB below the Feedback's own decay at 44.1 kHz and 0.98 dB at
22.05 kHz; one knob position over, where the frame lands, it is 0.00 dB.
What would fix it. Either of two, neither costed:
delay_framesoption (ordelay_samples), taken as a float and usedas the read offset directly. A whole number up to 2²⁴ is exact in
float32, so a class that wants N frames gets N.
delay_msstays, foreveryone who thinks in milliseconds. This is the one we'd pick.
framesto the nearest whole frame when it is within, say,2⁻⁸ of one. Cheaper to add, but it quietly moves any fractional delay a
caller really asked for, so we like it less.
A static
delay_msrender at 48 kHz would not change under either. Under(1) nothing that does not pass the new option changes at all.
Who is affected. Every Phase 5 class that lands a static Time on a
whole frame and hands it as milliseconds:
PingPongDelay(its docstringnow names the positions), and
DigitalDelayandSlapbackDelay, whichcarry the same law. At
9e4ea23,rebuilt/digitaldelay.py:66-68stillsays every static Time lands, lossless.
rebuilt/slapbackdelay.py:188already counts the misses in a comment (21 of its 128 positions at
44.1 kHz, 20 at 22.05 kHz), but its docstring at
:60still says thatwith Wow at 0 the repeat loses nothing. DigitalDelay's positions are not
counted here.
What the library does meanwhile.
PingPongDelaystates the positionsand the cost in its docstring and dossier, and two tests pin them: the set
of off-frame positions per rate, and the 19 961 / 39 split at MIDI 95. Both
go red the day the node lands every frame, which is the signal to strike
the exception.
Reopens when: an audiodsp release lets a caller ask for a delay in frames,
or changes how
delay_msbecomes frames. Brad's ruling of 2026-09-28 putsnode fixes after Phase 5's classes, in one release.
Status
Found separately by five class runs (SlapbackDelay, PingPongDelay, DigitalDelay, AnalogDelay, MultiTapDelay), each of which now guards or discloses it. Decided 2026-09-28: fixed after effects Phase 5, because the cure adds a public option and reopens every delay class. Each class carries a test that goes red when the node lands exactly.