#pragma once // bank_model — the HEART of ReaSampler, deliberately free of any REAPER type so // it compiles and unit-tests OUTSIDE the DAW. It owns the per-project sample // bank: the `Sample` metadata struct and the `BankIndex` (add / remove / query / // tier moves / dedup-by-hash + JSON round-trip to/from std::string). // // PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO // vendor/ includes. Standard library only. #include #include #include #include namespace reasampler { // How the source audio was obtained. Kept in the pure core (no REAPER coupling); // the capture backends (M3/M8) map their own notion onto these. enum class SourceMode { MasterMix, // offline render of the master output SelectedTracks, // offline render of selected tracks SelectedItems, // offline render of selected media items TimeSelection, // offline render bounded by the time selection RazorArea, // offline render of a razor edit area Realtime, // realtime record of wet output }; // Retention tier. `Scratch` is auto-prunable working material; `Archive` is kept. enum class Tier { Scratch, Archive, }; // Sample-accurate source bounds, in both project seconds and PPQ (ticks). Both // are stored because capture needs seconds and musical placement needs PPQ; we // refuse to re-derive one from the other and risk rounding (precision invariant). struct SourceRange { double startSeconds = 0.0; double endSeconds = 0.0; double startPpq = 0.0; double endPpq = 0.0; bool operator==(const SourceRange& o) const; }; // Present only when a sample was resampled FROM another sample. Carries the // parent's id and the FX-chain snapshot string (a thin drift fingerprint, NOT a // restorable chunk) captured at resample time; the re-capture-from-source action // (M10) uses it to detect chain drift and replay the original capture request. struct Provenance { std::string parentSampleId; std::string fxChainSnapshot; bool operator==(const Provenance& o) const; }; // Loudness / level metrics measured from the captured file. struct Levels { double peakDb = 0.0; double rmsDb = 0.0; double lufs = 0.0; bool operator==(const Levels& o) const; }; // Sample-accurate sustain-loop bounds, as frame indices into the captured file // (Phase S seam field, D-B). A bank intrinsic — a fact about the file, like // sampleRate or length — consumed by the future MIDI-playback instrument to hold // notes past the recorded length. Modeled as one optional struct (not two loose // optionals) so "both points or neither" is a structural invariant, not a rule to // re-check at every boundary. Frame indices, not seconds, because the loop is a // per-sample-frame contract; the instrument reads the file's sample rate to relate // them to time. Invariant (enforced at the deserialize boundary): 0 <= start <= end. // start == end is a valid zero-length loop marker. struct LoopPoints { std::int64_t start = 0; std::int64_t end = 0; bool operator==(const LoopPoints& o) const; }; // The metadata record for one captured sample. The audio itself lives in a // project-relative file; `relativePath` is ALWAYS relative (enforced at the // BankIndex::add boundary — see AddResult). struct Sample { std::string id; // stable unique id (assigned by the caller) std::string displayName; std::string relativePath; // project-relative; never absolute (invariant) SourceMode sourceMode = SourceMode::MasterMix; SourceRange sourceRange; // Track GUID(s) the capture came from, when applicable (empty otherwise). std::vector trackGuids; double wetDry = 1.0; // 1.0 = fully wet, 0.0 = fully dry int channelCount = 0; int sampleRate = 0; double lengthSeconds = 0.0; double lengthBeats = 0.0; double captureTempo = 0.0; // project tempo (BPM) at capture time // Time signature at capture time (L7 F1 — stamped alongside captureTempo so the // bars.beats.subdivisions read-out is stable under later project meter changes). // 0/0 means UNSTAMPED (pre-L7 sample, or a capture that could not read the meter); // the metadata formatter renders a blank musical read-out for 0/0 and keeps s.ms. int captureTimeSigNum = 0; // meter numerator (e.g. 4 in 4/4); 0 = unstamped int captureTimeSigDenom = 0; // meter denominator (e.g. 4 in 4/4); 0 = unstamped std::optional key; // musical key, when known // Phase S seam fields (D-B) — bank intrinsics for the MIDI-playback instrument, // additive like `provenance` (M1). Both default cleanly empty: pre-Phase-S // samples deserialize without them and re-serialize without inventing values. // - rootNote: MIDI note (0..127) the sample was recorded at, so the instrument // can repitch it across the keyboard. DISTINCT from the musical `key` above: // `key` is a human label ("F#m"); `rootNote` is the exact pitch for repitch. // Populated at/after capture only where derivable — left empty (never guessed) // when the source is not a single played note. // - loop: sustain-loop bounds, populated only where explicitly set. std::optional rootNote; std::optional loop; Levels levels; bool clipped = false; Tier tier = Tier::Scratch; std::string contentHash; // dedup key (see BankIndex) std::optional provenance; // set only when resampled std::int64_t createdTimestamp = 0; // unix epoch seconds bool operator==(const Sample& o) const; bool operator!=(const Sample& o) const { return !(*this == o); } // A scratch-tier sample is auto-prunable; archive is kept. bool isAutoPrunable() const { return tier == Tier::Scratch; } }; // Outcome of BankIndex::add. `add` rejects rather than silently mutating: // - RejectedAbsolutePath: relativePath was absolute (precision invariant). // - RejectedEmptyId: id was empty (the collection is keyed by id). // - Collapsed: content hash matched an existing entry; the existing // entry is kept and the add is a no-op (dedup). // - Added: inserted as a new entry. enum class AddResult { Added, Collapsed, RejectedAbsolutePath, RejectedEmptyId, }; // An ordered, id-keyed collection of Samples with content-hash dedup, tier // moves/filtering, and lossless JSON round-trip. Insertion order is preserved // so a future panel (M5) can iterate in stable order. class BankIndex { public: // Adds a sample. Enforces the relative-paths-only invariant and dedups by // content hash (an equal-hash add collapses onto the existing entry rather // than duplicating). See AddResult for the full outcome set. AddResult add(const Sample& sample); // Removes the sample with `id`. Returns true if one was removed. bool remove(const std::string& id); // Replaces the sample carrying `id` IN PLACE (preserving its position in // insertion order), with `updated`. Used by M10 re-capture-from-source: a // provenanced sample's file is regenerated and its metadata (relativePath, // contentHash, levels, timestamp, ...) refreshed while its identity (id) and // slot are kept, so the bank panel shows the same tile updated rather than a // reordered new entry. `updated.id` should equal `id` (the caller keeps the id // stable); a differing id is written through as given (the caller's contract). // Does NOT dedup — an in-place refresh of one entry is not a new insert, so the // collapse-by-hash rule (which guards NEW inserts) does not apply. Returns false // (no mutation) if `id` is absent or `updated.relativePath` is absolute // (the relative-paths-only invariant still holds for the replacement). bool updateInPlace(const std::string& id, const Sample& updated); // Returns the sample with `id`, or nullptr if absent. The pointer is // invalidated by any mutating call. const Sample* query(const std::string& id) const; // Returns the sample whose contentHash matches, or nullptr. Empty hashes are // never matched (they do not participate in dedup). const Sample* findByHash(const std::string& contentHash) const; // Moves the sample with `id` to `tier`. Returns true if the sample existed. bool moveTier(const std::string& id, Tier tier); // Returns copies of all samples in the given tier, in insertion order. std::vector byTier(Tier tier) const; // All samples in insertion order. const std::vector& all() const { return samples_; } std::size_t size() const { return samples_.size(); } bool empty() const { return samples_.empty(); } bool operator==(const BankIndex& o) const { return samples_ == o.samples_; } // Serializes the whole index to a JSON string (lossless round-trip). std::string serialize() const; // Parses a JSON string produced by serialize(). Returns std::nullopt on // malformed / truncated input (error signaled, never UB). On success the // returned index satisfies deserialize(serialize(x)) == x. static std::optional deserialize(const std::string& json); private: std::vector samples_; // insertion order preserved }; } // namespace reasampler