DSPark 1.8.0
Header-only C++20 DSP for real-time and offline audio
Loading...
Searching...
No Matches
Hilbert.h
1// DSPark - Professional Audio DSP Framework
2// Copyright (c) 2026 Cristian Moresi - MIT License
3
4#pragma once
5
25#include "DspMath.h"
26#include "DenormalGuard.h"
27#include "SimdOps.h" // SIMD dot product for the FIR convolution
28
29#include <algorithm>
30#include <array>
31#include <cassert>
32#include <cmath>
33#include <cstdint>
34#include <span>
35
36namespace dspark {
37
38namespace detail {
39// Unwindowed discrete Hilbert impulse, shared by finite-source operators and
40// the real-time windowed FIR. Keep the parity test in the integer source clock.
41[[nodiscard]] inline double hilbertIdealImpulse(std::int64_t index) noexcept
42{
43 constexpr double kPi = 3.14159265358979323846;
44 return index % 2 == 0 ? 0.0 : 2.0 / (kPi * static_cast<double>(index));
45}
46} // namespace detail
47
72template <FloatType T>
74{
75public:
76 struct Result
77 {
78 T real;
79 T imag;
80 };
81
88 void prepare(double sampleRate) noexcept
89 {
90 assert(sampleRate > 0.0);
91 if (!(sampleRate > 0.0)) return;
92
93 sampleRate_ = sampleRate;
94 reset();
95 isPrepared_ = true;
96 }
97
110 [[nodiscard]] inline Result process(T input) noexcept
111 {
112 delay_[static_cast<size_t>(writePos_)] = input;
113 delay_[static_cast<size_t>(writePos_ + kTaps)] = input;
114
115 // Contiguous window of the last kTaps samples, oldest first:
116 // window[j] = x[n - (kTaps - 1 - j)].
117 const T* window = delay_.data() + writePos_ + 1;
118
119 // imag = sum_k h[k] * x[n-k]: with an oldest-first window this is the
120 // dot product against the REVERSED kernel. The pointer is cached in a
121 // member so the hot path never touches the magic-static init guard.
122 const T imag = simd::dotProduct(kernelData_, window, kTaps);
123
124 // real = x[n - kCenter]: index kTaps-1-kCenter == kCenter (odd length).
125 const T re = window[kCenter];
126
127 writePos_ = (writePos_ + 1 == kTaps) ? 0 : (writePos_ + 1);
128 return { re, imag };
129 }
130
137 void processBlock(std::span<const T> input,
138 std::span<T> outReal,
139 std::span<T> outImag) noexcept
140 {
141 assert(isPrepared_);
142 assert(outReal.size() >= input.size() && outImag.size() >= input.size());
143 DenormalGuard dg;
144
145 const size_t numSamples =
146 std::min(input.size(), std::min(outReal.size(), outImag.size()));
147 for (size_t i = 0; i < numSamples; ++i)
148 {
149 const auto res = process(input[i]);
150 outReal[i] = res.real;
151 outImag[i] = res.imag;
152 }
153 }
154
156 void reset() noexcept
157 {
158 delay_.fill(T(0));
159 writePos_ = 0;
160 }
161
163 [[nodiscard]] static constexpr int getLatencySamples() noexcept { return kCenter; }
164
165private:
166 // 191-tap FIR: < 0.1% analytic ripple above ~1 kHz at 48 kHz (< 2.5% at
167 // 500 Hz), 95-sample group delay. Odd length keeps a true integer center tap.
168 static constexpr int kTaps = 191;
169 static constexpr int kCenter = kTaps / 2;
170
174 [[nodiscard]] static const std::array<T, kTaps>& reversedKernel() noexcept
175 {
176 static const std::array<T, kTaps> kernel = []
177 {
178 std::array<T, kTaps> k {};
179 constexpr double kPi = 3.14159265358979323846;
180 for (int i = 0; i < kTaps; ++i)
181 {
182 // Ideal Hilbert kernel h[n] = 2/(pi*n) for odd n, 0 for even n,
183 // windowed with Blackman to control ripple / sideband leakage.
184 const int n = i - kCenter;
185 double ideal = detail::hilbertIdealImpulse(n);
186 double w = 0.42
187 - 0.5 * std::cos(2.0 * kPi * static_cast<double>(i) / static_cast<double>(kTaps - 1))
188 + 0.08 * std::cos(4.0 * kPi * static_cast<double>(i) / static_cast<double>(kTaps - 1));
189 k[static_cast<size_t>(kTaps - 1 - i)] = static_cast<T>(ideal * w);
190 }
191 return k;
192 }();
193 return kernel;
194 }
195
196 double sampleRate_ = 0.0;
197 bool isPrepared_ = false;
198 int writePos_ = 0;
199
200 // Cached at construction so process() skips the magic-static guard check
201 // (the static outlives every instance; copies stay valid).
202 const T* kernelData_ = reversedKernel().data();
203
204 // Mirrored delay line (double-write) for contiguous SIMD reads. No
205 // over-alignment: the window starts at a variable offset every sample, so
206 // the SIMD dot product uses unaligned loads regardless.
207 std::array<T, kTaps * 2> delay_{};
208};
209
233template <FloatType T>
235{
236public:
237 struct Result
238 {
241 };
242
244 void reset() noexcept
245 {
246 for (auto& s : sections_) s = {};
247 delayedImag_ = 0.0;
248 }
249
251 [[nodiscard]] inline Result process(T input) noexcept
252 {
253 double re = 0.0, im = 0.0;
254 step(static_cast<double>(input), re, im);
255 return { static_cast<T>(re), static_cast<T>(im) };
256 }
257
260 [[nodiscard]] inline T magnitude(T input) noexcept
261 {
262 double re = 0.0, im = 0.0;
263 step(static_cast<double>(input), re, im);
264 return static_cast<T>(std::sqrt(re * re + im * im));
265 }
266
275 void magnitudeBlock(const T* in, T* out, int numSamples) noexcept
276 {
277 constexpr int kChunk = 64;
278 double re[kChunk];
279 double im[kChunk];
280 for (int start = 0; start < numSamples; start += kChunk)
281 {
282 const int n = std::min(kChunk, numSamples - start);
283 for (int i = 0; i < n; ++i)
284 re[i] = im[i] = static_cast<double>(in[start + i]);
285 for (int k = 0; k < kSections; ++k)
286 Section::runPair(sections_[static_cast<size_t>(k)], re, kReal[k],
287 sections_[static_cast<size_t>(kSections + k)], im, kImag[k], n);
288 for (int i = 0; i < n; ++i)
289 {
290 const double imDelayed = delayedImag_;
291 delayedImag_ = im[i];
292 out[start + i] = static_cast<T>(std::sqrt(re[i] * re[i] + imDelayed * imDelayed));
293 }
294 }
295 }
296
297private:
298 static constexpr int kSections = 7;
299
300 inline void step(double input, double& re, double& im) noexcept
301 {
302 re = input;
303 double quad = input;
304 for (int k = 0; k < kSections; ++k)
305 {
306 re = sections_[static_cast<size_t>(k)].process(re, kReal[k]);
307 quad = sections_[static_cast<size_t>(kSections + k)].process(quad, kImag[k]);
308 }
309 im = delayedImag_;
310 delayedImag_ = quad;
311 }
312
314 struct Section
315 {
316 double x1 = 0.0, x2 = 0.0, y1 = 0.0, y2 = 0.0;
317
318 // y = (c x[n] - x[n-2]) + c y[n-2]: the recursive term enters last,
319 // so the loop-carried path is one multiply and one add.
320 inline double process(double x, double c) noexcept
321 {
322 const double y = (c * x - x2) + c * y2;
323 x2 = x1; x1 = x;
324 y2 = y1; y1 = y;
325 return y;
326 }
327
331 static inline void runPair(Section& p, double* dp, double cp,
332 Section& q, double* dq, double cq, int n) noexcept
333 {
334 double px1 = p.x1, px2 = p.x2, py1 = p.y1, py2 = p.y2;
335 double qx1 = q.x1, qx2 = q.x2, qy1 = q.y1, qy2 = q.y2;
336 for (int i = 0; i < n; ++i)
337 {
338 const double xp = dp[i];
339 const double xq = dq[i];
340 const double yp = (cp * xp - px2) + cp * py2;
341 const double yq = (cq * xq - qx2) + cq * qy2;
342 px2 = px1; px1 = xp; py2 = py1; py1 = yp;
343 qx2 = qx1; qx1 = xq; qy2 = qy1; qy1 = yq;
344 dp[i] = yp;
345 dq[i] = yq;
346 }
347 p.x1 = px1; p.x2 = px2; p.y1 = py1; p.y2 = py2;
348 q.x1 = qx1; q.x2 = qx2; q.y1 = qy1; q.y2 = qy2;
349 }
350 };
351
352 static constexpr double kReal[kSections] = {
353 0.0849750635881296, 0.5135957829646070, 0.8197816632773615, 0.9420612863757530,
354 0.9822486171863649, 0.9947076541867014, 0.9986500580725346 };
355 static constexpr double kImag[kSections] = { // followed by the one-sample delay
356 0.2887645620083060, 0.6955314021190576, 0.8968265992273647, 0.9678214299445055,
357 0.9902622348692460, 0.9971984927047717, 0.9995996402566010 };
358
359 std::array<Section, 2 * kSections> sections_ {};
360 double delayedImag_ = 0.0;
361};
362
363} // namespace dspark
RAII scope guard to disable denormalised (subnormal) floating-point numbers.
Zero-latency analytic pair from two allpass chains (a 90-degree phase-difference network).
Definition Hilbert.h:235
Result process(T input) noexcept
Processes one sample into the analytic pair. RT-safe.
Definition Hilbert.h:251
void reset() noexcept
Clears the allpass states. RT-safe.
Definition Hilbert.h:244
T magnitude(T input) noexcept
Processes one sample and returns the analytic magnitude sqrt(real^2 + imag^2): a ripple-free envelope...
Definition Hilbert.h:260
void magnitudeBlock(const T *in, T *out, int numSamples) noexcept
Analytic magnitude of a block: out[i] = |analytic(in[i])|.
Definition Hilbert.h:275
90-degree phase-differencing network (analytic-signal generator).
Definition Hilbert.h:74
static constexpr int getLatencySamples() noexcept
FIR group-delay latency applied to both outputs, in samples.
Definition Hilbert.h:163
void reset() noexcept
Resets the delay line. Mandatory when seeking or starting playback.
Definition Hilbert.h:156
void prepare(double sampleRate) noexcept
Prepares the transformer. The FIR kernel is sample-rate independent; sampleRate is accepted for API s...
Definition Hilbert.h:88
Result process(T input) noexcept
Processes one sample, returning the analytic signal {real, imag}.
Definition Hilbert.h:110
void processBlock(std::span< const T > input, std::span< T > outReal, std::span< T > outImag) noexcept
Processes a block of samples. Optimized for CPU cache.
Definition Hilbert.h:137
double hilbertIdealImpulse(std::int64_t index) noexcept
Definition Hilbert.h:41
float dotProduct(const float *DSPARK_RESTRICT a, const float *DSPARK_RESTRICT b, int count) noexcept
Computes the dot product of two arrays.
Definition SimdOps.h:419
Main namespace for the DSPark framework.
T real
In-phase component (allpass-filtered input).
Definition Hilbert.h:239
T imag
Quadrature component, 90 degrees behind real (like the Hilbert transform of a cosine).
Definition Hilbert.h:240
T real
In-phase component (input delayed by the FIR group delay).
Definition Hilbert.h:78
T imag
Quadrature component (90-deg shifted).
Definition Hilbert.h:79