diff --git a/src/vst/reasampler_editor.cpp b/src/vst/reasampler_editor.cpp index 0a81bb1..a46c222 100644 --- a/src/vst/reasampler_editor.cpp +++ b/src/vst/reasampler_editor.cpp @@ -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& 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& 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(map_.zones.size())) { const PerformanceZone& z = map_.zones[static_cast(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); diff --git a/src/vst/reasampler_processor.cpp b/src/vst/reasampler_processor.cpp index 2b0bc69..0a846a8 100644 --- a/src/vst/reasampler_processor.cpp +++ b/src/vst/reasampler_processor.cpp @@ -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 lock(reloadMutex_); @@ -248,6 +258,9 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) { std::lock_guard 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 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) { diff --git a/src/vst/reasampler_processor.h b/src/vst/reasampler_processor.h index 9fe9b22..62b3cc4 100644 --- a/src/vst/reasampler_processor.h +++ b/src/vst/reasampler_processor.h @@ -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 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 diff --git a/src/vst/sample_map.cpp b/src/vst/sample_map.cpp index 43359bf..f498c04 100644 --- a/src/vst/sample_map.cpp +++ b/src/vst/sample_map.cpp @@ -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& banksJson, + const std::vector& ids) { + if (!banksJson || banksJson->empty()) return LegacyLiftDecision::Retry; + const std::optional 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& ids) { refs.erase(std::remove_if(refs.begin(), refs.end(), [&ids](const SampleRefEntry& e) { @@ -723,7 +743,7 @@ std::vector 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(state.sampleRefs.size())); for (const SampleRefEntry& e : state.sampleRefs) { putU32le(out, static_cast(e.sampleId.size())); @@ -736,6 +756,8 @@ std::vector serializeComponentState(const ComponentState& state) { putU64le(out, asU64(e.ref.loop.end)); putU32le(out, static_cast(static_cast(e.ref.channelCount))); + putU32le(out, static_cast(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& 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)); } diff --git a/src/vst/sample_map.h b/src/vst/sample_map.h index 4f38901..27344b6 100644 --- a/src/vst/sample_map.h +++ b/src/vst/sample_map.h @@ -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; @@ -102,11 +107,26 @@ std::vector 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& 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& banksJson, + const std::vector& 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& 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& 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 diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp index 1912660..afba62c 100644 --- a/tests/test_sample_map.cpp +++ b/tests/test_sample_map.cpp @@ -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 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 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;