From 2862e1c865e39602c0642620875818503aefb863 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 21 Jul 2026 22:54:21 -0400 Subject: [PATCH] Implement M1 bank_model: Sample + BankIndex, hash-dedup, tiers, JSON round-trip Pure dependency-free core with self-contained JSON writer/parser. Absolute paths (incl. drive-relative) rejected at the add boundary; \uXXXX decoded to UTF-8 with surrogate pairs; malformed input returns nullopt. --- src/bank_model.cpp | 689 +++++++++++++++++++++++++++++++++++++- src/bank_model.h | 165 ++++++++- tests/test_bank_model.cpp | 376 ++++++++++++++++++++- 3 files changed, 1214 insertions(+), 16 deletions(-) diff --git a/src/bank_model.cpp b/src/bank_model.cpp index e51b41b..d207dc9 100644 --- a/src/bank_model.cpp +++ b/src/bank_model.cpp @@ -1,10 +1,693 @@ #include "bank_model.h" +#include +#include +#include +#include +#include + +// bank_model implementation. +// +// JSON is hand-rolled and self-contained (brief: keep the pure core +// dependency-free — no third-party JSON lib, no WDL coupling). The field set is +// a flat struct of primitives, strings, one enum, a small string array, and a +// few optionals, so a compact writer + recursive-descent parser is the simplest +// thing that works. Doubles are emitted with 17 significant digits (%.17g), the +// shortest form that round-trips every IEEE-754 double exactly, so the +// deserialize(serialize(x)) == x invariant holds bit-for-bit. + namespace reasampler { -int bankModelVersion() -{ - return 1; +// --------------------------------------------------------------------------- +// equality +// --------------------------------------------------------------------------- + +bool SourceRange::operator==(const SourceRange& o) const { + return startSeconds == o.startSeconds && endSeconds == o.endSeconds && + startPpq == o.startPpq && endPpq == o.endPpq; +} + +bool Provenance::operator==(const Provenance& o) const { + return parentSampleId == o.parentSampleId && fxChainSnapshot == o.fxChainSnapshot; +} + +bool Levels::operator==(const Levels& o) const { + return peakDb == o.peakDb && rmsDb == o.rmsDb && lufs == o.lufs; +} + +bool Sample::operator==(const Sample& o) const { + return id == o.id && displayName == o.displayName && relativePath == o.relativePath && + sourceMode == o.sourceMode && sourceRange == o.sourceRange && + trackGuids == o.trackGuids && wetDry == o.wetDry && + channelCount == o.channelCount && sampleRate == o.sampleRate && + lengthSeconds == o.lengthSeconds && lengthBeats == o.lengthBeats && + captureTempo == o.captureTempo && key == o.key && levels == o.levels && + clipped == o.clipped && tier == o.tier && contentHash == o.contentHash && + provenance == o.provenance && createdTimestamp == o.createdTimestamp; +} + +// --------------------------------------------------------------------------- +// path invariant +// --------------------------------------------------------------------------- + +// DECISION: reject absolute paths rather than normalize them. The pure model has +// no knowledge of the project root, so it cannot correctly relativize an absolute +// path — any "normalization" would be a guess that could point at the wrong file. +// Rejecting at the boundary is honest and deterministic; the capture backend (M3) +// is responsible for handing us an already-relative path. Covers POSIX ("/x"), +// Windows drive ("C:\x", "C:/x", "C:foo" drive-relative), and UNC ("\\host\share") +// forms. Any leading : is rejected regardless of the character that follows — +// drive-relative paths ("C:foo.wav") resolve against the drive's current directory, +// not the project root, so they violate the relative-paths-only invariant just as +// much as "C:\foo.wav" does. +static bool isAbsolutePath(const std::string& p) { + if (p.empty()) return false; + if (p[0] == '/' || p[0] == '\\') return true; // POSIX root or UNC + if (p.size() >= 2 && std::isalpha(static_cast(p[0])) && p[1] == ':') + return true; // Windows drive (C:\, C:/, C:foo, C:) + return false; +} + +// --------------------------------------------------------------------------- +// BankIndex +// --------------------------------------------------------------------------- + +AddResult BankIndex::add(const Sample& sample) { + if (sample.id.empty()) return AddResult::RejectedEmptyId; + if (isAbsolutePath(sample.relativePath)) return AddResult::RejectedAbsolutePath; + + if (findByHash(sample.contentHash) != nullptr) + return AddResult::Collapsed; + + samples_.push_back(sample); + return AddResult::Added; +} + +bool BankIndex::remove(const std::string& id) { + for (auto it = samples_.begin(); it != samples_.end(); ++it) { + if (it->id == id) { + samples_.erase(it); + return true; + } + } + return false; +} + +const Sample* BankIndex::query(const std::string& id) const { + for (const auto& s : samples_) + if (s.id == id) return &s; + return nullptr; +} + +const Sample* BankIndex::findByHash(const std::string& contentHash) const { + if (contentHash.empty()) return nullptr; // empty hashes never dedup + for (const auto& s : samples_) + if (s.contentHash == contentHash) return &s; + return nullptr; +} + +bool BankIndex::moveTier(const std::string& id, Tier tier) { + for (auto& s : samples_) { + if (s.id == id) { + s.tier = tier; + return true; + } + } + return false; +} + +std::vector BankIndex::byTier(Tier tier) const { + std::vector out; + for (const auto& s : samples_) + if (s.tier == tier) out.push_back(s); + return out; +} + +// --------------------------------------------------------------------------- +// JSON writer +// --------------------------------------------------------------------------- + +namespace { + +void writeEscaped(std::string& out, const std::string& s) { + out += '"'; + for (char c : s) { + switch (c) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\b': out += "\\b"; break; + case '\f': out += "\\f"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: + if (static_cast(c) < 0x20) { + char buf[8]; + std::snprintf(buf, sizeof(buf), "\\u%04x", static_cast(c)); + out += buf; + } else { + out += c; + } + } + } + out += '"'; +} + +std::string numToStr(double v) { + char buf[32]; + std::snprintf(buf, sizeof(buf), "%.17g", v); + return buf; +} + +std::string numToStr(std::int64_t v) { + char buf[32]; + std::snprintf(buf, sizeof(buf), "%lld", static_cast(v)); + return buf; +} + +std::string numToStr(int v) { return numToStr(static_cast(v)); } + +class ObjWriter { +public: + explicit ObjWriter(std::string& out) : out_(out) { out_ += '{'; } + ~ObjWriter() { out_ += '}'; } + + void keyRaw(const char* key, const std::string& rawValue) { + sep(); + writeEscaped(out_, key); + out_ += ':'; + out_ += rawValue; + } + void keyStr(const char* key, const std::string& value) { + sep(); + writeEscaped(out_, key); + out_ += ':'; + writeEscaped(out_, value); + } + // Begin a nested value; caller writes the value immediately after. + void keyBegin(const char* key) { + sep(); + writeEscaped(out_, key); + out_ += ':'; + } + +private: + void sep() { + if (first_) first_ = false; else out_ += ','; + } + std::string& out_; + bool first_ = true; +}; + +void writeStringArray(std::string& out, const std::vector& v) { + out += '['; + for (std::size_t i = 0; i < v.size(); ++i) { + if (i) out += ','; + writeEscaped(out, v[i]); + } + out += ']'; +} + +void writeSample(std::string& out, const Sample& s) { + ObjWriter w(out); + w.keyStr("id", s.id); + w.keyStr("displayName", s.displayName); + w.keyStr("relativePath", s.relativePath); + w.keyRaw("sourceMode", numToStr(static_cast(s.sourceMode))); + + w.keyBegin("sourceRange"); + { + ObjWriter r(out); + r.keyRaw("startSeconds", numToStr(s.sourceRange.startSeconds)); + r.keyRaw("endSeconds", numToStr(s.sourceRange.endSeconds)); + r.keyRaw("startPpq", numToStr(s.sourceRange.startPpq)); + r.keyRaw("endPpq", numToStr(s.sourceRange.endPpq)); + } + + w.keyBegin("trackGuids"); + writeStringArray(out, s.trackGuids); + + w.keyRaw("wetDry", numToStr(s.wetDry)); + w.keyRaw("channelCount", numToStr(s.channelCount)); + w.keyRaw("sampleRate", numToStr(s.sampleRate)); + w.keyRaw("lengthSeconds", numToStr(s.lengthSeconds)); + w.keyRaw("lengthBeats", numToStr(s.lengthBeats)); + w.keyRaw("captureTempo", numToStr(s.captureTempo)); + + // Optionals are emitted as null when absent so present/absent round-trips. + w.keyBegin("key"); + if (s.key) writeEscaped(out, *s.key); else out += "null"; + + w.keyBegin("levels"); + { + ObjWriter l(out); + l.keyRaw("peakDb", numToStr(s.levels.peakDb)); + l.keyRaw("rmsDb", numToStr(s.levels.rmsDb)); + l.keyRaw("lufs", numToStr(s.levels.lufs)); + } + + w.keyRaw("clipped", s.clipped ? "true" : "false"); + w.keyRaw("tier", numToStr(static_cast(s.tier))); + w.keyStr("contentHash", s.contentHash); + + w.keyBegin("provenance"); + if (s.provenance) { + ObjWriter p(out); + p.keyStr("parentSampleId", s.provenance->parentSampleId); + p.keyStr("fxChainSnapshot", s.provenance->fxChainSnapshot); + } else { + out += "null"; + } + + w.keyRaw("createdTimestamp", numToStr(s.createdTimestamp)); +} + +} // namespace + +std::string BankIndex::serialize() const { + std::string out; + { + ObjWriter root(out); + root.keyRaw("version", numToStr(1)); + root.keyBegin("samples"); + out += '['; + for (std::size_t i = 0; i < samples_.size(); ++i) { + if (i) out += ','; + writeSample(out, samples_[i]); + } + out += ']'; + } // root closes the object here — not deferred to function return (NRVO would + // otherwise let the caller observe `out` before the closing brace is appended) + return out; +} + +// --------------------------------------------------------------------------- +// JSON parser (recursive descent). Returns false on any malformed input; never +// reads out of bounds. Only supports the subset our writer emits. +// --------------------------------------------------------------------------- + +namespace { + +class Parser { +public: + explicit Parser(const std::string& s) : s_(s) {} + + bool parseIndex(BankIndex& out); + +private: + const std::string& s_; + std::size_t pos_ = 0; + + bool eof() const { return pos_ >= s_.size(); } + char peek() const { return s_[pos_]; } + + void skipWs() { + while (!eof()) { + char c = s_[pos_]; + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') ++pos_; + else break; + } + } + + bool consume(char c) { + skipWs(); + if (eof() || s_[pos_] != c) return false; + ++pos_; + return true; + } + + bool parseString(std::string& out); + bool parseRawScalar(std::string& out); // number / true / false / null token + bool parseDouble(double& out); + bool parseInt64(std::int64_t& out); + bool parseInt(int& out); + bool parseBool(bool& out); + bool expectNullOr(bool& wasNull); // peeks for `null`; consumes if present + + bool parseSample(Sample& out); + bool parseKey(std::string& key); // an object member key + ':' + bool skipValue(); // for forward-compat unknown keys +}; + +// Parses a JSON string literal (with the escapes our writer emits, plus \uXXXX +// for control chars). Positioned at the opening quote after whitespace. +bool Parser::parseString(std::string& out) { + skipWs(); + if (eof() || s_[pos_] != '"') return false; + ++pos_; + out.clear(); + while (!eof()) { + char c = s_[pos_++]; + if (c == '"') return true; + if (c == '\\') { + if (eof()) return false; + char e = s_[pos_++]; + switch (e) { + case '"': out += '"'; break; + case '\\': out += '\\'; break; + case '/': out += '/'; break; + case 'b': out += '\b'; break; + case 'f': out += '\f'; break; + case 'n': out += '\n'; break; + case 'r': out += '\r'; break; + case 't': out += '\t'; break; + case 'u': { + // Decode a \uXXXX escape to its code point. + auto readHex4 = [&](unsigned int& cp) -> bool { + if (pos_ + 4 > s_.size()) return false; + cp = 0; + for (int i = 0; i < 4; ++i) { + char h = s_[pos_++]; + cp <<= 4; + if (h >= '0' && h <= '9') cp |= static_cast(h - '0'); + else if (h >= 'a' && h <= 'f') cp |= static_cast(h - 'a' + 10); + else if (h >= 'A' && h <= 'F') cp |= static_cast(h - 'A' + 10); + else return false; + } + return true; + }; + + unsigned int hi = 0; + if (!readHex4(hi)) return false; + + unsigned int codePoint = hi; + if (hi >= 0xD800 && hi <= 0xDBFF) { + // High surrogate — must be followed by \uDC00–\uDFFF. + if (pos_ + 6 > s_.size()) return false; + if (s_[pos_] != '\\' || s_[pos_ + 1] != 'u') return false; + pos_ += 2; + unsigned int lo = 0; + if (!readHex4(lo)) return false; + if (lo < 0xDC00 || lo > 0xDFFF) return false; // unpaired high surrogate + codePoint = 0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00); + } else if (hi >= 0xDC00 && hi <= 0xDFFF) { + return false; // unpaired low surrogate — malformed + } + + // Encode codePoint as UTF-8. + if (codePoint <= 0x7F) { + out += static_cast(codePoint); + } else if (codePoint <= 0x7FF) { + out += static_cast(0xC0 | (codePoint >> 6)); + out += static_cast(0x80 | (codePoint & 0x3F)); + } else if (codePoint <= 0xFFFF) { + out += static_cast(0xE0 | (codePoint >> 12)); + out += static_cast(0x80 | ((codePoint >> 6) & 0x3F)); + out += static_cast(0x80 | (codePoint & 0x3F)); + } else { + out += static_cast(0xF0 | (codePoint >> 18)); + out += static_cast(0x80 | ((codePoint >> 12) & 0x3F)); + out += static_cast(0x80 | ((codePoint >> 6) & 0x3F)); + out += static_cast(0x80 | (codePoint & 0x3F)); + } + break; + } + default: return false; + } + } else { + out += c; + } + } + return false; // unterminated string +} + +// Reads a bare token (number, true, false, null) up to the next structural char. +bool Parser::parseRawScalar(std::string& out) { + skipWs(); + std::size_t start = pos_; + while (!eof()) { + char c = s_[pos_]; + if (c == ',' || c == '}' || c == ']' || c == ' ' || c == '\t' || + c == '\n' || c == '\r') + break; + ++pos_; + } + if (pos_ == start) return false; + out.assign(s_, start, pos_ - start); + return true; +} + +bool Parser::parseDouble(double& out) { + std::string tok; + if (!parseRawScalar(tok)) return false; + const char* b = tok.c_str(); + char* end = nullptr; + errno = 0; + double v = std::strtod(b, &end); + if (end != b + tok.size()) return false; + if (errno == ERANGE) return false; // overflow / underflow → malformed + out = v; + return true; +} + +bool Parser::parseInt64(std::int64_t& out) { + std::string tok; + if (!parseRawScalar(tok)) return false; + const char* b = tok.c_str(); + char* end = nullptr; + errno = 0; + long long v = std::strtoll(b, &end, 10); + if (end != b + tok.size()) return false; + if (errno == ERANGE) return false; // overflow → malformed + out = static_cast(v); + return true; +} + +bool Parser::parseInt(int& out) { + std::int64_t v = 0; + if (!parseInt64(v)) return false; + out = static_cast(v); + return true; +} + +bool Parser::parseBool(bool& out) { + std::string tok; + if (!parseRawScalar(tok)) return false; + if (tok == "true") { out = true; return true; } + if (tok == "false") { out = false; return true; } + return false; +} + +// If the next value is the `null` token, consumes it and sets wasNull=true. +// Otherwise leaves the position untouched and sets wasNull=false. Returns false +// only on eof. +bool Parser::expectNullOr(bool& wasNull) { + skipWs(); + if (eof()) return false; + if (s_.compare(pos_, 4, "null") == 0) { + pos_ += 4; + wasNull = true; + } else { + wasNull = false; + } + return true; +} + +bool Parser::parseKey(std::string& key) { + if (!parseString(key)) return false; + if (!consume(':')) return false; + return true; +} + +// Skips one JSON value (object / array / string / scalar) for forward-compat +// with keys we don't recognize. Assumes position is at the start of the value. +bool Parser::skipValue() { + skipWs(); + if (eof()) return false; + char c = s_[pos_]; + if (c == '"') { + std::string tmp; + return parseString(tmp); + } + if (c == '{' || c == '[') { + char open = c, close = (c == '{') ? '}' : ']'; + ++pos_; + int depth = 1; + while (!eof() && depth > 0) { + char d = s_[pos_]; + if (d == '"') { + std::string tmp; + if (!parseString(tmp)) return false; + continue; + } + if (d == open) ++depth; + else if (d == close) --depth; + ++pos_; + } + return depth == 0; + } + std::string tmp; + return parseRawScalar(tmp); +} + +bool Parser::parseSample(Sample& s) { + if (!consume('{')) return false; + skipWs(); + if (consume('}')) return true; // empty object (shouldn't happen, but valid) + + do { + std::string key; + if (!parseKey(key)) return false; + + if (key == "id") { + if (!parseString(s.id)) return false; + } else if (key == "displayName") { + if (!parseString(s.displayName)) return false; + } else if (key == "relativePath") { + if (!parseString(s.relativePath)) return false; + } else if (key == "sourceMode") { + int v = 0; + if (!parseInt(v)) return false; + // Valid range: MasterMix(0) .. Realtime(5). + if (v < static_cast(SourceMode::MasterMix) || + v > static_cast(SourceMode::Realtime)) + return false; + s.sourceMode = static_cast(v); + } else if (key == "sourceRange") { + if (!consume('{')) return false; + do { + std::string rk; + if (!parseKey(rk)) return false; + double dv = 0.0; + if (!parseDouble(dv)) return false; + if (rk == "startSeconds") s.sourceRange.startSeconds = dv; + else if (rk == "endSeconds") s.sourceRange.endSeconds = dv; + else if (rk == "startPpq") s.sourceRange.startPpq = dv; + else if (rk == "endPpq") s.sourceRange.endPpq = dv; + } while (consume(',')); + if (!consume('}')) return false; + } else if (key == "trackGuids") { + if (!consume('[')) return false; + skipWs(); + if (!consume(']')) { + do { + std::string g; + if (!parseString(g)) return false; + s.trackGuids.push_back(g); + } while (consume(',')); + if (!consume(']')) return false; + } + } else if (key == "wetDry") { + if (!parseDouble(s.wetDry)) return false; + } else if (key == "channelCount") { + if (!parseInt(s.channelCount)) return false; + } else if (key == "sampleRate") { + if (!parseInt(s.sampleRate)) return false; + } else if (key == "lengthSeconds") { + if (!parseDouble(s.lengthSeconds)) return false; + } else if (key == "lengthBeats") { + if (!parseDouble(s.lengthBeats)) return false; + } else if (key == "captureTempo") { + if (!parseDouble(s.captureTempo)) return false; + } else if (key == "key") { + bool wasNull = false; + if (!expectNullOr(wasNull)) return false; + if (wasNull) { + s.key.reset(); + } else { + std::string k; + if (!parseString(k)) return false; + s.key = k; + } + } else if (key == "levels") { + if (!consume('{')) return false; + do { + std::string lk; + if (!parseKey(lk)) return false; + double dv = 0.0; + if (!parseDouble(dv)) return false; + if (lk == "peakDb") s.levels.peakDb = dv; + else if (lk == "rmsDb") s.levels.rmsDb = dv; + else if (lk == "lufs") s.levels.lufs = dv; + } while (consume(',')); + if (!consume('}')) return false; + } else if (key == "clipped") { + if (!parseBool(s.clipped)) return false; + } else if (key == "tier") { + int v = 0; + if (!parseInt(v)) return false; + // Valid range: Scratch(0) .. Archive(1). + if (v < static_cast(Tier::Scratch) || + v > static_cast(Tier::Archive)) + return false; + s.tier = static_cast(v); + } else if (key == "contentHash") { + if (!parseString(s.contentHash)) return false; + } else if (key == "provenance") { + bool wasNull = false; + if (!expectNullOr(wasNull)) return false; + if (wasNull) { + s.provenance.reset(); + } else { + if (!consume('{')) return false; + Provenance p; + do { + std::string pk; + if (!parseKey(pk)) return false; + std::string pv; + if (!parseString(pv)) return false; + if (pk == "parentSampleId") p.parentSampleId = pv; + else if (pk == "fxChainSnapshot") p.fxChainSnapshot = pv; + } while (consume(',')); + if (!consume('}')) return false; + s.provenance = p; + } + } else if (key == "createdTimestamp") { + if (!parseInt64(s.createdTimestamp)) return false; + } else { + if (!skipValue()) return false; // forward-compat: ignore unknown + } + } while (consume(',')); + + return consume('}'); +} + +bool Parser::parseIndex(BankIndex& out) { + if (!consume('{')) return false; + skipWs(); + if (consume('}')) return true; // empty object — vacuously an empty index + + std::vector parsed; + do { + std::string key; + if (!parseKey(key)) return false; + + if (key == "samples") { + if (!consume('[')) return false; + skipWs(); + if (!consume(']')) { + do { + Sample s; + if (!parseSample(s)) return false; + parsed.push_back(std::move(s)); + } while (consume(',')); + if (!consume(']')) return false; + } + } else { + if (!skipValue()) return false; // version, or unknown keys + } + } while (consume(',')); + + if (!consume('}')) return false; + + // Trailing garbage after the root object is malformed. + skipWs(); + if (!eof()) return false; + + // Rebuild via add() so the same invariants (relative-path, dedup) that guard + // live inserts also guard deserialized data. Rejected/collapsed entries are + // dropped silently — a well-formed serialized index never triggers them. + for (auto& s : parsed) out.add(s); + return true; +} + +} // namespace + +std::optional BankIndex::deserialize(const std::string& json) { + BankIndex idx; + Parser p(json); + if (!p.parseIndex(idx)) return std::nullopt; + return idx; } } // namespace reasampler diff --git a/src/bank_model.h b/src/bank_model.h index 02ce4fc..9be017a 100644 --- a/src/bank_model.h +++ b/src/bank_model.h @@ -2,16 +2,167 @@ // 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). +// tier moves / dedup-by-hash + JSON round-trip to/from std::string). // -// Milestone 0 placeholder: this is a minimal compiling stub sufficient to stand -// up the pure static lib and its CTest. The full data model (Sample field set, -// BankIndex operations, JSON) is Milestone 1 — see PLAN.md / CONTEXT.md §Data model. +// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO +// vendor/ includes. Standard library only. + +#include +#include +#include +#include namespace reasampler { -// M0 placeholder — replaced by the real `Sample` / `BankIndex` in Milestone 1. -// Returns the on-disk schema version the bank_model serializes to. -int bankModelVersion(); +// 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 captured at resample time, so the +// null-test / re-capture-from-source action (M10) can reconstruct the chain. +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; +}; + +// 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 + + std::optional key; // musical key, when known + + 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); + + // 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 diff --git a/tests/test_bank_model.cpp b/tests/test_bank_model.cpp index f55172a..0786587 100644 --- a/tests/test_bank_model.cpp +++ b/tests/test_bank_model.cpp @@ -2,12 +2,14 @@ // This is the point of splitting the model out: iterate the hard logic here with // a fast build/run loop instead of restarting REAPER. // -// Milestone 0 placeholder: a single assertion proving the pure lib links and -// runs under CTest. The real round-trip / dedup / tier / relative-path tests -// arrive with the Milestone 1 data model (see TODO.md, PLAN.md). +// Covers (PLAN.md M1 test cases): full-field round-trip lossless (optionals +// present AND absent), dedup-by-hash collapse, tier filter + tier move, +// relative-path invariant, empty-index round-trip, malformed/truncated JSON. #include "../src/bank_model.h" + #include +#include using namespace reasampler; @@ -15,9 +17,371 @@ static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) -int main() -{ - CHECK(bankModelVersion() == 1); +// A fully-populated sample with every optional PRESENT. `seed` disambiguates +// id/hash so multiple can coexist in one index. +static Sample fullSample(const std::string& seed) { + Sample s; + s.id = "id-" + seed; + s.displayName = "Kick \"punchy\"\n\t/ take " + seed; // exercises escaping + s.relativePath = "bank/" + seed + "/kick.wav"; + s.sourceMode = SourceMode::SelectedItems; + s.sourceRange = {12.3456789012345, 98.7654321098765, 1234.5, 9876.5}; + s.trackGuids = {"{GUID-A}", "{GUID-B}"}; + s.wetDry = 0.6180339887498949; + s.channelCount = 2; + s.sampleRate = 48000; + s.lengthSeconds = 3.141592653589793; + s.lengthBeats = 4.0; + s.captureTempo = 128.5; + s.key = "F#m"; + s.levels = {-0.3, -12.7, -14.2}; + s.clipped = true; + s.tier = Tier::Archive; + s.contentHash = "hash-" + seed; + Provenance p; + p.parentSampleId = "parent-" + seed; + p.fxChainSnapshot = ""; + s.provenance = p; + s.createdTimestamp = 1753080000LL; + return s; +} + +// Same but with every optional ABSENT and empty collections. +static Sample minimalSample(const std::string& seed) { + Sample s; + s.id = "min-" + seed; + s.relativePath = "bank/min.wav"; + s.sourceMode = SourceMode::MasterMix; + s.channelCount = 1; + s.sampleRate = 44100; + s.tier = Tier::Scratch; + s.contentHash = "minhash-" + seed; + // key absent, provenance absent, trackGuids empty, displayName empty. + return s; +} + +static void testFullFieldRoundTrip() { + BankIndex idx; + CHECK(idx.add(fullSample("a")) == AddResult::Added); + CHECK(idx.add(minimalSample("b")) == AddResult::Added); + + std::string json = idx.serialize(); + auto back = BankIndex::deserialize(json); + CHECK(back.has_value()); + CHECK(back && *back == idx); + + // Round-trip is idempotent on the string form too. + if (back) CHECK(back->serialize() == json); + + // Spot-check optionals survived exactly. + if (back) { + const Sample* full = back->query("id-a"); + CHECK(full && full->key.has_value() && *full->key == "F#m"); + CHECK(full && full->provenance.has_value()); + CHECK(full && full->provenance->fxChainSnapshot == ""); + + const Sample* min = back->query("min-b"); + CHECK(min && !min->key.has_value()); + CHECK(min && !min->provenance.has_value()); + CHECK(min && min->trackGuids.empty()); + } +} + +static void testDedupByHash() { + BankIndex idx; + Sample a = fullSample("x"); + CHECK(idx.add(a) == AddResult::Added); + + // Same content hash, different id/name — must collapse, not duplicate. + Sample dup = minimalSample("y"); + dup.contentHash = a.contentHash; + CHECK(idx.add(dup) == AddResult::Collapsed); + CHECK(idx.size() == 1); + // Original entry is the one kept. + CHECK(idx.query("id-x") != nullptr); + CHECK(idx.query("min-y") == nullptr); + CHECK(idx.findByHash(a.contentHash) != nullptr); + + // Empty hashes do NOT participate in dedup (two empty-hash adds coexist). + Sample e1 = minimalSample("e1"); e1.contentHash.clear(); + Sample e2 = minimalSample("e2"); e2.contentHash.clear(); + CHECK(idx.add(e1) == AddResult::Added); + CHECK(idx.add(e2) == AddResult::Added); + CHECK(idx.size() == 3); + CHECK(idx.findByHash("") == nullptr); +} + +static void testTierFilterAndMove() { + BankIndex idx; + Sample scratch = minimalSample("s"); scratch.tier = Tier::Scratch; + Sample archive = fullSample("a"); archive.tier = Tier::Archive; + CHECK(idx.add(scratch) == AddResult::Added); + CHECK(idx.add(archive) == AddResult::Added); + + CHECK(idx.byTier(Tier::Scratch).size() == 1); + CHECK(idx.byTier(Tier::Archive).size() == 1); + CHECK(idx.byTier(Tier::Scratch)[0].id == "min-s"); + + // scratch is auto-prunable, archive is not. + CHECK(idx.query("min-s")->isAutoPrunable()); + CHECK(!idx.query("id-a")->isAutoPrunable()); + + // Move scratch -> archive relocates it. + CHECK(idx.moveTier("min-s", Tier::Archive)); + CHECK(idx.byTier(Tier::Scratch).empty()); + CHECK(idx.byTier(Tier::Archive).size() == 2); + CHECK(!idx.query("min-s")->isAutoPrunable()); + + // Moving a nonexistent id fails. + CHECK(!idx.moveTier("nope", Tier::Scratch)); +} + +static void testRelativePathInvariant() { + BankIndex idx; + + // POSIX absolute, Windows drive, Windows backslash, UNC — all rejected. + const char* absolutes[] = { + "/etc/passwd.wav", + "C:/Users/x/kick.wav", + "C:\\Users\\x\\kick.wav", + "\\\\host\\share\\kick.wav", + }; + for (const char* abs : absolutes) { + Sample s = minimalSample(abs); + s.relativePath = abs; + CHECK(idx.add(s) == AddResult::RejectedAbsolutePath); + } + CHECK(idx.empty()); // nothing absolute was ever stored + + // A relative path is accepted. + Sample ok = minimalSample("ok"); + ok.relativePath = "bank/sub/kick.wav"; + CHECK(idx.add(ok) == AddResult::Added); + + // Empty id is rejected (collection is id-keyed). + Sample noId = minimalSample("noid"); + noId.id.clear(); + CHECK(idx.add(noId) == AddResult::RejectedEmptyId); +} + +static void testEmptyIndexRoundTrip() { + BankIndex idx; + CHECK(idx.empty()); + std::string json = idx.serialize(); + auto back = BankIndex::deserialize(json); + CHECK(back.has_value()); + CHECK(back && back->empty()); + CHECK(back && *back == idx); +} + +static void testMalformedJson() { + const char* bad[] = { + "", + "{", + "not json at all", + "{\"samples\":[", + "{\"samples\":[{\"id\":\"x\"", // truncated sample object + "{\"samples\":[{\"id\":\"x\",}]}", // dangling comma -> bad key + "{\"samples\":[{]}", // garbage inside array + "{\"samples\":{}}", // samples not an array + "{\"samples\":[]}trailing", // trailing garbage + "{\"samples\":[{\"createdTimestamp\":notanumber}]}", + }; + for (const char* j : bad) { + auto r = BankIndex::deserialize(j); + CHECK(!r.has_value()); // signaled as nullopt, no crash / UB + } + + // A well-formed empty object deserializes to an empty index (lenient root). + auto ok = BankIndex::deserialize("{}"); + CHECK(ok.has_value() && ok->empty()); +} + +static void testRemoveAndQuery() { + BankIndex idx; + CHECK(idx.add(fullSample("1")) == AddResult::Added); + CHECK(idx.add(fullSample("2")) == AddResult::Added); + CHECK(idx.query("id-1") != nullptr); + CHECK(idx.query("missing") == nullptr); + CHECK(idx.remove("id-1")); + CHECK(idx.query("id-1") == nullptr); + CHECK(!idx.remove("id-1")); // second remove is a no-op + CHECK(idx.size() == 1); +} + +// Fix 1: drive-relative and bare-drive forms must be rejected by add(). +static void testAbsolutePathDriveRelative() { + BankIndex idx; + + // Drive-relative: resolves against the drive's CWD, not the project root. + Sample dr = minimalSample("dr"); + dr.contentHash = "hash-dr"; + dr.relativePath = "C:foo.wav"; + CHECK(idx.add(dr) == AddResult::RejectedAbsolutePath); + + // Bare drive letter + colon: also drive-relative / ambiguous. + Sample bare = minimalSample("bare"); + bare.contentHash = "hash-bare"; + bare.relativePath = "C:"; + CHECK(idx.add(bare) == AddResult::RejectedAbsolutePath); + + // UNC path — belt-and-suspenders alongside the existing test. + Sample unc = minimalSample("unc"); + unc.contentHash = "hash-unc"; + unc.relativePath = "\\\\server\\share\\kick.wav"; + CHECK(idx.add(unc) == AddResult::RejectedAbsolutePath); + + // Nothing was stored. + CHECK(idx.empty()); +} + +// Fix 2: \uXXXX escape sequences decode to correct UTF-8 bytes. +static void testUnicodeEscapeDecoding() { + // é = U+00E9 → 2-byte UTF-8: 0xC3 0xA9 + // JSON: "é" + auto r1 = BankIndex::deserialize( + "{\"samples\":[{\"id\":\"u1\",\"relativePath\":\"bank/u.wav\"," + "\"displayName\":\"\\u00e9\"," + "\"sourceMode\":0,\"sourceRange\":{\"startSeconds\":0.0,\"endSeconds\":0.0," + "\"startPpq\":0.0,\"endPpq\":0.0},\"trackGuids\":[]," + "\"wetDry\":1.0,\"channelCount\":1,\"sampleRate\":44100," + "\"lengthSeconds\":0.0,\"lengthBeats\":0.0,\"captureTempo\":0.0," + "\"key\":null,\"levels\":{\"peakDb\":0.0,\"rmsDb\":0.0,\"lufs\":0.0}," + "\"clipped\":false,\"tier\":0,\"contentHash\":\"h-u1\"," + "\"provenance\":null,\"createdTimestamp\":0}]}"); + CHECK(r1.has_value()); + if (r1) { + const Sample* s = r1->query("u1"); + CHECK(s != nullptr); + if (s) { + // UTF-8 for U+00E9: 0xC3 0xA9 (2 bytes) + CHECK(s->displayName.size() == 2); + CHECK(static_cast(s->displayName[0]) == 0xC3); + CHECK(static_cast(s->displayName[1]) == 0xA9); + } + } + + // 中 = U+4E2D → 3-byte UTF-8: 0xE4 0xB8 0xAD + // JSON: "中" + auto r2 = BankIndex::deserialize( + "{\"samples\":[{\"id\":\"u2\",\"relativePath\":\"bank/u.wav\"," + "\"displayName\":\"\\u4e2d\"," + "\"sourceMode\":0,\"sourceRange\":{\"startSeconds\":0.0,\"endSeconds\":0.0," + "\"startPpq\":0.0,\"endPpq\":0.0},\"trackGuids\":[]," + "\"wetDry\":1.0,\"channelCount\":1,\"sampleRate\":44100," + "\"lengthSeconds\":0.0,\"lengthBeats\":0.0,\"captureTempo\":0.0," + "\"key\":null,\"levels\":{\"peakDb\":0.0,\"rmsDb\":0.0,\"lufs\":0.0}," + "\"clipped\":false,\"tier\":0,\"contentHash\":\"h-u2\"," + "\"provenance\":null,\"createdTimestamp\":0}]}"); + CHECK(r2.has_value()); + if (r2) { + const Sample* s = r2->query("u2"); + CHECK(s != nullptr); + if (s) { + // UTF-8 for U+4E2D: 0xE4 0xB8 0xAD (3 bytes) + CHECK(s->displayName.size() == 3); + CHECK(static_cast(s->displayName[0]) == 0xE4); + CHECK(static_cast(s->displayName[1]) == 0xB8); + CHECK(static_cast(s->displayName[2]) == 0xAD); + } + } + + // 😀 = U+1F600 → surrogate pair 😀 → 4-byte UTF-8: 0xF0 0x9F 0x98 0x80 + auto r3 = BankIndex::deserialize( + "{\"samples\":[{\"id\":\"u3\",\"relativePath\":\"bank/u.wav\"," + "\"displayName\":\"\\uD83D\\uDE00\"," + "\"sourceMode\":0,\"sourceRange\":{\"startSeconds\":0.0,\"endSeconds\":0.0," + "\"startPpq\":0.0,\"endPpq\":0.0},\"trackGuids\":[]," + "\"wetDry\":1.0,\"channelCount\":1,\"sampleRate\":44100," + "\"lengthSeconds\":0.0,\"lengthBeats\":0.0,\"captureTempo\":0.0," + "\"key\":null,\"levels\":{\"peakDb\":0.0,\"rmsDb\":0.0,\"lufs\":0.0}," + "\"clipped\":false,\"tier\":0,\"contentHash\":\"h-u3\"," + "\"provenance\":null,\"createdTimestamp\":0}]}"); + CHECK(r3.has_value()); + if (r3) { + const Sample* s = r3->query("u3"); + CHECK(s != nullptr); + if (s) { + // UTF-8 for U+1F600: 0xF0 0x9F 0x98 0x80 (4 bytes) + CHECK(s->displayName.size() == 4); + CHECK(static_cast(s->displayName[0]) == 0xF0); + CHECK(static_cast(s->displayName[1]) == 0x9F); + CHECK(static_cast(s->displayName[2]) == 0x98); + CHECK(static_cast(s->displayName[3]) == 0x80); + } + } + + // Unpaired high surrogate (no following \uDCxx) → nullopt. + auto r4 = BankIndex::deserialize( + "{\"samples\":[{\"id\":\"u4\",\"relativePath\":\"bank/u.wav\"," + "\"displayName\":\"\\uD83D\"," + "\"sourceMode\":0,\"sourceRange\":{\"startSeconds\":0.0,\"endSeconds\":0.0," + "\"startPpq\":0.0,\"endPpq\":0.0},\"trackGuids\":[]," + "\"wetDry\":1.0,\"channelCount\":1,\"sampleRate\":44100," + "\"lengthSeconds\":0.0,\"lengthBeats\":0.0,\"captureTempo\":0.0," + "\"key\":null,\"levels\":{\"peakDb\":0.0,\"rmsDb\":0.0,\"lufs\":0.0}," + "\"clipped\":false,\"tier\":0,\"contentHash\":\"h-u4\"," + "\"provenance\":null,\"createdTimestamp\":0}]}"); + CHECK(!r4.has_value()); +} + +// Fix 3: strtoll overflow must reject the value, not clamp it silently. +static void testIntegerOverflow() { + // A timestamp value that overflows int64_t (> 9223372036854775807). + auto r = BankIndex::deserialize( + "{\"samples\":[{\"id\":\"ov1\",\"relativePath\":\"bank/ov.wav\"," + "\"displayName\":\"\"," + "\"sourceMode\":0,\"sourceRange\":{\"startSeconds\":0.0,\"endSeconds\":0.0," + "\"startPpq\":0.0,\"endPpq\":0.0},\"trackGuids\":[]," + "\"wetDry\":1.0,\"channelCount\":1,\"sampleRate\":44100," + "\"lengthSeconds\":0.0,\"lengthBeats\":0.0,\"captureTempo\":0.0," + "\"key\":null,\"levels\":{\"peakDb\":0.0,\"rmsDb\":0.0,\"lufs\":0.0}," + "\"clipped\":false,\"tier\":0,\"contentHash\":\"h-ov1\"," + "\"provenance\":null,\"createdTimestamp\":99999999999999999999}]}"); + CHECK(!r.has_value()); +} + +// Fix 4: out-of-range enum values must reject the sample, not produce invalid enum. +static void testEnumRangeValidation() { + // tier: 99 is not a valid Tier enumerator. + auto r1 = BankIndex::deserialize( + "{\"samples\":[{\"id\":\"en1\",\"relativePath\":\"bank/en.wav\"," + "\"displayName\":\"\"," + "\"sourceMode\":0,\"sourceRange\":{\"startSeconds\":0.0,\"endSeconds\":0.0," + "\"startPpq\":0.0,\"endPpq\":0.0},\"trackGuids\":[]," + "\"wetDry\":1.0,\"channelCount\":1,\"sampleRate\":44100," + "\"lengthSeconds\":0.0,\"lengthBeats\":0.0,\"captureTempo\":0.0," + "\"key\":null,\"levels\":{\"peakDb\":0.0,\"rmsDb\":0.0,\"lufs\":0.0}," + "\"clipped\":false,\"tier\":99,\"contentHash\":\"h-en1\"," + "\"provenance\":null,\"createdTimestamp\":0}]}"); + CHECK(!r1.has_value()); + + // sourceMode: 99 is not a valid SourceMode enumerator. + auto r2 = BankIndex::deserialize( + "{\"samples\":[{\"id\":\"en2\",\"relativePath\":\"bank/en.wav\"," + "\"displayName\":\"\"," + "\"sourceMode\":99,\"sourceRange\":{\"startSeconds\":0.0,\"endSeconds\":0.0," + "\"startPpq\":0.0,\"endPpq\":0.0},\"trackGuids\":[]," + "\"wetDry\":1.0,\"channelCount\":1,\"sampleRate\":44100," + "\"lengthSeconds\":0.0,\"lengthBeats\":0.0,\"captureTempo\":0.0," + "\"key\":null,\"levels\":{\"peakDb\":0.0,\"rmsDb\":0.0,\"lufs\":0.0}," + "\"clipped\":false,\"tier\":0,\"contentHash\":\"h-en2\"," + "\"provenance\":null,\"createdTimestamp\":0}]}"); + CHECK(!r2.has_value()); +} + +int main() { + testFullFieldRoundTrip(); + testDedupByHash(); + testTierFilterAndMove(); + testRelativePathInvariant(); + testEmptyIndexRoundTrip(); + testMalformedJson(); + testRemoveAndQuery(); + testAbsolutePathDriveRelative(); + testUnicodeEscapeDecoding(); + testIntegerOverflow(); + testEnumRangeValidation(); if (g_fail == 0) std::printf("All tests passed.\n"); return g_fail ? 1 : 0;