Pulp 1.88.0
Cross-platform audio plugin framework
Loading...
Searching...
No Matches
resampler.hpp
Go to the documentation of this file.
1#pragma once
2
46
49
50#include <algorithm>
51#include <cmath>
52#include <cstddef>
53#include <vector>
54
55namespace pulp::signal {
56
57// ────────────────────────────────────────────────────────────────────────────
58// Polyphase-FIR resampler with arbitrary ratio.
59// ────────────────────────────────────────────────────────────────────────────
60
66 double stopband_db = 96.0;
67 double transition_fraction = 0.10;
68 double cutoff_fraction = 0.90;
69 std::size_t phases = 64;
70};
71
73 std::size_t output_frames = 0;
74 std::size_t input_frames_consumed = 0;
75};
76
77template <typename SampleType = float>
79public:
80 ResamplerT() = default;
81
96 void prepare(double input_rate,
97 double output_rate,
98 std::size_t channels,
99 std::size_t max_block_size,
101 input_rate_ = input_rate;
102 output_rate_ = output_rate;
103 channels_ = (channels == 0) ? 1u : channels;
104 quality_ = quality;
105 if (quality_.phases < 2u) quality_.phases = 2u;
106
107 // Design the prototype LP at the polyphase sample rate
108 // (= L × input_rate). When each phase is applied to the input
109 // delay line, the effective filter response — in input-rate-
110 // normalized terms — is the prototype's response scaled by L
111 // along the frequency axis (phase p contains every Lth tap of
112 // the prototype). To reject everything above min(in_nyq, out_nyq),
113 // the cutoff must sit below min(in_nyq, out_nyq) in continuous Hz
114 // terms; we then normalize to the polyphase rate.
115 const double min_rate = std::min(input_rate_, output_rate_);
116 const double min_nyq = 0.5 * min_rate;
117 const double cutoff_hz = quality_.cutoff_fraction * min_nyq;
118 const double transition_hz = quality_.transition_fraction * min_nyq;
119
120 // Polyphase rate is L × input_rate so the L-fold spacing of
121 // each phase places the effective cutoff at `fc_norm * L` in
122 // input-rate-normalized units, which equals `cutoff_hz / input_rate`.
123 const double poly_rate = static_cast<double>(quality_.phases) * input_rate_;
124 const double fc_norm = cutoff_hz / poly_rate;
125 const double df_norm = transition_hz / poly_rate;
126
127 const double beta = kaiser_beta_for_stopband(quality_.stopband_db);
128 std::size_t n_taps = kaiser_length_for_transition(quality_.stopband_db, df_norm);
129
130 // Hard-cap the prototype length. The tap count grows with the rate ratio, so
131 // an extreme input:output ratio (e.g. a hostile IR header) would otherwise
132 // demand a multi-GB prototype and minutes of design work. A real audio
133 // resample needs only a few hundred taps; this ceiling is orders of
134 // magnitude above that, so it only ever clamps a pathological ratio —
135 // trading a slightly wider transition band for a bounded allocation.
136 constexpr std::size_t kMaxProtoTaps = 1u << 16; // 65536
137 if (n_taps > kMaxProtoTaps) n_taps = kMaxProtoTaps;
138
139 // Force n_taps to be `L * taps_per_phase` so the polyphase
140 // decomposition is rectangular.
141 const std::size_t L = quality_.phases;
142 const std::size_t taps_per_phase = (n_taps + L - 1u) / L;
143 n_taps = taps_per_phase * L;
144
145 prototype_ = design_windowed_sinc(n_taps, fc_norm, beta);
146
147 // Scale by L so each phase has unit DC gain after polyphase split.
148 for (auto& v : prototype_) v *= static_cast<double>(L);
149
150 // Slice into L phases of `taps_per_phase` each. Phase p contains
151 // prototype_[p, p+L, p+2L, ...]. Each phase is stored with its taps
152 // REVERSED (index k holds tap `taps-1-k`): the convolution reads the
153 // most-recent-`taps` window newest-last, so reversing the phase table
154 // here lets the hot loop walk the window and the coefficients in the
155 // same ascending direction — straight vector loads, no reverse shuffle.
156 phases_.assign(L, std::vector<SampleType>(taps_per_phase, SampleType{0.0f}));
157 for (std::size_t p = 0; p < L; ++p) {
158 for (std::size_t k = 0; k < taps_per_phase; ++k) {
159 phases_[p][k] =
160 static_cast<SampleType>(prototype_[(taps_per_phase - 1u - k) * L + p]);
161 }
162 }
163 taps_per_phase_ = taps_per_phase;
164
165 // Each channel owns a fixed-capacity mirrored history, so the complete
166 // oldest-to-newest tap span stays contiguous at every write position.
167 delays_.clear();
168 delays_.resize(channels_);
169 for (auto& delay : delays_) delay.prepare(taps_per_phase);
170
171 // Output scratch — worst-case output count for a max-size input block.
172 const double ratio = output_rate_ / input_rate_;
173 const std::size_t worst_out =
174 static_cast<std::size_t>(std::ceil(static_cast<double>(max_block_size) * ratio)) + 8u;
175 scratch_out_.assign(channels_, std::vector<SampleType>(worst_out,
176 SampleType{0.0f}));
177
178 // Step (input samples per output sample) in the input domain.
179 step_ = input_rate_ / output_rate_;
180 phase_acc_ = 0.0;
181 }
182
186 void set_ratio(double input_rate, double output_rate) {
187 if (input_rate <= 0.0 || output_rate <= 0.0) return;
188 input_rate_ = input_rate;
189 output_rate_ = output_rate;
190 step_ = input_rate_ / output_rate_;
191 }
192
196 void reset() {
197 for (auto& delay : delays_) delay.reset();
198 phase_acc_ = 0.0;
199 }
200
206 ResamplerProcessResult process_block_detailed(const SampleType* const* input,
207 std::size_t in_count,
208 SampleType* const* output,
209 std::size_t out_capacity) {
210 if (channels_ == 0 || taps_per_phase_ == 0 || phases_.empty()) return {};
211 const std::size_t L = phases_.size();
212 const double L_d = static_cast<double>(L);
213
214 std::size_t out_n = 0;
215 std::size_t input_consumed = 0;
216
217 // Process: for each output sample we need, check whether the
218 // phase accumulator has crossed an integer (meaning we must
219 // push the next input sample into the delay line) and then
220 // evaluate the polyphase filter at the fractional position.
221 while (out_n < out_capacity) {
222 // Push input samples until the accumulator is below 1.0.
223 while (phase_acc_ >= 1.0) {
224 if (input_consumed >= in_count) {
225 // Ran out of input — return what we have.
226 return {out_n, input_consumed};
227 }
228 for (std::size_t c = 0; c < channels_; ++c) {
229 delays_[c].push(input[c][input_consumed]);
230 }
231 ++input_consumed;
232 phase_acc_ -= 1.0;
233 }
234
235 // We need at least one input sample in the line. On the
236 // very first call, the delay is zeroed which still yields
237 // a valid (silence) output until input arrives.
238 const double phase_pos = phase_acc_ * L_d; // in [0, L)
239 std::size_t p0 = static_cast<std::size_t>(phase_pos);
240 if (p0 >= L) p0 = L - 1u;
241 std::size_t p1 = p0 + 1u;
242 double frac = phase_pos - static_cast<double>(p0);
243 if (p1 >= L) { p1 = p0; frac = 0.0; }
244
245 for (std::size_t c = 0; c < channels_; ++c) {
246 const auto window = delays_[c].window();
247 const auto& ph0 = phases_[p0];
248 const auto& ph1 = phases_[p1];
249
250 // Convolve the contiguous window against the (reversed) phase
251 // coefficients: y = sum_k win[k] * ph[k]. The mirror write makes
252 // `win[0 .. taps-1]` the physical most-recent-`taps` span oldest→
253 // newest, and the phase table is stored reversed to match, so both
254 // walk ascending with straight vector loads.
255 //
256 // Four independent single-precision lane accumulators let the
257 // compiler fold the reduction into SIMD FMAs. This reassociates the
258 // sum (and drops the double accumulator), so the result differs from
259 // a strict left-to-right double sum in the last few ulps — but the
260 // error sits ~69 dB below the filter's stopband floor (the
261 // "float reassociation stays below the stopband" test asserts it),
262 // far under any audible or spec-relevant level.
263 const SampleType* win = window.data();
264 const std::size_t T = taps_per_phase_;
265 SampleType a0 = SampleType{0.0f};
266 SampleType a1 = SampleType{0.0f};
267 SampleType a2 = SampleType{0.0f};
268 SampleType a3 = SampleType{0.0f};
269 std::size_t k = 0;
270 if (frac != 0.0) {
271 SampleType b0 = SampleType{0.0f};
272 SampleType b1 = SampleType{0.0f};
273 SampleType b2 = SampleType{0.0f};
274 SampleType b3 = SampleType{0.0f};
275 for (; k + 4u <= T; k += 4u) {
276 a0 += win[k] * ph0[k]; b0 += win[k] * ph1[k];
277 a1 += win[k + 1u] * ph0[k + 1u]; b1 += win[k + 1u] * ph1[k + 1u];
278 a2 += win[k + 2u] * ph0[k + 2u]; b2 += win[k + 2u] * ph1[k + 2u];
279 a3 += win[k + 3u] * ph0[k + 3u]; b3 += win[k + 3u] * ph1[k + 3u];
280 }
281 SampleType acc0 = (a0 + a1) + (a2 + a3);
282 SampleType acc1 = (b0 + b1) + (b2 + b3);
283 for (; k < T; ++k) { acc0 += win[k] * ph0[k]; acc1 += win[k] * ph1[k]; }
284 output[c][out_n] = acc0 + static_cast<SampleType>(frac) * (acc1 - acc0);
285 } else {
286 // Integer phase position (e.g. integer resample ratios): the
287 // second phase is weighted by frac == 0, so it never reaches the
288 // output — skip the redundant second MAC. y == acc0 exactly.
289 for (; k + 4u <= T; k += 4u) {
290 a0 += win[k] * ph0[k];
291 a1 += win[k + 1u] * ph0[k + 1u];
292 a2 += win[k + 2u] * ph0[k + 2u];
293 a3 += win[k + 3u] * ph0[k + 3u];
294 }
295 SampleType acc0 = (a0 + a1) + (a2 + a3);
296 for (; k < T; ++k) acc0 += win[k] * ph0[k];
297 output[c][out_n] = acc0;
298 }
299 }
300
301 ++out_n;
302 phase_acc_ += step_;
303 }
304 return {out_n, input_consumed};
305 }
306
307 std::size_t process_block(const SampleType* const* input,
308 std::size_t in_count,
309 SampleType* const* output,
310 std::size_t out_capacity) {
311 return process_block_detailed(input, in_count, output, out_capacity).output_frames;
312 }
313
315 std::size_t in_count,
316 SampleType* output,
317 std::size_t out_capacity) {
318 const SampleType* in_ptrs[1] = { input };
319 SampleType* out_ptrs[1] = { output };
320 return process_block_detailed(in_ptrs, in_count, out_ptrs, out_capacity);
321 }
322
324 std::size_t process_block_mono(const SampleType* input,
325 std::size_t in_count,
326 SampleType* output,
327 std::size_t out_capacity) {
328 return process_block_mono_detailed(input, in_count, output, out_capacity).output_frames;
329 }
330
335 std::size_t max_output_for(std::size_t in_count) const {
336 const double ratio = output_rate_ / input_rate_;
337 return static_cast<std::size_t>(std::ceil(static_cast<double>(in_count) * ratio)) + 8u;
338 }
339
342 std::size_t taps_per_phase() const { return taps_per_phase_; }
343
345 std::size_t phases() const { return phases_.size(); }
346
351 std::size_t prototype_length() const { return prototype_.size(); }
352
353 double input_rate() const { return input_rate_; }
354 double output_rate() const { return output_rate_; }
355 std::size_t channels() const { return channels_; }
356
357private:
358 double input_rate_ = 0.0;
359 double output_rate_ = 0.0;
360 std::size_t channels_ = 0;
361 ResamplerQuality quality_{};
362
363 // Prototype LP (length L * taps_per_phase). Kept around so tests
364 // can introspect it. Phases are the runtime representation.
365 std::vector<double> prototype_;
366 std::vector<std::vector<SampleType>> phases_;
367 std::size_t taps_per_phase_ = 0;
368
369 // Per-channel contiguous oldest-to-newest sample history.
370 std::vector<MirroredHistoryBuffer<SampleType>> delays_;
371
372 // Per-channel scratch — not used directly by process_block but
373 // kept available so future block-process variants can drain into
374 // it without allocating.
375 std::vector<std::vector<SampleType>> scratch_out_;
376
377 // Phase accumulator in input samples per output sample.
378 double step_ = 1.0;
379 double phase_acc_ = 0.0;
380};
381
384
385} // namespace pulp::signal
Definition resampler.hpp:78
double input_rate() const
Definition resampler.hpp:353
std::size_t process_block_mono(const SampleType *input, std::size_t in_count, SampleType *output, std::size_t out_capacity)
Mono convenience wrapper around process_block.
Definition resampler.hpp:324
ResamplerProcessResult process_block_detailed(const SampleType *const *input, std::size_t in_count, SampleType *const *output, std::size_t out_capacity)
Block API.
Definition resampler.hpp:206
void set_ratio(double input_rate, double output_rate)
Update the streaming ratio without rebuilding the filter.
Definition resampler.hpp:186
void reset()
Zero the delay lines + phase accumulator.
Definition resampler.hpp:196
void prepare(double input_rate, double output_rate, std::size_t channels, std::size_t max_block_size, ResamplerQuality quality={})
Configure the conversion.
Definition resampler.hpp:96
std::size_t channels() const
Definition resampler.hpp:355
std::size_t taps_per_phase() const
Per-phase filter length.
Definition resampler.hpp:342
ResamplerProcessResult process_block_mono_detailed(const SampleType *input, std::size_t in_count, SampleType *output, std::size_t out_capacity)
Definition resampler.hpp:314
double output_rate() const
Definition resampler.hpp:354
std::size_t prototype_length() const
Total prototype length (= phases * taps_per_phase).
Definition resampler.hpp:351
std::size_t phases() const
Number of polyphase branches (L).
Definition resampler.hpp:345
std::size_t max_output_for(std::size_t in_count) const
Worst-case output sample count for a given input count.
Definition resampler.hpp:335
std::size_t process_block(const SampleType *const *input, std::size_t in_count, SampleType *const *output, std::size_t out_capacity)
Definition resampler.hpp:307
Fixed-capacity contiguous sample history for single-thread DSP algorithms.
Definition additive_bank.hpp:218
ResamplerT< double > Resampler64
Definition resampler.hpp:383
@ c
Definition spectrum_trace.hpp:35
@ delay
Definition envelope.hpp:57
@ quality
Definition realtime_pitch_time_geometry.hpp:25
@ ratio
base_frequency_hz * ratio.
Definition fm_operator_engine.hpp:20
ResamplerT< float > Resampler
Definition resampler.hpp:382
std::size_t kaiser_length_for_transition(double stopband_db, double normalized_width)
Definition windowed_sinc_design.hpp:36
std::vector< double > design_windowed_sinc(std::size_t taps, double cutoff, double beta)
Definition windowed_sinc_design.hpp:58
double kaiser_beta_for_stopband(double stopband_db)
Definition windowed_sinc_design.hpp:27
Definition resampler.hpp:72
std::size_t input_frames_consumed
Definition resampler.hpp:74
std::size_t output_frames
Definition resampler.hpp:73
Quality knobs for the Kaiser-window prototype.
Definition resampler.hpp:65
double transition_fraction
transition width / min Nyquist
Definition resampler.hpp:67
double stopband_db
minimum stopband attenuation
Definition resampler.hpp:66
double cutoff_fraction
cutoff / min Nyquist
Definition resampler.hpp:68
std::size_t phases
polyphase oversample factor L
Definition resampler.hpp:69
Shared Kaiser-windowed-sinc FIR design helpers.