// 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 #include #include 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 monoSine(std::size_t frames, double cycles, float amp) { std::vector 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 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 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 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 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 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 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 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 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 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 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 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); } // --- lastFrameAboveThreshold: the realtime tail's decay-scan boundary primitive -- // A mono decaying ramp: frame i has amplitude that falls linearly to zero. With a // threshold set between two frames' levels, the last frame above it is deterministic. static void testLastFrameDecayingRamp() { // 10 mono frames, amplitude 1.0 - i*0.1: frame0=1.0 ... frame9=0.1. std::vector buf(10); for (std::size_t i = 0; i < 10; ++i) buf[i] = 1.0f - 0.1f * (float)i; // Threshold 0.35: frames 0..6 (levels 1.0..0.4) exceed it; frame 6 is the last // (level 0.4 > 0.35), frame 7 (0.3) does not. Strict > semantics. CHECK(lastFrameAboveThreshold(buf, 1, 10, 0.35f) == 6); // Threshold just under frame 9's level (0.1): the very last frame stays. CHECK(lastFrameAboveThreshold(buf, 1, 10, 0.05f) == 9); // Threshold above the loudest frame: nothing survives. CHECK(lastFrameAboveThreshold(buf, 1, 10, 1.5f) == kNoFrameAboveThreshold); } // Pure silence at or below the threshold -> sentinel (the "trim back to end" case: // no frame in the tail window exceeds -72 dB). static void testLastFrameSilence() { std::vector zeros(20, 0.0f); CHECK(lastFrameAboveThreshold(zeros, 2, 10, 0.001f) == kNoFrameAboveThreshold); // A DC level exactly AT the threshold does not count (strict >). std::vector atThresh(8, 0.25f); CHECK(lastFrameAboveThreshold(atThresh, 1, 8, 0.25f) == kNoFrameAboveThreshold); } // Every frame above the threshold (a non-decaying source): the last frame is the // boundary — the caller keeps the whole window (the 8 s cap did its job). static void testLastFrameAllAbove() { std::vector loud(12, 0.8f); // 6 stereo frames CHECK(lastFrameAboveThreshold(loud, 2, 6, 0.1f) == 5); } // Per-frame peak is the MAX abs across channels (no fold): a frame with one loud // channel and one silent channel is "above" on the strength of the loud one, and a // negative sample is compared by magnitude. static void testLastFramePerChannelMaxAbs() { // 3 stereo frames. Frame0: (0.9, 0.0) loud L. Frame1: (0.0, -0.9) loud R (negative // -> abs). Frame2: (0.05, -0.05) both quiet. std::vector buf = {0.9f, 0.0f, 0.0f, -0.9f, 0.05f, -0.05f}; // Threshold 0.5: frame2 is below (peak 0.05), frame1 is above (|-0.9|=0.9). CHECK(lastFrameAboveThreshold(buf, 2, 3, 0.5f) == 1); // If both channels of the last frame mattered independently, a fold-average // (0.9+0.0)/2 = 0.45 on frame0 would fall below 0.5 — but frame0's L alone (0.9) // is above, proving max-abs, not average. Lower the threshold to isolate frame0. std::vector f0 = {0.9f, 0.0f}; CHECK(lastFrameAboveThreshold(f0, 2, 1, 0.5f) == 0); } // Degenerate: zero channels, zero frames, and a frameCount that overstates the // buffer (must clamp to available frames, no OOB read). static void testLastFrameDegenerate() { std::vector buf = {0.5f, 0.5f, 0.5f, 0.5f}; // 2 stereo frames CHECK(lastFrameAboveThreshold(buf, 0, 2, 0.1f) == kNoFrameAboveThreshold); CHECK(lastFrameAboveThreshold(buf, 2, 0, 0.1f) == kNoFrameAboveThreshold); std::vector empty; CHECK(lastFrameAboveThreshold(empty, 2, 10, 0.1f) == kNoFrameAboveThreshold); // frameCount=100 but only 2 real stereo frames: clamps to frame 1 (the last real // frame), which is above -> index 1, no read past the buffer. CHECK(lastFrameAboveThreshold(buf, 2, 100, 0.1f) == 1); } int main() { testSineEnvelope(); testRampMonotonic(); testDcAndSilence(); testMultiChannelNoFold(); testChannelsNotAveraged(); testShortBuffer(); testNonDivisibleRemainderBin(); testSingleBinWholeBuffer(); testDegenerateInputs(); testLargeBinCountOverflowGuard(); testLastFrameDecayingRamp(); testLastFrameSilence(); testLastFrameAllAbove(); testLastFramePerChannelMaxAbs(); testLastFrameDegenerate(); if (g_fail == 0) std::printf("All tests passed.\n"); return g_fail ? 1 : 0; }