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:
@@ -117,12 +117,19 @@ void drawEnvelope(LICE_IBitmap* bmp, const Rect& r, const Envelope& env) {
|
||||
drawWaveform(bmp, toKitBox(r), env);
|
||||
}
|
||||
|
||||
// A display name for a bank sample id from the snapshotted list ("?" if the id no longer
|
||||
// resolves — e.g. a zone naming a deleted sample).
|
||||
std::string sampleLabel(const std::vector<SampleChoice>& samples, const std::string& id) {
|
||||
// A display name for a bank sample id: the snapshotted bank list first, then the
|
||||
// instance-OWNED ref's displayName (pS — the label survives with the extension absent /
|
||||
// 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) {
|
||||
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 "?";
|
||||
}
|
||||
|
||||
@@ -1220,10 +1227,14 @@ void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) {
|
||||
// signal.
|
||||
std::string title = reasampler::vstPluginName(); // channel-derived (S18)
|
||||
if (processor_ && processor_->bridge().isConnected()) {
|
||||
if (samples_.empty()) title += " [bank empty]";
|
||||
else if (selectedId_.empty() && map_.zones.empty()) title += " [pick a capture]";
|
||||
else if (!map_.zones.empty()) title += " [" + std::to_string(map_.zones.size()) + " zone(s)]";
|
||||
else title += " [" + sampleLabel(samples_, selectedId_) + "]";
|
||||
// The instance's OWN loaded state outranks bank availability (pS: the bank is a
|
||||
// browser source, not the instrument's identity) — a self-contained instance names
|
||||
// its sound (refs displayName fallback) even when the bank snapshot is empty.
|
||||
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 {
|
||||
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())) {
|
||||
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
|
||||
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
|
||||
// the Focus state (accent nudge + ring); values in tabular mono so digits don't jitter.
|
||||
const Rect fields = noteEntryFieldsArea(content);
|
||||
|
||||
@@ -155,6 +155,16 @@ tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool state) {
|
||||
// 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
|
||||
// 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();
|
||||
} else {
|
||||
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_);
|
||||
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).
|
||||
reloadInstrument();
|
||||
return kResultOk;
|
||||
@@ -481,9 +494,11 @@ std::string ReaSamplerProcessor::reloadInstrument() {
|
||||
bridge_.readReasamplerExtState(kProjExtBanksKey);
|
||||
std::lock_guard<std::mutex> rl(refsMutex_);
|
||||
if (banksJson) refreshRefsFromBank(sampleRefs_, *banksJson, ids);
|
||||
// Hygiene: the owned table tracks exactly what the instance currently plays, so a
|
||||
// de-referenced sample's entry drops here (never grows with browsing history).
|
||||
retainRefs(sampleRefs_, ids);
|
||||
// The LOAD path never prunes the owned table: dropping entries here on a transient
|
||||
// bank miss could destroy the owned intrinsics of the previous selection — the ONE
|
||||
// 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)
|
||||
}
|
||||
const std::string projectDir = bridge_.activeProjectDir();
|
||||
@@ -672,6 +687,25 @@ void ReaSamplerProcessor::retireIdleDrain() {
|
||||
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::pollBankSync(bool isFocusedTarget) {
|
||||
// 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
|
||||
// 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
|
||||
// never churns; a lift whose bank stays unreadable (or whose id went stale) retries a
|
||||
// cheap null publish on the editor cadence only. 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).
|
||||
// never churns; a bank that is not readable YET retries a cheap null publish on the
|
||||
// editor cadence only. TERMINATING GUARD (#A, legacyLiftShouldRun): once the bank blob
|
||||
// PARSES and no referenced id resolves in it, the ids are provably stale — there is
|
||||
// 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;
|
||||
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) {
|
||||
|
||||
@@ -168,6 +168,9 @@ public:
|
||||
// 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)
|
||||
// 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
|
||||
// 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).
|
||||
@@ -286,6 +289,12 @@ private:
|
||||
// the new params bake into the next real reload. Off the audio thread only.
|
||||
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
|
||||
// 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
|
||||
@@ -383,11 +392,20 @@ private:
|
||||
// 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
|
||||
// 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
|
||||
// poll-driven reload besides a generation change is the pre-v10 LEGACY LIFT (see
|
||||
// pollBankSync). NOT read on the audio thread.
|
||||
// depends on this poll — a v10 blob plays from its own refs at setState time. Besides a
|
||||
// generation change, pollBankSync reloads only for an APPLIED S8 assignment and for the
|
||||
// pre-v10 LEGACY LIFT. NOT read on the audio thread.
|
||||
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
|
||||
// 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
|
||||
|
||||
+36
-5
@@ -134,12 +134,32 @@ void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson,
|
||||
const SelectedSample distilled = distill(*found);
|
||||
bool updated = false;
|
||||
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) {
|
||||
refs.erase(std::remove_if(refs.begin(), refs.end(),
|
||||
[&ids](const SampleRefEntry& e) {
|
||||
@@ -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
|
||||
// the v9 lift). Wire shape per kSelectionZonesRefsV10Version: entry count, then per
|
||||
// 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()));
|
||||
for (const SampleRefEntry& e : state.sampleRefs) {
|
||||
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));
|
||||
putU32le(out,
|
||||
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 —
|
||||
// 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);
|
||||
const std::uint32_t pathLen = r.u32();
|
||||
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.start = 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
|
||||
out.sampleRefs.push_back(std::move(e));
|
||||
}
|
||||
|
||||
+31
-6
@@ -90,6 +90,11 @@ struct PerformanceMap; // defined below (Tier 1); referencedSampleIds spans bot
|
||||
struct SampleRefEntry {
|
||||
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
|
||||
// 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>;
|
||||
|
||||
@@ -102,11 +107,26 @@ std::vector<std::string> referencedSampleIds(const std::string& selectionId,
|
||||
const PerformanceMap& map);
|
||||
|
||||
// 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
|
||||
// instance owns its copy; a bank deletion never strips a ref. Empty/malformed blob -> no-op.
|
||||
// distillation selectSample performs), copying the bank display name alongside the decode
|
||||
// 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,
|
||||
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
|
||||
// exactly what the instance currently plays, so it cannot grow with browsing history).
|
||||
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
|
||||
// (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
|
||||
// the id to droppedSampleIds. Empty/malformed blob or empty map -> empty result (the shell
|
||||
// then falls back to Tier-0 — see reloadInstrument).
|
||||
// the id to droppedSampleIds. Empty/malformed blob or empty map -> empty result.
|
||||
//
|
||||
// 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,
|
||||
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
|
||||
// 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
|
||||
// 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
|
||||
// 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).
|
||||
@@ -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
|
||||
// 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
|
||||
// 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;
|
||||
|
||||
// The pre-GA combined-state version (everything through the FB1 master gain, no channel-mode
|
||||
|
||||
@@ -2124,7 +2124,8 @@ static void testReconcileBrowseSequenceNoShadowing() {
|
||||
|
||||
static SampleRefEntry refEntry(const std::string& id, const std::string& rel, int root,
|
||||
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;
|
||||
e.sampleId = id;
|
||||
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.end = loopEnd;
|
||||
e.ref.channelCount = channels;
|
||||
e.displayName = name;
|
||||
return e;
|
||||
}
|
||||
|
||||
@@ -2145,7 +2147,8 @@ static void testSampleRefsRoundTrip() {
|
||||
s.channelModeExplicit = true;
|
||||
s.masterGainLinear = 0.5;
|
||||
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,
|
||||
/*hasLoop=*/false, 0, 0, /*channels=*/1));
|
||||
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 &&
|
||||
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].displayName == "Kick Drum");
|
||||
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.channelCount == 1);
|
||||
CHECK(back.sampleRefs.size() == 2 && back.sampleRefs[1].displayName.empty());
|
||||
// Envelope neighbours undisturbed (the refs read consumed exactly its own bytes).
|
||||
CHECK(back.selectionId == "kick");
|
||||
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].ref.relativePath == "b/a.wav");
|
||||
CHECK(refs.size() == 1 && refs[0].ref.channelCount == 2);
|
||||
// Recapture-style bank edit: path + root changed -> the owned copy refreshes.
|
||||
refreshRefsFromBank(refs, bookJson({makeSample("a", "Kick", "b/a2.wav", 40)}, {}), {"a"});
|
||||
CHECK(refs.size() == 1 && refs[0].displayName == "Kick"); // name copied with the ref
|
||||
// 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.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.
|
||||
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].displayName == "Kick 2");
|
||||
// Malformed / empty blobs: no-op.
|
||||
refreshRefsFromBank(refs, "{garbage", {"a"});
|
||||
refreshRefsFromBank(refs, "", {"a"});
|
||||
@@ -2333,8 +2341,9 @@ static void testSampleRefsTruncatedMidEntry() {
|
||||
s.sampleRefs.push_back(refEntry("pad", "b/p.wav", 60));
|
||||
std::vector<std::uint8_t> bytes = serializeComponentState(s);
|
||||
// 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,
|
||||
// 4 root, 1+8+8 loop, 4 channels). Cutting 40 bytes lands 20 bytes into entry 2.
|
||||
// (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, 4+0 name). Cutting 40 bytes lands 27 bytes into
|
||||
// entry 2 (inside loop.start).
|
||||
CHECK(bytes.size() > 40);
|
||||
bytes.resize(bytes.size() - 40);
|
||||
const ComponentState back = deserializeComponentState(bytes, 44100.0);
|
||||
@@ -2344,6 +2353,47 @@ static void testSampleRefsTruncatedMidEntry() {
|
||||
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() {
|
||||
testSelectByIdHit();
|
||||
testSelectEmptyIdIsSilence();
|
||||
@@ -2478,6 +2528,8 @@ int main() {
|
||||
testRefreshRefsFromBankUpsertAndOwnership();
|
||||
testRetainRefsFiltersToPlayedSet();
|
||||
testSampleRefsTruncatedMidEntry();
|
||||
testSampleRefsReaderRangeFallbacks();
|
||||
testLegacyLiftDecision();
|
||||
|
||||
if (g_fail == 0) std::printf("sample_map: all tests passed\n");
|
||||
return g_fail != 0;
|
||||
|
||||
Reference in New Issue
Block a user