DSPark 1.6.1
Header-only audio DSP framework in pure C++20 — zero dependencies
Loading...
Searching...
No Matches
Resampler.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
32#include "AudioBuffer.h"
33#include "AudioSpec.h"
34#include "SimdOps.h"
35
36#include <algorithm>
37#include <cassert>
38#include <cmath>
39#include <cstddef>
40#include <limits>
41#include <numbers>
42#include <vector>
43
44namespace dspark {
45
67template <typename T>
69{
70public:
71 enum class Quality
72 {
73 Draft,
74 Normal,
75 High,
76 Ultra
77 };
78
88 void prepare(double sourceRate, double targetRate,
89 Quality quality = Quality::Normal)
90 {
91 // Guard against non-positive rates (would make ratio_ = inf/NaN and
92 // poison every downstream division).
93 sourceRate_ = std::max(sourceRate, 1.0);
94 targetRate_ = std::max(targetRate, 1.0);
95 ratio_ = targetRate_ / sourceRate_;
96 srcStep_ = 1.0 / ratio_; // source samples advanced per output sample
97
98 sincPoints_ = qualityToSincPoints(quality);
99 kaiserBeta_ = qualityToKaiserBeta(quality);
100
101 buildSincTable();
102 reset();
103 }
104
114 void prepare(const AudioSpec& spec, double targetRate,
115 Quality quality = Quality::Normal)
116 {
117 prepare(spec.sampleRate, targetRate, quality);
118 ensureChannelStates(spec.numChannels);
119 }
120
127 void reset() noexcept
128 {
129 // assign (not fill): after a re-prepare with a higher quality the
130 // histories must GROW to the new sincPoints_, otherwise the mirror
131 // write at [writePos + sincPoints_] lands past the end of the
132 // allocation (confirmed heap overflow under ASan).
133 resetChannelState(mono_);
134 for (auto& cs : channelStates_)
135 resetChannelState(cs);
136 }
137
149 [[nodiscard]] std::vector<T> process(const T* input, int inputLength)
150 {
151 if (sincTable_.empty()) return {}; // not prepared
152
153 // Clamp through double before the int cast: huge ratios could
154 // otherwise overflow the cast itself (undefined behaviour).
155 const double outLenD =
156 std::ceil(static_cast<double>(inputLength) * ratio_);
157 const int outputLength = static_cast<int>(
158 std::min(outLenD, static_cast<double>(std::numeric_limits<int>::max())));
159
160 std::vector<T> output(static_cast<size_t>(outputLength));
161 const int halfSinc = sincPoints_ / 2;
162
163 for (int outIdx = 0; outIdx < outputLength; ++outIdx)
164 {
165 // Calculate absolute source position to avoid float drift accumulation
166 double srcPos = static_cast<double>(outIdx) / ratio_;
167 if (srcPos >= inputLength) break;
168
169 int intPos = static_cast<int>(srcPos);
170 double frac = srcPos - static_cast<double>(intPos);
171
172 output[static_cast<size_t>(outIdx)] = interpolateOffline(input, inputLength, intPos, frac, halfSinc);
173 }
174
175 return output;
176 }
177
186 int processBlock(const T* input, int inputLength, T* output) noexcept
187 {
188 return processChannel(input, inputLength, output, mono_);
189 }
190
201 {
202 int numCh = std::min(input.getNumChannels(), output.getNumChannels());
203 int inLen = input.getNumSamples();
204
205 assert(static_cast<int>(channelStates_.size()) >= numCh &&
206 "Resampler channels not allocated! Call prepare(spec...) first.");
207 // Release-safe: never index channelStates_ past what prepare() allocated
208 // (the assert above flags the missing prepare(spec) in debug builds).
209 numCh = std::min(numCh, static_cast<int>(channelStates_.size()));
210
211 int outCount = 0;
212 for (int ch = 0; ch < numCh; ++ch)
213 {
214 outCount = processChannel(input.getChannel(ch), inLen,
215 output.getChannel(ch),
216 channelStates_[static_cast<size_t>(ch)]);
217 }
218
219 return outCount;
220 }
221
227 [[nodiscard]] int getMaxOutputSamples(int inputLength) const noexcept
228 {
229 const double maxOut =
230 std::ceil(static_cast<double>(inputLength) * ratio_) + 2.0;
231 return static_cast<int>(
232 std::min(maxOut, static_cast<double>(std::numeric_limits<int>::max())));
233 }
234
236 [[nodiscard]] double getRatio() const noexcept { return ratio_; }
237
246 [[nodiscard]] int getLatency() const noexcept
247 {
248 return static_cast<int>(std::round(static_cast<double>(sincPoints_ / 2) * ratio_));
249 }
250
251private:
252 static constexpr int kOversample = 256;
253
254 static int qualityToSincPoints(Quality q) noexcept
255 {
256 switch (q)
257 {
258 case Quality::Draft: return 8;
259 case Quality::Normal: return 32;
260 case Quality::High: return 64;
261 case Quality::Ultra: return 128;
262 }
263 return 32;
264 }
265
276 static double qualityToKaiserBeta(Quality q) noexcept
277 {
278 switch (q)
279 {
280 case Quality::Draft: return 6.0;
281 case Quality::Normal: return 10.0;
282 case Quality::High: return 12.5;
283 case Quality::Ultra: return 14.5;
284 }
285 return 10.0;
286 }
287
289 [[nodiscard]] static double besselI0(double x) noexcept
290 {
291 double sum = 1.0, term = 1.0;
292 for (int k = 1; k <= 50; ++k)
293 {
294 const double half = x / (2.0 * k);
295 term *= half * half;
296 sum += term;
297 if (term < 1e-15 * sum) break;
298 }
299 return sum;
300 }
301
302 void buildSincTable()
303 {
304 // kOversample + 1 phases: the extra phase holds the frac = 1.0 kernel,
305 // so the 2-point phase interpolation in the read path never has to
306 // clamp or wrap (exact at both ends of the fractional range).
307 sincTable_.resize(static_cast<size_t>((kOversample + 1) * sincPoints_));
308
309 const int halfSinc = sincPoints_ / 2;
310 constexpr double kPi = std::numbers::pi;
311 const double beta = kaiserBeta_;
312 const double i0Beta = besselI0(beta);
313
314 // Apply 0.95 margin on downsampling to prevent transition-band aliasing.
315 double cutoff = (ratio_ < 1.0) ? (ratio_ * 0.95) : 1.0;
316
317 for (int phase = 0; phase <= kOversample; ++phase)
318 {
319 const double frac = static_cast<double>(phase) / static_cast<double>(kOversample);
320 const int base = phase * sincPoints_;
321 double sum = 0.0;
322
323 for (int tap = 0; tap < sincPoints_; ++tap)
324 {
325 // Tap alignment: tap j weighs source sample intPos-halfSinc+1+j,
326 // so its position relative to the interpolation point
327 // intPos + frac is t = (j - halfSinc + 1) - frac. This is the
328 // symmetric placement for an even-length kernel: every tap
329 // stays inside the open window support (-halfSinc, halfSinc)
330 // for frac in (0, 1). Shifting the window one tap earlier
331 // would pin the first tap at |t| >= halfSinc where the Kaiser
332 // window is exactly zero (a dead tap in every phase) and
333 // shorten the group delay to halfSinc-1, breaking getLatency().
334 // The MINUS sign on frac is essential: `+ frac` would sample
335 // the kernel time-reversed (heavy zipper distortion).
336 const double t = static_cast<double>(tap - halfSinc + 1) - frac;
337 const double x = t * cutoff;
338
339 double sincVal = (std::abs(x) < 1e-10)
340 ? cutoff
341 : cutoff * std::sin(kPi * x) / (kPi * x);
342
343 // Continuous Kaiser window evaluated at the SAME shifted
344 // position as the sinc (a per-tap fixed window leaves a small
345 // phase-dependent ripple in the passband).
346 const double wx = t / static_cast<double>(halfSinc);
347 const double win = (std::abs(wx) >= 1.0)
348 ? 0.0
349 : besselI0(beta * std::sqrt(1.0 - wx * wx)) / i0Beta;
350
351 const double v = sincVal * win;
352 sincTable_[static_cast<size_t>(base + tap)] = static_cast<T>(v);
353 sum += v;
354 }
355
356 // Normalise each polyphase branch to unity DC gain so the passband
357 // is flat (otherwise the windowed sinc leaves a small gain ripple).
358 if (std::abs(sum) > 1e-12)
359 {
360 const T inv = static_cast<T>(1.0 / sum);
361 for (int tap = 0; tap < sincPoints_; ++tap)
362 sincTable_[static_cast<size_t>(base + tap)] *= inv;
363 }
364 }
365 }
366
375 T interpolateOffline(const T* data, int length, int intPos, double frac, int halfSinc) const noexcept
376 {
377 const double exactPhase = frac * static_cast<double>(kOversample);
378 int p0 = static_cast<int>(exactPhase);
379 if (p0 > kOversample - 1) p0 = kOversample - 1; // frac is < 1 by contract
380 const T pf = static_cast<T>(exactPhase - static_cast<double>(p0));
381
382 const T* k0 = sincTable_.data() + static_cast<size_t>(p0) * sincPoints_;
383 const T* k1 = k0 + sincPoints_; // safe: table holds kOversample+1 phases
384
385 // Tap j weighs data[intPos - halfSinc + 1 + j] (see buildSincTable).
386 const int firstSrc = intPos - halfSinc + 1;
387 if (firstSrc >= 0 && firstSrc + sincPoints_ <= length)
388 {
389 // Fully in range: two SIMD dot products + one lerp.
390 const T* src = data + firstSrc;
391 const T s0 = simd::dotProduct(k0, src, sincPoints_);
392 const T s1 = simd::dotProduct(k1, src, sincPoints_);
393 return s0 + pf * (s1 - s0);
394 }
395
396 // Edge path: zero-pad outside the buffer.
397 T result = T(0);
398 for (int tap = 0; tap < sincPoints_; ++tap)
399 {
400 const int srcIdx = firstSrc + tap;
401 if (srcIdx < 0 || srcIdx >= length) continue;
402 const T kernel = k0[tap] + pf * (k1[tap] - k0[tap]);
403 result += data[srcIdx] * kernel;
404 }
405 return result;
406 }
407
417 T interpolateFromHistory(const T* historyPtr, int readPos, double frac) const noexcept
418 {
419 const double exactPhase = frac * static_cast<double>(kOversample);
420 int p0 = static_cast<int>(exactPhase);
421 if (p0 > kOversample - 1) p0 = kOversample - 1; // frac is < 1 by contract
422 const T pf = static_cast<T>(exactPhase - static_cast<double>(p0));
423
424 const T* k0 = sincTable_.data() + static_cast<size_t>(p0) * sincPoints_;
425 const T* k1 = k0 + sincPoints_; // safe: table holds kOversample+1 phases
426
427 // Linear memory read from 'readPos'. No modulo required.
428 const T* histPtr = &historyPtr[readPos];
429
430 const T s0 = simd::dotProduct(k0, histPtr, sincPoints_);
431 const T s1 = simd::dotProduct(k1, histPtr, sincPoints_);
432 return s0 + pf * (s1 - s0);
433 }
434
435 struct ChannelState
436 {
437 std::vector<T> history;
438 int writePos = 0;
439 double fractionalPos = 0.0;
440 };
441
442 void resetChannelState(ChannelState& cs)
443 {
444 cs.history.assign(static_cast<size_t>(sincPoints_ * 2), T(0));
445 cs.writePos = 0;
446 cs.fractionalPos = 0.0;
447 }
448
449 void ensureChannelStates(int numChannels)
450 {
451 if (static_cast<int>(channelStates_.size()) < numChannels)
452 channelStates_.resize(static_cast<size_t>(numChannels));
453
454 // Double size supports the modulo-free mirrored convolution. The size
455 // check is against the CURRENT sincPoints_: quality may have changed
456 // between prepares, and a stale (smaller) history would overflow.
457 const size_t needed = static_cast<size_t>(sincPoints_ * 2);
458 for (auto& cs : channelStates_)
459 {
460 if (cs.history.size() != needed)
461 {
462 cs.history.assign(needed, T(0));
463 cs.writePos = 0;
464 cs.fractionalPos = 0.0;
465 }
466 }
467 }
468
469 int processChannel(const T* input, int inputLength, T* output,
470 ChannelState& state) noexcept
471 {
472 if (state.history.empty()) return 0; // not prepared
473
474 // Hot state lives in locals for the duration of the block: this both
475 // tells the compiler the fields cannot alias the output writes (no
476 // per-sample reloads) and keeps them in registers.
477 T* const hist = state.history.data();
478 const int n = sincPoints_;
479 const double step = srcStep_;
480 int writePos = state.writePos;
481 double frac = state.fractionalPos;
482 int outIdx = 0;
483
484 for (int i = 0; i < inputLength; ++i)
485 {
486 // Double-buffered push: mirror write keeps the read window
487 // [writePos, writePos + n) contiguous (no modulo).
488 const T x = input[i];
489 hist[writePos] = x;
490 hist[writePos + n] = x;
491 if (++writePos >= n)
492 writePos = 0;
493
494 while (frac < 1.0)
495 {
496 output[outIdx++] = interpolateFromHistory(hist, writePos, frac);
497 frac += step;
498 }
499
500 frac -= 1.0;
501 }
502
503 state.writePos = writePos;
504 state.fractionalPos = frac;
505 return outIdx;
506 }
507
508 double sourceRate_ = 44100.0;
509 double targetRate_ = 48000.0;
510 double ratio_ = 1.0;
511 double srcStep_ = 1.0;
512 double kaiserBeta_ = 10.0;
513
514 int sincPoints_ = 32;
515
516 std::vector<T> sincTable_;
517 ChannelState mono_;
518 std::vector<ChannelState> channelStates_;
519};
520
521} // namespace dspark
Owning audio buffer and non-owning view for real-time DSP processing.
Describes the audio processing environment (sample rate, block size, channels).
SIMD-accelerated buffer operations for real-time audio processing.
Non-owning view over audio channel data.
Definition AudioBuffer.h:50
Windowed-sinc sample rate converter optimized for real-time DSP.
Definition Resampler.h:69
int processBlock(AudioBufferView< T > input, AudioBufferView< T > output) noexcept
Resamples multi-channel audio using AudioBufferView (streaming).
Definition Resampler.h:200
@ High
64-point sinc, high quality (~-140 dB beyond the transition band)
@ Ultra
128-point sinc, mastering grade (deep stopband even at the band edge)
@ Normal
32-point sinc, balanced (~-56 dB worst-case images)
@ Draft
8-point sinc, fastest (previews; HF response drops early)
int getLatency() const noexcept
Returns the streaming latency in output samples.
Definition Resampler.h:246
double getRatio() const noexcept
Returns the conversion ratio (targetRate / sourceRate).
Definition Resampler.h:236
void reset() noexcept
Resets the internal state (delay lines, all channels) to zero.
Definition Resampler.h:127
std::vector< T > process(const T *input, int inputLength)
Resamples an entire buffer (offline batch processing).
Definition Resampler.h:149
int processBlock(const T *input, int inputLength, T *output) noexcept
Resamples a block of audio in single-channel streaming mode.
Definition Resampler.h:186
void prepare(const AudioSpec &spec, double targetRate, Quality quality=Quality::Normal)
Prepares the resampler using AudioSpec (unified API).
Definition Resampler.h:114
void prepare(double sourceRate, double targetRate, Quality quality=Quality::Normal)
Prepares the resampler for a given rate conversion.
Definition Resampler.h:88
int getMaxOutputSamples(int inputLength) const noexcept
Returns the maximum number of output samples for a given input length.
Definition Resampler.h:227
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.
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
double sampleRate
Sample rate in Hz.
Definition AudioSpec.h:43