Merge pS-zonebleed: fix zone-bleed (3a) — Sample-face zone reconcile so the played zone matches the drawn one

This commit is contained in:
2026-07-27 19:47:09 -04:00
6 changed files with 201 additions and 10 deletions
+29 -9
View File
@@ -231,6 +231,18 @@ void ReaSamplerEditor::commitAndReload() {
#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 {
SetupMarkers m;
// 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)) {
// Load: commit the pending pick (if any) into the loaded selection + reload, then Sample.
if (!browsePendingId_.empty()) {
selectedId_ = browsePendingId_;
commitAndReload();
loadSelection(browsePendingId_);
}
browsePendingId_.clear();
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.
const std::string id = visible_[static_cast<std::size_t>(card)].id;
if (lastBrowseClickCard_ == card && browsePendingId_ == id) {
selectedId_ = id;
commitAndReload();
loadSelection(id);
browsePendingId_.clear();
lastBrowseClickCard_ = -1;
searchFocused_ = false;
@@ -1933,10 +1943,13 @@ void ReaSamplerEditor::onMouseDown(int x, int y) {
Rect addR{content.left + pad, content.top + 4, content.left + pad + 96,
content.top + 4 + 20};
if (contains(addR, x, y)) {
// Add a full-keyboard zone for the picked capture (or the first visible sample as a
// Add a narrow default zone for the picked capture (or the first visible sample as a
// sensible seed). No pick -> nothing to add. If a full-keyboard zone for the seed id
// already exists, select it rather than appending a duplicate (mirrors the upsert the
// root-marker drag path already performs, preventing overlapping identical zones).
// already exists (pre-fix bleed survivor), select it rather than appending a duplicate
// (mirrors the upsert the root-marker drag path already performs).
// NARROW DEFAULT: seed [root-6, root+5] (one octave centred on the bank root, clamped
// to [0,127]) so the new zone is immediately "authored" (narrow) and survives
// reconcileSingleCaptureZones without being treated as a Sample-face full-range zone.
std::string seed = !selectedId_.empty() ? selectedId_
: (!visible_.empty() ? visible_.front().id : std::string());
if (seed.empty()) return;
@@ -1948,10 +1961,17 @@ void ReaSamplerEditor::onMouseDown(int x, int y) {
return;
}
}
// Look up the seed's root note from the browser list (absent root defaults to 60).
int seedRoot = 60;
for (const SampleChoice& sc : samples_) {
if (sc.id == seed) { if (sc.rootNote.has_value()) seedRoot = *sc.rootNote; break; }
}
const int lo = (std::max)(0, seedRoot - 6);
const int hi = (std::min)(127, seedRoot + 5);
PerformanceZone z;
z.sampleId = seed;
z.lowNote = 0;
z.highNote = 127;
z.lowNote = lo;
z.highNote = hi;
map_.zones.push_back(z);
selectedZone_ = static_cast<int>(map_.zones.size()) - 1;
commitAndReload();
+6
View File
@@ -224,6 +224,12 @@ private:
// the audio thread. UI thread only. One place so every edit commits identically.
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
// activeFilterBankId_; "" = All) into visible_. Called on refresh + filter change.
void rebuildVisible();
+15 -1
View File
@@ -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).
const ComponentState cs = deserializeComponentState(bytes, sampleRate_);
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); // bool return ignored: setPerformanceMap + reloadFromBank run unconditionally on load
setPerformanceMap(restored);
// 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).
{
@@ -503,6 +510,13 @@ ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) {
// takes) — the instrument updates its OWN state, never the bank. reloadFromBank below
// rebuilds against the new selection, so skip a redundant reload here.
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;
}
+21
View File
@@ -235,6 +235,27 @@ ResolvedPerformance resolvePerformance(const std::string& banksJson,
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,
const std::vector<DecodedZonePcm>& decoded) {
Keymap km;
+22
View File
@@ -236,6 +236,28 @@ struct PerformanceMap {
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` (the selection's own params are not reset); 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
// 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