fix(vst): zone-bleed (3a) — reconcile the Sample-face zone on load so the loaded sample zone is the one first-match resolve plays

This commit is contained in:
2026-07-27 19:09:53 -04:00
parent a4e33b1c04
commit 7fd812bd34
6 changed files with 186 additions and 5 deletions
+108
View File
@@ -1665,6 +1665,108 @@ static void testBuildTier0KeymapResolvesSecondsAt48k() {
CHECK(a.releaseFrames == 2880); // round(0.060 * 48000)
}
// --- single-capture zone lifecycle: reconcileSingleCaptureZones (zone-bleed fix, 3a) ---
// The bled state: two full-range Sample-face zones. Reconcile on load keeps only the
// selected sample's zone, params intact (a return to that sample restores its edits).
static void testReconcileKeepsOnlySelectedFullRangeZone() {
PerformanceMap m;
m.zones.push_back(zone("a", 0, 127));
PerformanceZone b = zone("b", 0, 127);
b.keyTrack = 0.5; // distinctive param — must survive the reconcile
m.zones.push_back(b);
CHECK(reconcileSingleCaptureZones(m, "b"));
CHECK(m.zones.size() == 1);
CHECK(m.zones.size() == 1 && m.zones[0].sampleId == "b");
CHECK(m.zones.size() == 1 && m.zones[0].keyTrack == 0.5);
}
// Loading a sample with no zone yet empties a Sample-face-shaped map — the shell then
// plays the selection via the Tier-0 fast path (product defaults), never the stale zone.
static void testReconcileClearsWhenSelectionUnzoned() {
PerformanceMap m;
m.zones.push_back(zone("a", 0, 127));
CHECK(reconcileSingleCaptureZones(m, "b"));
CHECK(m.zones.empty());
}
// Any narrow key range marks Zone-view authorship: the map (including a legitimate
// full-range fallback zone) is untouched — first-match order is load-bearing there.
static void testReconcileLeavesAuthoredMapUntouched() {
PerformanceMap m;
m.zones.push_back(zone("a", 60, 72)); // authored narrow range
m.zones.push_back(zone("b", 0, 127)); // authored full-range fallback layer
CHECK(!reconcileSingleCaptureZones(m, "c"));
CHECK(m.zones.size() == 2);
CHECK(m.zones.size() == 2 && m.zones[0].sampleId == "a" && m.zones[1].sampleId == "b");
}
// A map already holding exactly the selection's one zone is coherent — no change reported,
// so callers do not republish/reload needlessly.
static void testReconcileNoOpWhenAlreadyCoherent() {
PerformanceMap m;
m.zones.push_back(zone("a", 0, 127));
CHECK(!reconcileSingleCaptureZones(m, "a"));
CHECK(m.zones.size() == 1 && m.zones[0].sampleId == "a");
}
// Guards: an empty map and an empty selection both leave the map untouched.
static void testReconcileGuards() {
PerformanceMap empty;
CHECK(!reconcileSingleCaptureZones(empty, "a"));
PerformanceMap m;
m.zones.push_back(zone("a", 0, 127));
m.zones.push_back(zone("b", 0, 127));
CHECK(!reconcileSingleCaptureZones(m, "")); // no selection -> never mutate
CHECK(m.zones.size() == 2);
}
// The reported browse sequence, end to end at the pure layer: edit sample A (full-range
// zone materialized), browse-load B, edit B (zone appended AFTER A's). First proves the
// bug — first-match resolve plays A's zone while B is loaded — then proves the reconcile
// at the load step makes the loaded sample's zone the one resolve() returns.
static void testReconcileBrowseSequenceNoShadowing() {
const std::string json = bookJson(
{makeSample("a", "A", "reasampler_bank/a.wav", 60),
makeSample("b", "B", "reasampler_bank/b.wav", 60)}, {});
PerformanceMap m;
m.zones.push_back(zone("a", 0, 127)); // edit on A materializes A's zone
m.zones.push_back(zone("b", 0, 127)); // browse to B (pre-fix: no reconcile) + edit B
// PCM markers: A decodes to 0.75, B to 0.25 — which zone resolve() picked is audible
// in frames[0] of the resolved sample.
const auto keymapFor = [&](const PerformanceMap& map) {
const ResolvedPerformance r = resolvePerformance(json, map);
std::vector<DecodedZonePcm> decoded;
for (const ResolvedZone& rz : r.zones) {
decoded.push_back(DecodedZonePcm{
{rz.relativePath == "reasampler_bank/a.wav" ? 0.75f : 0.25f}, 44100});
}
return buildZonedKeymap(r.zones, decoded);
};
// The bled map: the engine resolves A's zone (index 0) — the shadowing bug.
const Keymap bled = keymapFor(m);
CHECK(bled.zones.size() == 2);
const ZoneResolution shadow = bled.resolve(60, 100);
CHECK(shadow.matched);
CHECK(shadow.matched &&
bled.samples[bled.zones[shadow.zoneIndex].sampleIndex].frames[0] == 0.75f);
// The fix at the load step: reconcile on the selection change keeps only B's zone —
// the loaded sample's zone IS the zone resolve() returns, at every note.
CHECK(reconcileSingleCaptureZones(m, "b"));
const Keymap fixed = keymapFor(m);
CHECK(fixed.zones.size() == 1);
for (int note : {0, 60, 127}) {
const ZoneResolution r = fixed.resolve(note, 100);
CHECK(r.matched);
CHECK(r.matched &&
fixed.samples[fixed.zones[r.zoneIndex].sampleIndex].frames[0] == 0.25f);
}
}
int main() {
testSelectByIdHit();
testSelectEmptyIdIsSilence();
@@ -1763,6 +1865,12 @@ int main() {
testComponentStateV6TruncatedVelocity();
testV5EnvelopeWithMarkerAndPlayParamsRoundTrip();
testV4BlobWithPlayParamsLiftsMarkerZeroKeepsPlay();
testReconcileKeepsOnlySelectedFullRangeZone();
testReconcileClearsWhenSelectionUnzoned();
testReconcileLeavesAuthoredMapUntouched();
testReconcileNoOpWhenAlreadyCoherent();
testReconcileGuards();
testReconcileBrowseSequenceNoShadowing();
if (g_fail == 0) std::printf("sample_map: all tests passed\n");
return g_fail != 0;