Pulp 1.88.0
Cross-platform audio plugin framework
Loading...
Searching...
No Matches
impulse_response.hpp
Go to the documentation of this file.
1#pragma once
2
3// Load an impulse response into a convolution-ready mono buffer. Two entry
4// points share one DSP pipeline (sum to mono → resample to the target rate →
5// trim the resampler group delay → cap → optional unit-energy normalize):
6//
7// read_impulse_response() — decode an audio FILE (WAV / AIFF / FLAC via
8// the FormatRegistry) and run the pipeline.
9// read_impulse_response_pcm() — run the pipeline over already-decoded PCM.
10//
11// The PCM entry point exists because not every host can decode a file: a wasm
12// build has no FormatRegistry (none of core/audio/src is in the web source
13// subsets), so the browser decodes with AudioContext.decodeAudioData and hands
14// the raw float samples straight in. It is the same pipeline, not a second one.
15//
16// This is the shared loader behind Pulp's convolution demos (SuperConvolver's
17// cabinet/room IRs and the GPU NAM cabinet IR); the convolver itself is
18// pulp::signal::PartitionedConvolver.
19//
20// The FFT/convolution primitive and the resampler already live in core; this is
21// the source-to-buffer glue they share. Header-only so it adds no link edge to
22// pulp-audio — a consumer already linking pulp::audio + pulp::signal gets it for
23// free. The file entry point (and its FormatRegistry include) compiles out under
24// PULP_WASM, so the PCM entry point carries no decoder dependency there.
25//
26// Off the audio thread only (it allocates and decodes).
27
29
30#if !defined(PULP_WASM)
33#endif
34
35#include <algorithm>
36#include <cmath>
37#include <cstddef>
38#include <cstdint>
39#include <optional>
40#include <string>
41#include <vector>
42
43namespace pulp::audio {
44
45// Plausible-audio sample-rate window. A hostile WAV header claiming, say, 500 MHz
46// makes the Kaiser resampler demand ~10⁹ taps (a multi-GB allocation + minutes of
47// design work); a higher claimed rate also *shortens* the computed duration, so it
48// sails past the length preflight. Anything outside this range is not a real audio
49// file — reject it. The ceiling covers every standard hi-res rate (up to 384 kHz)
50// and is set so the natural resampler tap count for a downsample to a normal
51// session rate stays under Resampler::prepare's 65536-tap cap — i.e. an accepted
52// IR is resampled at full designed quality, never silently clamped.
53inline constexpr double kMinIrSampleRate = 4000.0;
54inline constexpr double kMaxIrSampleRate = 400000.0;
55
68
69namespace detail {
70
76inline std::optional<std::vector<float>> finish_impulse_response(
77 std::vector<float> mono, double file_sr, double session_sr,
78 const ImpulseResponseLoadOptions& opts) {
79 const std::size_t frames = mono.size();
80
81 // Resample from the source rate to the target rate so the IR length and tone
82 // are correct regardless of the source's recording rate.
83 std::vector<float> ir;
84 if (std::abs(file_sr - session_sr) > 1e-3 && file_sr > 0.0) {
86 rs.prepare(file_sr, session_sr, 1, frames);
87 // Pad with one filter span of zeros so the resampler can flush its
88 // group-delay tail and the full IR is captured.
89 const std::size_t pad = rs.taps_per_phase();
90 std::vector<float> padded = std::move(mono);
91 padded.resize(frames + pad, 0.0f);
92 const std::size_t cap = rs.max_output_for(padded.size());
93 ir.assign(cap, 0.0f);
94 const std::size_t produced =
95 rs.process_block_mono(padded.data(), padded.size(), ir.data(), cap);
96 ir.resize(produced);
97
98 // Drop the resampler's leading group delay so the IR onset (and the
99 // dry/wet alignment) is not shifted by an artificial silence. Group delay
100 // is (taps_per_phase-1)/2 at the INPUT rate; convert to output samples by
101 // the resample ratio.
102 const double ratio = session_sr / file_sr;
103 const std::size_t gd_out = static_cast<std::size_t>(
104 (static_cast<double>(rs.taps_per_phase()) - 1.0) * 0.5 * ratio);
105 if (gd_out > 0 && gd_out < ir.size())
106 ir.erase(ir.begin(), ir.begin() + static_cast<std::ptrdiff_t>(gd_out));
107 } else {
108 ir = std::move(mono);
109 }
110 if (ir.empty()) return std::nullopt;
111
112 // Final clamp at the target rate (resampler margin + rounding can nudge the
113 // length just past the cap).
114 if (opts.max_seconds > 0.0) {
115 const std::size_t max_session_frames =
116 static_cast<std::size_t>(opts.max_seconds * session_sr);
117 if (ir.size() > max_session_frames) ir.resize(max_session_frames);
118 }
119
120 // Reject non-finite content regardless of the normalize flag — a NaN/Inf
121 // sample would otherwise spread through the convolver's FFT. Zero energy is
122 // only fatal when normalizing (division by zero); an all-zero IR is a valid,
123 // if pointless, silence when normalization is off.
124 double energy = 0.0;
125 for (float v : ir) {
126 if (!std::isfinite(v)) return std::nullopt;
127 energy += static_cast<double>(v) * v;
128 }
129 if (opts.normalize_unit_energy) {
130 if (energy <= 0.0) return std::nullopt;
131 const float g = static_cast<float>(1.0 / std::sqrt(energy));
132 for (float& v : ir) v *= g;
133 }
134 return ir;
135}
136
137} // namespace detail
138
150inline std::optional<std::vector<float>> read_impulse_response_pcm(
151 const float* samples, std::size_t frames, std::uint32_t channels,
152 double src_rate, double target_sample_rate,
153 const ImpulseResponseLoadOptions& opts = {}) {
154 if (samples == nullptr || frames == 0 || channels == 0) return std::nullopt;
155 const double session_sr = target_sample_rate > 0.0 ? target_sample_rate : 48000.0;
156 const double file_sr = src_rate > 0.0 ? src_rate : session_sr;
157
158 // Same fail-closed guards the file path applies to a decoded buffer: an
159 // implausible rate would explode the resampler's tap count, and an absurdly
160 // long buffer is a fat-finger, not an IR.
161 if (file_sr < kMinIrSampleRate || file_sr > kMaxIrSampleRate) return std::nullopt;
162 if (opts.reject_longer_than_seconds > 0.0
163 && static_cast<double>(frames) / file_sr > opts.reject_longer_than_seconds)
164 return std::nullopt;
165
166 // Truncate at the source rate so the resampled result also fits the cap.
167 std::size_t n = frames;
168 if (opts.max_seconds > 0.0) {
169 const std::size_t max_src_frames =
170 static_cast<std::size_t>(opts.max_seconds * file_sr);
171 if (n > max_src_frames) n = max_src_frames;
172 }
173
174 // Sum to mono (mono IR; true-stereo IR is a future extension).
175 std::vector<float> mono(n, 0.0f);
176 for (std::uint32_t c = 0; c < channels; ++c) {
177 const float* plane = samples + static_cast<std::size_t>(c) * frames;
178 for (std::size_t i = 0; i < n; ++i) mono[i] += plane[i];
179 }
180 if (channels > 1) {
181 const float inv = 1.0f / static_cast<float>(channels);
182 for (float& v : mono) v *= inv;
183 }
184
185 return detail::finish_impulse_response(std::move(mono), file_sr, session_sr, opts);
186}
187
188#if !defined(PULP_WASM)
189
193inline std::optional<std::vector<float>> read_impulse_response(
194 const std::string& path, double target_sample_rate,
195 const ImpulseResponseLoadOptions& opts = {}) {
196 const double session_sr = target_sample_rate > 0.0 ? target_sample_rate : 48000.0;
197
198 // Preflight on metadata (no decode) to reject a pathologically large file
199 // before read() pulls the whole thing into memory. A claimed sample rate
200 // outside the plausible-audio window is rejected outright (it also defeats the
201 // duration math), and the duration check uses the real rate, not an absurd one.
202 if (opts.reject_longer_than_seconds > 0.0) {
203 if (auto info = FormatRegistry::instance().read_info(path)) {
204 if (info->num_frames == 0) return std::nullopt;
205 const double claimed_sr = static_cast<double>(info->sample_rate);
206 if (info->sample_rate > 0
207 && (claimed_sr < kMinIrSampleRate || claimed_sr > kMaxIrSampleRate))
208 return std::nullopt;
209 const double info_sr = info->sample_rate > 0 ? claimed_sr : session_sr;
210 const double dur = static_cast<double>(info->num_frames) / info_sr;
211 if (dur > opts.reject_longer_than_seconds) return std::nullopt;
212 }
213 }
214
215 // The path was chosen by the user or host; the duration preflight above and
216 // the decoded-data re-check below own the bound.
217 const auto data = FormatRegistry::instance().read(path, DecodeLimits::unbounded());
218 if (!data || data->channels.empty() || data->num_frames() == 0) return std::nullopt;
219 const double file_sr =
220 data->sample_rate > 0 ? static_cast<double>(data->sample_rate) : session_sr;
221
222 // The metadata preflight can fail open (a header that defeats read_info but not
223 // read, or a hostile rate). Re-check against the DECODED data, fail-closed:
224 // reject an out-of-range file rate (which would explode the resampler) and any
225 // file whose true duration exceeds the reject threshold.
226 if (file_sr < kMinIrSampleRate || file_sr > kMaxIrSampleRate) return std::nullopt;
227 if (opts.reject_longer_than_seconds > 0.0
228 && static_cast<double>(data->num_frames()) / file_sr > opts.reject_longer_than_seconds)
229 return std::nullopt;
230
231 // Truncate at the source rate so the resampled result also fits the cap.
232 std::size_t frames = static_cast<std::size_t>(data->num_frames());
233 if (opts.max_seconds > 0.0) {
234 const std::size_t max_file_frames =
235 static_cast<std::size_t>(opts.max_seconds * file_sr);
236 if (frames > max_file_frames) frames = max_file_frames;
237 }
238 const std::uint32_t ch = data->num_channels();
239
240 // Sum to mono (mono IR; true-stereo IR is a future extension).
241 std::vector<float> mono(frames, 0.0f);
242 for (std::uint32_t c = 0; c < ch; ++c) {
243 const auto& src = data->channels[c];
244 const std::size_t m = std::min<std::size_t>(frames, src.size());
245 for (std::size_t i = 0; i < m; ++i) mono[i] += src[i];
246 }
247 if (ch > 1) {
248 const float inv = 1.0f / static_cast<float>(ch);
249 for (float& v : mono) v *= inv;
250 }
251
252 return detail::finish_impulse_response(std::move(mono), file_sr, session_sr, opts);
253}
254
255#endif // !PULP_WASM
256
257} // namespace pulp::audio
static FormatRegistry & instance()
Get the global singleton registry (pre-populated with built-in formats).
std::optional< AudioFileData > read(const std::string &path) const
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
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 taps_per_phase() const
Per-phase filter length.
Definition resampler.hpp:342
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
Definition impulse_response.hpp:69
std::optional< std::vector< float > > finish_impulse_response(std::vector< float > mono, double file_sr, double session_sr, const ImpulseResponseLoadOptions &opts)
The post-decode half of the IR pipeline, shared by every entry point: resample the mono buffer from f...
Definition impulse_response.hpp:76
Definition analyzer_provider.hpp:12
constexpr double kMaxIrSampleRate
Definition impulse_response.hpp:54
std::optional< std::vector< float > > read_impulse_response(const std::string &path, double target_sample_rate, const ImpulseResponseLoadOptions &opts={})
Read an IR file → mono → resampled to target_sample_rate → (optionally) unit-energy normalized.
Definition impulse_response.hpp:193
constexpr double kMinIrSampleRate
Definition impulse_response.hpp:53
std::optional< std::vector< float > > read_impulse_response_pcm(const float *samples, std::size_t frames, std::uint32_t channels, double src_rate, double target_sample_rate, const ImpulseResponseLoadOptions &opts={})
Read already-decoded PCM → mono → resampled to target_sample_rate → (optionally) unit-energy normaliz...
Definition impulse_response.hpp:150
@ mono
Definition aax_model.hpp:22
@ c
Definition pitch.hpp:20
@ i
Definition formant_filter_bank.hpp:20
ResamplerT< float > Resampler
Definition resampler.hpp:382
@ m
Definition input_events.hpp:40
@ v
Definition input_events.hpp:41
Polyphase-FIR resampler with a Kaiser-window low-pass prototype.
static DecodeLimits unbounded()
No ceilings at all: every field zero.
Definition format_registry.hpp:32
Definition impulse_response.hpp:56
double reject_longer_than_seconds
Refuse a file whose duration exceeds this many seconds before decoding (fat-finger guard).
Definition impulse_response.hpp:66
double max_seconds
Truncate the IR to at most this many seconds (measured at both the source and the target rate so a hu...
Definition impulse_response.hpp:60
bool normalize_unit_energy
Scale so the IR has unit energy (‖ir‖₂ = 1), keeping a dry/wet mix sane regardless of the file's reco...
Definition impulse_response.hpp:63