Sound Byte Libs 0.5.1-121-g3358a44
C++ firmware library for audio applications on 32-bit ARM Cortex-M processors
Loading...
Searching...
No Matches
thiran_allpass.hpp
Go to the documentation of this file.
1// sbl/dsp/primitives/thiran_allpass.hpp — Maximally-flat group-delay allpass
2//
3// Fractional delay for a fixed delay amount: the phase delay is flat to
4// order 2N at DC, so a waveguide loop can be tuned to a fraction of a sample
5// without the comb-filtering a linear interpolator introduces. Hermite
6// interpolation (DelayLine::read_cubic) remains the tool for a delay that
7// moves per sample; this is the tool for one that is set and held
8// (RPT-029 Part 3).
9//
10// The delay line carries the integer part; this carries the fraction. Total
11// phase delay D is clamped to [N - 0.5, N + 0.5], the range where the design
12// is both accurate and stable (Thiran is stable for D > N - 1). D = N gives
13// every coefficient as zero — a pure N-sample delay, exactly.
14//
15// Energy: an allpass has unit magnitude at every frequency, so it stores and
16// returns energy but never creates it (RPT-029 Part 4 rule 1). Its state is
17// a delay line's worth of past input and output; the energy function is the
18// usual sum of squares over that state.
19//
20// Usage:
21// sbl::dsp::primitives::ThiranAllpass<1> tuner;
22// tuner.set_delay(1.0f + frac); // frac in [-0.5, 0.5]
23// float y = tuner.process(x);
24// float d = tuner.group_delay_samples();
25
26#ifndef SBL_DSP_PRIMITIVES_THIRAN_ALLPASS_HPP_
27#define SBL_DSP_PRIMITIVES_THIRAN_ALLPASS_HPP_
28
29#include <cstdint>
30
32
33namespace sbl::dsp::primitives {
34
35namespace detail {
36
37/// Binomial coefficient C(n, k) for the small orders this filter allows.
38inline constexpr float binomial(uint8_t n, uint8_t k) {
39 float result = 1.0f;
40 for (uint8_t i = 0; i < k; ++i) {
41 result *= static_cast<float>(n - i) / static_cast<float>(i + 1);
42 }
43 return result;
44}
45
46} // namespace detail
47
48/**
49 * @brief Order-N Thiran allpass: fractional delay by phase, not interpolation
50 *
51 * @tparam N Filter order; the delay it can express is N ± 0.5 samples.
52 *
53 * @note All public methods are ISR-safe — bounded computation, no I/O.
54 */
55template<uint8_t N = 1>
57 static_assert(N >= 1, "Thiran allpass needs at least first order");
58 static_assert(N <= 4, "high orders cost accuracy in float; use a longer delay line");
59
60public:
61 ThiranAllpass() { set_delay(static_cast<float>(N)); }
62
63 /**
64 * @brief Set the total phase delay in samples
65 *
66 * Clamped to [N - 0.5, N + 0.5]. Coefficients are recomputed here, so
67 * call it when the pitch changes (block rate), not per sample.
68 */
69 void set_delay(float delay_samples) {
70 constexpr float lo = static_cast<float>(N) - 0.5f;
71 constexpr float hi = static_cast<float>(N) + 0.5f;
72 const float d = math::clamp(delay_samples, lo, hi);
73 delay_ = d;
74
75 // a_k = (-1)^k C(N,k) prod_{n=0..N} (D - N + n) / (D - N + k + n)
76 for (uint8_t k = 1; k <= N; ++k) {
77 float coeff = detail::binomial(N, k);
78 for (uint8_t n = 0; n <= N; ++n) {
79 coeff *= (d - static_cast<float>(N) + static_cast<float>(n)) /
80 (d - static_cast<float>(N) + static_cast<float>(k + n));
81 }
82 a_[k - 1] = (k & 1) ? -coeff : coeff;
83 }
84 }
85
86 /**
87 * @brief Process one sample
88 *
89 * Numerator coefficients are the denominator's, reversed — that is what
90 * makes the magnitude response unity.
91 */
92 float process(float x) {
93 float y = a_[N - 1] * x; // b_0 = a_N
94 for (uint8_t k = 1; k < N; ++k) {
95 y += a_[N - 1 - k] * x_hist_[k - 1]; // b_k = a_(N-k)
96 }
97 y += x_hist_[N - 1]; // b_N = a_0 = 1
98 for (uint8_t k = 1; k <= N; ++k) {
99 y -= a_[k - 1] * y_hist_[k - 1];
100 }
101
102 for (uint8_t i = N - 1; i > 0; --i) {
103 x_hist_[i] = x_hist_[i - 1];
104 y_hist_[i] = y_hist_[i - 1];
105 }
106 x_hist_[0] = x;
107 y_hist_[0] = y;
108 return y;
109 }
110
111 /// Phase delay the filter is tuned to, in samples (flat at DC by design).
112 float group_delay_samples() const { return delay_; }
113
114 void reset() {
115 for (uint8_t i = 0; i < N; ++i) {
116 x_hist_[i] = 0.0f;
117 y_hist_[i] = 0.0f;
118 }
119 }
120
121 static constexpr uint8_t order() { return N; }
122
123 /// Smallest and largest delay this order can express.
124 static constexpr float min_delay() { return static_cast<float>(N) - 0.5f; }
125 static constexpr float max_delay() { return static_cast<float>(N) + 0.5f; }
126
127private:
128 float a_[N]{}; ///< a_1 .. a_N
129 float x_hist_[N]{}; ///< x[n-1] .. x[n-N]
130 float y_hist_[N]{}; ///< y[n-1] .. y[n-N]
131 float delay_ = static_cast<float>(N);
132};
133
134} // namespace sbl::dsp::primitives
135
136#endif // SBL_DSP_PRIMITIVES_THIRAN_ALLPASS_HPP_
Clamping (Cross-cutting — Math)
Order-N Thiran allpass: fractional delay by phase, not interpolation.
static constexpr float min_delay()
Smallest and largest delay this order can express.
float process(float x)
Process one sample.
void set_delay(float delay_samples)
Set the total phase delay in samples.
float group_delay_samples() const
Phase delay the filter is tuned to, in samples (flat at DC by design).
static constexpr uint8_t order()
constexpr float clamp(float x, float lo, float hi)
x held to [lo, hi]; NaN → lo.
Definition clamp.hpp:13
constexpr float binomial(uint8_t n, uint8_t k)
Binomial coefficient C(n, k) for the small orders this filter allows.
Stateful, single-concern building blocks.