Merge ps-w1-t2-sample-intrinsics: S2 Sample seam fields (rootNote + loop points)

This commit is contained in:
2026-07-26 15:37:29 -04:00
5 changed files with 226 additions and 1 deletions
+134
View File
@@ -36,6 +36,8 @@ static Sample fullSample(const std::string& seed) {
s.captureTimeSigNum = 6; // L7 F1 meter stamp (non-4/4 to prove it round-trips)
s.captureTimeSigDenom = 8;
s.key = "F#m";
s.rootNote = 60; // Phase S seam field (present)
s.loop = LoopPoints{4096, 65536}; // Phase S seam field (present)
s.levels = {-0.3, -12.7, -14.2};
s.clipped = true;
s.tier = Tier::Archive;
@@ -86,11 +88,18 @@ static void testFullFieldRoundTrip() {
CHECK(minMeter && minMeter->captureTimeSigNum == 0 && minMeter->captureTimeSigDenom == 0);
CHECK(full && full->provenance.has_value());
CHECK(full && full->provenance->fxChainSnapshot == "<FXCHAIN\n BYPASS 0 0 0\n>");
// Phase S seam fields survive round-trip exactly.
CHECK(full && full->rootNote.has_value() && *full->rootNote == 60);
CHECK(full && full->loop.has_value());
CHECK(full && full->loop && full->loop->start == 4096 && full->loop->end == 65536);
const Sample* min = back->query("min-b");
CHECK(min && !min->key.has_value());
CHECK(min && !min->provenance.has_value());
CHECK(min && min->trackGuids.empty());
// Seam fields absent on the minimal sample and stay absent.
CHECK(min && !min->rootNote.has_value());
CHECK(min && !min->loop.has_value());
}
}
@@ -417,6 +426,128 @@ static void testEnumRangeValidation() {
CHECK(!r2.has_value());
}
// S2 test case 2: a legacy Sample JSON — written before the Phase S seam fields
// existed, so it has NO "rootNote" or "loop" keys at all — parses to clean empty
// optionals (no loss, no migration) and re-serializes without inventing values.
// (The parser's forward-compat unknown-key skipping is what makes the reverse case
// — new keys ignored by an old parser — safe too; here we test old-JSON→new-parser.)
static void testLegacyJsonDefaults() {
const char* legacy =
"{\"samples\":[{\"id\":\"leg1\",\"relativePath\":\"bank/leg.wav\","
"\"displayName\":\"legacy\","
"\"sourceMode\":0,\"sourceRange\":{\"startSeconds\":1.5,\"endSeconds\":2.5,"
"\"startPpq\":0.0,\"endPpq\":0.0},\"trackGuids\":[],"
"\"wetDry\":1.0,\"channelCount\":2,\"sampleRate\":48000,"
"\"lengthSeconds\":1.0,\"lengthBeats\":0.0,\"captureTempo\":120.0,"
"\"key\":null,\"levels\":{\"peakDb\":0.0,\"rmsDb\":0.0,\"lufs\":0.0},"
"\"clipped\":false,\"tier\":0,\"contentHash\":\"h-leg1\","
"\"provenance\":null,\"createdTimestamp\":0}]}";
auto r = BankIndex::deserialize(legacy);
CHECK(r.has_value());
if (r) {
const Sample* s = r->query("leg1");
CHECK(s != nullptr);
CHECK(s && !s->rootNote.has_value()); // clean default, not a guessed value
CHECK(s && !s->loop.has_value());
// Re-serialize is lossless: parsing it again yields an equal index. This
// proves the absent fields did not silently gain values on the way out.
std::string out = r->serialize();
auto again = BankIndex::deserialize(out);
CHECK(again.has_value());
CHECK(again && *again == *r);
if (again) {
const Sample* s2 = again->query("leg1");
CHECK(s2 && !s2->rootNote.has_value());
CHECK(s2 && !s2->loop.has_value());
}
}
}
// S2 test case 4: boundary values for the seam fields are representable and
// round-trip. rootNote 0 and 127 (the MIDI edges); loopStart == loopEnd (a valid
// zero-length marker); a loop whose end sits at the file's last frame. Also asserts
// the deserialize-boundary validation rules reject out-of-range input rather than
// storing a bogus value.
static void testSeamFieldBoundaries() {
// rootNote at both MIDI edges + equal-and-end-anchored loop points round-trip.
BankIndex idx;
Sample lo = minimalSample("lo"); lo.contentHash = "h-lo";
lo.rootNote = 0;
lo.loop = LoopPoints{0, 0}; // zero-length marker at frame 0
Sample hi = minimalSample("hi"); hi.contentHash = "h-hi";
hi.rootNote = 127;
hi.loop = LoopPoints{100, 100}; // start == end elsewhere
Sample end = minimalSample("end"); end.contentHash = "h-end";
end.loop = LoopPoints{0, 9223372036854775807LL}; // end at max int64 frame
CHECK(idx.add(lo) == AddResult::Added);
CHECK(idx.add(hi) == AddResult::Added);
CHECK(idx.add(end) == AddResult::Added);
auto back = BankIndex::deserialize(idx.serialize());
CHECK(back.has_value());
CHECK(back && *back == idx);
if (back) {
CHECK(back->query("min-lo")->rootNote == 0);
CHECK(back->query("min-hi")->rootNote == 127);
// Named local: a brace-init with a comma inside CHECK(...) would be parsed
// as two macro arguments by the preprocessor.
const LoopPoints zeroLen{0, 0};
CHECK(back->query("min-lo")->loop == zeroLen);
CHECK(back->query("min-end")->loop->end == 9223372036854775807LL);
}
// Validation rule (chosen for this design, surfaced in the handoff):
// rootNote must be 0..127; loop must satisfy 0 <= start <= end.
// Out-of-range input is rejected at the deserialize boundary (nullopt), mirroring
// the existing enum-range and integer-overflow rejections — never clamped.
const char* head =
"{\"samples\":[{\"id\":\"bad\",\"relativePath\":\"bank/b.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,";
const char* tail =
"\"levels\":{\"peakDb\":0.0,\"rmsDb\":0.0,\"lufs\":0.0},"
"\"clipped\":false,\"tier\":0,\"contentHash\":\"h-bad\","
"\"provenance\":null,\"createdTimestamp\":0}]}";
CHECK(!BankIndex::deserialize(std::string(head) + "\"rootNote\":128," + tail).has_value());
CHECK(!BankIndex::deserialize(std::string(head) + "\"rootNote\":-1," + tail).has_value());
CHECK(!BankIndex::deserialize(
std::string(head) + "\"loop\":{\"start\":10,\"end\":5}," + tail).has_value()); // start > end
CHECK(!BankIndex::deserialize(
std::string(head) + "\"loop\":{\"start\":-1,\"end\":5}," + tail).has_value()); // negative start
}
// S2 test case 3: the seam-field addition is purely additive — dedup-by-hash, tier
// moves/filtering, and BankIndex ordering are byte-for-byte unchanged by the
// presence (or absence) of rootNote/loop. Two samples differing ONLY in seam fields
// but sharing a content hash still collapse; a seam-populated sample tiers exactly
// like any other.
static void testSeamFieldsAdditiveInvariant() {
BankIndex idx;
Sample a = fullSample("z"); // has rootNote + loop populated
CHECK(idx.add(a) == AddResult::Added);
// Same hash, seam fields cleared — dedup keys off contentHash only, so this
// still collapses. Seam fields do NOT enter the dedup identity.
Sample dup = fullSample("z2");
dup.contentHash = a.contentHash;
dup.rootNote.reset();
dup.loop.reset();
CHECK(idx.add(dup) == AddResult::Collapsed);
CHECK(idx.size() == 1);
// Tier move on a seam-populated sample behaves exactly as before.
CHECK(idx.query("id-z")->tier == Tier::Archive);
CHECK(idx.moveTier("id-z", Tier::Scratch));
CHECK(idx.query("id-z")->tier == Tier::Scratch);
CHECK(idx.query("id-z")->rootNote == 60); // move did not disturb seam fields
}
int main() {
testFullFieldRoundTrip();
testDedupByHash();
@@ -430,6 +561,9 @@ int main() {
testUnicodeEscapeDecoding();
testIntegerOverflow();
testEnumRangeValidation();
testLegacyJsonDefaults();
testSeamFieldBoundaries();
testSeamFieldsAdditiveInvariant();
if (g_fail == 0) std::printf("All tests passed.\n");
return g_fail ? 1 : 0;