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:
@@ -231,6 +231,18 @@ void ReaSamplerEditor::commitAndReload() {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ReaSamplerEditor::loadSelection(const std::string& id) {
|
||||||
|
// Zone-bleed fix (3a): a Sample-face load REPLACES the loaded sound. The previous
|
||||||
|
// sample's materialized full-range zone must not linger — first-match resolve would
|
||||||
|
// keep playing it while the editor draws the new pick's zone (matched by sampleId,
|
||||||
|
// order-blind). Authored Zone-view maps (any narrow key range) are left untouched.
|
||||||
|
selectedId_ = id;
|
||||||
|
if (reconcileSingleCaptureZones(map_, selectedId_)) {
|
||||||
|
selectedZone_ = map_.zones.empty() ? -1 : 0;
|
||||||
|
}
|
||||||
|
commitAndReload();
|
||||||
|
}
|
||||||
|
|
||||||
ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t frames) const {
|
ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t frames) const {
|
||||||
SetupMarkers m;
|
SetupMarkers m;
|
||||||
// Seed from the bank's S2 intrinsic loop (fact about the file), then let a per-zone override
|
// Seed from the bank's S2 intrinsic loop (fact about the file), then let a per-zone override
|
||||||
@@ -1739,8 +1751,7 @@ void ReaSamplerEditor::onMouseDown(int x, int y) {
|
|||||||
if (contains(bm.confirm, x, y)) {
|
if (contains(bm.confirm, x, y)) {
|
||||||
// Load: commit the pending pick (if any) into the loaded selection + reload, then Sample.
|
// Load: commit the pending pick (if any) into the loaded selection + reload, then Sample.
|
||||||
if (!browsePendingId_.empty()) {
|
if (!browsePendingId_.empty()) {
|
||||||
selectedId_ = browsePendingId_;
|
loadSelection(browsePendingId_);
|
||||||
commitAndReload();
|
|
||||||
}
|
}
|
||||||
browsePendingId_.clear();
|
browsePendingId_.clear();
|
||||||
searchFocused_ = false;
|
searchFocused_ = false;
|
||||||
@@ -1778,8 +1789,7 @@ void ReaSamplerEditor::onMouseDown(int x, int y) {
|
|||||||
// is the load accelerator (commit + dismiss). Browse never loads on a single click.
|
// is the load accelerator (commit + dismiss). Browse never loads on a single click.
|
||||||
const std::string id = visible_[static_cast<std::size_t>(card)].id;
|
const std::string id = visible_[static_cast<std::size_t>(card)].id;
|
||||||
if (lastBrowseClickCard_ == card && browsePendingId_ == id) {
|
if (lastBrowseClickCard_ == card && browsePendingId_ == id) {
|
||||||
selectedId_ = id;
|
loadSelection(id);
|
||||||
commitAndReload();
|
|
||||||
browsePendingId_.clear();
|
browsePendingId_.clear();
|
||||||
lastBrowseClickCard_ = -1;
|
lastBrowseClickCard_ = -1;
|
||||||
searchFocused_ = false;
|
searchFocused_ = false;
|
||||||
|
|||||||
@@ -224,6 +224,12 @@ private:
|
|||||||
// the audio thread. UI thread only. One place so every edit commits identically.
|
// the audio thread. UI thread only. One place so every edit commits identically.
|
||||||
void commitAndReload();
|
void commitAndReload();
|
||||||
|
|
||||||
|
// Commit `id` as the loaded single-capture selection (the Browse Load confirm and the
|
||||||
|
// double-click accelerator both route here). Runs reconcileSingleCaptureZones first so
|
||||||
|
// the previous sample's materialized full-range zone cannot linger and shadow the new
|
||||||
|
// pick under first-match resolve (the zone-bleed fix, issue 3a), then publishes + reloads.
|
||||||
|
void loadSelection(const std::string& id);
|
||||||
|
|
||||||
// Recompute the capture cards visible under the current bank filter (samples_ narrowed by
|
// Recompute the capture cards visible under the current bank filter (samples_ narrowed by
|
||||||
// activeFilterBankId_; "" = All) into visible_. Called on refresh + filter change.
|
// activeFilterBankId_; "" = All) into visible_. Called on refresh + filter change.
|
||||||
void rebuildVisible();
|
void rebuildVisible();
|
||||||
|
|||||||
@@ -182,7 +182,14 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
|
|||||||
// pre-setup call would assert inside readZonesPayload (a programming error, not a field case).
|
// pre-setup call would assert inside readZonesPayload (a programming error, not a field case).
|
||||||
const ComponentState cs = deserializeComponentState(bytes, sampleRate_);
|
const ComponentState cs = deserializeComponentState(bytes, sampleRate_);
|
||||||
setSelectedSampleId(cs.selectionId);
|
setSelectedSampleId(cs.selectionId);
|
||||||
setPerformanceMap(cs.map);
|
// Zone-bleed fix (3a) heal-on-load: a blob saved under the pre-fix editor may carry a
|
||||||
|
// pile of stale full-range zones (one per sample ever browsed), the oldest shadowing the
|
||||||
|
// saved selection under first-match resolve. Reconciling here restores "the sample the
|
||||||
|
// editor shows is the sample the engine plays" for already-affected projects; authored
|
||||||
|
// Zone-view maps (any narrow key range) pass through untouched.
|
||||||
|
PerformanceMap restored = cs.map;
|
||||||
|
reconcileSingleCaptureZones(restored, cs.selectionId);
|
||||||
|
setPerformanceMap(restored);
|
||||||
// S8: restore the last-consumed assignment generation so a re-open does not re-apply a
|
// S8: restore the last-consumed assignment generation so a re-open does not re-apply a
|
||||||
// stale assign_request (the user may have manually changed the selection after the assign).
|
// stale assign_request (the user may have manually changed the selection after the assign).
|
||||||
{
|
{
|
||||||
@@ -503,6 +510,13 @@ ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) {
|
|||||||
// takes) — the instrument updates its OWN state, never the bank. reloadFromBank below
|
// takes) — the instrument updates its OWN state, never the bank. reloadFromBank below
|
||||||
// rebuilds against the new selection, so skip a redundant reload here.
|
// rebuilds against the new selection, so skip a redundant reload here.
|
||||||
setSelectedSampleId(decision.sampleId);
|
setSelectedSampleId(decision.sampleId);
|
||||||
|
// Zone-bleed fix (3a), peer of the editor's Browse Load: a stale full-range zone
|
||||||
|
// materialized for the previously loaded sample would shadow the assigned pick under
|
||||||
|
// first-match resolve. Authored maps (any narrow key range) are untouched.
|
||||||
|
PerformanceMap reconciled = performanceMap();
|
||||||
|
if (reconcileSingleCaptureZones(reconciled, decision.sampleId)) {
|
||||||
|
setPerformanceMap(reconciled);
|
||||||
|
}
|
||||||
result.applied = true;
|
result.applied = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -235,6 +235,27 @@ ResolvedPerformance resolvePerformance(const std::string& banksJson,
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool reconcileSingleCaptureZones(PerformanceMap& map, const std::string& selectedId) {
|
||||||
|
if (selectedId.empty() || map.zones.empty()) return false;
|
||||||
|
for (const PerformanceZone& z : map.zones) {
|
||||||
|
// An authored key range marks Zone-view intent — first-match order is load-bearing
|
||||||
|
// there, so the map is left exactly as authored.
|
||||||
|
if (z.lowNote != 0 || z.highNote != 127) return false;
|
||||||
|
}
|
||||||
|
// Every zone is full-range: the map is purely Sample-face-shaped. Keep only the first
|
||||||
|
// zone bound to the selection (preserving its params); drop the stale shadowers.
|
||||||
|
// Decide BEFORE mutating so the no-change path leaves the map bit-identical.
|
||||||
|
std::size_t keepIdx = map.zones.size(); // size() = no zone for the selection
|
||||||
|
for (std::size_t i = 0; i < map.zones.size(); ++i) {
|
||||||
|
if (map.zones[i].sampleId == selectedId) { keepIdx = i; break; }
|
||||||
|
}
|
||||||
|
const std::size_t keptCount = (keepIdx < map.zones.size()) ? 1u : 0u;
|
||||||
|
if (keptCount == map.zones.size()) return false; // one zone, already the selection's
|
||||||
|
if (keptCount == 1 && keepIdx != 0) map.zones[0] = std::move(map.zones[keepIdx]);
|
||||||
|
map.zones.resize(keptCount);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
|
Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
|
||||||
const std::vector<DecodedZonePcm>& decoded) {
|
const std::vector<DecodedZonePcm>& decoded) {
|
||||||
Keymap km;
|
Keymap km;
|
||||||
|
|||||||
@@ -236,6 +236,28 @@ struct PerformanceMap {
|
|||||||
bool empty() const { return zones.empty(); }
|
bool empty() const { return zones.empty(); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Single-capture ("Sample face") zone-lifecycle reconcile — the zone-bleed fix (issue 3a).
|
||||||
|
//
|
||||||
|
// The Sample face materializes ONE full-range [0,127] zone for the loaded sample on first
|
||||||
|
// control edit (ensureSampleZone). Loading a different sample used to change only the
|
||||||
|
// selection id, leaving the previous sample's full-range zone in the map — and since zone
|
||||||
|
// resolution is FIRST-MATCH in order, that stale zone shadowed every later one forever: the
|
||||||
|
// engine kept playing the old sample while the editor drew the new one's zone (matched by
|
||||||
|
// sampleId, order-blind). This function is called at every selection-change site so the zone
|
||||||
|
// the editor draws is the zone the engine plays.
|
||||||
|
//
|
||||||
|
// Rules (pure, order-preserving where it matters):
|
||||||
|
// * empty `selectedId` or empty map -> untouched, false.
|
||||||
|
// * ANY zone with an authored key range (not the full [0,127]) -> the map is Zone-view
|
||||||
|
// authorship; first-match order is load-bearing there — untouched, false. The Sample
|
||||||
|
// face never creates a narrow zone, so a narrow zone proves deliberate multi-zone intent.
|
||||||
|
// * else (every zone full-range — the map is purely Sample-face-shaped): keep only the
|
||||||
|
// first zone bound to `selectedId` (its params survive a return to that sample); drop
|
||||||
|
// the rest. A selection with no zone yet empties the map (the shell then plays the
|
||||||
|
// selection via the Tier-0 fast path with product defaults).
|
||||||
|
// Returns true iff the map changed (the caller republishes + reloads on true).
|
||||||
|
bool reconcileSingleCaptureZones(PerformanceMap& map, const std::string& selectedId);
|
||||||
|
|
||||||
// One resolved zone ready for the shell to decode + the pure build to stitch: the bank
|
// One resolved zone ready for the shell to decode + the pure build to stitch: the bank
|
||||||
// sample's project-relative WAV path (file seam), the EFFECTIVE root note (override beats
|
// sample's project-relative WAV path (file seam), the EFFECTIVE root note (override beats
|
||||||
// bank intrinsic beats middle-C default), the loop intrinsic, and the key range. Distinct
|
// bank intrinsic beats middle-C default), the loop intrinsic, and the key range. Distinct
|
||||||
|
|||||||
@@ -1665,6 +1665,108 @@ static void testBuildTier0KeymapResolvesSecondsAt48k() {
|
|||||||
CHECK(a.releaseFrames == 2880); // round(0.060 * 48000)
|
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() {
|
int main() {
|
||||||
testSelectByIdHit();
|
testSelectByIdHit();
|
||||||
testSelectEmptyIdIsSilence();
|
testSelectEmptyIdIsSilence();
|
||||||
@@ -1763,6 +1865,12 @@ int main() {
|
|||||||
testComponentStateV6TruncatedVelocity();
|
testComponentStateV6TruncatedVelocity();
|
||||||
testV5EnvelopeWithMarkerAndPlayParamsRoundTrip();
|
testV5EnvelopeWithMarkerAndPlayParamsRoundTrip();
|
||||||
testV4BlobWithPlayParamsLiftsMarkerZeroKeepsPlay();
|
testV4BlobWithPlayParamsLiftsMarkerZeroKeepsPlay();
|
||||||
|
testReconcileKeepsOnlySelectedFullRangeZone();
|
||||||
|
testReconcileClearsWhenSelectionUnzoned();
|
||||||
|
testReconcileLeavesAuthoredMapUntouched();
|
||||||
|
testReconcileNoOpWhenAlreadyCoherent();
|
||||||
|
testReconcileGuards();
|
||||||
|
testReconcileBrowseSequenceNoShadowing();
|
||||||
|
|
||||||
if (g_fail == 0) std::printf("sample_map: all tests passed\n");
|
if (g_fail == 0) std::printf("sample_map: all tests passed\n");
|
||||||
return g_fail != 0;
|
return g_fail != 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user