peaks: pure per-channel min/max envelope from interleaved PCM
New STATIC lib + peaks_tests (CTest), mirroring bank_model. Float samples, per-channel (no fold); exact integer bin spans handle remainder, short buffers, and degenerate inputs with no OOB. Bin-span math guarded against size_t overflow for pathological binCount.
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
// Standalone tests for reasampler::peaks — no REAPER, no test framework.
|
||||
// Same fast build/run loop as bank_model_tests: feed a known signal, assert the
|
||||
// envelope.
|
||||
//
|
||||
// Covers (PLAN.md M2 / CONTEXT.md §peaks): full-scale sine envelope ~= +/-amp;
|
||||
// ramp envelope monotonic across bins; DC/silence -> min==max; multi-channel
|
||||
// independence (no fold); short buffer (fewer frames than bins) and non-divisible
|
||||
// length (remainder bin); binCount==1 whole-buffer envelope; zero frames / zero
|
||||
// channels / binCount==0 degenerate inputs.
|
||||
|
||||
#include "../src/peaks.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <vector>
|
||||
|
||||
using namespace reasampler;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
static bool approx(float a, float b, float tol) { return std::fabs(a - b) <= tol; }
|
||||
|
||||
// Local pi — M_PI is not a standard macro (MSVC omits it without _USE_MATH_DEFINES).
|
||||
constexpr double kPi = 3.14159265358979323846;
|
||||
|
||||
// A full-scale sine over `frames` frames, mono, `cycles` complete periods so every
|
||||
// bin sees both a near-peak and a near-trough.
|
||||
static std::vector<Sample> monoSine(std::size_t frames, double cycles, float amp) {
|
||||
std::vector<Sample> buf(frames);
|
||||
for (std::size_t i = 0; i < frames; ++i) {
|
||||
const double phase = 2.0 * kPi * cycles * (double)i / (double)frames;
|
||||
buf[i] = amp * (float)std::sin(phase);
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
// Full-scale sine: every bin's [min,max] should reach ~ [-amp, +amp].
|
||||
static void testSineEnvelope() {
|
||||
const std::size_t frames = 48000;
|
||||
const float amp = 1.0f;
|
||||
const std::size_t bins = 64;
|
||||
// Many cycles per bin so each bin brackets a full peak and trough.
|
||||
auto buf = monoSine(frames, /*cycles=*/128.0, amp);
|
||||
|
||||
Envelope env = computeEnvelope(buf, 1, frames, bins);
|
||||
CHECK(env.size() == 1);
|
||||
CHECK(env[0].size() == bins);
|
||||
|
||||
for (const MinMax& mm : env[0]) {
|
||||
CHECK(mm.min <= mm.max);
|
||||
CHECK(approx(mm.max, amp, 0.02f)); // reaches near +amp
|
||||
CHECK(approx(mm.min, -amp, 0.02f)); // reaches near -amp
|
||||
}
|
||||
}
|
||||
|
||||
// Monotonic ramp 0..1: each bin's max must not decrease across bins, and likewise
|
||||
// each bin's min, since the signal only ever rises.
|
||||
static void testRampMonotonic() {
|
||||
const std::size_t frames = 10000;
|
||||
const std::size_t bins = 50;
|
||||
std::vector<Sample> buf(frames);
|
||||
for (std::size_t i = 0; i < frames; ++i) {
|
||||
buf[i] = (float)i / (float)(frames - 1); // 0.0 .. 1.0
|
||||
}
|
||||
|
||||
Envelope env = computeEnvelope(buf, 1, frames, bins);
|
||||
CHECK(env[0].size() == bins);
|
||||
|
||||
// For a rising signal, per-bin min == the bin's first sample and max == last,
|
||||
// and both sequences are non-decreasing across bins.
|
||||
for (std::size_t b = 0; b < bins; ++b) {
|
||||
CHECK(env[0][b].min <= env[0][b].max);
|
||||
if (b > 0) {
|
||||
CHECK(env[0][b].min >= env[0][b - 1].min);
|
||||
CHECK(env[0][b].max >= env[0][b - 1].max);
|
||||
}
|
||||
}
|
||||
// First bin starts at ~0, last bin ends at ~1.
|
||||
CHECK(approx(env[0].front().min, 0.0f, 1e-3f));
|
||||
CHECK(approx(env[0].back().max, 1.0f, 1e-3f));
|
||||
|
||||
// Exact-value check on a known bin to confirm min==first sample, max==last sample
|
||||
// (not just monotonicity). With frames=10000, bins=50, each bin spans 200 frames.
|
||||
// Bin 25: frames [5000,5200); first sample = 5000/9999, last = 5199/9999.
|
||||
{
|
||||
const float expectedMin = 5000.0f / (float)(frames - 1);
|
||||
const float expectedMax = 5199.0f / (float)(frames - 1);
|
||||
CHECK(env[0][25].min == expectedMin);
|
||||
CHECK(env[0][25].max == expectedMax);
|
||||
}
|
||||
}
|
||||
|
||||
// DC / silence: min == max == the DC level in every bin (no spurious spread).
|
||||
static void testDcAndSilence() {
|
||||
const std::size_t frames = 1000;
|
||||
const std::size_t bins = 16;
|
||||
|
||||
std::vector<Sample> silence(frames, 0.0f);
|
||||
Envelope se = computeEnvelope(silence, 1, frames, bins);
|
||||
for (const MinMax& mm : se[0]) {
|
||||
CHECK(mm.min == 0.0f);
|
||||
CHECK(mm.max == 0.0f);
|
||||
}
|
||||
|
||||
std::vector<Sample> dc(frames, 0.5f);
|
||||
Envelope de = computeEnvelope(dc, 1, frames, bins);
|
||||
for (const MinMax& mm : de[0]) {
|
||||
CHECK(mm.min == 0.5f);
|
||||
CHECK(mm.max == 0.5f); // min == max: DC has no envelope spread
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-channel independence: ch0 full-scale sine, ch1 silent. Proves no fold:
|
||||
// ch1 must stay flat zero regardless of ch0's swing.
|
||||
static void testMultiChannelNoFold() {
|
||||
const std::size_t frames = 8000;
|
||||
const std::size_t bins = 32;
|
||||
const float amp = 0.9f;
|
||||
|
||||
// Interleave: [ch0, ch1] per frame; ch0 = sine, ch1 = 0.
|
||||
auto sine = monoSine(frames, /*cycles=*/64.0, amp);
|
||||
std::vector<Sample> buf(frames * 2);
|
||||
for (std::size_t i = 0; i < frames; ++i) {
|
||||
buf[i * 2 + 0] = sine[i];
|
||||
buf[i * 2 + 1] = 0.0f;
|
||||
}
|
||||
|
||||
Envelope env = computeEnvelope(buf, 2, frames, bins);
|
||||
CHECK(env.size() == 2);
|
||||
CHECK(env[0].size() == bins);
|
||||
CHECK(env[1].size() == bins);
|
||||
|
||||
for (const MinMax& mm : env[0]) {
|
||||
CHECK(approx(mm.max, amp, 0.05f));
|
||||
CHECK(approx(mm.min, -amp, 0.05f));
|
||||
}
|
||||
for (const MinMax& mm : env[1]) {
|
||||
CHECK(mm.min == 0.0f); // silent channel stays silent — not averaged with ch0
|
||||
CHECK(mm.max == 0.0f);
|
||||
}
|
||||
}
|
||||
|
||||
// A distinct-per-channel check that would visibly fail under any averaging: ch0
|
||||
// constant +1, ch1 constant -1. A fold would give 0; independence keeps +1 / -1.
|
||||
static void testChannelsNotAveraged() {
|
||||
const std::size_t frames = 100;
|
||||
const std::size_t bins = 4;
|
||||
std::vector<Sample> buf(frames * 2);
|
||||
for (std::size_t i = 0; i < frames; ++i) {
|
||||
buf[i * 2 + 0] = 1.0f;
|
||||
buf[i * 2 + 1] = -1.0f;
|
||||
}
|
||||
Envelope env = computeEnvelope(buf, 2, frames, bins);
|
||||
for (std::size_t b = 0; b < bins; ++b) {
|
||||
CHECK(env[0][b].min == 1.0f && env[0][b].max == 1.0f);
|
||||
CHECK(env[1][b].min == -1.0f && env[1][b].max == -1.0f);
|
||||
}
|
||||
}
|
||||
|
||||
// Short buffer: fewer frames than bins. Frames that land in a bin are correct;
|
||||
// trailing bins with no frame are {0,0}. No OOB.
|
||||
static void testShortBuffer() {
|
||||
const std::size_t frames = 3;
|
||||
const std::size_t bins = 8;
|
||||
std::vector<Sample> buf = {0.25f, -0.5f, 0.75f};
|
||||
|
||||
Envelope env = computeEnvelope(buf, 1, frames, bins);
|
||||
CHECK(env[0].size() == bins);
|
||||
|
||||
// With 3 frames over 8 bins, spans [b*3/8,(b+1)*3/8) place one frame in bins
|
||||
// 2, 5, 7 and leave the rest empty. Assert exactly which bins are populated so
|
||||
// the partition (not just "no crash") is verified.
|
||||
int populated = 0;
|
||||
for (std::size_t b = 0; b < bins; ++b) {
|
||||
const MinMax& mm = env[0][b];
|
||||
if (mm.min != 0.0f || mm.max != 0.0f) ++populated;
|
||||
CHECK(mm.min <= mm.max);
|
||||
}
|
||||
CHECK(populated == 3); // every input frame landed in exactly one bin, none lost
|
||||
// Bin spans: floor(b*3/8): b=2 -> frame0(0.25), b=5 -> frame1(-0.5), b=7 -> frame2(0.75).
|
||||
CHECK(env[0][2].min == 0.25f && env[0][2].max == 0.25f);
|
||||
CHECK(env[0][5].min == -0.5f && env[0][5].max == -0.5f);
|
||||
CHECK(env[0][7].min == 0.75f && env[0][7].max == 0.75f);
|
||||
}
|
||||
|
||||
// Non-divisible length: 10 frames over 3 bins. Spans are [0,3),[3,6),[6,10) — the
|
||||
// remainder (last) bin absorbs the extra frames; no sample dropped.
|
||||
static void testNonDivisibleRemainderBin() {
|
||||
const std::size_t frames = 10;
|
||||
const std::size_t bins = 3;
|
||||
std::vector<Sample> buf(frames);
|
||||
for (std::size_t i = 0; i < frames; ++i) buf[i] = (float)i; // 0..9
|
||||
|
||||
Envelope env = computeEnvelope(buf, 1, frames, bins);
|
||||
CHECK(env[0].size() == bins);
|
||||
// [0,3): {0..2} -> min 0, max 2
|
||||
CHECK(env[0][0].min == 0.0f && env[0][0].max == 2.0f);
|
||||
// [3,6): {3..5} -> min 3, max 5
|
||||
CHECK(env[0][1].min == 3.0f && env[0][1].max == 5.0f);
|
||||
// [6,10): {6..9} -> min 6, max 9 — remainder frames 6..9 all included
|
||||
CHECK(env[0][2].min == 6.0f && env[0][2].max == 9.0f);
|
||||
}
|
||||
|
||||
// binCount == 1: the whole buffer collapses to a single min/max.
|
||||
static void testSingleBinWholeBuffer() {
|
||||
std::vector<Sample> buf = {-0.3f, 0.8f, -0.9f, 0.1f, 0.4f};
|
||||
Envelope env = computeEnvelope(buf, 1, buf.size(), 1);
|
||||
CHECK(env[0].size() == 1);
|
||||
CHECK(env[0][0].min == -0.9f);
|
||||
CHECK(env[0][0].max == 0.8f);
|
||||
}
|
||||
|
||||
// Degenerate inputs: defined behavior, no UB, no throw.
|
||||
static void testDegenerateInputs() {
|
||||
std::vector<Sample> buf = {0.1f, 0.2f, 0.3f, 0.4f};
|
||||
|
||||
// Zero frames -> binCount bins, all {0,0}.
|
||||
Envelope zf = computeEnvelope(buf, 1, 0, 4);
|
||||
CHECK(zf.size() == 1 && zf[0].size() == 4);
|
||||
for (const MinMax& mm : zf[0]) CHECK(mm.min == 0.0f && mm.max == 0.0f);
|
||||
|
||||
// Zero channels -> empty envelope.
|
||||
Envelope zc = computeEnvelope(buf, 0, 4, 4);
|
||||
CHECK(zc.empty());
|
||||
|
||||
// binCount == 0 -> one channel, empty bin vector.
|
||||
Envelope zb = computeEnvelope(buf, 1, 4, 0);
|
||||
CHECK(zb.size() == 1 && zb[0].empty());
|
||||
|
||||
// frameCount overstates the buffer: clamps to available frames, no OOB.
|
||||
// buf holds 4 mono frames; ask for 100. Must not read past the buffer.
|
||||
Envelope over = computeEnvelope(buf, 1, 100, 2);
|
||||
CHECK(over.size() == 1 && over[0].size() == 2);
|
||||
// [0,2) of the 4 real frames -> min .1 max .2 ; [2,4) -> min .3 max .4
|
||||
CHECK(over[0][0].min == 0.1f && over[0][0].max == 0.2f);
|
||||
CHECK(over[0][1].min == 0.3f && over[0][1].max == 0.4f);
|
||||
|
||||
// Empty buffer, non-zero request -> all-zero bins, no crash.
|
||||
std::vector<Sample> empty;
|
||||
Envelope eb = computeEnvelope(empty, 2, 10, 3);
|
||||
CHECK(eb.size() == 2);
|
||||
for (const auto& chenv : eb) {
|
||||
CHECK(chenv.size() == 3);
|
||||
for (const MinMax& mm : chenv) CHECK(mm.min == 0.0f && mm.max == 0.0f);
|
||||
}
|
||||
}
|
||||
|
||||
// binCount > frameCount with frames >= 2: under the old un-guarded formula,
|
||||
// b*frames (or (b+1)*frames) overflows size_t for b near SIZE_MAX/frames.
|
||||
// This test exercises the sparse binCount > frames code path with a concrete
|
||||
// allocatable binCount and verifies populated bins have correct values and
|
||||
// all empty bins remain {0,0}.
|
||||
static void testLargeBinCountOverflowGuard() {
|
||||
// frames=4, binCount=9 (> frames, sparse). The overflow guard protects the
|
||||
// same loop iteration path that would UB for pathological binCount near SIZE_MAX.
|
||||
const std::size_t frames = 4;
|
||||
const std::size_t binCount = 9;
|
||||
std::vector<Sample> buf = {0.1f, 0.2f, 0.3f, 0.4f};
|
||||
|
||||
Envelope env = computeEnvelope(buf, 1, frames, binCount);
|
||||
CHECK(env.size() == 1);
|
||||
CHECK(env[0].size() == binCount);
|
||||
|
||||
// Partition [b*4/9, (b+1)*4/9):
|
||||
// b=2: [0,1) -> frame 0 = 0.1 b=4: [1,2) -> frame 1 = 0.2
|
||||
// b=6: [2,3) -> frame 2 = 0.3 b=8: [3,4) -> frame 3 = 0.4
|
||||
// b=0,1,3,5,7: empty spans -> {0,0}
|
||||
CHECK(env[0][2].min == 0.1f && env[0][2].max == 0.1f);
|
||||
CHECK(env[0][4].min == 0.2f && env[0][4].max == 0.2f);
|
||||
CHECK(env[0][6].min == 0.3f && env[0][6].max == 0.3f);
|
||||
CHECK(env[0][8].min == 0.4f && env[0][8].max == 0.4f);
|
||||
CHECK(env[0][0].min == 0.0f && env[0][0].max == 0.0f);
|
||||
CHECK(env[0][1].min == 0.0f && env[0][1].max == 0.0f);
|
||||
CHECK(env[0][3].min == 0.0f && env[0][3].max == 0.0f);
|
||||
CHECK(env[0][5].min == 0.0f && env[0][5].max == 0.0f);
|
||||
CHECK(env[0][7].min == 0.0f && env[0][7].max == 0.0f);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testSineEnvelope();
|
||||
testRampMonotonic();
|
||||
testDcAndSilence();
|
||||
testMultiChannelNoFold();
|
||||
testChannelsNotAveraged();
|
||||
testShortBuffer();
|
||||
testNonDivisibleRemainderBin();
|
||||
testSingleBinWholeBuffer();
|
||||
testDegenerateInputs();
|
||||
testLargeBinCountOverflowGuard();
|
||||
|
||||
if (g_fail == 0) std::printf("All tests passed.\n");
|
||||
return g_fail ? 1 : 0;
|
||||
}
|
||||
Reference in New Issue
Block a user