Skip to content

Fractional delay

<pulp/signal/fractional_delay.hpp> provides a prepared mono delay line with a stable first-order Thiran allpass and order-3/order-5 Lagrange reconstruction. It also provides a prepared shared-history ring with explicit writes and any number of stateless Lagrange read heads. Together they cover ordinary variable delay, multitap, ping-pong feedback, and simultaneous old/new transition reads without requiring effect-specific private rings.

There was no general Thiran primitive to promote. Existing Pulp code provides a linear DelayLineT, the shared Interpolator::lagrange order-3 kernel, and effect-private Hermite/Lagrange rings. The new line reuses the shared order-3 kernel and does not replace those established effect internals.

Processing contract

prepare(maximum_delay_samples, method) allocates and fixes the ring capacity and method. For Thiran at an arbitrary static delay or modulation within a specific integer interval, use prepare_thiran1(maximum_delay_samples, integer_interval_start). It prepares the half-open delay range [integer_interval_start, integer_interval_start + 1). The general prepare(..., thiran1) overload selects the default interval [1, 2). Rejected geometry or allocation leaves the previously prepared line usable. reset() clears history and recursive state. No processing or reset operation allocates.

Processing pushes the current sample before reading. Ring tap zero therefore means the current sample, although every public method requires a causal minimum delay. The valid requested ranges are:

Method Delay range Read geometry
thiran1 prepared [k, k + 1) only fixed tap k - 1, then an allpass delay in [1,2)
lagrange3 [1, prepared maximum] taps around integer delay at {-1,0,+1,+2}
lagrange5 [2, prepared maximum] taps around integer delay at {-2,-1,0,+1,+2,+3}

The prepared ring retains the requested maximum plus every older stencil tap; required_older_lookback() reports that geometry. Before prepare, configuration queries report zero/false; method() and thiran_integer_interval_start() return std::nullopt. Processing itself has zero host-compensated latency: the requested delay is the audible signal-path delay, and its result is returned during the same call.

Values within eight floating-point epsilon-scaled units of an integer are canonicalized to that integer. This makes exact-tap behavior deterministic after ordinary control arithmetic. The line supports exact in-place blocks. A nonzero block with a null input, output, or delay array returns invalid_argument without advancing or writing output.

Retuning and faults

Lagrange delay may change on every sample across the full prepared range. Thiran-1 may change on every sample only inside its prepared integer interval. Its recursive transfer function changes discontinuously if the integer tap changes, so the exact/canonicalized upper endpoint and all values outside the interval fail with invalid_delay. There is no hidden smoother or crossfade. To move Thiran across an integer boundary, callers must prepare two lines for the adjacent intervals and crossfade them at the composition layer. This keeps the primitive's state and transfer contract explicit.

Per-sample and block calls return typed status. A nonfinite sample, invalid delay, or derived value outside the sample type injects zero for that timeline position, emits zero, and clears the recursive Thiran state. Later finite input therefore recovers deterministically without retaining a NaN or infinity in the ring. A block continues after faults, reports the first fault status, and counts all faulted frames.

Thiran design surface

The public pure design_thiran1() helper exposes:

H(z) = (a + z^-1) / (1 + a z^-1)
a = (1 - D) / (1 + D), D in [1,2]

The pole is at -a; this range keeps its radius at or below 1/3. Magnitude is exactly unity. thiran1_group_delay_samples() exposes the exact frequency-dependent group delay so physical-model tuning and visualizations do not need to rederive it. Its DC group delay is D; the response becomes frequency-dependent away from DC, which is the allpass tradeoff rather than an error.

Lagrange interpolation is FIR and follows the requested fractional delay more uniformly across frequency, but its magnitude is not exactly unity. Order 5 reduces high-frequency approximation error at the cost of two more taps and a two-sample causal minimum. lagrange5_weights() and lagrange5() expose the pure six-tap design/value surface; order 3 delegates to the existing Interpolator::lagrange helper.

Shared history and stateless read heads

FractionalDelayHistoryT<SampleType> separates the timeline write from reads:

pulp::signal::FractionalDelayHistory history;
history.prepare(maximum_delay_samples);

const auto left_loop = history.read_lagrange5_at(left_delay);
const auto right_loop = history.read_lagrange5_at(right_delay);
const auto next = input + feedback * 0.5f * (left_loop.sample + right_loop.sample);
history.push(next);

The cursor denotes the next write, so delay 1 is the most recently pushed sample. A read-before-write request of delay D therefore returns the signal at n - D, which makes feedback loop timing explicit. If a caller pushes first, the cursor advances first too. Read heads are const and stateless: repeated reads see the identical snapshot, and callers may read several taps or both sides of a delay transition before committing the next sample.

The shared ring accepts only lagrange3 and lagrange5; recursive Thiran state cannot be shared by stateless heads. The causal minimum is 2 samples for order 3 and 3 samples for order 5 because their centered stencils must not inspect the not-yet-written sample. read_lagrange3_at() and read_lagrange5_at() are the convenience forms; read_at(delay, method) supports runtime method selection. All accept delays through the prepared maximum and may cross integer boundaries without changing objects or allocating.

Preparation retains exactly maximum_delay_samples + 3 samples, enough for the oldest order-5 stencil tap. retained_samples(), retained_bytes(), and required_older_lookback() make that bound inspectable. Failed preparation is transactional. push() advances nonfinite input as zero and reports non_finite_input; invalid reads report a typed status without advancing or altering history. reset(), push(), and every read are allocation-free, lock-free, and non-throwing after preparation. The prepared owner is move-only; a moved-from object becomes an empty unprepared object and may be prepared again.