Merge pS-zonebleed: fix zone-bleed (3a) — Sample-face zone reconcile so the played zone matches the drawn one
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user