DSPark 1.6.1
Header-only audio DSP framework in pure C++20 — zero dependencies
Loading...
Searching...
No Matches
FIRFilter.h
Go to the documentation of this file.
1// DSPark -- Professional Audio DSP Framework
2// Copyright (c) 2026 Cristian Moresi -- MIT License
3
4#pragma once
5
37#include "AudioBuffer.h"
38#include "SimdOps.h"
39#include "WindowFunctions.h"
40
41#include <algorithm>
42#include <atomic>
43#include <cassert>
44#include <cmath>
45#include <numbers>
46#include <span>
47#include <vector>
48
49namespace dspark {
50
51// ============================================================================
52// FIRDesign -- Coefficient design via windowed-sinc method
53// ============================================================================
54
73template <typename T>
75{
76public:
86 [[nodiscard]] static std::vector<T> lowPass(double sampleRate, double cutoffHz,
87 int numTaps, T beta = T(5)) noexcept
88 {
89 assert(numTaps >= 3 && (numTaps % 2 == 1));
90 numTaps = sanitizeTaps(numTaps);
91 return designSinc(cutoffHz / sampleRate, numTaps, beta, false);
92 }
93
105 [[nodiscard]] static std::vector<T> highPass(double sampleRate, double cutoffHz,
106 int numTaps, T beta = T(5)) noexcept
107 {
108 assert(numTaps >= 3 && (numTaps % 2 == 1));
109 numTaps = sanitizeTaps(numTaps);
110 return designSinc(cutoffHz / sampleRate, numTaps, beta, true);
111 }
112
125 [[nodiscard]] static std::vector<T> bandPass(double sampleRate, double lowCutoffHz,
126 double highCutoffHz, int numTaps,
127 T beta = T(5)) noexcept
128 {
129 assert(numTaps >= 3 && (numTaps % 2 == 1));
130 assert(lowCutoffHz < highCutoffHz);
131 numTaps = sanitizeTaps(numTaps);
132
133 auto lp1 = designSinc(highCutoffHz / sampleRate, numTaps, beta, false);
134 auto lp2 = designSinc(lowCutoffHz / sampleRate, numTaps, beta, false);
135
136 for (int i = 0; i < numTaps; ++i)
137 lp1[static_cast<size_t>(i)] -= lp2[static_cast<size_t>(i)];
138
139 return lp1;
140 }
141
152 [[nodiscard]] static std::vector<T> bandStop(double sampleRate, double lowCutoffHz,
153 double highCutoffHz, int numTaps,
154 T beta = T(5)) noexcept
155 {
156 assert(numTaps >= 3 && (numTaps % 2 == 1));
157 // Sanitise HERE too: bandPass() sanitises its own copy, so an even
158 // request would return numTaps+1 coefficients while the loops below
159 // still ran over the caller's numTaps -- leaving the last tap
160 // un-negated (a broken, asymmetric filter) in release builds.
161 numTaps = sanitizeTaps(numTaps);
162
163 auto bp = bandPass(sampleRate, lowCutoffHz, highCutoffHz, numTaps, beta);
164
165 // Spectral inversion: negate all and add 1 to centre tap
166 int centre = numTaps / 2;
167 for (int i = 0; i < numTaps; ++i)
168 bp[static_cast<size_t>(i)] = -bp[static_cast<size_t>(i)];
169 bp[static_cast<size_t>(centre)] += T(1);
170
171 return bp;
172 }
173
185 [[nodiscard]] static int estimateTaps(double sampleRate, double transitionHz,
186 double attenuationDb) noexcept
187 {
188 assert(sampleRate > 0.0 && transitionHz > 0.0);
189 // Release-safe floor: a zero/negative transition width would push the
190 // estimate through ceil(inf) into undefined integer conversion.
191 double normTransition = std::max(transitionHz / sampleRate, 1e-6);
192 int n = static_cast<int>(std::ceil((attenuationDb - 7.95) / (14.36 * normTransition)));
193 if (n < 3) n = 3;
194 if (n % 2 == 0) ++n; // Ensure odd
195 return n;
196 }
197
204 [[nodiscard]] static T estimateKaiserBeta(double attenuationDb) noexcept
205 {
206 if (attenuationDb > 50.0)
207 return static_cast<T>(0.1102 * (attenuationDb - 8.7));
208 else if (attenuationDb >= 21.0)
209 return static_cast<T>(0.5842 * std::pow(attenuationDb - 21.0, 0.4)
210 + 0.07886 * (attenuationDb - 21.0));
211 else
212 return T(0);
213 }
214
215private:
219 [[nodiscard]] static int sanitizeTaps(int numTaps) noexcept
220 {
221 if (numTaps < 3) numTaps = 3;
222 return numTaps | 1;
223 }
224
234 [[nodiscard]] static std::vector<T> designSinc(double normFreq, int numTaps,
235 T beta, bool invert) noexcept
236 {
237 std::vector<double> design(static_cast<size_t>(numTaps));
238 std::vector<double> window(static_cast<size_t>(numTaps));
239
240 // Generate Kaiser window (double engine regardless of T)
241 WindowFunctions<double>::kaiser(window.data(), numTaps,
242 static_cast<double>(beta), false);
243
244 const int centre = numTaps / 2;
245 const double fc = normFreq * 2.0; // Normalised to [0, 1] for sinc
246 constexpr double kPi = std::numbers::pi;
247
248 // Compute windowed sinc and its DC sum in one pass
249 double sum = 0.0;
250 for (int i = 0; i < numTaps; ++i)
251 {
252 const int n = i - centre;
253 const double x = static_cast<double>(n) * kPi;
254 const double s = (n == 0) ? fc : std::sin(fc * x) / x;
255 const double c = s * window[static_cast<size_t>(i)];
256 design[static_cast<size_t>(i)] = c;
257 sum += c;
258 }
259
260 // Normalise for unity gain at DC (low-pass) or Nyquist (high-pass)
261 if (std::abs(sum) > 1e-10)
262 {
263 const double invSum = 1.0 / sum;
264 for (auto& c : design) c *= invSum;
265 }
266
267 // Spectral inversion for high-pass
268 if (invert)
269 {
270 for (auto& c : design) c = -c;
271 design[static_cast<size_t>(centre)] += 1.0;
272 }
273
274 std::vector<T> coeffs(static_cast<size_t>(numTaps));
275 for (int i = 0; i < numTaps; ++i)
276 coeffs[static_cast<size_t>(i)] = static_cast<T>(design[static_cast<size_t>(i)]);
277 return coeffs;
278 }
279};
280
281// ============================================================================
282// FIRFilter -- FIR filter processor (SIMD direct convolution, thread-safe)
283// ============================================================================
284
298template <typename T>
300{
301public:
302 FIRFilter() = default;
303 ~FIRFilter() = default;
304
312 void prepare(int maxTaps, int numChannels)
313 {
314 assert(maxTaps > 0 && numChannels > 0);
315
316 maxTaps_ = maxTaps;
317 numChannels_ = numChannels;
318
319 // SPSC coefficient publication: a shared staging buffer (written by the
320 // control thread under a seqlock) and an audio-thread-private active
321 // buffer (copied once per update, never overwritten mid-block).
322 stagingCoeffs_.assign(static_cast<size_t>(maxTaps_), T(0));
323 activeCoeffs_.assign(static_cast<size_t>(maxTaps_), T(0));
324 stagingTaps_.store(0, std::memory_order_relaxed);
325 activeTaps_ = 0;
326 coeffSeq_.store(0, std::memory_order_release);
327 coeffDirty_.store(false, std::memory_order_release);
328 reportedTaps_.store(0, std::memory_order_release);
329
330 // Flattened delay line buffer using the "mirror" technique.
331 // Size: numChannels * (maxTaps * 2)
332 delayLineBuffer_.assign(static_cast<size_t>(numChannels_ * maxTaps_ * 2), T(0));
333 writePositions_.assign(static_cast<size_t>(numChannels_), 0);
334
335 isPrepared_.store(true, std::memory_order_release);
336 }
337
348 void setCoefficients(std::span<const T> coeffs) noexcept
349 {
350 if (!isPrepared_.load(std::memory_order_acquire)) return;
351
352 const int numTaps = static_cast<int>(coeffs.size());
353 if (numTaps == 0 || numTaps > maxTaps_) return;
354
355 // Seqlock publish: odd sequence = write in progress.
356 coeffSeq_.fetch_add(1, std::memory_order_acq_rel);
357 // Write REVERSED coefficients to the staging buffer for SIMD dot product.
358 for (int k = 0; k < numTaps; ++k)
359 stagingCoeffs_[static_cast<size_t>(k)] = coeffs[static_cast<size_t>(numTaps - 1 - k)];
360 stagingTaps_.store(numTaps, std::memory_order_relaxed);
361 coeffSeq_.fetch_add(1, std::memory_order_release); // even = consistent
362 coeffDirty_.store(true, std::memory_order_release);
363 reportedTaps_.store(numTaps, std::memory_order_release);
364 }
365
369 void reset() noexcept
370 {
371 std::fill(delayLineBuffer_.begin(), delayLineBuffer_.end(), T(0));
372 std::fill(writePositions_.begin(), writePositions_.end(), 0);
373 }
374
383 void processBlock(AudioBufferView<T> buffer) noexcept
384 {
385 if (!isPrepared_.load(std::memory_order_acquire)) return;
386
387 // Pick up any pending coefficient update once per block (seqlock copy
388 // into the audio-thread-private active buffer).
389 pullCoeffsIfDirty();
390
391 const int currentTaps = activeTaps_;
392 if (currentTaps == 0) return; // Bypass if uninitialized
393
394 const T* currentCoeffs = activeCoeffs_.data();
395 const int numChannels = std::min(buffer.getNumChannels(), numChannels_);
396 const int numSamples = buffer.getNumSamples();
397
398 for (int ch = 0; ch < numChannels; ++ch)
399 {
400 T* data = buffer.getChannel(ch);
401 auto& wp = writePositions_[static_cast<size_t>(ch)];
402 const int channelOffset = ch * (maxTaps_ * 2);
403 T* dl = delayLineBuffer_.data() + channelOffset;
404
405 for (int i = 0; i < numSamples; ++i)
406 {
407 // Write into mirror buffer based on fixed maxTaps_ bound
408 dl[wp] = data[i];
409 dl[wp + maxTaps_] = data[i];
410
411 // Calculate oldest sample position for the contiguous read
412 const T* readPtr = dl + wp + maxTaps_ - currentTaps + 1;
413
414 // Execute SIMD convolution (unaligned load assumed for readPtr)
415 data[i] = simd::dotProduct(currentCoeffs, readPtr, currentTaps);
416
417 // Advance write position and wrap fixed bound
418 if (++wp >= maxTaps_) wp = 0;
419 }
420 }
421 }
422
433 [[nodiscard]] T processSample(T input, int channel) noexcept
434 {
435 if (!isPrepared_.load(std::memory_order_acquire)) return input;
436
437 // Release-safe channel bound (processBlock clamps; this entry point
438 // must too, or an out-of-range channel indexes the flat delay line OOB).
439 assert(channel >= 0 && channel < numChannels_);
440 if (channel < 0 || channel >= numChannels_) return input;
441
442 // Cheap relaxed check; the seqlock copy only runs when an update landed.
443 if (coeffDirty_.load(std::memory_order_relaxed)) [[unlikely]]
444 pullCoeffsIfDirty();
445
446 const int currentTaps = activeTaps_;
447 if (currentTaps == 0) return input;
448
449 const T* currentCoeffs = activeCoeffs_.data();
450
451 auto& wp = writePositions_[static_cast<size_t>(channel)];
452 const int channelOffset = channel * (maxTaps_ * 2);
453 T* dl = delayLineBuffer_.data() + channelOffset;
454
455 dl[wp] = input;
456 dl[wp + maxTaps_] = input;
457
458 const T* readPtr = dl + wp + maxTaps_ - currentTaps + 1;
459 const T output = simd::dotProduct(currentCoeffs, readPtr, currentTaps);
460
461 if (++wp >= maxTaps_) wp = 0;
462
463 return output;
464 }
465
471 [[nodiscard]] int getLatency() const noexcept
472 {
473 const int taps = reportedTaps_.load(std::memory_order_relaxed);
474 return taps > 0 ? (taps - 1) / 2 : 0;
475 }
476
477private:
480 void pullCoeffsIfDirty() noexcept
481 {
482 if (!coeffDirty_.exchange(false, std::memory_order_acquire)) return;
483 unsigned s0, s1;
484 int n;
485 do {
486 s0 = coeffSeq_.load(std::memory_order_acquire);
487 // Defensive clamp: the loop bound must never exceed the buffers
488 // even if this speculative read races a concurrent publish.
489 n = std::min(stagingTaps_.load(std::memory_order_relaxed), maxTaps_);
490 for (int k = 0; k < n; ++k)
491 activeCoeffs_[static_cast<size_t>(k)] = stagingCoeffs_[static_cast<size_t>(k)];
492 // The fence orders the copy above BEFORE the re-read below. A plain
493 // load-acquire is not enough: acquire only orders LATER accesses,
494 // so on weakly-ordered CPUs (ARM) the copy could sink below the
495 // second read and a torn copy would pass the s0 == s1 check.
496 std::atomic_thread_fence(std::memory_order_acquire);
497 s1 = coeffSeq_.load(std::memory_order_relaxed);
498 } while ((s0 & 1u) != 0u || s0 != s1);
499 activeTaps_ = n;
500 }
501
502 int maxTaps_{0};
503 int numChannels_{0};
504 std::atomic<bool> isPrepared_{false};
505
506 // SPSC seqlock coefficient publication (writer: setCoefficients).
507 std::vector<T> stagingCoeffs_;
508 std::atomic<int> stagingTaps_{0};
509 std::atomic<unsigned> coeffSeq_{0};
510 std::atomic<bool> coeffDirty_{false};
511 std::atomic<int> reportedTaps_{0};
512
513 // Audio-thread-private active coefficient set (never overwritten mid-block).
514 std::vector<T> activeCoeffs_;
515 int activeTaps_{0};
516
517 // Flattened, cache-contiguous delay lines
518 std::vector<T> delayLineBuffer_;
519 std::vector<int> writePositions_;
520};
521
522} // namespace dspark
Owning audio buffer and non-owning view for real-time DSP processing.
SIMD-accelerated buffer operations for real-time audio processing.
Standard window functions for spectral analysis and FIR filter design.
Non-owning view over audio channel data.
Definition AudioBuffer.h:50
Static methods for designing FIR filter coefficients.
Definition FIRFilter.h:75
static std::vector< T > bandStop(double sampleRate, double lowCutoffHz, double highCutoffHz, int numTaps, T beta=T(5)) noexcept
Designs a band-stop (notch) FIR filter.
Definition FIRFilter.h:152
static int estimateTaps(double sampleRate, double transitionHz, double attenuationDb) noexcept
Estimates the required number of taps for a given specification.
Definition FIRFilter.h:185
static std::vector< T > bandPass(double sampleRate, double lowCutoffHz, double highCutoffHz, int numTaps, T beta=T(5)) noexcept
Designs a band-pass FIR filter.
Definition FIRFilter.h:125
static T estimateKaiserBeta(double attenuationDb) noexcept
Estimates the Kaiser beta parameter for a desired attenuation.
Definition FIRFilter.h:204
static std::vector< T > lowPass(double sampleRate, double cutoffHz, int numTaps, T beta=T(5)) noexcept
Designs a low-pass FIR filter.
Definition FIRFilter.h:86
static std::vector< T > highPass(double sampleRate, double cutoffHz, int numTaps, T beta=T(5)) noexcept
Designs a high-pass FIR filter.
Definition FIRFilter.h:105
FIR filter using direct-form convolution with a mirrored delay line.
Definition FIRFilter.h:300
void reset() noexcept
Resets all delay lines to zero, clearing the filter's memory.
Definition FIRFilter.h:369
void processBlock(AudioBufferView< T > buffer) noexcept
Processes a full audio buffer in-place.
Definition FIRFilter.h:383
~FIRFilter()=default
void prepare(int maxTaps, int numChannels)
Pre-allocates memory and initializes the delay lines.
Definition FIRFilter.h:312
FIRFilter()=default
T processSample(T input, int channel) noexcept
Processes a single sample through the FIR filter.
Definition FIRFilter.h:433
void setCoefficients(std::span< const T > coeffs) noexcept
Sets the filter coefficients asynchronously.
Definition FIRFilter.h:348
int getLatency() const noexcept
Returns the filter's group delay (latency) in samples.
Definition FIRFilter.h:471
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.
static void kaiser(T *output, int size, T beta, bool periodic=true) noexcept
Kaiser window with configurable shape parameter beta.