pS remediation: legacy lift terminates on stale-id proof; displayName in v10 refs; refs-reader range fallbacks; load path keeps owned refs

This commit is contained in:
2026-07-28 12:17:36 -04:00
parent 261a6affa5
commit cb89dbf5c4
6 changed files with 213 additions and 36 deletions
+22 -8
View File
@@ -117,12 +117,19 @@ void drawEnvelope(LICE_IBitmap* bmp, const Rect& r, const Envelope& env) {
drawWaveform(bmp, toKitBox(r), env); drawWaveform(bmp, toKitBox(r), env);
} }
// A display name for a bank sample id from the snapshotted list ("?" if the id no longer // A display name for a bank sample id: the snapshotted bank list first, then the
// resolves — e.g. a zone naming a deleted sample). // instance-OWNED ref's displayName (pS — the label survives with the extension absent /
std::string sampleLabel(const std::vector<SampleChoice>& samples, const std::string& id) { // bank unreadable, mirroring the waveform + loop-marker ref fallback). "?" only when
// neither source knows the id (a stale zone naming a deleted sample, or a pre-displayName
// refs table not yet back-filled by a bank refresh).
std::string sampleLabel(const std::vector<SampleChoice>& samples, const SampleRefs& refs,
const std::string& id) {
for (const SampleChoice& c : samples) { for (const SampleChoice& c : samples) {
if (c.id == id) return c.displayName.empty() ? c.id : c.displayName; if (c.id == id) return c.displayName.empty() ? c.id : c.displayName;
} }
for (const SampleRefEntry& e : refs) {
if (e.sampleId == id && !e.displayName.empty()) return e.displayName;
}
return "?"; return "?";
} }
@@ -1220,10 +1227,14 @@ void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) {
// signal. // signal.
std::string title = reasampler::vstPluginName(); // channel-derived (S18) std::string title = reasampler::vstPluginName(); // channel-derived (S18)
if (processor_ && processor_->bridge().isConnected()) { if (processor_ && processor_->bridge().isConnected()) {
if (samples_.empty()) title += " [bank empty]"; // The instance's OWN loaded state outranks bank availability (pS: the bank is a
else if (selectedId_.empty() && map_.zones.empty()) title += " [pick a capture]"; // browser source, not the instrument's identity) — a self-contained instance names
else if (!map_.zones.empty()) title += " [" + std::to_string(map_.zones.size()) + " zone(s)]"; // its sound (refs displayName fallback) even when the bank snapshot is empty.
else title += " [" + sampleLabel(samples_, selectedId_) + "]"; if (!map_.zones.empty()) title += " [" + std::to_string(map_.zones.size()) + " zone(s)]";
else if (!selectedId_.empty())
title += " [" + sampleLabel(samples_, processor_->sampleRefs(), selectedId_) + "]";
else if (samples_.empty()) title += " [bank empty]";
else title += " [pick a capture]";
} else { } else {
title += " [host: no bridge]"; title += " [host: no bridge]";
} }
@@ -1980,7 +1991,10 @@ void ReaSamplerEditor::paintZone(LICE_IBitmap* bmp, int w, int h) {
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) { if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)]; const PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
kitText(bmp, Rect{infoR.left, infoR.top, infoR.left + 120, infoR.bottom}, kitText(bmp, Rect{infoR.left, infoR.top, infoR.left + 120, infoR.bottom},
sampleLabel(samples_, z.sampleId).c_str(), Font::Label, Role::TextPrimary); sampleLabel(samples_, processor_ ? processor_->sampleRefs() : SampleRefs{},
z.sampleId)
.c_str(),
Font::Label, Role::TextPrimary);
// Three fields laid out left-to-right after the sample label. A focused field lifts to // Three fields laid out left-to-right after the sample label. A focused field lifts to
// the Focus state (accent nudge + ring); values in tabular mono so digits don't jitter. // the Focus state (accent nudge + ring); values in tabular mono so digits don't jitter.
const Rect fields = noteEntryFieldsArea(content); const Rect fields = noteEntryFieldsArea(content);
+45 -8
View File
@@ -155,6 +155,16 @@ tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool state) {
// Self-contained (pS): this rebuild resolves + decodes from the instance-OWNED // Self-contained (pS): this rebuild resolves + decodes from the instance-OWNED
// sample refs — it needs no bank read, so it plays regardless of whether the // sample refs — it needs no bank read, so it plays regardless of whether the
// extension's PROJEXTSTATE has parsed yet (or the extension exists at all). // extension's PROJEXTSTATE has parsed yet (or the extension exists at all).
//
// #B: this unconditional rebuild is ALSO the NON-editor legacy trigger for a
// pre-v10 blob (refs empty + intent): reloadInstrument's opportunistic
// refreshRefsFromBank copies the refs in when the bank blob is readable by
// activation time, so an upgraded project plays on load without the instrument
// ever being opened (and the next save is self-contained). Residual load-order
// race, DAW-verifiable only: if the host activates this instance BEFORE the
// project's ext-state lines parse, the lift misses here and — with no editor open —
// nothing retries until the next activation or editor tick. MIGRATION NOTE: open a
// pre-v10 instrument once after upgrading if it restores silent.
reloadInstrument(); reloadInstrument();
} else { } else {
std::lock_guard<std::mutex> lock(reloadMutex_); std::lock_guard<std::mutex> lock(reloadMutex_);
@@ -248,6 +258,9 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
std::lock_guard<std::mutex> lock(refsMutex_); std::lock_guard<std::mutex> lock(refsMutex_);
sampleRefs_ = cs.sampleRefs; sampleRefs_ = cs.sampleRefs;
} }
// A new blob is new facts: a staleness proof latched against the PREVIOUS state does
// not carry over (#A — the legacy lift gets one fresh run per restored state).
legacyLiftConcluded_.store(false, std::memory_order_relaxed);
// Rebuild from the restored state (off-thread — setState is a load-time call). // Rebuild from the restored state (off-thread — setState is a load-time call).
reloadInstrument(); reloadInstrument();
return kResultOk; return kResultOk;
@@ -481,9 +494,11 @@ std::string ReaSamplerProcessor::reloadInstrument() {
bridge_.readReasamplerExtState(kProjExtBanksKey); bridge_.readReasamplerExtState(kProjExtBanksKey);
std::lock_guard<std::mutex> rl(refsMutex_); std::lock_guard<std::mutex> rl(refsMutex_);
if (banksJson) refreshRefsFromBank(sampleRefs_, *banksJson, ids); if (banksJson) refreshRefsFromBank(sampleRefs_, *banksJson, ids);
// Hygiene: the owned table tracks exactly what the instance currently plays, so a // The LOAD path never prunes the owned table: dropping entries here on a transient
// de-referenced sample's entry drops here (never grows with browsing history). // bank miss could destroy the owned intrinsics of the previous selection — the ONE
retainRefs(sampleRefs_, ids); // copy that survives with the extension absent. Entries for de-referenced ids stay
// in memory (bounded by in-session browsing); hygiene lives at the PERSIST boundary,
// where getState filters its snapshot via retainRefs to what the instance plays.
refs = sampleRefs_; // snapshot for the decode below (outside the refs lock) refs = sampleRefs_; // snapshot for the decode below (outside the refs lock)
} }
const std::string projectDir = bridge_.activeProjectDir(); const std::string projectDir = bridge_.activeProjectDir();
@@ -672,6 +687,25 @@ void ReaSamplerProcessor::retireIdleDrain() {
graveyard_.end()); graveyard_.end());
} }
bool ReaSamplerProcessor::legacyLiftShouldRun() {
// #A terminating guard for the pre-v10 legacy lift. The caller has already established
// refs-empty + intent; this decides whether a lift attempt can MAKE PROGRESS before
// paying for a full reload. Once concluded, the steady state is this one relaxed load —
// no bank read, no parse, no reload churn.
if (legacyLiftConcluded_.load(std::memory_order_relaxed)) return false;
const LegacyLiftDecision decision = legacyLiftDecision(
bridge_.readReasamplerExtState(kProjExtBanksKey),
referencedSampleIds(selectedSampleId(), performanceMap()));
if (decision == LegacyLiftDecision::Stale) {
// Provably stale (the bank parses and knows none of the referenced ids): give up
// PERMANENTLY. A later bank change that re-introduces an id bumps the generation,
// and the genChanged reload refreshes the refs without consulting this latch.
legacyLiftConcluded_.store(true, std::memory_order_relaxed);
return false;
}
return true; // Retry (blob not readable yet) or Lift (a ref can be copied in)
}
ReaSamplerProcessor::BankSyncResult ReaSamplerProcessor::BankSyncResult
ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) { ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) {
// OFF THE AUDIO THREAD (the editor's UI timer calls this). Both reads allocate and call // OFF THE AUDIO THREAD (the editor's UI timer calls this). Both reads allocate and call
@@ -759,13 +793,16 @@ ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) {
// editor tick until the lift lands: reloadInstrument folds the bank blob into the refs // editor tick until the lift lands: reloadInstrument folds the bank blob into the refs
// when readable, after which the table is non-empty and this never fires again (the // when readable, after which the table is non-empty and this never fires again (the
// next save is then self-contained). A deliberately-empty instance has no intent and // next save is then self-contained). A deliberately-empty instance has no intent and
// never churns; a lift whose bank stays unreadable (or whose id went stale) retries a // never churns; a bank that is not readable YET retries a cheap null publish on the
// cheap null publish on the editor cadence only. This is a MIGRATION convenience for // editor cadence only. TERMINATING GUARD (#A, legacyLiftShouldRun): once the bank blob
// old projects, NOT a playback dependency — a v10 blob plays from its refs with no // PARSES and no referenced id resolves in it, the ids are provably stale — there is
// poll at all (pS). // nothing to lift, so the lift concludes permanently instead of churning a full bank
// read + reload every tick forever. This is a MIGRATION convenience for old projects,
// NOT a playback dependency — a v10 blob plays from its refs with no poll at all (pS).
bool legacyLift = false; bool legacyLift = false;
if (!genChanged && !result.applied && sampleRefs().empty()) { if (!genChanged && !result.applied && sampleRefs().empty()) {
legacyLift = !selectedSampleId().empty() || !performanceMap().empty(); const bool hasIntent = !selectedSampleId().empty() || !performanceMap().empty();
legacyLift = hasIntent && legacyLiftShouldRun();
} }
if (genChanged || result.applied || legacyLift) { if (genChanged || result.applied || legacyLift) {
+21 -3
View File
@@ -168,6 +168,9 @@ public:
// a non-target instance neither applies nor advances its marker. // a non-target instance neither applies nor advances its marker.
// * LEGACY LIFT: a pre-v10 blob restored with intent but no refs retries the (cheap) // * LEGACY LIFT: a pre-v10 blob restored with intent but no refs retries the (cheap)
// bank read until the blob is parseable, then reloads ONCE to copy the refs in. // bank read until the blob is parseable, then reloads ONCE to copy the refs in.
// TERMINATING: once the blob parses and NO referenced id resolves, the ids are
// provably stale — the lift concludes permanently (legacyLiftShouldRun) instead of
// churning a full bank read + reload every tick forever.
// The consumed marker advances in component state (marked dirty via the host handler) so a // The consumed marker advances in component state (marked dirty via the host handler) so a
// re-open does not re-apply. `isFocusedTarget` is the shell's thundering-herd policy input // re-open does not re-apply. `isFocusedTarget` is the shell's thundering-herd policy input
// (the editor passes true only for the instance whose editor is open — see the handoff). // (the editor passes true only for the instance whose editor is open — see the handoff).
@@ -286,6 +289,12 @@ private:
// the new params bake into the next real reload. Off the audio thread only. // the new params bake into the next real reload. Off the audio thread only.
void rebuildVoiceEngine(); void rebuildVoiceEngine();
// The pre-v10 LEGACY LIFT gate (#A): true when a lift attempt this tick could make
// progress. Latches legacyLiftConcluded_ on a Stale proof (see the member below); the
// pure decision itself is sample_map's legacyLiftDecision. Off the audio thread only
// (bridge read + bank parse).
bool legacyLiftShouldRun();
// Publish `built` (null = install silence) into live_: prune the graveyard by the last // Publish `built` (null = install silence) into live_: prune the graveyard by the last
// process()-published generation, swap `built` into live_, displace the previous live into // process()-published generation, swap `built` into live_, displace the previous live into
// the drain slot, and park the drain-evicted instrument in the graveyard. REQUIRES // the drain slot, and park the drain-evicted instrument in the graveyard. REQUIRES
@@ -383,11 +392,20 @@ private:
// FIRST poll after an editor open BASELINES the seen value without a redundant reload // FIRST poll after an editor open BASELINES the seen value without a redundant reload
// (setState already loaded the instrument from the OWNED refs); a subsequent generation // (setState already loaded the instrument from the OWNED refs); a subsequent generation
// CHANGE then drives the reload. Since pS there is NO reopen-heal here: playback never // CHANGE then drives the reload. Since pS there is NO reopen-heal here: playback never
// depends on this poll — a v10 blob plays from its own refs at setState time. The only // depends on this poll — a v10 blob plays from its own refs at setState time. Besides a
// poll-driven reload besides a generation change is the pre-v10 LEGACY LIFT (see // generation change, pollBankSync reloads only for an APPLIED S8 assignment and for the
// pollBankSync). NOT read on the audio thread. // pre-v10 LEGACY LIFT. NOT read on the audio thread.
std::int64_t lastSeenBankGeneration_ = -1; std::int64_t lastSeenBankGeneration_ = -1;
// The pre-v10 LEGACY LIFT's terminating latch (#A): set once legacyLiftShouldRun proves
// the referenced ids STALE against a readable bank blob (LegacyLiftDecision::Stale) —
// there is nothing to lift, so the lift stops re-firing (the steady state is one relaxed
// load per tick, no bank read). Reset by setState (a new blob = new facts). NOT consulted
// by the genChanged/applied reload paths, so a later bank change that re-introduces an id
// (e.g. an extension-side undo) still refreshes the refs — the latch only gates the lift.
// Atomic: written on the UI-timer thread (pollBankSync) and the host load thread (setState).
std::atomic<bool> legacyLiftConcluded_{false};
// S-VIEW-4 preview-trigger velocity (MIDI 1..127). Persisted in component state (v6) so the // S-VIEW-4 preview-trigger velocity (MIDI 1..127). Persisted in component state (v6) so the
// user's chosen strike velocity survives a project save/reload. Since Wave 2 the Sample-view // user's chosen strike velocity survives a project save/reload. Since Wave 2 the Sample-view
// velocity knob writes it on the UI thread, so it is guarded by previewMutex_; setState and // velocity knob writes it on the UI thread, so it is guarded by previewMutex_; setState and
+36 -5
View File
@@ -134,10 +134,30 @@ void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson,
const SelectedSample distilled = distill(*found); const SelectedSample distilled = distill(*found);
bool updated = false; bool updated = false;
for (SampleRefEntry& e : refs) { for (SampleRefEntry& e : refs) {
if (e.sampleId == id) { e.ref = distilled; updated = true; break; } if (e.sampleId == id) {
e.ref = distilled;
e.displayName = found->displayName; // rename sync rides the same refresh
updated = true;
break;
} }
if (!updated) refs.push_back(SampleRefEntry{id, distilled});
} }
if (!updated) refs.push_back(SampleRefEntry{id, distilled, found->displayName});
}
}
LegacyLiftDecision legacyLiftDecision(const std::optional<std::string>& banksJson,
const std::vector<std::string>& ids) {
if (!banksJson || banksJson->empty()) return LegacyLiftDecision::Retry;
const std::optional<BankBook> book = BankBook::deserialize(*banksJson);
if (!book) return LegacyLiftDecision::Retry; // present but unparseable: not readable YET
for (const std::string& id : ids) {
for (const Bank& b : book->banks()) {
if (b.index.query(id)) return LegacyLiftDecision::Lift;
}
}
// The blob parses and knows none of the referenced ids (or there are none): provably
// stale — a lift can never make progress against this bank.
return LegacyLiftDecision::Stale;
} }
void retainRefs(SampleRefs& refs, const std::vector<std::string>& ids) { void retainRefs(SampleRefs& refs, const std::vector<std::string>& ids) {
@@ -723,7 +743,7 @@ std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
// table, following the explicit flag so a v9 blob is a strict prefix up to here (see // table, following the explicit flag so a v9 blob is a strict prefix up to here (see
// the v9 lift). Wire shape per kSelectionZonesRefsV10Version: entry count, then per // the v9 lift). Wire shape per kSelectionZonesRefsV10Version: entry count, then per
// entry id + path (length-prefixed), rootNote, loop (hasLoop + start/end, always // entry id + path (length-prefixed), rootNote, loop (hasLoop + start/end, always
// written), channelCount. // written), channelCount, displayName (length-prefixed; display-only).
putU32le(out, static_cast<std::uint32_t>(state.sampleRefs.size())); putU32le(out, static_cast<std::uint32_t>(state.sampleRefs.size()));
for (const SampleRefEntry& e : state.sampleRefs) { for (const SampleRefEntry& e : state.sampleRefs) {
putU32le(out, static_cast<std::uint32_t>(e.sampleId.size())); putU32le(out, static_cast<std::uint32_t>(e.sampleId.size()));
@@ -736,6 +756,8 @@ std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
putU64le(out, asU64(e.ref.loop.end)); putU64le(out, asU64(e.ref.loop.end));
putU32le(out, putU32le(out,
static_cast<std::uint32_t>(static_cast<std::int32_t>(e.ref.channelCount))); static_cast<std::uint32_t>(static_cast<std::int32_t>(e.ref.channelCount)));
putU32le(out, static_cast<std::uint32_t>(e.displayName.size()));
out.insert(out.end(), e.displayName.begin(), e.displayName.end());
} }
// Length-prefixed selection id (it precedes the zones payload, so it MUST be framed — // Length-prefixed selection id (it precedes the zones payload, so it MUST be framed —
// unlike the v1 selection blob where the id ran to end-of-stream). // unlike the v1 selection blob where the id ran to end-of-stream).
@@ -883,11 +905,20 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
e.sampleId = r.str(refIdLen); e.sampleId = r.str(refIdLen);
const std::uint32_t pathLen = r.u32(); const std::uint32_t pathLen = r.u32();
e.ref.relativePath = r.str(pathLen); e.ref.relativePath = r.str(pathLen);
e.ref.rootNote = r.i32(); // Range fallbacks (the refs table is the ONLY copy on the play path, so a
// corrupt field must degrade to the field's default, never poison playback —
// the previewVelocity/voiceCount posture): an out-of-MIDI-range root falls back
// to the middle-C default distill() uses; a negative channel count falls back
// to 0 = unknown (the GA auto-default then skips it).
const std::int32_t root = r.i32();
e.ref.rootNote = (root >= 0 && root <= 127) ? root : 60;
e.ref.loop.hasLoop = (r.u8() != 0); e.ref.loop.hasLoop = (r.u8() != 0);
e.ref.loop.start = r.i64(); e.ref.loop.start = r.i64();
e.ref.loop.end = r.i64(); e.ref.loop.end = r.i64();
e.ref.channelCount = r.i32(); const std::int32_t channels = r.i32();
e.ref.channelCount = channels >= 0 ? channels : 0;
const std::uint32_t nameLen = r.u32();
e.displayName = r.str(nameLen);
if (!r.ok) break; // truncated mid-entry -> keep what parsed, drop the rest if (!r.ok) break; // truncated mid-entry -> keep what parsed, drop the rest
out.sampleRefs.push_back(std::move(e)); out.sampleRefs.push_back(std::move(e));
} }
+31 -6
View File
@@ -90,6 +90,11 @@ struct PerformanceMap; // defined below (Tier 1); referencedSampleIds spans bot
struct SampleRefEntry { struct SampleRefEntry {
std::string sampleId; // the bank sample id this ref was copied from (the seam key) std::string sampleId; // the bank sample id this ref was copied from (the seam key)
SelectedSample ref; // path + intrinsics, sufficient to decode + play without a bank SelectedSample ref; // path + intrinsics, sufficient to decode + play without a bank
// The sample's bank display name at copy time — DISPLAY ONLY (the editor's label falls
// back to it when the bank snapshot is unavailable, mirroring the waveform/loop ref
// fallback); never consulted by resolution. Empty for a table written before the field
// existed in-session (it back-fills on the next bank refresh).
std::string displayName;
}; };
using SampleRefs = std::vector<SampleRefEntry>; using SampleRefs = std::vector<SampleRefEntry>;
@@ -102,11 +107,26 @@ std::vector<std::string> referencedSampleIds(const std::string& selectionId,
const PerformanceMap& map); const PerformanceMap& map);
// Upsert a ref for each id in `ids` that resolves in the live bank blob (the same // Upsert a ref for each id in `ids` that resolves in the live bank blob (the same
// distillation selectSample performs). A miss leaves any existing entry untouched — the // distillation selectSample performs), copying the bank display name alongside the decode
// instance owns its copy; a bank deletion never strips a ref. Empty/malformed blob -> no-op. // intrinsics. A miss leaves any existing entry untouched — the instance owns its copy; a
// bank deletion never strips a ref. Empty/malformed blob -> no-op.
void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson, void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson,
const std::vector<std::string>& ids); const std::vector<std::string>& ids);
// The pre-v10 LEGACY-LIFT terminating decision (pure, so the no-churn rule is provable
// without a host): can a refs lift MAKE PROGRESS against this bank blob for the ids the
// instance references?
// * Retry — the blob is absent/empty/unparseable: not readable YET, keep retrying (the
// project's ext-state may simply not have parsed).
// * Lift — the blob parses and at least one id resolves: a lift copies a ref in (the
// refs table then goes non-empty and the lift never re-fires).
// * Stale — the blob parses and NO id resolves (an empty `ids` included): the ids are
// PROVABLY stale — the bank is readable and does not know them — so there is nothing
// to lift, ever. The shell latches this and stops retrying (no per-tick churn).
enum class LegacyLiftDecision { Retry, Lift, Stale };
LegacyLiftDecision legacyLiftDecision(const std::optional<std::string>& banksJson,
const std::vector<std::string>& ids);
// Keep only the entries whose id is in `ids` (getState hygiene: the persisted table tracks // Keep only the entries whose id is in `ids` (getState hygiene: the persisted table tracks
// exactly what the instance currently plays, so it cannot grow with browsing history). // exactly what the instance currently plays, so it cannot grow with browsing history).
void retainRefs(SampleRefs& refs, const std::vector<std::string>& ids); void retainRefs(SampleRefs& refs, const std::vector<std::string>& ids);
@@ -341,8 +361,12 @@ struct ResolvedPerformance {
// bank_book parse, no host, no PCM. Each zone's sampleId is looked up across every bank // bank_book parse, no host, no PCM. Each zone's sampleId is looked up across every bank
// (pool + named); a hit yields a ResolvedZone with the effective root note (rootOverride, // (pool + named); a hit yields a ResolvedZone with the effective root note (rootOverride,
// else the sample's S2 rootNote, else 60) and the sample's loop intrinsic; a miss appends // else the sample's S2 rootNote, else 60) and the sample's loop intrinsic; a miss appends
// the id to droppedSampleIds. Empty/malformed blob or empty map -> empty result (the shell // the id to droppedSampleIds. Empty/malformed blob or empty map -> empty result.
// then falls back to Tier-0 — see reloadInstrument). //
// NOT the live load path since pS: reloadInstrument resolves via resolvePerformanceFromRefs
// (the instance-owned refs). This bank-side resolver is retained as the TESTED REFERENCE
// the refs path is verified against (testResolveFromRefsMatchesBankResolve) — both share
// foldZone, so the drift test is what keeps the shared fold honest.
ResolvedPerformance resolvePerformance(const std::string& banksJson, ResolvedPerformance resolvePerformance(const std::string& banksJson,
const PerformanceMap& map); const PerformanceMap& map);
@@ -515,7 +539,7 @@ PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
// double, bit-cast; 0.0 = -inf/silence, 1.0 = unity, cap ~15.849 = +24 dB), then the GA 1-byte // double, bit-cast; 0.0 = -inf/silence, 1.0 = unity, cap ~15.849 = +24 dB), then the GA 1-byte
// channel-mode-EXPLICIT flag (0 = implicit/auto-default, 1 = the user deliberately toggled the // channel-mode-EXPLICIT flag (0 = implicit/auto-default, 1 = the user deliberately toggled the
// mode — see ComponentState::channelModeExplicit), then the pS SAMPLE-REFS table (v10 — the // mode — see ComponentState::channelModeExplicit), then the pS SAMPLE-REFS table (v10 — the
// instance-owned path + intrinsics per referenced sample; wire shape at // instance-owned path + intrinsics + display name per referenced sample; wire shape at
// kSelectionZonesRefsV10Version below), then a 4-byte LE // kSelectionZonesRefsV10Version below), then a 4-byte LE
// selection-id length + id bytes, then the CURRENT zones payload (identical to // selection-id length + id bytes, then the CURRENT zones payload (identical to
// serializePerformance's body — its own self-describing version, see the ZONES-PAYLOAD block). // serializePerformance's body — its own self-describing version, see the ZONES-PAYLOAD block).
@@ -595,7 +619,8 @@ inline constexpr std::uint32_t kComponentStateVersion = 10;
// id): 4-byte LE entry count, then per entry: 4-byte LE id length + id bytes, 4-byte LE // id): 4-byte LE entry count, then per entry: 4-byte LE id length + id bytes, 4-byte LE
// path length + path bytes, 4-byte LE rootNote (two's-complement), 1 byte loop.hasLoop, // path length + path bytes, 4-byte LE rootNote (two's-complement), 1 byte loop.hasLoop,
// 8-byte LE loop.start + 8-byte LE loop.end (two's-complement int64, written regardless of // 8-byte LE loop.start + 8-byte LE loop.end (two's-complement int64, written regardless of
// hasLoop), 4-byte LE channelCount (two's-complement). // hasLoop), 4-byte LE channelCount (two's-complement), 4-byte LE displayName length +
// displayName bytes (display-only; the editor label's extension-absent fallback).
inline constexpr std::uint32_t kSelectionZonesRefsV10Version = 10; inline constexpr std::uint32_t kSelectionZonesRefsV10Version = 10;
// The pre-GA combined-state version (everything through the FB1 master gain, no channel-mode // The pre-GA combined-state version (everything through the FB1 master gain, no channel-mode
+58 -6
View File
@@ -2124,7 +2124,8 @@ static void testReconcileBrowseSequenceNoShadowing() {
static SampleRefEntry refEntry(const std::string& id, const std::string& rel, int root, static SampleRefEntry refEntry(const std::string& id, const std::string& rel, int root,
bool hasLoop = false, std::int64_t loopStart = 0, bool hasLoop = false, std::int64_t loopStart = 0,
std::int64_t loopEnd = 0, int channels = 0) { std::int64_t loopEnd = 0, int channels = 0,
const std::string& name = "") {
SampleRefEntry e; SampleRefEntry e;
e.sampleId = id; e.sampleId = id;
e.ref.relativePath = rel; e.ref.relativePath = rel;
@@ -2133,6 +2134,7 @@ static SampleRefEntry refEntry(const std::string& id, const std::string& rel, in
e.ref.loop.start = loopStart; e.ref.loop.start = loopStart;
e.ref.loop.end = loopEnd; e.ref.loop.end = loopEnd;
e.ref.channelCount = channels; e.ref.channelCount = channels;
e.displayName = name;
return e; return e;
} }
@@ -2145,7 +2147,8 @@ static void testSampleRefsRoundTrip() {
s.channelModeExplicit = true; s.channelModeExplicit = true;
s.masterGainLinear = 0.5; s.masterGainLinear = 0.5;
s.sampleRefs.push_back(refEntry("kick", "reasampler_bank/kick.wav", 36, s.sampleRefs.push_back(refEntry("kick", "reasampler_bank/kick.wav", 36,
/*hasLoop=*/true, 100, 500, /*channels=*/2)); /*hasLoop=*/true, 100, 500, /*channels=*/2,
/*name=*/"Kick Drum"));
s.sampleRefs.push_back(refEntry("pad", "reasampler_bank/pad.wav", 60, s.sampleRefs.push_back(refEntry("pad", "reasampler_bank/pad.wav", 60,
/*hasLoop=*/false, 0, 0, /*channels=*/1)); /*hasLoop=*/false, 0, 0, /*channels=*/1));
s.map.zones.push_back(zone("pad", 48, 72)); s.map.zones.push_back(zone("pad", 48, 72));
@@ -2158,9 +2161,11 @@ static void testSampleRefsRoundTrip() {
CHECK(back.sampleRefs.size() == 2 && back.sampleRefs[0].ref.loop.hasLoop && CHECK(back.sampleRefs.size() == 2 && back.sampleRefs[0].ref.loop.hasLoop &&
back.sampleRefs[0].ref.loop.start == 100 && back.sampleRefs[0].ref.loop.end == 500); back.sampleRefs[0].ref.loop.start == 100 && back.sampleRefs[0].ref.loop.end == 500);
CHECK(back.sampleRefs.size() == 2 && back.sampleRefs[0].ref.channelCount == 2); CHECK(back.sampleRefs.size() == 2 && back.sampleRefs[0].ref.channelCount == 2);
CHECK(back.sampleRefs.size() == 2 && back.sampleRefs[0].displayName == "Kick Drum");
CHECK(back.sampleRefs.size() == 2 && back.sampleRefs[1].sampleId == "pad"); CHECK(back.sampleRefs.size() == 2 && back.sampleRefs[1].sampleId == "pad");
CHECK(back.sampleRefs.size() == 2 && !back.sampleRefs[1].ref.loop.hasLoop); CHECK(back.sampleRefs.size() == 2 && !back.sampleRefs[1].ref.loop.hasLoop);
CHECK(back.sampleRefs.size() == 2 && back.sampleRefs[1].ref.channelCount == 1); CHECK(back.sampleRefs.size() == 2 && back.sampleRefs[1].ref.channelCount == 1);
CHECK(back.sampleRefs.size() == 2 && back.sampleRefs[1].displayName.empty());
// Envelope neighbours undisturbed (the refs read consumed exactly its own bytes). // Envelope neighbours undisturbed (the refs read consumed exactly its own bytes).
CHECK(back.selectionId == "kick"); CHECK(back.selectionId == "kick");
CHECK(back.map.zones.size() == 1); CHECK(back.map.zones.size() == 1);
@@ -2296,13 +2301,16 @@ static void testRefreshRefsFromBankUpsertAndOwnership() {
CHECK(refs.size() == 1 && refs[0].sampleId == "a" && refs[0].ref.rootNote == 36); CHECK(refs.size() == 1 && refs[0].sampleId == "a" && refs[0].ref.rootNote == 36);
CHECK(refs.size() == 1 && refs[0].ref.relativePath == "b/a.wav"); CHECK(refs.size() == 1 && refs[0].ref.relativePath == "b/a.wav");
CHECK(refs.size() == 1 && refs[0].ref.channelCount == 2); CHECK(refs.size() == 1 && refs[0].ref.channelCount == 2);
// Recapture-style bank edit: path + root changed -> the owned copy refreshes. CHECK(refs.size() == 1 && refs[0].displayName == "Kick"); // name copied with the ref
refreshRefsFromBank(refs, bookJson({makeSample("a", "Kick", "b/a2.wav", 40)}, {}), {"a"}); // Recapture-style bank edit: path + root + name changed -> the owned copy refreshes.
refreshRefsFromBank(refs, bookJson({makeSample("a", "Kick 2", "b/a2.wav", 40)}, {}), {"a"});
CHECK(refs.size() == 1 && refs[0].ref.rootNote == 40); CHECK(refs.size() == 1 && refs[0].ref.rootNote == 40);
CHECK(refs.size() == 1 && refs[0].ref.relativePath == "b/a2.wav"); CHECK(refs.size() == 1 && refs[0].ref.relativePath == "b/a2.wav");
CHECK(refs.size() == 1 && refs[0].displayName == "Kick 2"); // rename sync
// Bank deletion: the id no longer resolves -> the OWNED copy survives untouched. // Bank deletion: the id no longer resolves -> the OWNED copy survives untouched.
refreshRefsFromBank(refs, bookJson({makeSample("x", "Other", "b/x.wav", 60)}, {}), {"a"}); refreshRefsFromBank(refs, bookJson({makeSample("x", "Other", "b/x.wav", 60)}, {}), {"a"});
CHECK(refs.size() == 1 && refs[0].ref.rootNote == 40); CHECK(refs.size() == 1 && refs[0].ref.rootNote == 40);
CHECK(refs.size() == 1 && refs[0].displayName == "Kick 2");
// Malformed / empty blobs: no-op. // Malformed / empty blobs: no-op.
refreshRefsFromBank(refs, "{garbage", {"a"}); refreshRefsFromBank(refs, "{garbage", {"a"});
refreshRefsFromBank(refs, "", {"a"}); refreshRefsFromBank(refs, "", {"a"});
@@ -2333,8 +2341,9 @@ static void testSampleRefsTruncatedMidEntry() {
s.sampleRefs.push_back(refEntry("pad", "b/p.wav", 60)); s.sampleRefs.push_back(refEntry("pad", "b/p.wav", 60));
std::vector<std::uint8_t> bytes = serializeComponentState(s); std::vector<std::uint8_t> bytes = serializeComponentState(s);
// The tail after the refs table is idLen(4) + "kick"(4) + the empty-map zones payload // The tail after the refs table is idLen(4) + "kick"(4) + the empty-map zones payload
// (marker 4 + version 4 + count 4) = 20 bytes; entry 2 is 43 bytes (4+3 id, 4+7 path, // (marker 4 + version 4 + count 4) = 20 bytes; entry 2 is 47 bytes (4+3 id, 4+7 path,
// 4 root, 1+8+8 loop, 4 channels). Cutting 40 bytes lands 20 bytes into entry 2. // 4 root, 1+8+8 loop, 4 channels, 4+0 name). Cutting 40 bytes lands 27 bytes into
// entry 2 (inside loop.start).
CHECK(bytes.size() > 40); CHECK(bytes.size() > 40);
bytes.resize(bytes.size() - 40); bytes.resize(bytes.size() - 40);
const ComponentState back = deserializeComponentState(bytes, 44100.0); const ComponentState back = deserializeComponentState(bytes, 44100.0);
@@ -2344,6 +2353,47 @@ static void testSampleRefsTruncatedMidEntry() {
CHECK(back.map.zones.empty()); CHECK(back.map.zones.empty());
} }
static void testSampleRefsReaderRangeFallbacks() {
// Corrupt-blob posture for the refs intrinsics (the refs table is the ONLY copy on the
// play path, so a bad field must degrade to its default, never poison playback): an
// out-of-MIDI-range rootNote falls back to the middle-C default distill() uses; a
// negative channelCount falls back to 0 = unknown (the GA auto-default then skips it).
// The fallback is per-field — in-range neighbours pass through untouched.
ComponentState s;
s.sampleRefs.push_back(refEntry("hi", "b/h.wav", /*root=*/999, false, 0, 0,
/*channels=*/-3));
s.sampleRefs.push_back(refEntry("lo", "b/l.wav", /*root=*/-5, false, 0, 0,
/*channels=*/1));
s.sampleRefs.push_back(refEntry("ok", "b/o.wav", /*root=*/36, false, 0, 0,
/*channels=*/2));
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
CHECK(back.sampleRefs.size() == 3);
CHECK(back.sampleRefs.size() == 3 && back.sampleRefs[0].ref.rootNote == 60);
CHECK(back.sampleRefs.size() == 3 && back.sampleRefs[0].ref.channelCount == 0);
CHECK(back.sampleRefs.size() == 3 && back.sampleRefs[1].ref.rootNote == 60);
CHECK(back.sampleRefs.size() == 3 && back.sampleRefs[1].ref.channelCount == 1);
CHECK(back.sampleRefs.size() == 3 && back.sampleRefs[2].ref.rootNote == 36);
CHECK(back.sampleRefs.size() == 3 && back.sampleRefs[2].ref.channelCount == 2);
}
static void testLegacyLiftDecision() {
// The #A terminating guard, pure: Retry while the blob is not readable YET (absent,
// empty, malformed — the project's ext-state may simply not have parsed); Lift when a
// referenced id resolves (a lift attempt makes progress); Stale — the shell latches
// permanently — when the blob PARSES and knows none of the referenced ids (an empty
// id list included), so a stale-id pre-v10 lift STOPS instead of churning every tick.
const std::string json = bookJson({makeSample("a", "Kick", "b/a.wav", 36)}, {});
const std::vector<std::string> ids{"a"};
CHECK(legacyLiftDecision(std::nullopt, ids) == LegacyLiftDecision::Retry);
CHECK(legacyLiftDecision(std::string(), ids) == LegacyLiftDecision::Retry);
CHECK(legacyLiftDecision(std::string("{garbage"), ids) == LegacyLiftDecision::Retry);
CHECK(legacyLiftDecision(json, ids) == LegacyLiftDecision::Lift);
// One resolvable id among stale ones is still progress (the lift copies what it can).
CHECK(legacyLiftDecision(json, {"ghost", "a"}) == LegacyLiftDecision::Lift);
CHECK(legacyLiftDecision(json, {"ghost"}) == LegacyLiftDecision::Stale);
CHECK(legacyLiftDecision(json, {}) == LegacyLiftDecision::Stale);
}
int main() { int main() {
testSelectByIdHit(); testSelectByIdHit();
testSelectEmptyIdIsSilence(); testSelectEmptyIdIsSilence();
@@ -2478,6 +2528,8 @@ int main() {
testRefreshRefsFromBankUpsertAndOwnership(); testRefreshRefsFromBankUpsertAndOwnership();
testRetainRefsFiltersToPlayedSet(); testRetainRefsFiltersToPlayedSet();
testSampleRefsTruncatedMidEntry(); testSampleRefsTruncatedMidEntry();
testSampleRefsReaderRangeFallbacks();
testLegacyLiftDecision();
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;