DSPark 1.6.1
Header-only audio DSP framework in pure C++20 — zero dependencies
Loading...
Searching...
No Matches
Oversampling.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
29#include "AudioBuffer.h"
30#include "AudioSpec.h"
31#include "FIRFilter.h"
32#include "SimdOps.h"
33
34#include <algorithm>
35#include <array>
36#include <cassert>
37#include <cstring>
38#include <vector>
39
40namespace dspark {
41
66template <typename T, int MaxChannels = 16>
68{
69public:
73 enum class Quality
74 {
75 Low,
76 Medium,
77 High,
78 Maximum
79 };
80
89 explicit Oversampling(int factor = 2, Quality quality = Quality::High)
90 : quality_(quality)
91 {
92 assert(factor >= 1 && (factor & (factor - 1)) == 0 && "Factor must be a power of 2");
93 assert(factor <= (1 << kMaxStages) && "Factor must be <= 16 (kMaxStages stages)");
94 // Release-safe normalisation: clamp into [1, 2^kMaxStages], then derive
95 // the stage count and re-derive factor_ = 2^numStages_ so the pair stays
96 // coherent even for a non-power-of-two request. An incoherent pair
97 // (e.g. factor_ = 3 driving one 2x stage) would make downsample() emit
98 // factor_/2 samples per base sample -- overrunning the caller's output
99 // buffer -- and overflow the per-stage histories sized for 2^numStages_.
100 factor = std::clamp(factor, 1, 1 << kMaxStages);
101 numStages_ = 0;
102 while ((1 << (numStages_ + 1)) <= factor)
103 ++numStages_;
104 factor_ = 1 << numStages_;
105 }
106
112 void prepare(const AudioSpec& spec)
113 {
114 baseSpec_ = spec;
115 upBuffer_.resize(spec.numChannels, spec.maxBlockSize * factor_);
116
117 const int taps = tapsForQuality(quality_);
118 const T beta = betaForQuality(quality_);
119
120 for (int stage = 0; stage < numStages_; ++stage)
121 {
122 // The max block size entering a stage is baseSize * 2^stage
123 int stageMaxSamples = spec.maxBlockSize * (1 << stage);
124 filters_[stage].design(taps, beta, spec.numChannels, stageMaxSamples);
125 }
126
127 reset();
128 }
129
134 void reset() noexcept
135 {
136 upBuffer_.clear();
137 for (int i = 0; i < numStages_; ++i)
138 filters_[i].reset();
139 }
140
142 [[nodiscard]] int getFactor() const noexcept { return factor_; }
143
145 [[nodiscard]] Quality getQuality() const noexcept { return quality_; }
146
151 [[nodiscard]] int getLatency() const noexcept
152 {
153 if (numStages_ == 0) return 0;
154 const int halfOrder = (tapsForQuality(quality_) - 1) / 2;
155 // Exact up->down round-trip group delay at the base rate. Each 2x half-band
156 // stage contributes (halfOrder+1) high-rate samples on the way up and the
157 // same on the way down; cascaded over numStages_ and referred to the base
158 // rate this telescopes to 2*(halfOrder+1)*(factor-1)/factor. Verified
159 // sample-exact against an impulse round-trip for factors 2/4/8/16 and all
160 // quality presets. (halfOrder+1 and factor are powers of two -> exact.)
161 return 2 * (halfOrder + 1) * (factor_ - 1) / factor_;
162 }
163
175 {
176 const int nCh = std::min(input.getNumChannels(), upBuffer_.getNumChannels());
177 // Release-safe clamp: a block larger than prepare()'s maxBlockSize would
178 // overflow the per-stage histories and the internal high-rate buffer.
179 const int nS = std::min(input.getNumSamples(), baseSpec_.maxBlockSize);
180
181 if (numStages_ == 0)
182 {
183 for (int ch = 0; ch < nCh; ++ch)
184 std::memcpy(upBuffer_.getChannel(ch), input.getChannel(ch), static_cast<std::size_t>(nS) * sizeof(T));
185 return upBuffer_.toView().getSubView(0, nS);
186 }
187
188 // Stage 0: Read directly from input view
189 filters_[0].processUpsample(input, upBuffer_.toView(), nCh, nS);
190
191 // Subsequent stages: in-place upsampling inside upBuffer_
192 int currentLen = nS * 2;
193 for (int stage = 1; stage < numStages_; ++stage)
194 {
195 filters_[stage].processUpsampleInPlace(upBuffer_.toView(), nCh, currentLen);
196 currentLen *= 2;
197 }
198
199 return upBuffer_.toView().getSubView(0, nS * factor_);
200 }
201
212 void downsample(AudioBufferView<T> output) noexcept
213 {
214 const int nCh = std::min(output.getNumChannels(), upBuffer_.getNumChannels());
215 const int nS = std::min(output.getNumSamples(), baseSpec_.maxBlockSize);
216
217 if (numStages_ == 0)
218 {
219 for (int ch = 0; ch < nCh; ++ch)
220 std::memcpy(output.getChannel(ch), upBuffer_.getChannel(ch), static_cast<std::size_t>(nS) * sizeof(T));
221 return;
222 }
223
224 int currentLen = nS * factor_;
225
226 for (int stage = numStages_ - 1; stage > 0; --stage)
227 {
228 filters_[stage].processDownsampleInPlace(upBuffer_.toView(), nCh, currentLen);
229 currentLen /= 2;
230 }
231
232 // Stage 0: Write directly to final output
233 filters_[0].processDownsample(upBuffer_.toView(), output, nCh, currentLen);
234
235 // Level transparency note: no gain compensation is applied (or needed).
236 // An impulse round-trip peaks slightly below 1.0 only because the
237 // impulse contains supra-Nyquist energy the anti-alias filters must
238 // remove; in-band sine-wave gain is already ~1.000 (verified to better
239 // than 0.01 dB across all quality presets).
240 }
241
242private:
243 static constexpr int kMaxStages = 4;
244
245 // ========================================================================
246 // Polyphase Block Half-Band FIR
247 // ========================================================================
252 struct PolyphaseHalfBand
253 {
254 std::vector<T> evenTaps;
255 std::vector<T> fullTaps;
256 T centerTap = T(0);
257 int halfOrder = 0;
258 int delaySamples = 0;
259
264 std::vector<T> history;
265 };
266 std::vector<ChannelState> upChannels;
267 std::vector<ChannelState> downChannels;
268
276 void design(int taps, T beta, int numChannels, int maxBlockSamples)
277 {
278 halfOrder = (taps - 1) / 2;
279 // INVARIANT: the polyphase split below collects the array-even taps,
280 // which coincide with the non-zero (odd-from-centre) half-band taps
281 // ONLY when halfOrder is odd. All quality presets (taps 31/63/127/255
282 // -> halfOrder 15/31/63/127) satisfy this. If you add a preset, keep
283 // (taps-1)/2 odd or the filter degenerates to silence.
284 assert((halfOrder & 1) == 1 && "half-band polyphase requires odd halfOrder");
285 // Pure-delay alignment for the centre-tap (odd) phase. With the full
286 // even branch (halfOrder+1 taps, group delay (halfOrder)/2 = numTaps/2
287 // base samples) the centre sample must sit numTaps/2 = (halfOrder+1)/2
288 // samples into the window to stay phase-aligned with the even phase.
289 delaySamples = (halfOrder + 1) / 2;
290
291 auto fullCoeffs = FIRDesign<T>::lowPass(1.0, 0.25, taps, beta);
292 centerTap = fullCoeffs[static_cast<std::size_t>(halfOrder)];
293
294 // Even polyphase branch = ALL even-array-index coefficients of the
295 // symmetric half-band (these are the non-zero off-centre taps, since
296 // the centre sits at the odd index halfOrder). Used by the upsample
297 // FIR phase. (halfOrder is odd, so i never equals halfOrder.)
298 evenTaps.clear();
299 for (int i = 0; i < taps; i += 2)
300 evenTaps.push_back(fullCoeffs[static_cast<std::size_t>(i)]);
301
302 // Full symmetric kernel for the downsampler. Decimation is done as a
303 // single, unambiguous high-rate convolution (filter then pick every
304 // other sample), which keeps every tap aligned to its true high-rate
305 // position -- unlike an even/odd polyphase split with independent
306 // delays, which left a 0.5-sample mismatch between the FIR and centre
307 // branches and capped the round-trip SNR.
308 fullTaps.assign(fullCoeffs.begin(), fullCoeffs.end());
309
310 // Up history: base-rate (numTaps + block). Down history: high-rate
311 // ((taps-1) + up to 2*maxBlockSamples high-rate input for this stage).
312 const int upHistorySize = static_cast<int>(evenTaps.size()) + maxBlockSamples;
313 const int downHistorySize = (taps - 1) + 2 * maxBlockSamples;
314
315 upChannels.resize(static_cast<std::size_t>(numChannels));
316 downChannels.resize(static_cast<std::size_t>(numChannels));
317
318 for (int ch = 0; ch < numChannels; ++ch)
319 {
320 upChannels[ch].history.assign(static_cast<std::size_t>(upHistorySize), T(0));
321 downChannels[ch].history.assign(static_cast<std::size_t>(downHistorySize), T(0));
322 }
323 }
324
328 void reset() noexcept
329 {
330 for (auto& ch : upChannels)
331 std::fill(ch.history.begin(), ch.history.end(), T(0));
332 for (auto& ch : downChannels)
333 std::fill(ch.history.begin(), ch.history.end(), T(0));
334 }
335
336 void processUpsample(AudioBufferView<const T> input, AudioBufferView<T> output, int nCh, int nS) noexcept
337 {
338 const int numTaps = static_cast<int>(evenTaps.size());
339 const T* taps = evenTaps.data();
340 const T centre2 = centerTap * T(2);
341
342 for (int ch = 0; ch < nCh; ++ch)
343 {
344 const T* src = input.getChannel(ch);
345 T* dst = output.getChannel(ch);
346 auto& hist = upChannels[ch].history;
347
348 // Append the new block after the fixed numTaps-sample history prefix.
349 std::memcpy(hist.data() + numTaps, src, static_cast<std::size_t>(nS) * sizeof(T));
350
351 const T* histD = hist.data();
352 for (int i = 0; i < nS; ++i)
353 {
354 // Even phase: SIMD FIR over the non-zero half-band taps.
355 dst[i * 2] = simd::dotProductT(taps, histD + i, numTaps) * T(2);
356 // Odd phase: pure delay through the centre tap.
357 dst[i * 2 + 1] = histD[i + numTaps - delaySamples] * centre2;
358 }
359
360 // Save the trailing numTaps input samples for the next block. Using
361 // the current nS keeps this correct under variable block sizes.
362 std::memmove(hist.data(), hist.data() + nS, static_cast<std::size_t>(numTaps) * sizeof(T));
363 }
364 }
365
366 void processUpsampleInPlace(AudioBufferView<T> buffer, int nCh, int currentLen) noexcept
367 {
368 const int numTaps = static_cast<int>(evenTaps.size());
369 const T* taps = evenTaps.data();
370 const T centre2 = centerTap * T(2);
371
372 for (int ch = 0; ch < nCh; ++ch)
373 {
374 T* data = buffer.getChannel(ch);
375 auto& hist = upChannels[ch].history;
376
377 // Append the new block after the fixed numTaps-sample history
378 // prefix. The FIR below reads only from this copy, which is what
379 // makes the x2 in-place expansion of `data` safe.
380 std::memcpy(hist.data() + numTaps, data, static_cast<std::size_t>(currentLen) * sizeof(T));
381
382 const T* histD = hist.data();
383 for (int i = 0; i < currentLen; ++i)
384 {
385 // Even phase: SIMD FIR over the non-zero half-band taps.
386 data[i * 2] = simd::dotProductT(taps, histD + i, numTaps) * T(2);
387 // Odd phase: pure delay through the centre tap.
388 data[i * 2 + 1] = histD[i + numTaps - delaySamples] * centre2;
389 }
390
391 // Save the trailing numTaps input samples for the next block. Using
392 // the current length keeps this correct under variable block sizes.
393 std::memmove(hist.data(), hist.data() + currentLen, static_cast<std::size_t>(numTaps) * sizeof(T));
394 }
395 }
396
397 // Decimating half-band as a single high-rate convolution: filter the
398 // incoming high-rate stream with the full symmetric kernel, then keep one
399 // sample in two. Every tap multiplies its true high-rate neighbour, so the
400 // FIR and centre contributions stay perfectly aligned (no polyphase
401 // 0.5-sample mismatch). Each output is one contiguous simd::dotProduct
402 // over hist[2n .. 2n+nTaps).
403 //
404 // hist layout (per channel, block-size agnostic): hist[0..hLen-1] carries
405 // the previous block's trailing hLen high-rate samples; the new block is
406 // copied to hist[hLen..]. The trailing hLen samples are re-saved afterwards.
407 void processDownsample(AudioBufferView<const T> input, AudioBufferView<T> output, int nCh, int currentLen) noexcept
408 {
409 const int outLen = currentLen / 2;
410 const int nTaps = static_cast<int>(fullTaps.size());
411 const int hLen = nTaps - 1;
412 const T* h = fullTaps.data();
413
414 for (int ch = 0; ch < nCh; ++ch)
415 {
416 const T* src = input.getChannel(ch);
417 T* dst = output.getChannel(ch);
418 auto& hist = downChannels[ch].history;
419
420 std::memcpy(hist.data() + hLen, src, static_cast<std::size_t>(currentLen) * sizeof(T));
421
422 const T* histD = hist.data();
423 for (int n = 0; n < outLen; ++n)
424 dst[n] = simd::dotProductT(h, histD + 2 * n, nTaps);
425
426 std::memmove(hist.data(), hist.data() + currentLen, static_cast<std::size_t>(hLen) * sizeof(T));
427 }
428 }
429
430 void processDownsampleInPlace(AudioBufferView<T> buffer, int nCh, int currentLen) noexcept
431 {
432 const int outLen = currentLen / 2;
433 const int nTaps = static_cast<int>(fullTaps.size());
434 const int hLen = nTaps - 1;
435 const T* h = fullTaps.data();
436
437 for (int ch = 0; ch < nCh; ++ch)
438 {
439 T* data = buffer.getChannel(ch);
440 auto& hist = downChannels[ch].history;
441
442 std::memcpy(hist.data() + hLen, data, static_cast<std::size_t>(currentLen) * sizeof(T));
443
444 const T* histD = hist.data();
445 for (int n = 0; n < outLen; ++n)
446 data[n] = simd::dotProductT(h, histD + 2 * n, nTaps);
447
448 std::memmove(hist.data(), hist.data() + currentLen, static_cast<std::size_t>(hLen) * sizeof(T));
449 }
450 }
451 };
452
453 static constexpr int tapsForQuality(Quality q) noexcept
454 {
455 switch (q)
456 {
457 case Quality::Low: return 31;
458 case Quality::Medium: return 63;
459 case Quality::High: return 127;
460 case Quality::Maximum: return 255;
461 }
462 return 127;
463 }
464
465 static constexpr T betaForQuality(Quality q) noexcept
466 {
467 switch (q)
468 {
469 case Quality::Low: return T(3.395);
470 case Quality::Medium: return T(5.653);
471 case Quality::High: return T(7.857);
472 case Quality::Maximum: return T(10.056);
473 }
474 return T(7.857);
475 }
476
477 int factor_;
478 int numStages_;
479 Quality quality_;
480 AudioSpec baseSpec_ {};
481 AudioBuffer<T, MaxChannels> upBuffer_;
482
483 std::array<PolyphaseHalfBand, kMaxStages> filters_;
484};
485
486} // namespace dspark
Owning audio buffer and non-owning view for real-time DSP processing.
Describes the audio processing environment (sample rate, block size, channels).
FIR (Finite Impulse Response) filter with windowed-sinc coefficient design.
SIMD-accelerated buffer operations for real-time audio processing.
Non-owning view over audio channel data.
Definition AudioBuffer.h:50
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
Power-of-two oversampling processor with polyphase anti-aliasing.
void downsample(AudioBufferView< T > output) noexcept
Downsamples the internal high-rate buffer back to base-rate.
void reset() noexcept
Flushes all internal delay lines and history buffers. Call this when resetting the transport or to cl...
int getFactor() const noexcept
Returns the current oversampling factor.
Quality getQuality() const noexcept
Returns the current quality preset.
AudioBufferView< T > upsample(AudioBufferView< const T > input) noexcept
Upsamples the input base-rate signal into the internal high-rate buffer.
int getLatency() const noexcept
Calculates the exact group delay latency of the oversampling chain.
Quality
Quality presets defining the steepness and rejection of the anti-aliasing filter.
@ Low
31 taps/stage, ~-40 dB stopband. For fast previewing.
@ High
127 taps/stage, ~-80 dB stopband. Professional standard.
@ Maximum
255 taps/stage, ~-100 dB stopband. Mastering grade.
@ Medium
63 taps/stage, ~-60 dB stopband. General purpose.
Oversampling(int factor=2, Quality quality=Quality::High)
Constructs the oversampling engine.
void prepare(const AudioSpec &spec)
Pre-allocates buffers and computes FIR filter coefficients.
T dotProductT(const T *DSPARK_RESTRICT a, const T *DSPARK_RESTRICT b, int count) noexcept
Definition SimdOps.h:1182
Main namespace for the DSPark framework.
Describes the audio environment for a DSP processor.
Definition AudioSpec.h:35
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
std::vector< T > history
Contiguous sliding-window history for the FIR convolution.