feat(bank_model): add Phase S seam fields (rootNote + loop points) to Sample

Additive optional MIDI root note and sustain-loop points with JSON round-trip and deserialize-boundary validation; capture leaves them empty (not derivable). Mirrors the provenance addition; BankIndex behavior unchanged.
This commit is contained in:
2026-07-26 15:29:57 -04:00
parent 5595ba42f9
commit b5a573aebb
5 changed files with 226 additions and 1 deletions
+56 -1
View File
@@ -35,6 +35,10 @@ 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 &&
@@ -43,7 +47,8 @@ bool Sample::operator==(const Sample& o) const {
lengthSeconds == o.lengthSeconds && lengthBeats == o.lengthBeats &&
captureTempo == o.captureTempo &&
captureTimeSigNum == o.captureTimeSigNum &&
captureTimeSigDenom == o.captureTimeSigDenom && key == o.key && levels == o.levels &&
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;
}
@@ -253,6 +258,21 @@ void writeSample(std::string& out, const Sample& s) {
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);
@@ -608,6 +628,41 @@ bool Parser::parseSample(Sample& s) {
if (!parseString(k)) return false;
s.key = k;
}
} else if (key == "rootNote") {
bool wasNull = false;
if (!expectNullOr(wasNull)) return false;
if (wasNull) {
s.rootNote.reset();
} else {
int v = 0;
if (!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 (!expectNullOr(wasNull)) return false;
if (wasNull) {
s.loop.reset();
} else {
if (!consume('{')) return false;
LoopPoints lp;
do {
std::string lk;
if (!parseKey(lk)) return false;
std::int64_t lv = 0;
if (!parseInt64(lv)) return false;
if (lk == "start") lp.start = lv;
else if (lk == "end") lp.end = lv;
} while (consume(','));
if (!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 (!consume('{')) return false;
do {