DSPark 1.8.0
Header-only C++20 DSP for real-time and offline audio
Loading...
Searching...
No Matches
FIRFilter.h
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 <array>
43#include <atomic>
44#include <cassert>
45#include <cmath>
46#include <numbers>
47#include <span>
48#include <vector>
49
50namespace dspark {
51
52// ============================================================================
53// FIRDesign -- Coefficient design via windowed-sinc method
54// ============================================================================
55
76template <typename T>
78{
79public:
89 [[nodiscard]] static std::vector<T> lowPass(double sampleRate, double cutoffHz,
90 int numTaps, T beta = T(5))
91 {
92 assert(numTaps >= 3 && (numTaps % 2 == 1));
93 numTaps = sanitizeTaps(numTaps);
94 return designSinc(cutoffHz / sampleRate, numTaps, beta, false);
95 }
96
108 [[nodiscard]] static std::vector<T> highPass(double sampleRate, double cutoffHz,
109 int numTaps, T beta = T(5))
110 {
111 assert(numTaps >= 3 && (numTaps % 2 == 1));
112 numTaps = sanitizeTaps(numTaps);
113 return designSinc(cutoffHz / sampleRate, numTaps, beta, true);
114 }
115
128 [[nodiscard]] static std::vector<T> bandPass(double sampleRate, double lowCutoffHz,
129 double highCutoffHz, int numTaps,
130 T beta = T(5))
131 {
132 assert(numTaps >= 3 && (numTaps % 2 == 1));
133 assert(lowCutoffHz < highCutoffHz);
134 numTaps = sanitizeTaps(numTaps);
135
136 auto lp1 = designSinc(highCutoffHz / sampleRate, numTaps, beta, false);
137 auto lp2 = designSinc(lowCutoffHz / sampleRate, numTaps, beta, false);
138
139 for (int i = 0; i < numTaps; ++i)
140 lp1[static_cast<size_t>(i)] -= lp2[static_cast<size_t>(i)];
141
142 return lp1;
143 }
144
155 [[nodiscard]] static std::vector<T> bandStop(double sampleRate, double lowCutoffHz,
156 double highCutoffHz, int numTaps,
157 T beta = T(5))
158 {
159 assert(numTaps >= 3 && (numTaps % 2 == 1));
160 // Sanitise HERE too: bandPass() sanitises its own copy, so an even
161 // request would return numTaps+1 coefficients while the loops below
162 // still ran over the caller's numTaps -- leaving the last tap
163 // un-negated (a broken, asymmetric filter) in release builds.
164 numTaps = sanitizeTaps(numTaps);
165
166 auto bp = bandPass(sampleRate, lowCutoffHz, highCutoffHz, numTaps, beta);
167
168 // Spectral inversion: negate all and add 1 to centre tap
169 int centre = numTaps / 2;
170 for (int i = 0; i < numTaps; ++i)
171 bp[static_cast<size_t>(i)] = -bp[static_cast<size_t>(i)];
172 bp[static_cast<size_t>(centre)] += T(1);
173
174 return bp;
175 }
176
188 [[nodiscard]] static int estimateTaps(double sampleRate, double transitionHz,
189 double attenuationDb) noexcept
190 {
191 assert(sampleRate > 0.0 && transitionHz > 0.0);
192 // Release-safe floor: a zero/negative transition width would push the
193 // estimate through ceil(inf) into undefined integer conversion.
194 double normTransition = std::max(transitionHz / sampleRate, 1e-6);
195 int n = static_cast<int>(std::ceil((attenuationDb - 7.95) / (14.36 * normTransition)));
196 if (n < 3) n = 3;
197 if (n % 2 == 0) ++n; // Ensure odd
198 return n;
199 }
200
207 [[nodiscard]] static T estimateKaiserBeta(double attenuationDb) noexcept
208 {
209 if (attenuationDb > 50.0)
210 return static_cast<T>(0.1102 * (attenuationDb - 8.7));
211 else if (attenuationDb >= 21.0)
212 return static_cast<T>(0.5842 * std::pow(attenuationDb - 21.0, 0.4)
213 + 0.07886 * (attenuationDb - 21.0));
214 else
215 return T(0);
216 }
217
218private:
222 [[nodiscard]] static int sanitizeTaps(int numTaps) noexcept
223 {
224 if (numTaps < 3) numTaps = 3;
225 return numTaps | 1;
226 }
227
237 [[nodiscard]] static std::vector<T> designSinc(double normFreq, int numTaps,
238 T beta, bool invert)
239 {
240 std::vector<double> design(static_cast<size_t>(numTaps));
241 std::vector<double> window(static_cast<size_t>(numTaps));
242
243 // Generate Kaiser window (double engine regardless of T)
244 WindowFunctions<double>::kaiser(window.data(), numTaps,
245 static_cast<double>(beta), false);
246
247 const int centre = numTaps / 2;
248 const double fc = normFreq * 2.0; // Normalised to [0, 1] for sinc
249 constexpr double kPi = std::numbers::pi;
250
251 // Compute windowed sinc and its DC sum in one pass
252 double sum = 0.0;
253 for (int i = 0; i < numTaps; ++i)
254 {
255 const int n = i - centre;
256 const double x = static_cast<double>(n) * kPi;
257 const double s = (n == 0) ? fc : std::sin(fc * x) / x;
258 const double c = s * window[static_cast<size_t>(i)];
259 design[static_cast<size_t>(i)] = c;
260 sum += c;
261 }
262
263 // Normalise for unity gain at DC (low-pass) or Nyquist (high-pass)
264 if (std::abs(sum) > 1e-10)
265 {
266 const double invSum = 1.0 / sum;
267 for (auto& c : design) c *= invSum;
268 }
269
270 // Spectral inversion for high-pass
271 if (invert)
272 {
273 for (auto& c : design) c = -c;
274 design[static_cast<size_t>(centre)] += 1.0;
275 }
276
277 std::vector<T> coeffs(static_cast<size_t>(numTaps));
278 for (int i = 0; i < numTaps; ++i)
279 coeffs[static_cast<size_t>(i)] = static_cast<T>(design[static_cast<size_t>(i)]);
280 return coeffs;
281 }
282};
283
284// ============================================================================
285// FIRFilter -- FIR filter processor (SIMD direct convolution, thread-safe)
286// ============================================================================
287
362template <typename T>
364{
365public:
366 FIRFilter() = default;
367 ~FIRFilter() = default;
368
376 void prepare(int maxTaps, int numChannels)
377 {
378 assert(maxTaps > 0 && numChannels > 0);
379
380 maxTaps_ = maxTaps;
381 numChannels_ = numChannels;
382
383 // SPSC coefficient publication: a shared staging buffer (written by the
384 // control thread under a seqlock) and an audio-thread-private active
385 // buffer (copied once per update, never overwritten mid-block). The
386 // SHARED staging words are std::atomic<T> so each cross-thread word
387 // access is defined (no data-race UB); the seqlock counter still guards
388 // against reading a half-published set. Allocated here, at setup time.
389 stagingCoeffs_ = std::vector<std::atomic<T>>(static_cast<size_t>(maxTaps_));
390 for (auto& s : stagingCoeffs_) s.store(T(0), std::memory_order_relaxed);
391 activeCoeffs_[0].assign(static_cast<size_t>(maxTaps_), T(0));
392 activeCoeffs_[1].assign(static_cast<size_t>(maxTaps_), T(0));
393 stagingTaps_.store(0, std::memory_order_relaxed);
394 activeIdx_ = 0;
395 activeTaps_ = 0;
396 coeffSeq_.store(0, std::memory_order_release);
397 coeffDirty_.store(false, std::memory_order_release);
398 reportedTaps_.store(0, std::memory_order_release);
399
400 // Flattened delay line buffer using the "mirror" technique.
401 // Size: numChannels * (maxTaps * 2)
402 delayLineBuffer_.assign(static_cast<size_t>(numChannels_ * maxTaps_ * 2), T(0));
403 writePositions_.assign(static_cast<size_t>(numChannels_), 0);
404
405 isPrepared_.store(true, std::memory_order_release);
406 }
407
418 void setCoefficients(std::span<const T> coeffs) noexcept
419 {
420 if (!isPrepared_.load(std::memory_order_acquire)) return;
421
422 const int numTaps = static_cast<int>(coeffs.size());
423 if (numTaps == 0 || numTaps > maxTaps_) return;
424
425 // Seqlock publish: odd sequence = write in progress.
426 coeffSeq_.fetch_add(1, std::memory_order_acq_rel);
427 // Release fence: pairs with the reader's acquire fence via fence-fence
428 // synchronization ([atomics.fences]/2). If the audio thread's copy loop
429 // reads ANY of the relaxed word stores below, that read forces its
430 // post-fence re-read of coeffSeq_ to observe the odd count above, so the
431 // retry loop rejects the torn copy. Without this fence the relaxed data
432 // words are NOT in coeffSeq_'s release sequence and the standard permits
433 // a torn copy to pass validation on weakly ordered targets (Boehm,
434 // "Can Seqlocks Get Along With Programming Language Memory Models?",
435 // MSPC 2012). Control-thread only: zero audio-path cost.
436 std::atomic_thread_fence(std::memory_order_release);
437 // Write REVERSED coefficients to the staging buffer for SIMD dot product.
438 // Relaxed stores: the atomic words make each access race-free; the fence
439 // above plus the seq counter order and publish the set.
440 for (int k = 0; k < numTaps; ++k)
441 stagingCoeffs_[static_cast<size_t>(k)].store(
442 coeffs[static_cast<size_t>(numTaps - 1 - k)], std::memory_order_relaxed);
443 stagingTaps_.store(numTaps, std::memory_order_relaxed);
444 coeffSeq_.fetch_add(1, std::memory_order_release); // even = consistent
445 coeffDirty_.store(true, std::memory_order_release);
446 reportedTaps_.store(numTaps, std::memory_order_release);
447 }
448
452 void reset() noexcept
453 {
454 std::fill(delayLineBuffer_.begin(), delayLineBuffer_.end(), T(0));
455 std::fill(writePositions_.begin(), writePositions_.end(), 0);
456 }
457
466 void processBlock(AudioBufferView<T> buffer) noexcept
467 {
468 if (!isPrepared_.load(std::memory_order_acquire)) return;
469
470 // Pick up any pending coefficient update once per block (seqlock copy
471 // into the audio-thread-private active buffer).
472 pullCoeffsIfDirty();
473
474 const int currentTaps = activeTaps_;
475 if (currentTaps == 0) return; // Bypass if uninitialized
476
477 // Hoisted exactly as the single-buffer .data() was: one private index
478 // load outside the loops, nothing added inside them.
479 const T* currentCoeffs = activeCoeffs_[static_cast<size_t>(activeIdx_)].data();
480 const int numChannels = std::min(buffer.getNumChannels(), numChannels_);
481 const int numSamples = buffer.getNumSamples();
482
483 for (int ch = 0; ch < numChannels; ++ch)
484 {
485 T* data = buffer.getChannel(ch);
486 auto& wp = writePositions_[static_cast<size_t>(ch)];
487 const int channelOffset = ch * (maxTaps_ * 2);
488 T* dl = delayLineBuffer_.data() + channelOffset;
489
490 for (int i = 0; i < numSamples; ++i)
491 {
492 // Write into mirror buffer based on fixed maxTaps_ bound
493 dl[wp] = data[i];
494 dl[wp + maxTaps_] = data[i];
495
496 // Calculate oldest sample position for the contiguous read
497 const T* readPtr = dl + wp + maxTaps_ - currentTaps + 1;
498
499 // Execute SIMD convolution (unaligned load assumed for readPtr)
500 data[i] = simd::dotProduct(currentCoeffs, readPtr, currentTaps);
501
502 // Advance write position and wrap fixed bound
503 if (++wp >= maxTaps_) wp = 0;
504 }
505 }
506 }
507
518 [[nodiscard]] T processSample(T input, int channel) noexcept
519 {
520 if (!isPrepared_.load(std::memory_order_acquire)) return input;
521
522 // Release-safe channel bound (processBlock clamps; this entry point
523 // must too, or an out-of-range channel indexes the flat delay line OOB).
524 assert(channel >= 0 && channel < numChannels_);
525 if (channel < 0 || channel >= numChannels_) return input;
526
527 // Cheap relaxed check; the seqlock copy only runs when an update landed.
528 if (coeffDirty_.load(std::memory_order_relaxed)) [[unlikely]]
529 pullCoeffsIfDirty();
530
531 const int currentTaps = activeTaps_;
532 if (currentTaps == 0) return input;
533
534 const T* currentCoeffs = activeCoeffs_[static_cast<size_t>(activeIdx_)].data();
535
536 auto& wp = writePositions_[static_cast<size_t>(channel)];
537 const int channelOffset = channel * (maxTaps_ * 2);
538 T* dl = delayLineBuffer_.data() + channelOffset;
539
540 dl[wp] = input;
541 dl[wp + maxTaps_] = input;
542
543 const T* readPtr = dl + wp + maxTaps_ - currentTaps + 1;
544 const T output = simd::dotProduct(currentCoeffs, readPtr, currentTaps);
545
546 if (++wp >= maxTaps_) wp = 0;
547
548 return output;
549 }
550
556 [[nodiscard]] int getLatency() const noexcept
557 {
558 const int taps = reportedTaps_.load(std::memory_order_relaxed);
559 return taps > 0 ? (taps - 1) / 2 : 0;
560 }
561
562private:
568 static constexpr int kSeqlockMaxAttempts = 3;
569
578 void pullCoeffsIfDirty() noexcept
579 {
580 if (!coeffDirty_.exchange(false, std::memory_order_acquire)) return;
581
582 // Commit-on-validate: the copy lands in the SPARE active buffer, never
583 // in the one the per-sample loop is reading. A give-up therefore leaves
584 // the live set untouched instead of leaving a torn mixture behind it.
585 const size_t spare = static_cast<size_t>(activeIdx_ ^ 1);
586 T* const dest = activeCoeffs_[spare].data();
587
588 bool adopted = false;
589 int n = 0;
590 for (int attempt = 0; attempt < kSeqlockMaxAttempts; ++attempt)
591 {
592 const unsigned s0 = coeffSeq_.load(std::memory_order_acquire);
593 // Odd counter: the writer is mid-publish. Skip the copy entirely --
594 // testing this BEFORE copying is what makes a collision cost one
595 // relaxed load instead of up to maxTaps loads that would then be
596 // thrown away, and is why the bound is affordable per sample.
597 if ((s0 & 1u) != 0u) continue;
598 // Defensive clamp: the loop bound must never exceed the buffers
599 // even if this speculative read races a concurrent publish.
600 n = std::min(stagingTaps_.load(std::memory_order_relaxed), maxTaps_);
601 for (int k = 0; k < n; ++k)
602 dest[k] = stagingCoeffs_[static_cast<size_t>(k)].load(std::memory_order_relaxed);
603 // The fence orders the copy above BEFORE the re-read below. A plain
604 // load-acquire is not enough: acquire only orders LATER accesses,
605 // so on weakly-ordered CPUs (ARM) the copy could sink below the
606 // second read and a torn copy would pass the equality check.
607 std::atomic_thread_fence(std::memory_order_acquire);
608 if (s0 == coeffSeq_.load(std::memory_order_relaxed))
609 {
610 adopted = true;
611 break;
612 }
613 }
614
615 if (!adopted)
616 {
617 // Adopt nothing: neither the index nor activeTaps_ moves, so the
618 // set already in use stays live. Re-arm and pick the update up on a
619 // later call.
620 coeffDirty_.store(true, std::memory_order_release);
621 return;
622 }
623
624 activeIdx_ ^= 1; // flip: the validated copy becomes the live set
625 activeTaps_ = n;
626 }
627
628 int maxTaps_{0};
629 int numChannels_{0};
630 std::atomic<bool> isPrepared_{false};
631
632 // SPSC seqlock coefficient publication (writer: setCoefficients).
633 // Atomic words: the staging buffer is read by the audio thread while the
634 // control thread writes it, so each word is std::atomic<T> (relaxed) to keep
635 // every concurrent access defined; the seq counter (acq_rel/release) orders
636 // and tear-protects the set. std::atomic<float/double> is lock-free and
637 // one machine word, so this is a plain load/store on every supported target.
638 std::vector<std::atomic<T>> stagingCoeffs_;
639 std::atomic<int> stagingTaps_{0};
640 std::atomic<unsigned> coeffSeq_{0};
641 std::atomic<bool> coeffDirty_{false};
642 std::atomic<int> reportedTaps_{0};
643
644 // Audio-thread-private active coefficient sets (never overwritten
645 // mid-block). TWO buffers plus a private index, not one: the bounded read
646 // must be able to give up without having damaged the set in use, so it
647 // copies into activeCoeffs_[activeIdx_ ^ 1] and only flips the index once
648 // the copy validates. Both are audio-thread-private, so the index needs no
649 // synchronization; the cost is one extra maxTaps buffer at prepare time.
650 std::array<std::vector<T>, 2> activeCoeffs_;
651 int activeIdx_{0};
652 int activeTaps_{0};
653
654 // Flattened, cache-contiguous delay lines
655 std::vector<T> delayLineBuffer_;
656 std::vector<int> writePositions_;
657};
658
659} // namespace dspark
Non-owning view over audio channel data.
Definition AudioBuffer.h:50
Static methods for designing FIR filter coefficients.
Definition FIRFilter.h:78
static std::vector< T > highPass(double sampleRate, double cutoffHz, int numTaps, T beta=T(5))
Designs a high-pass FIR filter.
Definition FIRFilter.h:108
static int estimateTaps(double sampleRate, double transitionHz, double attenuationDb) noexcept
Estimates the required number of taps for a given specification.
Definition FIRFilter.h:188
static std::vector< T > lowPass(double sampleRate, double cutoffHz, int numTaps, T beta=T(5))
Designs a low-pass FIR filter.
Definition FIRFilter.h:89
static T estimateKaiserBeta(double attenuationDb) noexcept
Estimates the Kaiser beta parameter for a desired attenuation.
Definition FIRFilter.h:207
static std::vector< T > bandStop(double sampleRate, double lowCutoffHz, double highCutoffHz, int numTaps, T beta=T(5))
Designs a band-stop (notch) FIR filter.
Definition FIRFilter.h:155
static std::vector< T > bandPass(double sampleRate, double lowCutoffHz, double highCutoffHz, int numTaps, T beta=T(5))
Designs a band-pass FIR filter.
Definition FIRFilter.h:128
FIR filter using direct-form convolution with a mirrored delay line.
Definition FIRFilter.h:364
void reset() noexcept
Resets all delay lines to zero, clearing the filter's memory.
Definition FIRFilter.h:452
void processBlock(AudioBufferView< T > buffer) noexcept
Processes a full audio buffer in-place.
Definition FIRFilter.h:466
~FIRFilter()=default
void prepare(int maxTaps, int numChannels)
Pre-allocates memory and initializes the delay lines.
Definition FIRFilter.h:376
FIRFilter()=default
T processSample(T input, int channel) noexcept
Processes a single sample through the FIR filter.
Definition FIRFilter.h:518
void setCoefficients(std::span< const T > coeffs) noexcept
Sets the filter coefficients asynchronously.
Definition FIRFilter.h:418
int getLatency() const noexcept
Returns the filter's group delay (latency) in samples.
Definition FIRFilter.h:556
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.