diff --git a/src/vst/reasampler_editor.cpp b/src/vst/reasampler_editor.cpp index e068f1f..a4b3b8a 100644 --- a/src/vst/reasampler_editor.cpp +++ b/src/vst/reasampler_editor.cpp @@ -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(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(map_.zones.size()) - 1; commitAndReload(); diff --git a/src/vst/reasampler_editor.h b/src/vst/reasampler_editor.h index b18897e..cf64810 100644 --- a/src/vst/reasampler_editor.h +++ b/src/vst/reasampler_editor.h @@ -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(); diff --git a/src/vst/reasampler_processor.cpp b/src/vst/reasampler_processor.cpp index 4fe5838..d5801bf 100644 --- a/src/vst/reasampler_processor.cpp +++ b/src/vst/reasampler_processor.cpp @@ -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; } diff --git a/src/vst/sample_map.cpp b/src/vst/sample_map.cpp index dd096eb..961507c 100644 --- a/src/vst/sample_map.cpp +++ b/src/vst/sample_map.cpp @@ -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& zones, const std::vector& decoded) { Keymap km; diff --git a/src/vst/sample_map.h b/src/vst/sample_map.h index ba2d1cf..14980eb 100644 --- a/src/vst/sample_map.h +++ b/src/vst/sample_map.h @@ -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 diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp index 0a4a2a8..a25d818 100644 --- a/tests/test_sample_map.cpp +++ b/tests/test_sample_map.cpp @@ -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 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;