Q-W1 pt2: core/shell/app relocation + sub-namespaces; one concrete ui::Rect (LTRB fork retired); slot_map split from bank_book; BankIndex→BankModel; 59/59 green
This commit is contained in:
@@ -0,0 +1,461 @@
|
||||
#include "core/model/bank_model.h"
|
||||
|
||||
#include <cctype>
|
||||
|
||||
#include "core/json/json.h"
|
||||
|
||||
// bank_model implementation.
|
||||
//
|
||||
// JSON rides on the shared core/json lexical layer (Q-W1: one reader/writer,
|
||||
// no per-module Parser copy). 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 DOMAIN parser over json::Reader 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::model {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 LoopPoints::operator==(const LoopPoints& o) const {
|
||||
return start == o.start && end == o.end;
|
||||
}
|
||||
|
||||
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 &&
|
||||
captureTimeSigNum == o.captureTimeSigNum &&
|
||||
captureTimeSigDenom == o.captureTimeSigDenom && key == o.key &&
|
||||
rootNote == o.rootNote && loop == o.loop && 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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BankModel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
AddResult BankModel::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 BankModel::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;
|
||||
}
|
||||
|
||||
bool BankModel::updateInPlace(const std::string& id, const Sample& updated) {
|
||||
if (isAbsolutePath(updated.relativePath)) return false; // invariant still holds
|
||||
for (auto& s : samples_) {
|
||||
if (s.id == id) {
|
||||
s = updated; // replace in place — position (insertion order) preserved
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const Sample* BankModel::query(const std::string& id) const {
|
||||
for (const auto& s : samples_)
|
||||
if (s.id == id) return &s;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const Sample* BankModel::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 BankModel::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> BankModel::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 {
|
||||
|
||||
using json::numToStr;
|
||||
using json::writeEscaped;
|
||||
using json::writeStringArray;
|
||||
using ObjWriter = json::Writer;
|
||||
|
||||
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));
|
||||
w.keyRaw("captureTimeSigNum", numToStr(s.captureTimeSigNum));
|
||||
w.keyRaw("captureTimeSigDenom", numToStr(s.captureTimeSigDenom));
|
||||
|
||||
// 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";
|
||||
|
||||
// Phase S seam fields (D-B). Emitted as null when absent (same shape as `key`
|
||||
// and `provenance`) so pre-Phase-S JSON — which lacks these keys entirely —
|
||||
// parses to empty optionals and re-serializes without invention.
|
||||
w.keyBegin("rootNote");
|
||||
if (s.rootNote) out += numToStr(*s.rootNote); else out += "null";
|
||||
|
||||
w.keyBegin("loop");
|
||||
if (s.loop) {
|
||||
ObjWriter lp(out);
|
||||
lp.keyRaw("start", numToStr(s.loop->start));
|
||||
lp.keyRaw("end", numToStr(s.loop->end));
|
||||
} 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 BankModel::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 over the shared json::Reader). Returns false
|
||||
// on any malformed input; never reads out of bounds. Only supports the subset
|
||||
// our writer emits. The lexical layer (strings, numbers, skip) lives in
|
||||
// core/json; only the Sample/index DOMAIN grammar lives here.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
namespace {
|
||||
|
||||
bool parseSample(json::Reader& r, Sample& s) {
|
||||
if (!r.consume('{')) return false;
|
||||
r.skipWs();
|
||||
if (r.consume('}')) return true; // empty object (shouldn't happen, but valid)
|
||||
|
||||
do {
|
||||
std::string key;
|
||||
if (!r.parseKey(key)) return false;
|
||||
|
||||
if (key == "id") {
|
||||
if (!r.parseString(s.id)) return false;
|
||||
} else if (key == "displayName") {
|
||||
if (!r.parseString(s.displayName)) return false;
|
||||
} else if (key == "relativePath") {
|
||||
if (!r.parseString(s.relativePath)) return false;
|
||||
} else if (key == "sourceMode") {
|
||||
int v = 0;
|
||||
if (!r.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 (!r.consume('{')) return false;
|
||||
do {
|
||||
std::string rk;
|
||||
if (!r.parseKey(rk)) return false;
|
||||
double dv = 0.0;
|
||||
if (!r.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 (r.consume(','));
|
||||
if (!r.consume('}')) return false;
|
||||
} else if (key == "trackGuids") {
|
||||
if (!r.consume('[')) return false;
|
||||
r.skipWs();
|
||||
if (!r.consume(']')) {
|
||||
do {
|
||||
std::string g;
|
||||
if (!r.parseString(g)) return false;
|
||||
s.trackGuids.push_back(g);
|
||||
} while (r.consume(','));
|
||||
if (!r.consume(']')) return false;
|
||||
}
|
||||
} else if (key == "wetDry") {
|
||||
if (!r.parseDouble(s.wetDry)) return false;
|
||||
} else if (key == "channelCount") {
|
||||
if (!r.parseInt(s.channelCount)) return false;
|
||||
} else if (key == "sampleRate") {
|
||||
if (!r.parseInt(s.sampleRate)) return false;
|
||||
} else if (key == "lengthSeconds") {
|
||||
if (!r.parseDouble(s.lengthSeconds)) return false;
|
||||
} else if (key == "lengthBeats") {
|
||||
if (!r.parseDouble(s.lengthBeats)) return false;
|
||||
} else if (key == "captureTempo") {
|
||||
if (!r.parseDouble(s.captureTempo)) return false;
|
||||
} else if (key == "captureTimeSigNum") {
|
||||
if (!r.parseInt(s.captureTimeSigNum)) return false;
|
||||
} else if (key == "captureTimeSigDenom") {
|
||||
if (!r.parseInt(s.captureTimeSigDenom)) return false;
|
||||
} else if (key == "key") {
|
||||
bool wasNull = false;
|
||||
if (!r.expectNullOr(wasNull)) return false;
|
||||
if (wasNull) {
|
||||
s.key.reset();
|
||||
} else {
|
||||
std::string k;
|
||||
if (!r.parseString(k)) return false;
|
||||
s.key = k;
|
||||
}
|
||||
} else if (key == "rootNote") {
|
||||
bool wasNull = false;
|
||||
if (!r.expectNullOr(wasNull)) return false;
|
||||
if (wasNull) {
|
||||
s.rootNote.reset();
|
||||
} else {
|
||||
int v = 0;
|
||||
if (!r.parseInt(v)) return false;
|
||||
// Valid MIDI note range: 0..127 inclusive (boundaries valid).
|
||||
if (v < 0 || v > 127) return false;
|
||||
s.rootNote = v;
|
||||
}
|
||||
} else if (key == "loop") {
|
||||
bool wasNull = false;
|
||||
if (!r.expectNullOr(wasNull)) return false;
|
||||
if (wasNull) {
|
||||
s.loop.reset();
|
||||
} else {
|
||||
if (!r.consume('{')) return false;
|
||||
LoopPoints lp;
|
||||
do {
|
||||
std::string lk;
|
||||
if (!r.parseKey(lk)) return false;
|
||||
std::int64_t lv = 0;
|
||||
if (!r.parseInt64(lv)) return false;
|
||||
if (lk == "start") lp.start = lv;
|
||||
else if (lk == "end") lp.end = lv;
|
||||
} while (r.consume(','));
|
||||
if (!r.consume('}')) return false;
|
||||
// Invariant: 0 <= start <= end. start == end is a valid zero-length
|
||||
// marker; a negative index or start > end is malformed, not silently
|
||||
// clamped (mirrors the enum-range rejection above).
|
||||
if (lp.start < 0 || lp.end < lp.start) return false;
|
||||
s.loop = lp;
|
||||
}
|
||||
} else if (key == "levels") {
|
||||
if (!r.consume('{')) return false;
|
||||
do {
|
||||
std::string lk;
|
||||
if (!r.parseKey(lk)) return false;
|
||||
double dv = 0.0;
|
||||
if (!r.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 (r.consume(','));
|
||||
if (!r.consume('}')) return false;
|
||||
} else if (key == "clipped") {
|
||||
if (!r.parseBool(s.clipped)) return false;
|
||||
} else if (key == "tier") {
|
||||
int v = 0;
|
||||
if (!r.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 (!r.parseString(s.contentHash)) return false;
|
||||
} else if (key == "provenance") {
|
||||
bool wasNull = false;
|
||||
if (!r.expectNullOr(wasNull)) return false;
|
||||
if (wasNull) {
|
||||
s.provenance.reset();
|
||||
} else {
|
||||
if (!r.consume('{')) return false;
|
||||
Provenance p;
|
||||
do {
|
||||
std::string pk;
|
||||
if (!r.parseKey(pk)) return false;
|
||||
std::string pv;
|
||||
if (!r.parseString(pv)) return false;
|
||||
if (pk == "parentSampleId") p.parentSampleId = pv;
|
||||
else if (pk == "fxChainSnapshot") p.fxChainSnapshot = pv;
|
||||
} while (r.consume(','));
|
||||
if (!r.consume('}')) return false;
|
||||
s.provenance = p;
|
||||
}
|
||||
} else if (key == "createdTimestamp") {
|
||||
if (!r.parseInt64(s.createdTimestamp)) return false;
|
||||
} else {
|
||||
if (!r.skipValue()) return false; // forward-compat: ignore unknown
|
||||
}
|
||||
} while (r.consume(','));
|
||||
|
||||
return r.consume('}');
|
||||
}
|
||||
|
||||
bool parseIndex(json::Reader& r, BankModel& out) {
|
||||
if (!r.consume('{')) return false;
|
||||
r.skipWs();
|
||||
if (r.consume('}')) return true; // empty object — vacuously an empty index
|
||||
|
||||
std::vector<Sample> parsed;
|
||||
do {
|
||||
std::string key;
|
||||
if (!r.parseKey(key)) return false;
|
||||
|
||||
if (key == "samples") {
|
||||
if (!r.consume('[')) return false;
|
||||
r.skipWs();
|
||||
if (!r.consume(']')) {
|
||||
do {
|
||||
Sample s;
|
||||
if (!parseSample(r, s)) return false;
|
||||
parsed.push_back(std::move(s));
|
||||
} while (r.consume(','));
|
||||
if (!r.consume(']')) return false;
|
||||
}
|
||||
} else {
|
||||
if (!r.skipValue()) return false; // version, or unknown keys
|
||||
}
|
||||
} while (r.consume(','));
|
||||
|
||||
if (!r.consume('}')) return false;
|
||||
|
||||
// Trailing garbage after the root object is malformed.
|
||||
r.skipWs();
|
||||
if (!r.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<BankModel> BankModel::deserialize(const std::string& blob) {
|
||||
BankModel idx;
|
||||
json::Reader r(blob);
|
||||
if (!parseIndex(r, idx)) return std::nullopt;
|
||||
return idx;
|
||||
}
|
||||
|
||||
} // namespace reasampler::model
|
||||
Reference in New Issue
Block a user