DSPark 1.6.1
Header-only audio DSP framework in pure C++20 — zero dependencies
Loading...
Searching...
No Matches
CrossoverFilter.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
46#include "../Core/AudioBuffer.h"
47#include "../Core/AudioSpec.h"
48#include "../Core/Biquad.h"
49#include "../Core/DspMath.h"
50#include "../Core/DenormalGuard.h"
51#include "../Core/FFT.h"
52#include "../Core/SmoothedValue.h"
53#include "../Core/StateBlob.h"
54
55#include <algorithm>
56#include <array>
57#include <atomic>
58#include <cmath>
59#include <cstddef>
60#include <cstdint>
61#include <cstdio>
62#include <memory>
63#include <numbers>
64#include <vector>
65
66namespace dspark {
67
75template <FloatType T, int MaxBands = 12>
77{
78public:
80 enum class FilterMode
81 {
84 };
85
87 {
88 initDefaultFrequencies();
89 }
90
91 // -- Lifecycle -----------------------------------------------------------
92
105 void prepare(const AudioSpec& spec)
106 {
107 if (!spec.isValid()) return;
108 prepared_ = false; // basic guarantee while (re)allocating
109 spec_ = spec;
110 // Per-split filters are Biquad<T, 16>; clamp so processing more than 16
111 // channels can't index their fixed per-channel state out of bounds.
112 numChannels_ = std::min(static_cast<int>(spec.numChannels), 16);
113
114 // Allocate flat IIR work buffer (Channels * MaxBlockSize)
115 workBuf_.assign(static_cast<size_t>(spec.numChannels) * static_cast<size_t>(spec.maxBlockSize), T(0));
116
117 // Linear-phase FFT resources. Allocated regardless of the current mode
118 // so setFilterMode(LinearPhase) after prepare() actually engages
119 // (mode-dependent allocation silently fell back to IIR). lpFft_ acts
120 // as the gate and is created last: if any allocation throws, the
121 // engine stays unavailable but coherent.
122 lpFft_.reset();
123 lpFftSize_ = 0;
124 firLength_ = 0;
125 lpLatency_ = 0;
126 if (spec.maxBlockSize <= kLpMaxBlockSize)
127 {
128 // For Overlap-Save, FFT size must be >= BlockSize + FIR_Length - 1
129 // We choose FIR_Length = maxBlockSize. Thus FFT >= 2 * maxBlockSize - 1.
130 // Floor at 4: FFTReal requires a power of two >= 4 (it throws
131 // below that, which a prepare with maxBlockSize 1 used to hit).
132 int fftPow2 = 4;
133 while (fftPow2 < spec.maxBlockSize * 2) fftPow2 <<= 1;
134 lpFftSize_ = fftPow2;
135 firLength_ = spec.maxBlockSize; // Use block size as FIR length for good resolution
136 // The kernel is centred at firLength/2 (the circular shift uses
137 // i - halfLen), so the exact group delay is firLength/2 - the old
138 // (firLength-1)/2 under-reported PDC by one sample for even sizes.
139 lpLatency_ = firLength_ / 2;
140
141 const int numBins = lpFftSize_ / 2 + 1;
142
143 lpMagnitudesFlat_.assign(static_cast<size_t>(MaxBands) * static_cast<size_t>(numBins) * 2, T(0));
144 lpPrevBlockFlat_.assign(static_cast<size_t>(spec.numChannels) * static_cast<size_t>(firLength_), T(0));
145
146 lpFftIn_.assign(static_cast<size_t>(lpFftSize_), T(0));
147 lpFftOut_.assign(static_cast<size_t>(lpFftSize_ + 2), T(0));
148 lpBandFft_.assign(static_cast<size_t>(lpFftSize_ + 2), T(0));
149 lpFftResult_.assign(static_cast<size_t>(lpFftSize_), T(0));
150
151 // Pre-allocate recompute scratch so a live crossover/order change never
152 // allocates on the audio thread (recomputeLinearPhaseMagnitudes runs there).
153 lpIdealMagsFlat_.assign(static_cast<size_t>(MaxBands) * static_cast<size_t>(numBins), T(0));
154 lpTimeResponse_.assign(static_cast<size_t>(lpFftSize_ + 2), T(0));
155 lpFirKernel_.assign(static_cast<size_t>(lpFftSize_), T(0));
156
157 lpFft_ = std::make_unique<FFTReal<T>>(lpFftSize_);
158 }
159
160 // Allocate flat allpass correction chains
161 // Access via: band * kMaxSplits + split
162 allpassFlat_.resize(static_cast<size_t>(MaxBands * kMaxSplits));
163
164 // Re-sync smoothers to the CURRENT targets. prepare() used to reset
165 // all split frequencies to the log-spaced defaults, silently discarding
166 // any configuration made before prepare().
167 for (int i = 0; i < kMaxSplits; ++i)
168 {
169 freqSmoothers_[i].prepare(spec.sampleRate, 5.0);
170 freqSmoothers_[i].setSmoothingType(SmoothedValue<T>::SmoothingType::Exponential);
171 T target = targetFrequencies_[i].load(std::memory_order_relaxed);
172 freqSmoothers_[i].reset(target);
173 frequencies_[i] = target;
174 }
175
176 lastNumSplits_ = 0;
177 lastMode_ = filterMode_.load(std::memory_order_relaxed);
178 dirty_.store(true, std::memory_order_relaxed);
179 lpMagDirty_.store(true, std::memory_order_relaxed);
180 reset();
181
182 prepared_ = true;
183 }
184
206 AudioBufferView<T>* bandOutputs, int numOutputBands) noexcept
207 {
208 if (!prepared_ || bandOutputs == nullptr) return 0;
209
210 const FilterMode mode = filterMode_.load(std::memory_order_relaxed);
211 const bool lpActive = (mode == FilterMode::LinearPhase) && lpFft_ != nullptr;
212 if (mode != lastMode_)
213 {
214 // Live mode switch: clear filter/overlap state so the incoming
215 // engine does not replay stale history (audible transient is
216 // documented behaviour of an engine switch).
217 lastMode_ = mode;
218 reset();
219 if (lpActive) syncFrequenciesToTargets();
220 }
221
222 // Check if UI requested a frequency update
223 if (freqUpdatePending_.exchange(false, std::memory_order_acquire))
224 {
225 if (lpActive)
226 {
227 // FIR kernels are rebuilt whole per change: apply instantly
228 // (per-sample smoothing cannot apply to a kernel rebuild).
229 syncFrequenciesToTargets();
230 }
231 else
232 {
233 for (int i = 0; i < kMaxSplits; ++i)
234 freqSmoothers_[i].setTargetValue(targetFrequencies_[i].load(std::memory_order_relaxed));
235 }
236 }
237
238 if (dirty_.load(std::memory_order_relaxed) &&
239 dirty_.exchange(false, std::memory_order_acquire))
240 {
241 updateCoefficients();
242 }
243
244 const int n = std::min(numOutputBands, numBands_.load(std::memory_order_relaxed));
245 if (n < 2) return 0;
246
247 // Defensive span clamp: never index past the prepared work buffers
248 // (fixed maxBlockSize stride) or past any output view.
249 int nS = std::min(input.getNumSamples(), spec_.maxBlockSize);
250 for (int b = 0; b < n; ++b)
251 nS = std::min(nS, bandOutputs[b].getNumSamples());
252 if (nS <= 0) return 0;
253
254 if (lpActive)
255 processLinearPhase(input, bandOutputs, n, nS);
256 else
257 processIIR(input, bandOutputs, n, nS);
258
259 passExtraChannels(input, bandOutputs, n, nS);
260 return n;
261 }
262
263 // -- Configuration -------------------------------------------------------
264
272 void setNumBands(int n) noexcept
273 {
274 numBands_.store(std::clamp(n, 2, MaxBands), std::memory_order_relaxed);
275 initDefaultFrequencies();
276 dirty_.store(true, std::memory_order_release);
277 lpMagDirty_.store(true, std::memory_order_release);
278 }
279
286 void setCrossoverFrequency(int index, T freqHz) noexcept
287 {
288 if (!std::isfinite(freqHz)) return;
289 if (index >= 0 && index < numBands_.load(std::memory_order_relaxed) - 1)
290 {
291 freqHz = std::max(freqHz, T(1));
292
293 // Read all current targets into a local array to sort them
294 std::array<T, kMaxSplits> localTargets;
295 for (int i = 0; i < kMaxSplits; ++i)
296 localTargets[i] = targetFrequencies_[i].load(std::memory_order_relaxed);
297
298 localTargets[static_cast<size_t>(index)] = freqHz;
299
300 // Sort on the UI thread to avoid Audio Thread priority inversion/CPU
301 // spikes. activeSplits is in [0, kMaxSplits] (numBands_ is clamped to
302 // [2, MaxBands]); an explicit insertion sort over this tiny fixed
303 // array is exact for the <=11 elements and avoids libstdc++'s generic
304 // std::sort, whose internal threshold constant trips a spurious GCC
305 // -O2 -Warray-bounds on the small std::array.
306 const int activeSplits = std::clamp(numBands_.load(std::memory_order_relaxed) - 1, 0, kMaxSplits);
307 for (int i = 1; i < activeSplits; ++i)
308 {
309 const T key = localTargets[static_cast<size_t>(i)];
310 int j = i - 1;
311 while (j >= 0 && localTargets[static_cast<size_t>(j)] > key)
312 {
313 localTargets[static_cast<size_t>(j + 1)] = localTargets[static_cast<size_t>(j)];
314 --j;
315 }
316 localTargets[static_cast<size_t>(j + 1)] = key;
317 }
318
319 for (int i = 0; i < kMaxSplits; ++i)
320 targetFrequencies_[i].store(localTargets[static_cast<size_t>(i)], std::memory_order_relaxed);
321
322 freqUpdatePending_.store(true, std::memory_order_release);
323 dirty_.store(true, std::memory_order_release);
324 lpMagDirty_.store(true, std::memory_order_release);
325 }
326 }
327
329 void setOrder(int order) noexcept
330 {
331 if (order == 12 || order == 24 || order == 48)
332 {
333 order_.store(order, std::memory_order_relaxed);
334 dirty_.store(true, std::memory_order_release);
335 lpMagDirty_.store(true, std::memory_order_release);
336 }
337 }
338
340 void setFilterMode(FilterMode mode) noexcept
341 {
342 const int m = std::clamp(static_cast<int>(mode), 0, 1);
343 filterMode_.store(static_cast<FilterMode>(m), std::memory_order_relaxed);
344 dirty_.store(true, std::memory_order_release);
345 lpMagDirty_.store(true, std::memory_order_release);
346 }
347
348 // -- Queries -------------------------------------------------------------
349
350 [[nodiscard]] int getNumBands() const noexcept { return numBands_.load(std::memory_order_relaxed); }
351 [[nodiscard]] int getOrder() const noexcept { return order_.load(std::memory_order_relaxed); }
352 [[nodiscard]] FilterMode getFilterMode() const noexcept { return filterMode_.load(std::memory_order_relaxed); }
353
355 [[nodiscard]] T getCrossoverFrequency(int index) const noexcept
356 {
357 if (index < 0 || index >= kMaxSplits) return T(0);
358 return targetFrequencies_[static_cast<size_t>(index)].load(std::memory_order_relaxed);
359 }
360
367 [[nodiscard]] int getLatency() const noexcept
368 {
369 return (filterMode_.load(std::memory_order_relaxed) == FilterMode::LinearPhase && lpFft_ != nullptr)
370 ? lpLatency_ : 0;
371 }
372
373 void reset() noexcept
374 {
375 for (auto& sp : splits_)
376 {
377 for (auto& b : sp.lp) b.reset();
378 for (auto& b : sp.hp) b.reset();
379 }
380 for (auto& apChain : allpassFlat_)
381 for (auto& b : apChain.stages) b.reset();
382
383 std::fill(lpPrevBlockFlat_.begin(), lpPrevBlockFlat_.end(), T(0));
384 }
385
386
388 [[nodiscard]] std::vector<uint8_t> getState() const
389 {
390 StateWriter w(stateId("XOVR"), 1);
391 const int n = getNumBands();
392 w.write("numBands", n);
393 w.write("order", getOrder());
394 w.write("mode", static_cast<int32_t>(getFilterMode()));
395 char key[16];
396 for (int i = 0; i < n - 1; ++i)
397 {
398 std::snprintf(key, sizeof(key), "x%d", i);
399 w.write(key, static_cast<float>(getCrossoverFrequency(i)));
400 }
401 return w.blob();
402 }
403
405 bool setState(const uint8_t* data, size_t size)
406 {
407 StateReader r(data, size);
408 if (!r.isValid() || r.processorId() != stateId("XOVR")) return false;
409 setNumBands(std::clamp(r.read("numBands", 2), 2, MaxBands));
410 setOrder(r.read("order", 24));
411 setFilterMode(static_cast<FilterMode>(std::clamp(r.read("mode", 0), 0, 1)));
412 const int n = getNumBands();
413 char key[16];
414 for (int i = 0; i < n - 1; ++i)
415 {
416 std::snprintf(key, sizeof(key), "x%d", i);
417 const float f = r.read(key, -1.0f);
418 if (f > 0.0f) setCrossoverFrequency(i, static_cast<T>(f));
419 }
420 return true;
421 }
422
423private:
424 static constexpr int kMaxSplits = MaxBands - 1;
425 static constexpr int kMaxStagesPerFilter = 4;
426 static constexpr int kLpMaxBlockSize = 1 << 18;
427
428 struct SplitPoint
429 {
430 std::array<Biquad<T, 16>, kMaxStagesPerFilter> lp;
431 std::array<Biquad<T, 16>, kMaxStagesPerFilter> hp;
432 };
433
434 struct AllPassChain
435 {
436 std::array<Biquad<T, 16>, kMaxStagesPerFilter> stages;
437 };
438
448 [[nodiscard]] static BiquadCoeffs makeFirstOrderAllPass(double sampleRate, double freq) noexcept
449 {
450 freq = std::clamp(freq, 1.0, std::max(1.0, sampleRate * 0.499));
451 const double w = std::tan(std::numbers::pi * freq / sampleRate);
452 const double a = (w - 1.0) / (w + 1.0);
453 BiquadCoeffs coeffs;
454 coeffs.b0 = a;
455 coeffs.b1 = 1.0;
456 coeffs.b2 = 0.0;
457 coeffs.a1 = a;
458 coeffs.a2 = 0.0;
459 return coeffs;
460 }
461
462 void initDefaultFrequencies() noexcept
463 {
464 int numSplits = numBands_.load(std::memory_order_relaxed) - 1;
465 const T logMin = std::log(T(100));
466 const T logMax = std::log(T(10000));
467
468 for (int s = 0; s < numSplits; ++s)
469 {
470 T t = static_cast<T>(s + 1) / static_cast<T>(numSplits + 1);
471 targetFrequencies_[s].store(std::exp(logMin + t * (logMax - logMin)), std::memory_order_relaxed);
472 }
473 freqUpdatePending_.store(true, std::memory_order_release);
474 }
475
479 void syncFrequenciesToTargets() noexcept
480 {
481 for (int i = 0; i < kMaxSplits; ++i)
482 {
483 const T t = targetFrequencies_[i].load(std::memory_order_relaxed);
484 frequencies_[i] = t;
485 freqSmoothers_[i].reset(t);
486 }
487 }
488
489 [[nodiscard]] static double clampSplitFreq(double f, double sr) noexcept
490 {
491 return std::clamp(f, 20.0, sr * 0.499);
492 }
493
494 void updateCoefficients() noexcept
495 {
496 if (spec_.sampleRate <= 0) return;
497
498 int numSplits = numBands_.load(std::memory_order_relaxed) - 1;
499 double sr = spec_.sampleRate;
500
501 // Splits (and their allpass chains) re-activated by a band count
502 // increase would otherwise replay arbitrarily old filter history.
503 if (numSplits > lastNumSplits_)
504 {
505 for (int s = lastNumSplits_; s < numSplits; ++s)
506 {
507 for (auto& b : splits_[s].lp) b.reset();
508 for (auto& b : splits_[s].hp) b.reset();
509 for (int b = 0; b < s; ++b)
510 for (auto& st : allpassFlat_[static_cast<size_t>(b * kMaxSplits + s)].stages) st.reset();
511 }
512 }
513 lastNumSplits_ = numSplits;
514
515 // The phase-correction allpass per band/split must equal the allpass
516 // that the split's LP/HP branches sum to: ONE first-order section for
517 // LR12 (LP1^2 - HP1^2 = AP1 exactly), ONE second-order section for
518 // LR24 (LP2^2 + HP2^2 = AP2(Q=0.7071)), TWO sections (q1, q2) for
519 // LR48. Applying numStagesPerFilter_ sections (the old behaviour)
520 // doubled the correction phase and carved up to -3.5 dB (LR24) /
521 // -13 dB (LR48) holes into the band sum with octave-spaced splits.
522 // Note: Frequencies are already guaranteed to be sorted by the setter thread.
523 switch (order_.load(std::memory_order_relaxed))
524 {
525 case 12:
526 numStagesPerFilter_ = 2;
527 numStagesAllpass_ = 1;
528 for (int s = 0; s < numSplits; ++s)
529 {
530 double f = clampSplitFreq(static_cast<double>(frequencies_[s]), sr);
531 auto lpC = BiquadCoeffs::makeFirstOrderLowPass(sr, f);
532 auto hpC = BiquadCoeffs::makeFirstOrderHighPass(sr, f);
533 auto apC = makeFirstOrderAllPass(sr, f);
534
535 // LR12 sums flat only with the high branch polarity
536 // inverted (LP^2 - HP^2 = allpass; LP^2 + HP^2 notches at
537 // fc). Bake the inversion into the first HP stage: odd
538 // bands come out inverted, the sum is allpass-flat.
539 auto hpC0 = hpC;
540 hpC0.b0 = -hpC0.b0;
541 hpC0.b1 = -hpC0.b1;
542 hpC0.b2 = -hpC0.b2;
543
544 splits_[s].lp[0].setCoeffs(lpC);
545 splits_[s].lp[1].setCoeffs(lpC);
546 splits_[s].hp[0].setCoeffs(hpC0);
547 splits_[s].hp[1].setCoeffs(hpC);
548 for (int b = 0; b < s; ++b)
549 allpassFlat_[static_cast<size_t>(b * kMaxSplits + s)].stages[0].setCoeffs(apC);
550 }
551 break;
552
553 case 24:
554 numStagesPerFilter_ = 2;
555 numStagesAllpass_ = 1;
556 for (int s = 0; s < numSplits; ++s)
557 {
558 double f = clampSplitFreq(static_cast<double>(frequencies_[s]), sr);
559 auto lpC = BiquadCoeffs::makeLowPass(sr, f, 0.7071);
560 auto hpC = BiquadCoeffs::makeHighPass(sr, f, 0.7071);
561 auto apC = BiquadCoeffs::makeAllPass(sr, f, 0.7071);
562
563 for (int st = 0; st < 2; ++st)
564 {
565 splits_[s].lp[st].setCoeffs(lpC);
566 splits_[s].hp[st].setCoeffs(hpC);
567 }
568 for (int b = 0; b < s; ++b)
569 allpassFlat_[static_cast<size_t>(b * kMaxSplits + s)].stages[0].setCoeffs(apC);
570 }
571 break;
572
573 case 48:
574 {
575 numStagesPerFilter_ = 4;
576 numStagesAllpass_ = 2;
577 constexpr double q1 = 0.5412;
578 constexpr double q2 = 1.3066;
579 const double qArr[4] = { q1, q2, q1, q2 };
580
581 for (int s = 0; s < numSplits; ++s)
582 {
583 double f = clampSplitFreq(static_cast<double>(frequencies_[s]), sr);
584
585 for (int st = 0; st < 4; ++st)
586 {
587 splits_[s].lp[st].setCoeffs(BiquadCoeffs::makeLowPass(sr, f, qArr[st]));
588 splits_[s].hp[st].setCoeffs(BiquadCoeffs::makeHighPass(sr, f, qArr[st]));
589 }
590 for (int b = 0; b < s; ++b)
591 {
592 auto& chain = allpassFlat_[static_cast<size_t>(b * kMaxSplits + s)];
593 chain.stages[0].setCoeffs(BiquadCoeffs::makeAllPass(sr, f, q1));
594 chain.stages[1].setCoeffs(BiquadCoeffs::makeAllPass(sr, f, q2));
595 }
596 }
597 break;
598 }
599
600 default: break;
601 }
602 }
603
604 void processIIR(AudioBufferView<T> input, AudioBufferView<T>* outputs, int numBands, int nS) noexcept
605 {
606 DenormalGuard guard;
607 const int nCh = std::min(input.getNumChannels(), numChannels_);
608 const int numSplits = numBands - 1;
609
610 bool anySmoothing = false;
611 for (int i = 0; i < numSplits; ++i)
612 anySmoothing = anySmoothing || freqSmoothers_[i].isSmoothing();
613
614 if (anySmoothing)
615 {
616 constexpr int kSubBlockSize = 32;
617 int offset = 0;
618 while (offset < nS)
619 {
620 int blockLen = std::min(kSubBlockSize, nS - offset);
621 for (int i = 0; i < numSplits; ++i)
622 {
623 for (int s = 0; s < blockLen; ++s)
624 (void)freqSmoothers_[i].getNextValue();
625 frequencies_[i] = freqSmoothers_[i].getCurrentValue();
626 }
627 updateCoefficients();
628 processIIRRange(input, outputs, numBands, nCh, offset, blockLen);
629 offset += blockLen;
630 }
631 }
632 else
633 {
634 processIIRRange(input, outputs, numBands, nCh, 0, nS);
635 }
636 }
637
638 inline T* getWorkBufChannel(int ch) noexcept
639 {
640 return workBuf_.data() + static_cast<size_t>(ch) * static_cast<size_t>(spec_.maxBlockSize);
641 }
642
643 void processIIRRange(AudioBufferView<T> input, AudioBufferView<T>* outputs, int numBands, int nCh, int offset, int blockLen) noexcept
644 {
645 const int numSplits = numBands - 1;
646
647 for (int ch = 0; ch < nCh; ++ch)
648 {
649 const T* src = input.getChannel(ch) + offset;
650 T* dst = getWorkBufChannel(ch);
651 std::copy(src, src + blockLen, dst);
652 }
653
654 for (int s = 0; s < numSplits; ++s)
655 {
656 for (int i = 0; i < blockLen; ++i)
657 {
658 for (int ch = 0; ch < nCh; ++ch)
659 {
660 T* workCh = getWorkBufChannel(ch);
661 const double sample = static_cast<double>(workCh[i]);
662
663 // The cascade stays in the biquad core's own precision:
664 // re-quantising to T between stages would put the
665 // reconstruction error back where the double core removed it.
666 double lpSample = sample;
667 for (int st = 0; st < numStagesPerFilter_; ++st)
668 lpSample = splits_[s].lp[st].processSampleCore(lpSample, ch);
669 outputs[s].getChannel(ch)[offset + i] = static_cast<T>(lpSample);
670
671 double hpSample = sample;
672 for (int st = 0; st < numStagesPerFilter_; ++st)
673 hpSample = splits_[s].hp[st].processSampleCore(hpSample, ch);
674 workCh[i] = static_cast<T>(hpSample);
675 }
676 }
677 }
678
679 for (int ch = 0; ch < nCh; ++ch)
680 {
681 T* dst = outputs[numBands - 1].getChannel(ch) + offset;
682 const T* src = getWorkBufChannel(ch);
683 std::copy(src, src + blockLen, dst);
684 }
685
686 for (int s = 1; s < numSplits; ++s)
687 {
688 for (int b = 0; b < s; ++b)
689 {
690 for (int i = 0; i < blockLen; ++i)
691 {
692 for (int ch = 0; ch < nCh; ++ch)
693 {
694 double sample = static_cast<double>(outputs[b].getChannel(ch)[offset + i]);
695 for (int st = 0; st < numStagesAllpass_; ++st)
696 sample = allpassFlat_[static_cast<size_t>(b * kMaxSplits + s)].stages[st].processSampleCore(sample, ch);
697 outputs[b].getChannel(ch)[offset + i] = static_cast<T>(sample);
698 }
699 }
700 }
701 }
702 }
703
707 void passExtraChannels(AudioBufferView<T> input, AudioBufferView<T>* outputs, int numBands, int nS) noexcept
708 {
709 const int inCh = input.getNumChannels();
710 for (int ch = numChannels_; ch < inCh; ++ch)
711 {
712 for (int b = 0; b < numBands; ++b)
713 {
714 if (ch >= outputs[b].getNumChannels()) continue;
715 T* dst = outputs[b].getChannel(ch);
716 if (b == 0)
717 {
718 const T* src = input.getChannel(ch);
719 std::copy(src, src + nS, dst);
720 }
721 else
722 {
723 std::fill(dst, dst + nS, T(0));
724 }
725 }
726 }
727 }
728
729 void recomputeLinearPhaseMagnitudes() noexcept
730 {
731 if (lpFftSize_ == 0) return;
732 if (!lpMagDirty_.load(std::memory_order_relaxed) ||
733 !lpMagDirty_.exchange(false, std::memory_order_acquire))
734 return;
735
736 const int numBins = lpFftSize_ / 2 + 1;
737 const int numSplits = numBands_.load(std::memory_order_relaxed) - 1;
738 const double sr = spec_.sampleRate;
739
740 int expo = 0;
741 switch (order_.load(std::memory_order_relaxed))
742 {
743 case 12: expo = 2; break;
744 case 24: expo = 4; break;
745 case 48: expo = 8; break;
746 }
747
748 // Ideal zero-phase magnitudes go into the pre-allocated flat scratch
749 // lpIdealMagsFlat_[band * numBins + bin] - no audio-thread allocation.
750 const int nBands = numBands_.load(std::memory_order_relaxed);
751
752 for (int k = 0; k < numBins; ++k)
753 {
754 double freq = sr * static_cast<double>(k) / static_cast<double>(lpFftSize_);
755 T lpMag[kMaxSplits], hpMag[kMaxSplits];
756
757 for (int s = 0; s < numSplits; ++s)
758 {
759 double fc = clampSplitFreq(static_cast<double>(frequencies_[s]), sr);
760 double ratio = freq / fc;
761 // Ideal Linkwitz-Riley magnitude |LP| = 1/(1 + (f/fc)^N) with
762 // N = expo = order/6 (2/4/8 -> 12/24/48 dB/oct). This must match the
763 // IIR path's slope; the exponent was previously expo*2, which made the
764 // linear-phase crossover twice as steep as the selected order.
765 double rPow = std::pow(ratio, static_cast<double>(expo));
766
767 double denom = 1.0 + rPow;
768 lpMag[s] = static_cast<T>(1.0 / denom);
769 hpMag[s] = static_cast<T>(rPow / denom);
770 }
771
772 lpIdealMagsFlat_[k] = lpMag[0]; // band 0 (lowpass)
773 for (int b = 1; b < nBands - 1; ++b)
774 lpIdealMagsFlat_[static_cast<size_t>(b * numBins + k)] = hpMag[b - 1] * lpMag[b];
775 lpIdealMagsFlat_[static_cast<size_t>((nBands - 1) * numBins + k)] = hpMag[numSplits - 1];
776 }
777
778 // Window Design & FIR Kernel generation (pre-allocated scratch)
779 for (int b = 0; b < nBands; ++b)
780 {
781 // Prepare complex bins for inverse FFT (zero phase)
782 for (int k = 0; k < numBins; ++k)
783 {
784 lpTimeResponse_[static_cast<size_t>(2 * k)] = lpIdealMagsFlat_[static_cast<size_t>(b * numBins + k)];
785 lpTimeResponse_[static_cast<size_t>(2 * k + 1)] = T(0);
786 }
787
788 lpFft_->inverse(lpTimeResponse_.data(), lpFirKernel_.data());
789
790 // Circular shift, windowing (Blackman), and zero-padding
791 std::fill(lpFftIn_.begin(), lpFftIn_.end(), T(0));
792 int halfLen = firLength_ / 2;
793 const double N = static_cast<double>(firLength_ - 1);
794
795 for (int i = 0; i < firLength_; ++i)
796 {
797 // Un-wrap circular time response to causal shifted response
798 int srcIdx = (i - halfLen + lpFftSize_) % lpFftSize_;
799
800 // Blackman Window (degenerate 1-tap kernel: unity)
801 double window = 1.0;
802 if (N >= 1.0)
803 {
804 double n = static_cast<double>(i);
805 window = 0.42 - 0.5 * std::cos(2.0 * std::numbers::pi * n / N) + 0.08 * std::cos(4.0 * std::numbers::pi * n / N);
806 }
807
808 lpFftIn_[static_cast<size_t>(i)] = lpFirKernel_[static_cast<size_t>(srcIdx)] * static_cast<T>(window);
809 }
810
811 // Forward FFT to get the usable overlap-save kernel
812 lpFft_->forward(lpFftIn_.data(), lpFftOut_.data());
813
814 // Store complex spectrum contiguously
815 T* bandMagData = lpMagnitudesFlat_.data() + static_cast<size_t>(b * numBins * 2);
816 std::copy(lpFftOut_.begin(), lpFftOut_.begin() + (numBins * 2), bandMagData);
817 }
818 }
819
820 inline T* getPrevBlockChannel(int ch) noexcept
821 {
822 return lpPrevBlockFlat_.data() + static_cast<size_t>(ch) * static_cast<size_t>(firLength_);
823 }
824
825 void processLinearPhase(AudioBufferView<T> input, AudioBufferView<T>* outputs, int numBands, int nS) noexcept
826 {
827 recomputeLinearPhaseMagnitudes();
828
829 const int nCh = std::min(input.getNumChannels(), numChannels_);
830 const int numBins = lpFftSize_ / 2 + 1;
831 const int overlapSize = firLength_ - 1;
832
833 for (int ch = 0; ch < nCh; ++ch)
834 {
835 T* channelData = input.getChannel(ch);
836 T* prev = getPrevBlockChannel(ch);
837
838 // Overlap-Save: [overlap block | new samples | zero pad]
839 for (int i = 0; i < overlapSize; ++i)
840 lpFftIn_[static_cast<size_t>(i)] = prev[i];
841
842 for (int i = 0; i < nS; ++i)
843 lpFftIn_[static_cast<size_t>(overlapSize + i)] = channelData[i];
844
845 for (int i = overlapSize + nS; i < lpFftSize_; ++i)
846 lpFftIn_[static_cast<size_t>(i)] = T(0);
847
848 // Save tail for next block
849 for (int i = 0; i < overlapSize; ++i)
850 {
851 if (nS - overlapSize + i >= 0)
852 prev[i] = channelData[nS - overlapSize + i];
853 else
854 prev[i] = lpFftIn_[static_cast<size_t>(nS + i)]; // Handle block size < overlap
855 }
856
857 lpFft_->forward(lpFftIn_.data(), lpFftOut_.data());
858
859 for (int b = 0; b < numBands; ++b)
860 {
861 const T* kernelSpectrum = lpMagnitudesFlat_.data() + static_cast<size_t>(b * numBins * 2);
862
863 // Complex multiplication
864 for (int k = 0; k < numBins; ++k)
865 {
866 T r1 = lpFftOut_[static_cast<size_t>(2 * k)];
867 T i1 = lpFftOut_[static_cast<size_t>(2 * k + 1)];
868 T r2 = kernelSpectrum[2 * k];
869 T i2 = kernelSpectrum[2 * k + 1];
870
871 lpBandFft_[static_cast<size_t>(2 * k)] = r1 * r2 - i1 * i2;
872 lpBandFft_[static_cast<size_t>(2 * k + 1)] = r1 * i2 + i1 * r2;
873 }
874
875 lpFft_->inverse(lpBandFft_.data(), lpFftResult_.data());
876
877 // Discard garbage and copy valid output
878 T* outCh = outputs[b].getChannel(ch);
879 for (int i = 0; i < nS; ++i)
880 outCh[i] = lpFftResult_[static_cast<size_t>(overlapSize + i)];
881 }
882 }
883 }
884
885 // -- Members -------------------------------------------------------------
886
887 AudioSpec spec_ {};
888 bool prepared_ = false;
889 std::atomic<int> numBands_ { 2 };
890 std::atomic<int> order_ { 24 };
891 int numChannels_ = 2;
892 std::atomic<FilterMode> filterMode_ { FilterMode::MinimumPhase };
893 std::atomic<bool> dirty_ { true };
894
895 std::array<std::atomic<T>, kMaxSplits> targetFrequencies_ {};
896 std::atomic<bool> freqUpdatePending_ { false };
897
898 std::array<T, kMaxSplits> frequencies_ {};
899 std::array<SmoothedValue<T>, kMaxSplits> freqSmoothers_;
900
901 int numStagesPerFilter_ = 2;
902 int numStagesAllpass_ = 1;
903 int lastNumSplits_ = 0;
905 std::array<SplitPoint, kMaxSplits> splits_ {};
906
907 // Flattened data structures for cache locality
908 std::vector<AllPassChain> allpassFlat_;
909 std::vector<T> workBuf_;
910
911 // Linear-phase state
912 std::unique_ptr<FFTReal<T>> lpFft_;
913 int lpFftSize_ = 0;
914 int firLength_ = 0;
915 int lpLatency_ = 0;
916 std::atomic<bool> lpMagDirty_ { true };
917
918 std::vector<T> lpMagnitudesFlat_; // [band * numBins * 2 + complexIdx]
919 std::vector<T> lpPrevBlockFlat_; // [ch * firLength_ + sampleIdx]
920 std::vector<T> lpFftIn_, lpFftOut_, lpBandFft_, lpFftResult_;
921 std::vector<T> lpIdealMagsFlat_; // [band * numBins + bin] ideal zero-phase mags (recompute scratch)
922 std::vector<T> lpTimeResponse_; // IFFT input scratch (lpFftSize_ + 2)
923 std::vector<T> lpFirKernel_; // IFFT output scratch (lpFftSize_)
924};
925
926} // namespace dspark
Non-owning view over audio channel data.
Definition AudioBuffer.h:50
Linkwitz-Riley crossover with 2-12 bands, LR12/LR24/LR48.
int getLatency() const noexcept
Returns latency in samples (0 for IIR, FIR group delay for linear-phase).
void setOrder(int order) noexcept
Sets the crossover slope: 12, 24 or 48 (dB/oct). Other values are ignored.
int getNumBands() const noexcept
void setNumBands(int n) noexcept
Sets the number of output bands (2..MaxBands).
bool setState(const uint8_t *data, size_t size)
Restores the split topology from a blob.
void setCrossoverFrequency(int index, T freqHz) noexcept
Sets a crossover frequency. Automatically maintains sorting.
T getCrossoverFrequency(int index) const noexcept
Returns the target frequency of split point index.
int processBlock(AudioBufferView< T > input, AudioBufferView< T > *bandOutputs, int numOutputBands) noexcept
Splits input into separate band outputs.
FilterMode
Filter processing mode.
@ LinearPhase
FFT-based FIR crossover (introduces latency, zero phase distortion, introduces pre-ringing).
@ MinimumPhase
IIR biquads with allpass phase correction (zero latency).
FilterMode getFilterMode() const noexcept
std::vector< uint8_t > getState() const
Serializes the split topology (setup/UI threads; allocates).
int getOrder() const noexcept
void setFilterMode(FilterMode mode) noexcept
Sets the processing mode. A live switch resets filter state (transient).
void prepare(const AudioSpec &spec)
Prepares the crossover for processing.
Zero-allocation parameter smoother for real-time audio.
Tolerant reader: missing keys yield defaults, unknown keys are skipped.
Definition StateBlob.h:160
float read(const char *key, float defaultValue) const
Reads a float, or defaultValue when the key is absent.
Definition StateBlob.h:203
bool isValid() const noexcept
Definition StateBlob.h:198
uint32_t processorId() const noexcept
Definition StateBlob.h:199
Serializes key/value parameters into a versioned blob.
Definition StateBlob.h:52
std::vector< uint8_t > blob() const
Finalizes and returns the blob.
Definition StateBlob.h:104
void write(const char *key, float value)
Writes a float parameter.
Definition StateBlob.h:70
Main namespace for the DSPark framework.
constexpr uint32_t stateId(const char(&tag)[5]) noexcept
Builds a FOURCC processor id, e.g. dspark::stateId("COMP").
Definition StateBlob.h:639
Describes the audio environment for a DSP processor.
Definition AudioSpec.h:35
constexpr bool isValid() const noexcept
Checks if the specification contains valid, processable parameters.
Definition AudioSpec.h:67
int numChannels
Number of audio channels (e.g., 1 = mono, 2 = stereo).
Definition AudioSpec.h:56
int maxBlockSize
Maximum number of samples per processing block.
Definition AudioSpec.h:51
double sampleRate
Sample rate in Hz.
Definition AudioSpec.h:43
static BiquadCoeffs makeFirstOrderHighPass(double sampleRate, double frequency) noexcept
First-order (6 dB/oct) high-pass filter.
Definition Biquad.h:418
static BiquadCoeffs makeHighPass(double sampleRate, double freq, double Q=0.7071067811865476) noexcept
High-pass filter.
Definition Biquad.h:127
static BiquadCoeffs makeAllPass(double sampleRate, double freq, double Q=0.7071067811865476) noexcept
All-pass filter.
Definition Biquad.h:366
static BiquadCoeffs makeFirstOrderLowPass(double sampleRate, double frequency) noexcept
First-order (6 dB/oct) low-pass filter.
Definition Biquad.h:395
static BiquadCoeffs makeLowPass(double sampleRate, double freq, double Q=0.7071067811865476) noexcept
Low-pass filter.
Definition Biquad.h:103