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);
|
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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user