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