#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 audio-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. // // NAMED AudioSample, not `Sample`: `reasampler::Sample` is already bank_model's // metadata struct. A `using Sample = float` here would collide at namespace scope // wherever both headers are visible (the bank_panel module includes both). The // audio-domain name also reads more precisely — this is one PCM sample value. using AudioSample = 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 { AudioSample min = 0.0f; AudioSample 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); // Sentinel returned by lastFrameAboveThreshold when NO frame in the scanned range // peaks above the threshold (pure silence at that level). SIZE_MAX is unambiguous: // no valid frame index can equal it (a real index is < frameCount <= SIZE_MAX for // any allocatable buffer), so the caller tests `== kNoFrameAboveThreshold` cleanly. inline constexpr std::size_t kNoFrameAboveThreshold = static_cast(-1); // Scans interleaved PCM BACKWARD for the last frame whose per-frame peak (the max // absolute value across all channels of that frame — NO stereo fold, just the // loudest channel that frame) exceeds `linearThreshold`, returning that frame index. // Returns kNoFrameAboveThreshold if no frame exceeds it (or on degenerate input). // // This is the boundary primitive behind the realtime tail's decay-scan trim // (docs/product/capture-tail.md §The realtime path): the recorded tail window is // scanned back from the end for the last frame still above -72 dB, and the file is // truncated one frame past it. Deliberately a separate primitive from // computeEnvelope — that answers "the min/max envelope over bins" (a thumbnail), // this answers "the last frame above a level" (a boundary). Bending the bin-oriented // envelope to a frame-exact boundary question is a worse fit (spec §option a). // // interleaved frame-interleaved samples: [f0c0, f0c1, ..., f1c0, f1c1, ...]. // Must hold >= frameCount * channelCount; extra is ignored, and a // short buffer is clamped to what it actually holds (no OOB read). // channelCount channels per frame (the stride). The per-frame test is the max // |sample| over these channels — the frame is "above" if its // loudest channel is above the threshold. // frameCount frames to consider (the scan starts at the last of these). // linearThreshold the comparison level as a LINEAR amplitude ratio (e.g. the // -72 dB ratio from render_settings::autoTrimEndRatio), NOT dB. // A frame counts as above when its peak is STRICTLY > this. // // Pure, stdlib-only, unit-tested (a synthetic decaying ramp, silence, all-above, // and degenerate inputs) so the trim boundary math is locked outside the DAW. std::size_t lastFrameAboveThreshold(const std::vector& interleaved, std::size_t channelCount, std::size_t frameCount, AudioSample linearThreshold); } // namespace reasampler