diff --git a/CMakeLists.txt b/CMakeLists.txt index 72c2d4f..c943c62 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -22,15 +22,26 @@ add_library(bank_model STATIC src/bank_model.cpp) target_include_directories(bank_model PUBLIC src) # --------------------------------------------------------------------------- -# 2) Standalone tests for the model (run without launching REAPER). +# 2) Pure peaks library — NO REAPER, NO SWELL. Waveform min/max thumbnails from +# raw PCM (Milestone 2). A sibling pure lib, kept distinct from bank_model. +# --------------------------------------------------------------------------- +add_library(peaks STATIC src/peaks.cpp) +target_include_directories(peaks PUBLIC src) + +# --------------------------------------------------------------------------- +# 3) Standalone tests for the pure modules (run without launching REAPER). # --------------------------------------------------------------------------- enable_testing() add_executable(bank_model_tests tests/test_bank_model.cpp) target_link_libraries(bank_model_tests PRIVATE bank_model) add_test(NAME bank_model_tests COMMAND bank_model_tests) +add_executable(peaks_tests tests/test_peaks.cpp) +target_link_libraries(peaks_tests PRIVATE peaks) +add_test(NAME peaks_tests COMMAND peaks_tests) + # --------------------------------------------------------------------------- -# 3) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked). +# 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked). # --------------------------------------------------------------------------- add_library(reaper_reasampler MODULE src/main.cpp diff --git a/src/peaks.cpp b/src/peaks.cpp new file mode 100644 index 0000000..a7e31c2 --- /dev/null +++ b/src/peaks.cpp @@ -0,0 +1,66 @@ +#include "peaks.h" + +#include +#include + +// peaks implementation. +// +// One linear pass per channel. The frame->bin partition is computed with integer +// arithmetic so it is exact for any frameCount / binCount pairing: bin b owns the +// half-open frame span [b*frameCount/binCount, (b+1)*frameCount/binCount). That +// span formula distributes the remainder deterministically (earlier bins get the +// extra frames) with no rounding drift and no dropped tail — the last bin's end is +// always exactly frameCount. + +namespace reasampler { + +Envelope computeEnvelope(const std::vector& interleaved, + std::size_t channelCount, + std::size_t frameCount, + std::size_t binCount) { + Envelope envelope(channelCount); + if (channelCount == 0) { + return envelope; // no channels -> no envelopes + } + + // Never read past what the buffer actually holds, even if the caller's + // frameCount overstates the buffer (defensive: no OOB on a short buffer). + const std::size_t availableFrames = interleaved.size() / channelCount; + const std::size_t frames = std::min(frameCount, availableFrames); + + for (std::size_t ch = 0; ch < channelCount; ++ch) { + ChannelEnvelope& bins = envelope[ch]; + bins.assign(binCount, MinMax{}); // empty/degenerate bins default to {0,0} + + for (std::size_t b = 0; b < binCount; ++b) { + // Half-open frame span for this bin: [b*frames/binCount, (b+1)*frames/binCount). + // Guard against size_t overflow in b*frames and (b+1)*frames: binCount is + // caller-controlled and unbounded, so when b >= SIZE_MAX/frames either + // multiplication could wrap. Any such bin is unreachable in practice + // (allocating that many MinMax entries would OOM first), but we guard + // explicitly to eliminate UB. + if (frames > 0 && b >= SIZE_MAX / frames) { + continue; // b*frames or (b+1)*frames would overflow; span is empty + } + const std::size_t begin = (b * frames) / binCount; + const std::size_t end = ((b + 1) * frames) / binCount; + if (begin >= end) { + continue; // empty span (binCount > frames) -> keep {0,0} + } + + const Sample first = interleaved[begin * channelCount + ch]; + Sample lo = first; + Sample hi = first; + for (std::size_t f = begin + 1; f < end; ++f) { + const Sample s = interleaved[f * channelCount + ch]; + lo = std::min(lo, s); + hi = std::max(hi, s); + } + bins[b] = MinMax{lo, hi}; + } + } + + return envelope; +} + +} // namespace reasampler diff --git a/src/peaks.h b/src/peaks.h new file mode 100644 index 0000000..6519c60 --- /dev/null +++ b/src/peaks.h @@ -0,0 +1,64 @@ +#pragma once +// peaks — waveform min/max envelope (thumbnail) computation from raw interleaved +// PCM. We compute our own thumbnails from the captured file rather than depending +// on REAPER's peak API: we own the file format, so this is simpler, testable, and +// dependency-free. A future bank panel (M5) calls this at whatever bin resolution +// the panel width dictates and draws one min/max envelope per channel. +// +// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO +// vendor/ includes. Standard library only. Builds and unit-tests without REAPER. + +#include +#include + +namespace reasampler { + +// Canonical in-memory sample type. `float` is REAPER's native audio buffer format +// (its render/PCM_source callbacks hand back interleaved 32-bit float), so peaks +// consumes that directly with no lossy conversion. If a capture ever lands as a +// different depth, the caller converts to float at the boundary — the thumbnail +// core stays single-typed. +using Sample = float; + +// One bin of a channel's envelope: the extremes of every sample that fell in it. +// min <= max always. For an empty bin (more bins than frames), both are 0. +struct MinMax { + Sample min = 0.0f; + Sample max = 0.0f; + + bool operator==(const MinMax& o) const { return min == o.min && max == o.max; } +}; + +// One channel's envelope: exactly `binCount` bins, in time order. +using ChannelEnvelope = std::vector; + +// Per-channel envelopes: outer index is channel (channelCount entries, order +// preserved — never mixed or folded), inner is that channel's bins. +using Envelope = std::vector; + +// Computes a per-channel min/max envelope from interleaved PCM. +// +// interleaved frame-interleaved samples: [f0c0, f0c1, ..., f1c0, f1c1, ...]. +// Size must be >= frameCount * channelCount; extra is ignored. +// channelCount channels per frame (the stride). Each channel is enveloped +// INDEPENDENTLY — no averaging, no stereo fold (precision +// invariant: channel count preserved). +// frameCount frames (samples-per-channel) to consider. +// binCount requested bins per channel. Honored exactly for any frameCount. +// +// Frame->bin partition: frames are split into `binCount` contiguous spans as +// evenly as possible; when frameCount does not divide evenly, the remainder is +// spread one-frame-per-bin across the earliest bins (ceil/floor split), so the +// tail is never dropped and no bin reads out of bounds. When binCount > frameCount +// the trailing empty bins are {0, 0}. +// +// Defined behavior for degenerate input (no UB, no throw): +// binCount == 0 -> per channel: an empty bin vector. +// channelCount == 0 -> an empty envelope (no channels). +// frameCount == 0 -> per channel: binCount bins, all {0, 0}. +Envelope computeEnvelope(const std::vector& interleaved, + std::size_t channelCount, + std::size_t frameCount, + std::size_t binCount); + +} // namespace reasampler diff --git a/tests/test_peaks.cpp b/tests/test_peaks.cpp new file mode 100644 index 0000000..ac81d2d --- /dev/null +++ b/tests/test_peaks.cpp @@ -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 +#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); +} + +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; +}