self-contained playback — ComponentState v10 SampleRefs (path+intrinsics owned by the instance), reloadInstrument decodes bank-free, heal timer + poll-to-play removed; bank is a browser source

This commit is contained in:
2026-07-28 11:45:10 -04:00
parent 93e28f4ea5
commit 261a6affa5
11 changed files with 697 additions and 349 deletions
+234 -1
View File
@@ -1755,7 +1755,7 @@ static void testVelocityCurveEndToEndThroughReloadComposition() {
CHECK(rp.zones[0].play.pitchEngine == PitchEngine::Preserve);
// 3. Build the zoned keymap from decoded DC-1 PCM and play it through an engine constructed
// the way reloadFromBank constructs it (Preserve voices pre-sized to a real window).
// the way reloadInstrument constructs it (Preserve voices pre-sized to a real window).
auto steadyLevelAt = [&](int vel) -> double {
DecodedZonePcm pcm;
pcm.monoFrames.assign(4000, 1.0f);
@@ -2120,6 +2120,230 @@ static void testReconcileBrowseSequenceNoShadowing() {
}
}
// --- pS self-contained playback: the instance-owned sample refs (envelope v10) ---------------
static SampleRefEntry refEntry(const std::string& id, const std::string& rel, int root,
bool hasLoop = false, std::int64_t loopStart = 0,
std::int64_t loopEnd = 0, int channels = 0) {
SampleRefEntry e;
e.sampleId = id;
e.ref.relativePath = rel;
e.ref.rootNote = root;
e.ref.loop.hasLoop = hasLoop;
e.ref.loop.start = loopStart;
e.ref.loop.end = loopEnd;
e.ref.channelCount = channels;
return e;
}
static void testSampleRefsRoundTrip() {
// v10: the owned refs table round-trips — path + every decode intrinsic per entry —
// with the envelope neighbours (selection, zones, explicit flag, gain) intact.
ComponentState s;
s.selectionId = "kick";
s.channelMode = ChannelMode::Stereo;
s.channelModeExplicit = true;
s.masterGainLinear = 0.5;
s.sampleRefs.push_back(refEntry("kick", "reasampler_bank/kick.wav", 36,
/*hasLoop=*/true, 100, 500, /*channels=*/2));
s.sampleRefs.push_back(refEntry("pad", "reasampler_bank/pad.wav", 60,
/*hasLoop=*/false, 0, 0, /*channels=*/1));
s.map.zones.push_back(zone("pad", 48, 72));
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
CHECK(back.sampleRefs.size() == 2);
CHECK(back.sampleRefs.size() == 2 && back.sampleRefs[0].sampleId == "kick");
CHECK(back.sampleRefs.size() == 2 &&
back.sampleRefs[0].ref.relativePath == "reasampler_bank/kick.wav");
CHECK(back.sampleRefs.size() == 2 && back.sampleRefs[0].ref.rootNote == 36);
CHECK(back.sampleRefs.size() == 2 && back.sampleRefs[0].ref.loop.hasLoop &&
back.sampleRefs[0].ref.loop.start == 100 && back.sampleRefs[0].ref.loop.end == 500);
CHECK(back.sampleRefs.size() == 2 && back.sampleRefs[0].ref.channelCount == 2);
CHECK(back.sampleRefs.size() == 2 && back.sampleRefs[1].sampleId == "pad");
CHECK(back.sampleRefs.size() == 2 && !back.sampleRefs[1].ref.loop.hasLoop);
CHECK(back.sampleRefs.size() == 2 && back.sampleRefs[1].ref.channelCount == 1);
// Envelope neighbours undisturbed (the refs read consumed exactly its own bytes).
CHECK(back.selectionId == "kick");
CHECK(back.map.zones.size() == 1);
CHECK(back.channelMode == ChannelMode::Stereo && back.channelModeExplicit);
CHECK(std::fabs(back.masterGainLinear - 0.5) < 1e-12);
}
static void testSampleRefsResolvePlayableKeymapWithoutBank() {
// THE pS architecture correction, end to end in the pure domain: a restored blob
// carrying refs resolves to a PLAYABLE keymap with NO bank blob anywhere in the path —
// deserialize -> resolvePerformanceFromRefs -> buildZonedKeymap. This is the load path
// an instance takes when the extension has not loaded (or does not exist).
ComponentState s;
s.sampleRefs.push_back(refEntry("a", "b/a.wav", 36));
s.sampleRefs.push_back(refEntry("b", "b/b.wav", 48));
s.map.zones.push_back(zone("a", 36, 47));
s.map.zones.push_back(zone("b", 48, 59, /*rootOverride=*/50));
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
const ResolvedPerformance r = resolvePerformanceFromRefs(back.sampleRefs, back.map);
CHECK(r.zones.size() == 2);
CHECK(r.droppedSampleIds.empty());
CHECK(r.zones.size() == 2 && r.zones[0].relativePath == "b/a.wav");
CHECK(r.zones.size() == 2 && r.zones[0].rootNote == 36); // ref intrinsic
CHECK(r.zones.size() == 2 && r.zones[1].rootNote == 50); // zone override beats intrinsic
std::vector<DecodedZonePcm> decoded;
decoded.push_back(DecodedZonePcm{{0.1f, 0.2f}, 44100});
decoded.push_back(DecodedZonePcm{{0.3f}, 44100});
const Keymap km = buildZonedKeymap(r.zones, decoded);
CHECK(km.resolve(40, 100).matched && km.resolve(40, 100).zoneIndex == 0);
CHECK(km.resolve(52, 100).matched && km.resolve(52, 100).zoneIndex == 1);
}
static void testResolveFromRefsMissingRefDrops() {
// MISSING-REF = defined no-play: a zone whose id has no ref (never copied, or a pre-v10
// blob not yet lifted) drops cleanly + reports; the survivor still plays — the same
// shape as the bank path's stale-id policy. (The shell's missing-FILE no-play is the
// decode seam: an unreadable WAV yields empty PCM and the zone drops in
// buildZonedKeymap — see testBuildZonedKeymapDropsEmptyPcm.)
SampleRefs refs;
refs.push_back(refEntry("a", "b/a.wav", 36));
PerformanceMap m;
m.zones.push_back(zone("a", 0, 59));
m.zones.push_back(zone("ghost", 60, 127));
const ResolvedPerformance r = resolvePerformanceFromRefs(refs, m);
CHECK(r.zones.size() == 1);
CHECK(r.zones.size() == 1 && r.zones[0].relativePath == "b/a.wav");
CHECK(r.droppedSampleIds.size() == 1);
CHECK(r.droppedSampleIds.size() == 1 && r.droppedSampleIds[0] == "ghost");
}
static void testResolveFromRefsMatchesBankResolve() {
// The two resolution paths share ONE fold (foldZone): the same map resolved via the
// bank blob and via a refs table refreshed FROM that bank yields identical effective
// zones — the paths cannot drift.
Sample s1 = makeSample("a", "Pad", "b/a.wav", 40);
s1.loop = LoopPoints{200, 800};
const std::string json = bookJson({s1}, {});
PerformanceMap m;
PerformanceZone z = zone("a", 10, 90, /*rootOverride=*/72);
z.startPoint = 512;
m.zones.push_back(z);
SampleRefs refs;
refreshRefsFromBank(refs, json, referencedSampleIds("", m));
const ResolvedPerformance viaBank = resolvePerformance(json, m);
const ResolvedPerformance viaRefs = resolvePerformanceFromRefs(refs, m);
CHECK(viaBank.zones.size() == 1 && viaRefs.zones.size() == 1);
if (viaBank.zones.size() == 1 && viaRefs.zones.size() == 1) {
CHECK(viaRefs.zones[0].relativePath == viaBank.zones[0].relativePath);
CHECK(viaRefs.zones[0].rootNote == viaBank.zones[0].rootNote); // 72 (override)
CHECK(viaRefs.zones[0].loop.hasLoop == viaBank.zones[0].loop.hasLoop);
CHECK(viaRefs.zones[0].loop.start == viaBank.zones[0].loop.start); // 200 (intrinsic)
CHECK(viaRefs.zones[0].loop.end == viaBank.zones[0].loop.end);
CHECK(viaRefs.zones[0].startFrame == viaBank.zones[0].startFrame); // 512
}
}
static void testComponentStateV9LiftsToEmptyRefs() {
// OLD-BLOB FALLBACK: a genuine v9 blob (no refs table) restores with an EMPTY table and
// every other field intact — the shell then lifts via the bridge-resolve path once the
// bank is readable and re-saves self-contained. Hand-built (serializeComponentState now
// emits v10, so it cannot make a v9 blob).
std::vector<std::uint8_t> v9;
v9.push_back(9); v9.push_back(0); v9.push_back(0); v9.push_back(0); // version 9
v9.push_back(0); // channel mode = mono
for (int i = 0; i < 8; ++i) v9.push_back(0); // marker = 0
v9.push_back(88); // preview velocity
v9.push_back(7); // voice count
v9.push_back(0); // voice mode = poly
v9.push_back(0); // trigger = retrigger
for (int i = 0; i < 8; ++i) v9.push_back(0); // gain double bytes...
v9[17 + 6] = 0xF0; v9[17 + 7] = 0x3F; // ...= 1.0 (LE IEEE-754)
v9.push_back(1); // explicit flag = true
const std::string id = "saved";
v9.push_back(static_cast<std::uint8_t>(id.size())); v9.push_back(0); v9.push_back(0); v9.push_back(0);
v9.insert(v9.end(), id.begin(), id.end());
v9.push_back(0); v9.push_back(0); v9.push_back(0); v9.push_back(0); // zone count 0
const ComponentState back = deserializeComponentState(v9, 44100.0);
CHECK(back.sampleRefs.empty()); // pre-pS blob -> empty table (bridge-resolve lift)
CHECK(back.selectionId == "saved");
CHECK(back.channelModeExplicit);
CHECK(back.previewVelocity == 88);
CHECK(back.voiceCount == 7);
CHECK(back.masterGainLinear == 1.0);
CHECK(back.map.zones.empty());
}
static void testReferencedSampleIdsDedup() {
// Selection first, then map order, duplicates collapsed; an empty selection contributes
// nothing (no phantom "" id in the refs table).
PerformanceMap m;
m.zones.push_back(zone("a", 0, 59));
m.zones.push_back(zone("b", 60, 99));
m.zones.push_back(zone("a", 100, 127)); // duplicate id across zones
const std::vector<std::string> ids = referencedSampleIds("b", m); // selection dups a zone
CHECK(ids.size() == 2);
CHECK(ids.size() == 2 && ids[0] == "b" && ids[1] == "a");
const std::vector<std::string> noSel = referencedSampleIds("", m);
CHECK(noSel.size() == 2);
CHECK(noSel.size() == 2 && noSel[0] == "a" && noSel[1] == "b");
}
static void testRefreshRefsFromBankUpsertAndOwnership() {
// Upsert: a resolvable id copies in (the selectSample distillation); a re-refresh after
// a bank edit UPDATES the owned copy (S9 recapture sync); a bank MISS never strips the
// owned entry (a bank deletion cannot silence a self-contained instance); an empty or
// malformed blob is a no-op.
Sample s1 = makeSample("a", "Kick", "b/a.wav", 36);
s1.channelCount = 2;
const std::string json1 = bookJson({s1}, {});
SampleRefs refs;
refreshRefsFromBank(refs, json1, {"a", "ghost"});
CHECK(refs.size() == 1); // "ghost" does not resolve -> no entry minted
CHECK(refs.size() == 1 && refs[0].sampleId == "a" && refs[0].ref.rootNote == 36);
CHECK(refs.size() == 1 && refs[0].ref.relativePath == "b/a.wav");
CHECK(refs.size() == 1 && refs[0].ref.channelCount == 2);
// Recapture-style bank edit: path + root changed -> the owned copy refreshes.
refreshRefsFromBank(refs, bookJson({makeSample("a", "Kick", "b/a2.wav", 40)}, {}), {"a"});
CHECK(refs.size() == 1 && refs[0].ref.rootNote == 40);
CHECK(refs.size() == 1 && refs[0].ref.relativePath == "b/a2.wav");
// Bank deletion: the id no longer resolves -> the OWNED copy survives untouched.
refreshRefsFromBank(refs, bookJson({makeSample("x", "Other", "b/x.wav", 60)}, {}), {"a"});
CHECK(refs.size() == 1 && refs[0].ref.rootNote == 40);
// Malformed / empty blobs: no-op.
refreshRefsFromBank(refs, "{garbage", {"a"});
refreshRefsFromBank(refs, "", {"a"});
CHECK(refs.size() == 1 && refs[0].ref.rootNote == 40);
}
static void testRetainRefsFiltersToPlayedSet() {
// getState hygiene: only the entries the instance currently plays persist — the table
// cannot grow with browsing history. Order of survivors is preserved.
SampleRefs refs;
refs.push_back(refEntry("a", "b/a.wav", 36));
refs.push_back(refEntry("b", "b/b.wav", 48));
refs.push_back(refEntry("c", "b/c.wav", 60));
retainRefs(refs, {"c", "a"});
CHECK(refs.size() == 2);
CHECK(refs.size() == 2 && refs[0].sampleId == "a" && refs[1].sampleId == "c");
retainRefs(refs, {});
CHECK(refs.empty());
}
static void testSampleRefsTruncatedMidEntry() {
// A blob cut mid-refs-entry keeps the entries that parsed cleanly and restores the rest
// of the state empty (the selection/zones behind the cut are unreadable anyway) — the
// established truncation posture, never a throw across the host boundary.
ComponentState s;
s.selectionId = "kick";
s.sampleRefs.push_back(refEntry("kick", "b/k.wav", 36));
s.sampleRefs.push_back(refEntry("pad", "b/p.wav", 60));
std::vector<std::uint8_t> bytes = serializeComponentState(s);
// The tail after the refs table is idLen(4) + "kick"(4) + the empty-map zones payload
// (marker 4 + version 4 + count 4) = 20 bytes; entry 2 is 43 bytes (4+3 id, 4+7 path,
// 4 root, 1+8+8 loop, 4 channels). Cutting 40 bytes lands 20 bytes into entry 2.
CHECK(bytes.size() > 40);
bytes.resize(bytes.size() - 40);
const ComponentState back = deserializeComponentState(bytes, 44100.0);
CHECK(back.sampleRefs.size() == 1);
CHECK(back.sampleRefs.size() == 1 && back.sampleRefs[0].sampleId == "kick");
CHECK(back.selectionId.empty());
CHECK(back.map.zones.empty());
}
int main() {
testSelectByIdHit();
testSelectEmptyIdIsSilence();
@@ -2245,6 +2469,15 @@ int main() {
testReconcileNoOpWhenAlreadyCoherent();
testReconcileGuards();
testReconcileBrowseSequenceNoShadowing();
testSampleRefsRoundTrip();
testSampleRefsResolvePlayableKeymapWithoutBank();
testResolveFromRefsMissingRefDrops();
testResolveFromRefsMatchesBankResolve();
testComponentStateV9LiftsToEmptyRefs();
testReferencedSampleIdsDedup();
testRefreshRefsFromBankUpsertAndOwnership();
testRetainRefsFiltersToPlayedSet();
testSampleRefsTruncatedMidEntry();
if (g_fail == 0) std::printf("sample_map: all tests passed\n");
return g_fail != 0;