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:
+234
-1
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user