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.
This commit is contained in:
+686
-3
@@ -1,10 +1,693 @@
|
||||
#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 {
|
||||
|
||||
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 <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
|
||||
|
||||
+158
-7
@@ -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 <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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<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
|
||||
|
||||
+370
-6
@@ -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 <cstdio>
|
||||
#include <string>
|
||||
|
||||
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 = "<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");
|
||||
return g_fail ? 1 : 0;
|
||||
|
||||
Reference in New Issue
Block a user