Merge M1: pure bank_model core (Sample + BankIndex, dedup, tiers, JSON)

This commit is contained in:
2026-07-21 22:59:24 -04:00
3 changed files with 1214 additions and 16 deletions
+686 -3
View File
@@ -1,10 +1,693 @@
#include "bank_model.h" #include "bank_model.h"
#include <cctype>
#include <cerrno>
#include <cstdio>
#include <cstdlib>
#include <cstring>
// 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 { namespace reasampler {
int bankModelVersion() // ---------------------------------------------------------------------------
{ // equality
return 1; // ---------------------------------------------------------------------------
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 <alpha>: 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<unsigned char>(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<Sample> BankIndex::byTier(Tier tier) const {
std::vector<Sample> 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<unsigned char>(c) < 0x20) {
char buf[8];
std::snprintf(buf, sizeof(buf), "\\u%04x", static_cast<unsigned char>(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<long long>(v));
return buf;
}
std::string numToStr(int v) { return numToStr(static_cast<std::int64_t>(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<std::string>& 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<int>(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<int>(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<unsigned>(h - '0');
else if (h >= 'a' && h <= 'f') cp |= static_cast<unsigned>(h - 'a' + 10);
else if (h >= 'A' && h <= 'F') cp |= static_cast<unsigned>(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<char>(codePoint);
} else if (codePoint <= 0x7FF) {
out += static_cast<char>(0xC0 | (codePoint >> 6));
out += static_cast<char>(0x80 | (codePoint & 0x3F));
} else if (codePoint <= 0xFFFF) {
out += static_cast<char>(0xE0 | (codePoint >> 12));
out += static_cast<char>(0x80 | ((codePoint >> 6) & 0x3F));
out += static_cast<char>(0x80 | (codePoint & 0x3F));
} else {
out += static_cast<char>(0xF0 | (codePoint >> 18));
out += static_cast<char>(0x80 | ((codePoint >> 12) & 0x3F));
out += static_cast<char>(0x80 | ((codePoint >> 6) & 0x3F));
out += static_cast<char>(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<std::int64_t>(v);
return true;
}
bool Parser::parseInt(int& out) {
std::int64_t v = 0;
if (!parseInt64(v)) return false;
out = static_cast<int>(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<int>(SourceMode::MasterMix) ||
v > static_cast<int>(SourceMode::Realtime))
return false;
s.sourceMode = static_cast<SourceMode>(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<int>(Tier::Scratch) ||
v > static_cast<int>(Tier::Archive))
return false;
s.tier = static_cast<Tier>(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<Sample> 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> BankIndex::deserialize(const std::string& json) {
BankIndex idx;
Parser p(json);
if (!p.parseIndex(idx)) return std::nullopt;
return idx;
} }
} // namespace reasampler } // namespace reasampler
+158 -7
View File
@@ -2,16 +2,167 @@
// bank_model — the HEART of ReaSampler, deliberately free of any REAPER type so // 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 // 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 / // 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 // PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
// up the pure static lib and its CTest. The full data model (Sample field set, // vendor/ includes. Standard library only.
// BankIndex operations, JSON) is Milestone 1 — see PLAN.md / CONTEXT.md §Data model.
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
namespace reasampler { namespace reasampler {
// M0 placeholder — replaced by the real `Sample` / `BankIndex` in Milestone 1. // How the source audio was obtained. Kept in the pure core (no REAPER coupling);
// Returns the on-disk schema version the bank_model serializes to. // the capture backends (M3/M8) map their own notion onto these.
int bankModelVersion(); 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<std::string> 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<std::string> key; // musical key, when known
Levels levels;
bool clipped = false;
Tier tier = Tier::Scratch;
std::string contentHash; // dedup key (see BankIndex)
std::optional<Provenance> 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<Sample> byTier(Tier tier) const;
// All samples in insertion order.
const std::vector<Sample>& 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<BankIndex> deserialize(const std::string& json);
private:
std::vector<Sample> samples_; // insertion order preserved
};
} // namespace reasampler } // namespace reasampler
+370 -6
View File
@@ -2,12 +2,14 @@
// This is the point of splitting the model out: iterate the hard logic here with // This is the point of splitting the model out: iterate the hard logic here with
// a fast build/run loop instead of restarting REAPER. // a fast build/run loop instead of restarting REAPER.
// //
// Milestone 0 placeholder: a single assertion proving the pure lib links and // Covers (PLAN.md M1 test cases): full-field round-trip lossless (optionals
// runs under CTest. The real round-trip / dedup / tier / relative-path tests // present AND absent), dedup-by-hash collapse, tier filter + tier move,
// arrive with the Milestone 1 data model (see TODO.md, PLAN.md). // relative-path invariant, empty-index round-trip, malformed/truncated JSON.
#include "../src/bank_model.h" #include "../src/bank_model.h"
#include <cstdio> #include <cstdio>
#include <string>
using namespace reasampler; using namespace reasampler;
@@ -15,9 +17,371 @@ static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \ #define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
int main() // A fully-populated sample with every optional PRESENT. `seed` disambiguates
{ // id/hash so multiple can coexist in one index.
CHECK(bankModelVersion() == 1); 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 = "<FXCHAIN\n BYPASS 0 0 0\n>";
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 == "<FXCHAIN\n BYPASS 0 0 0\n>");
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<unsigned char>(s->displayName[0]) == 0xC3);
CHECK(static_cast<unsigned char>(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<unsigned char>(s->displayName[0]) == 0xE4);
CHECK(static_cast<unsigned char>(s->displayName[1]) == 0xB8);
CHECK(static_cast<unsigned char>(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<unsigned char>(s->displayName[0]) == 0xF0);
CHECK(static_cast<unsigned char>(s->displayName[1]) == 0x9F);
CHECK(static_cast<unsigned char>(s->displayName[2]) == 0x98);
CHECK(static_cast<unsigned char>(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"); if (g_fail == 0) std::printf("All tests passed.\n");
return g_fail ? 1 : 0; return g_fail ? 1 : 0;