Pulp 1.88.0
Cross-platform audio plugin framework
Loading...
Searching...
No Matches
validation_assertions.hpp
Go to the documentation of this file.
1#pragma once
2
28
29#include <pulp/audio/buffer.hpp>
31#include <pulp/midi/buffer.hpp>
34#include <pulp/state/store.hpp>
35
36#include <algorithm>
37#include <cmath>
38#include <cstdint>
39#include <span>
40#include <string>
41#include <utility>
42#include <vector>
43
45
51 bool ok = true;
52 std::string message;
53
54 explicit operator bool() const { return ok; }
55
56 static CheckResult pass() { return {true, {}}; }
57 static CheckResult fail(std::string why) { return {false, std::move(why)}; }
58};
59
60// ── Audio ────────────────────────────────────────────────────────────────
61
65inline CheckResult check_finite(std::span<const float> samples) {
66 for (std::size_t i = 0; i < samples.size(); ++i) {
67 if (!std::isfinite(samples[i])) {
68 return CheckResult::fail(
69 "non-finite sample at index " + std::to_string(i) + " (" +
70 (std::isnan(samples[i]) ? "NaN" : "Inf") + ")");
71 }
72 }
73 return CheckResult::pass();
74}
75
79template <typename Sample>
81 for (std::size_t ch = 0; ch < buffer.num_channels(); ++ch) {
82 const auto channel = buffer.channel(ch);
83 for (std::size_t i = 0; i < channel.size(); ++i) {
84 if (!std::isfinite(channel[i])) {
85 return CheckResult::fail(
86 "non-finite sample at ch " + std::to_string(ch) +
87 " index " + std::to_string(i) + " (" +
88 (std::isnan(channel[i]) ? "NaN" : "Inf") + ")");
89 }
90 }
91 }
92 return CheckResult::pass();
93}
94
98inline CheckResult check_any_nonzero(std::span<const float> samples,
99 float eps = 1.0e-7f) {
100 for (float s : samples) {
101 if (std::fabs(s) > eps) {
102 return CheckResult::pass();
103 }
104 }
105 return CheckResult::fail("all " + std::to_string(samples.size()) +
106 " samples are within +/-" + std::to_string(eps) +
107 " of zero (output is silent)");
108}
109
113inline CheckResult check_silent(std::span<const float> samples,
114 float eps = 1.0e-7f) {
115 for (std::size_t i = 0; i < samples.size(); ++i) {
116 if (!std::isfinite(samples[i])) {
117 return CheckResult::fail(
118 "non-finite sample at index " + std::to_string(i));
119 }
120 if (std::fabs(samples[i]) > eps) {
121 return CheckResult::fail(
122 "expected silence but sample " + std::to_string(i) +
123 " = " + std::to_string(samples[i]) + " exceeds +/-" +
124 std::to_string(eps));
125 }
126 }
127 return CheckResult::pass();
128}
129
134inline CheckResult check_peak_below(std::span<const float> samples,
135 float peak_limit) {
136 for (std::size_t i = 0; i < samples.size(); ++i) {
137 if (!std::isfinite(samples[i])) {
138 return CheckResult::fail(
139 "non-finite sample at index " + std::to_string(i));
140 }
141 if (std::fabs(samples[i]) > peak_limit) {
142 return CheckResult::fail(
143 "sample " + std::to_string(i) + " = " +
144 std::to_string(samples[i]) + " exceeds peak limit " +
145 std::to_string(peak_limit));
146 }
147 }
148 return CheckResult::pass();
149}
150
151// ── Parameters ──────────────────────────────────────────────────────────
152
173 float raw, float tol = 1.0e-4f) {
174 if (!std::isfinite(raw)) {
175 return CheckResult::fail("raw input is not finite");
176 }
177 const float span = range.max - range.min;
178 const float scale = std::max(1.0f, std::max(std::fabs(range.min),
179 std::fabs(range.max)));
180 const float eff_tol = std::max(tol, 1.0e-4f * scale);
181
182 const float normalized = range.normalize(raw);
183 if (!std::isfinite(normalized) || normalized < -eff_tol ||
184 normalized > 1.0f + eff_tol) {
185 return CheckResult::fail("normalize(" + std::to_string(raw) +
186 ") = " + std::to_string(normalized) +
187 " is not finite/in [0,1]");
188 }
189 const float projected = range.denormalize(normalized);
190 if (!std::isfinite(projected) || projected < range.min - eff_tol ||
191 projected > range.max + eff_tol) {
192 return CheckResult::fail(
193 "denormalized value " + std::to_string(projected) +
194 " is not finite/in range [" + std::to_string(range.min) + ", " +
195 std::to_string(range.max) + "]");
196 }
197
198 // Independent expectation for the linear/stepped case (no idempotency
199 // self-reference): clamp raw into range, then snap to step. This is what a
200 // correct linear denormalize must produce, so a constant/garbage map fails.
201 if (range.is_linear()) {
202 float expected = std::clamp(raw, range.min, range.max);
203 if (range.step > 0.0f) {
204 expected = range.min +
205 std::round((expected - range.min) / range.step) *
206 range.step;
207 expected = std::clamp(expected, range.min, range.max);
208 }
209 if (std::fabs(projected - expected) > eff_tol) {
210 return CheckResult::fail(
211 "linear map wrong: expected " + std::to_string(expected) +
212 ", got " + std::to_string(projected));
213 }
214 return CheckResult::pass();
215 }
216
217 // Skewed range: verify the endpoint anchors so a constant map can't pass.
218 if (span > 0.0f) {
219 const float at_zero = range.denormalize(0.0f);
220 const float at_one = range.denormalize(1.0f);
221 if (std::fabs(at_zero - range.min) > eff_tol ||
222 std::fabs(at_one - range.max) > eff_tol) {
223 return CheckResult::fail(
224 "skewed map endpoints off: denorm(0)=" +
225 std::to_string(at_zero) + " denorm(1)=" +
226 std::to_string(at_one));
227 }
228 }
229 // And that projecting the already-projected value is stable (rules out a
230 // non-monotonic/garbage interior on top of correct anchors).
231 const float reprojected = range.denormalize(range.normalize(projected));
232 if (std::fabs(reprojected - projected) > eff_tol) {
233 return CheckResult::fail(
234 "skewed round trip not stable: " + std::to_string(projected) +
235 " -> " + std::to_string(reprojected));
236 }
237 return CheckResult::pass();
238}
239
240// ── State ────────────────────────────────────────────────────────────────
241
258 const std::vector<uint8_t> first = host.save_state();
259 if (first.empty()) {
260 return CheckResult::fail("save_state() produced an empty blob");
261 }
262
263 // Perturb every parameter to a value distinct from its current one so a
264 // loader that ignores the blob is detectable. Pick the far end of the range
265 // from the current value; skip degenerate (min == max) params.
266 auto& store = host.state();
267 bool perturbed_any = false;
268 for (const auto& info : store.all_params()) {
269 const auto& r = info.range;
270 if (!(r.max > r.min)) {
271 continue;
272 }
273 const float current = store.get_value(info.id);
274 const float alt = (current - r.min) >= (r.max - current) ? r.min : r.max;
275 if (alt != current) {
276 store.set_value(info.id, alt);
277 perturbed_any = true;
278 }
279 }
280
281 if (!host.load_state(first)) {
282 return CheckResult::fail("load_state() rejected a blob it just saved");
283 }
284 const std::vector<uint8_t> second = host.save_state();
285 if (first != second) {
286 return CheckResult::fail(
287 "state not restored across save/perturb/load/save (" +
288 std::to_string(first.size()) + " vs " +
289 std::to_string(second.size()) +
290 " bytes; load_state may be ignoring the blob)");
291 }
292 if (!perturbed_any) {
293 // No automatable parameter could be perturbed (e.g. a pure custom-state
294 // plugin). The check then only proves save determinism + that load
295 // accepted its own blob — flag it so the caller knows coverage is thin.
296 return CheckResult{true,
297 "save/load determinism only: no perturbable "
298 "parameters to prove load actually restores"};
299 }
300 return CheckResult::pass();
301}
302
303// ── MIDI / SysEx ──────────────────────────────────────────────────────────
304
306inline CheckResult check_sysex_payload_equal(std::span<const uint8_t> expected,
307 std::span<const uint8_t> actual) {
308 if (expected.size() != actual.size()) {
309 return CheckResult::fail(
310 "sysex length mismatch: expected " +
311 std::to_string(expected.size()) + " bytes, got " +
312 std::to_string(actual.size()));
313 }
314 for (std::size_t i = 0; i < expected.size(); ++i) {
315 if (expected[i] != actual[i]) {
316 return CheckResult::fail(
317 "sysex byte " + std::to_string(i) + " mismatch: expected " +
318 std::to_string(expected[i]) + ", got " +
319 std::to_string(actual[i]));
320 }
321 }
322 return CheckResult::pass();
323}
324
337 const midi::MidiBuffer& actual) {
338 if (expected.size() != actual.size()) {
339 return CheckResult::fail(
340 "short-message count mismatch: expected " +
341 std::to_string(expected.size()) + ", got " +
342 std::to_string(actual.size()));
343 }
344 for (std::size_t i = 0; i < expected.size(); ++i) {
345 const auto& e = expected[i];
346 const auto& a = actual[i];
347 if (e.sample_offset != a.sample_offset) {
348 return CheckResult::fail(
349 "event " + std::to_string(i) + " sample_offset mismatch: " +
350 std::to_string(e.sample_offset) + " vs " +
351 std::to_string(a.sample_offset));
352 }
353 if (e.size() != a.size()) {
354 return CheckResult::fail(
355 "event " + std::to_string(i) + " byte-length mismatch: " +
356 std::to_string(e.size()) + " vs " + std::to_string(a.size()));
357 }
358 for (uint32_t b = 0; b < e.size(); ++b) {
359 if (e.data()[b] != a.data()[b]) {
360 return CheckResult::fail(
361 "event " + std::to_string(i) + " byte " +
362 std::to_string(b) + " mismatch");
363 }
364 }
365 }
366
367 const auto& expected_sysex = expected.sysex();
368 const auto& actual_sysex = actual.sysex();
369 if (expected_sysex.size() != actual_sysex.size()) {
370 return CheckResult::fail(
371 "sysex count mismatch: expected " +
372 std::to_string(expected_sysex.size()) + ", got " +
373 std::to_string(actual_sysex.size()));
374 }
375 for (std::size_t i = 0; i < expected_sysex.size(); ++i) {
376 if (expected_sysex[i].sample_offset != actual_sysex[i].sample_offset) {
377 return CheckResult::fail(
378 "sysex " + std::to_string(i) + " sample_offset mismatch");
379 }
380 const auto expected_bytes = expected_sysex[i].data.to_vector();
381 const auto actual_bytes = actual_sysex[i].data.to_vector();
382 auto byte_result = check_sysex_payload_equal(expected_bytes, actual_bytes);
383 if (!byte_result) {
384 return CheckResult::fail("sysex " + std::to_string(i) + ": " +
385 byte_result.message);
386 }
387 }
388
389 // UMP (MIDI 2.0) sidecar. A null buffer and an empty buffer are treated as
390 // equivalent ("no UMP this block"); a non-empty mismatch must fail.
391 const midi::UmpBuffer* expected_ump = expected.ump();
392 const midi::UmpBuffer* actual_ump = actual.ump();
393 const std::size_t expected_ump_count = expected_ump ? expected_ump->size() : 0;
394 const std::size_t actual_ump_count = actual_ump ? actual_ump->size() : 0;
395 if (expected_ump_count != actual_ump_count) {
396 return CheckResult::fail(
397 "UMP packet count mismatch: expected " +
398 std::to_string(expected_ump_count) + ", got " +
399 std::to_string(actual_ump_count));
400 }
401 for (std::size_t i = 0; i < expected_ump_count; ++i) {
402 const auto& e = (*expected_ump)[i];
403 const auto& a = (*actual_ump)[i];
404 if (e.sample_offset != a.sample_offset) {
405 return CheckResult::fail(
406 "UMP " + std::to_string(i) + " sample_offset mismatch");
407 }
408 if (e.packet.word_count != a.packet.word_count) {
409 return CheckResult::fail(
410 "UMP " + std::to_string(i) + " word_count mismatch");
411 }
412 for (int w = 0; w < e.packet.word_count && w < 4; ++w) {
413 if (e.packet.words[w] != a.packet.words[w]) {
414 return CheckResult::fail("UMP " + std::to_string(i) +
415 " word " + std::to_string(w) +
416 " mismatch");
417 }
418 }
419 }
420 return CheckResult::pass();
421}
422
423} // namespace pulp::format::validation
Non-owning view over multi-channel audio sample data.
Definition buffer.hpp:30
Headless plugin host for testing and offline processing.
Definition headless.hpp:29
Collection of timestamped MIDI events within a single audio buffer period.
Definition buffer.hpp:31
std::size_t size() const
Definition buffer.hpp:123
const std::vector< SysexEvent > & sysex() const
Definition buffer.hpp:416
const class UmpBuffer * ump() const
Attached UmpBuffer or nullptr.
Definition buffer.hpp:217
A time-ordered list of UMP events for one audio block.
Definition ump_buffer.hpp:41
std::size_t size() const
Definition ump_buffer.hpp:96
Headless processing adapter for programmatic plugin use without UI or audio device.
Definition validation_assertions.hpp:44
CheckResult check_any_nonzero(std::span< const float > samples, float eps=1.0e-7f)
True when at least one sample's magnitude exceeds eps — i.e.
Definition validation_assertions.hpp:98
CheckResult check_silent(std::span< const float > samples, float eps=1.0e-7f)
True when every sample's magnitude is at or below eps — the inverse of check_any_nonzero,...
Definition validation_assertions.hpp:113
CheckResult check_midi_events_equal(const midi::MidiBuffer &expected, const midi::MidiBuffer &actual)
True when two MIDI buffers carry the same short messages (bytes + sample offset, in order),...
Definition validation_assertions.hpp:336
CheckResult check_finite(std::span< const float > samples)
True when no sample is NaN or infinite.
Definition validation_assertions.hpp:65
CheckResult check_sysex_payload_equal(std::span< const uint8_t > expected, std::span< const uint8_t > actual)
True when two SysEx payloads carry identical bytes.
Definition validation_assertions.hpp:306
CheckResult check_param_round_trip(const state::ParamRange &range, float raw, float tol=1.0e-4f)
Verify a parameter's normalize↔denormalize map is correct for raw.
Definition validation_assertions.hpp:172
CheckResult check_peak_below(std::span< const float > samples, float peak_limit)
True when every sample's magnitude is at or below peak_limit (linear, not dB).
Definition validation_assertions.hpp:134
CheckResult check_state_round_trip(HeadlessHost &host)
Verify plugin state actually round-trips: save a snapshot, perturb every parameter to a different val...
Definition validation_assertions.hpp:257
Bounded event contracts shared by host ingress and graph-authored nodes.
Definition anticipation_eligibility.hpp:9
Outcome of a single validation check.
Definition validation_assertions.hpp:50
static CheckResult fail(std::string why)
Definition validation_assertions.hpp:57
static CheckResult pass()
Definition validation_assertions.hpp:56
bool ok
Definition validation_assertions.hpp:51
std::string message
Definition validation_assertions.hpp:52
Defines the numeric range of a parameter, including normalization.
Definition parameter.hpp:131
bool is_linear() const
True when this range is a plain affine (linear) map — the common case.
Definition parameter.hpp:163
float min
Definition parameter.hpp:132
float denormalize(float normalized) const
Map a normalized [0, 1] value back to the real range.
Definition parameter.hpp:188
float max
Definition parameter.hpp:133
float normalize(float value) const
Map a real value to the normalized [0, 1] range.
Definition parameter.hpp:168
float step
Quantization step. 0 = continuous.
Definition parameter.hpp:135
Sample-accurate MIDI 2.0 UMP packet stream.