#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); // The merged min/max for display column `col` (0-based, of `columnCount` total columns) // of a pre-computed per-bin ChannelEnvelope: the true extremes of every bin that projects // to that column. This is the display-side collapse of an envelope computed at HIGHER // resolution than the drawn width (oversampled bins -> per-pixel-column min/max), so a // steep transient whose adjacent bins hold disjoint spans (e.g. {0.9,1.0} then // {-1.0,-0.9}) renders as one gap-free vertical span instead of two separated dots. // // Bin->column mapping mirrors computeEnvelope's half-open partition: // column col owns bins [col*nbins/columnCount, (col+1)*nbins/columnCount). // When that range is empty (more columns than bins), the enclosing bin // (col*nbins/columnCount) fills the column — so no column is left empty and no bin is // ever dropped. columnCount <= 0 or bins.empty() returns {0, 0}; `col` is clamped to // [0, columnCount-1]. Pure. MinMax columnMinMax(const ChannelEnvelope& bins, int columnCount, int col); // 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