Restore the bank fold and usage publish to the resume path, guard setActive against repeats, and make the meter fold's bound literal

The resume also hands back to a full reload when the fold moves the loaded capture's decode source, so the refs table and the audio cannot skew.
This commit is contained in:
2026-08-02 13:15:47 -04:00
parent 5c6525fb91
commit da14509ab5
15 changed files with 218 additions and 76 deletions
+24 -6
View File
@@ -17,12 +17,30 @@ separate: `setActive(false)` parks the decoded sample and destroys the voice sta
decode; sounding voices are still destroyed across it (a surviving `live_` would be displaced decode; sounding voices are still destroyed across it (a surviving `live_` would be displaced
into the drain slot and resurrect stale sustained voices as ghosts); an instance with nothing into the drain slot and resurrect stale sustained voices as ghosts); an instance with nothing
decoded still routes through the full reload, which is where the pre-v10 legacy lift lives; and decoded still routes through the full reload, which is where the pre-v10 legacy lift lives; and
`getLatencySamples()` still answers from the persisted enable, untouched by the cycle. The `getLatencySamples()` still answers from the persisted enable, untouched by the cycle. The build
build shared by the reload, the voice-param rebuild and the reactivation was factored to one shared by the reload, the voice-param rebuild and the reactivation was factored to one site so the three cannot drift
site so the three cannot drift on the generation stamp or the ring size. Daniel reversed the on the generation stamp or the ring size. Daniel reversed the deferral (*"I thought we agreed
deferral (*"I thought we agreed to decouple the unnecessary functions from the reactivation to decouple the unnecessary functions from the reactivation path"*); Γ-F2 and Γ-F6 are untouched
path"*); Γ-F2 and Γ-F6 are untouched — dynamic latency ships, the deactivate/reactivate is — dynamic latency ships, the deactivate/reactivate is still the accepted cost of the toggle,
still the accepted cost of the toggle, just a much cheaper one. just a much cheaper one.
**`reloadInstrument` did three things, not one, and the resume path had to keep all three.** The
first review pass discussed only the pre-v10 legacy lift; the other two were dropped silently and
restored in the follow-up. `refreshRefsFromBank` — the recapture sync — and `publishUsage` — the
`rsusage_` prune-protection write — are each a `GetProjExtState` plus a parse, neither disk nor
decode, so both run on the resume path and the spec's "no disk I/O and no WAV decode" still holds
exactly. This matters only with **no editor open**: `pollBankSync`, the only other route to
either, has exactly one caller and it is the editor's sync tick. Restoring the refresh alone
would have been worse than dropping it — the refs would name a recapture's new file while the
parked PCM still played the old one — so the resume compares the selected ref across the fold
(`sameDecodeSource`, `core/instrument/map/sample_map`) and hands back to the full reload when it
moved. The decode is eliminated in the case that matters and re-run in the case that needs it.
Three smaller consequences fell out of the same pass: `setActive` now treats a repeat of the
state it already holds as a no-op (the base is an empty stub, so a repeated deactivate would have
parked an empty optional over a still-valid sample); the park is disengaged before the build
rather than left moved-from, so a throwing build cannot publish permanent silence; and
`flushLatencyRestart` checks `restartComponent`'s `tresult` and rolls its announcement back on a
refusal, since a latch on a value the host never took would strand its delay compensation.
### Comment-reduction pass (tree-wide, twelve parallel tracks) ### Comment-reduction pass (tree-wide, twelve parallel tracks)
+13 -14
View File
@@ -63,9 +63,8 @@ change, then advancing at 1/newDuration). **Phase Γ opened six [Daniel]-class f
tracks below and indexed in `docs/product/instrument-control-surface.md` §8. **Γ-F6 closed tracks below and indexed in `docs/product/instrument-control-surface.md` §8. **Γ-F6 closed
with a correction to the analysis, not merely a ruling**: dynamic reported latency is routine with a correction to the analysis, not merely a ruling**: dynamic reported latency is routine
for VST3 instruments and REAPER handles it as a matter of course; what makes the mandated for VST3 instruments and REAPER handles it as a matter of course; what makes the mandated
restart expensive *here* is self-inflicted (`setActive(true)` calls `reloadInstrument`), so restart expensive *here* was self-inflicted (`setActive(true)` called `reloadInstrument`), so
the cost is ours to reduce and the reduction is filed in `docs/TODO.md` rather than designed the cost was ours to reduce. **That reduction landed in Γ-W3** — see `docs/COMPLETED.md`.
around.
**Γ-F3 was subsequently REVERSED and a seventh fork opened AND CLOSED, all by Daniel's later **Γ-F3 was subsequently REVERSED and a seventh fork opened AND CLOSED, all by Daniel's later
rulings of 2026-08-01.** Γ-F3 (*"the stage-time ceiling stays 2.0 s"*) is replaced by *"extend rulings of 2026-08-01.** Γ-F3 (*"the stage-time ceiling stays 2.0 s"*) is replaced by *"extend
@@ -651,14 +650,15 @@ every parameter's **plain unit, range and display precision** (§6.7). Today the
**What the Γ-F6 ruling changed in the analysis, not just in the plan.** Dynamic latency **What the Γ-F6 ruling changed in the analysis, not just in the plan.** Dynamic latency
reporting is **routine** for VST3 instruments and REAPER handles it as a matter of course; reporting is **routine** for VST3 instruments and REAPER handles it as a matter of course;
the SDK's deactivate/reactivate requirement (`pluginterfaces/vst/ivsteditcontroller.h:105-108`) the SDK's deactivate/reactivate requirement (`pluginterfaces/vst/ivsteditcontroller.h:105-108`)
is the normal contract, not an exotic one. What makes the cycle expensive **here** is entirely is the normal contract, not an exotic one. What made the cycle expensive **here** was entirely
our own doing: `ReaSamplerProcessor::setActive(true)` calls `reloadInstrument()` — a bridge our own doing: `ReaSamplerProcessor::setActive(true)` called `reloadInstrument()` — a bridge
read plus a full WAV re-decode (`reasampler_processor.cpp:89-97`) — where a typical plugin's read plus a full WAV re-decode — where a typical plugin's `setActive` only allocates and frees
`setActive` only allocates and frees buffers, and the deactivate side's freeing of buffers. **The cost was therefore ours to reduce, and the reduction was decoupling reload from
`live_`/`draining_`/graveyard (`:98-107`) is likewise our own design. **The cost is therefore activation — not abandoning dynamic latency.** **Landed in Γ-W3**: the activate branch
ours to reduce if it ever matters, and the reduction is decoupling reload from activation — (`reasampler_processor.cpp:86-132`) now resumes the voice state around a parked `SampleData`
not abandoning dynamic latency.** That improvement is filed as a `docs/TODO.md` entry with its and reloads only when there is nothing to resume from or a bank refresh moved what the park was
trigger condition; it is not scheduled in this phase. decoded from; the deactivate branch parks the PCM and frees everything else. Narrative and
consequences are in `docs/COMPLETED.md`.
**Sequencing against Phase Ξ — the ordering claim is RETIRED and replaced by an owned **Sequencing against Phase Ξ — the ordering claim is RETIRED and replaced by an owned
correction.** This plan previously asserted that Γ must run before Ξ-W2 and called it *"a correction.** This plan previously asserted that Γ must run before Ξ-W2 and called it *"a
@@ -1421,9 +1421,8 @@ value semantics, any deck geometry, or the bake's reset *membership* (W3-T2's).
nothing new is persisted and therefore it should not; if the `setState` verification says nothing new is persisted and therefore it should not; if the `setState` verification says
otherwise, it takes the reserved rung and says so. otherwise, it takes the reserved rung and says so.
- **Closed, do not reopen:** Rate lifted from latched to live (§3.5 records the cost); the - **Closed, do not reopen:** Rate lifted from latched to live (§3.5 records the cost); the
limiter enable made automatable (§3.8 — its one reopening condition is the `docs/TODO.md` limiter enable made automatable (§3.8 — its one reopening condition was the reload/activation
reload/activation decoupling, and the answer is to do that first, not to re-litigate the decoupling, which landed in Γ-W3, so the condition is discharged rather than pending).
classification).
--- ---
+9 -7
View File
@@ -242,17 +242,19 @@ alpha and this entry is re-filed against the new value.
## The editor's drag state machine has no seam, and `reasampler_editor.h` is near the ceiling ## The editor's drag state machine has no seam, and `reasampler_editor.h` is near the ceiling
**Context (Γ-W3, meter re-review).** `reasampler_editor.h` stands at **563 lines** against the **Context (Γ-W3, meter re-review).** `reasampler_editor.h` stands at **564 lines** against the
~600-line ceiling — 37 lines of margin — and it keeps growing because every new surface on the ~600-line ceiling — 36 lines of margin — and it keeps growing because every new surface on the
Sample face adds its transient state there. The obvious seam is the drag state machine: `drag_` Sample face adds its transient state there. The obvious seam is the drag state machine: `drag_`
plus the per-gesture anchors it is read against. plus the per-gesture anchors it is read against.
**Why it was declined rather than taken.** `drag_` has **42 references across 13 shell TUs** **Why it was declined rather than taken.** `drag_` has **42 references across 13 shell TUs**
(measured over `src/shell/instrument/*.cpp`; the declaration in the header is additional), and (measured over `src/shell/instrument/*.cpp`; the declaration in the header is additional). Of
every input TU both writes it and branches on it. Extracting it is a real refactor of the the six input TUs, three write it and branch on it (`editor_input`, `_waveform`, `_curve`) and
editor's input half, not a header move — and doing it inside a wave whose subject is the MASTER three only write it (`_chrome`, `_browse`, `_deck`) — which is what makes the anchor invariant
deck would have put an unrelated high-blast-radius change in the same diff. Declining was right; observed rather than enforced. Extracting it is a real refactor of the editor's input half, not
leaving it unrecorded was not. a header move — and doing it inside a wave whose subject is the MASTER deck would have put an
unrelated high-blast-radius change in the same diff. Declining was right; leaving it unrecorded
was not.
**The shape a fix would take.** A `DragState` type owning the kind plus its anchor payload, **The shape a fix would take.** A `DragState` type owning the kind plus its anchor payload,
with the input TUs mutating it through named transitions rather than assigning `drag_` and its with the input TUs mutating it through named transitions rather than assigning `drag_` and its
+10 -1
View File
@@ -1409,6 +1409,15 @@ back through the full reload, which is what keeps the pre-v10 legacy lift reacha
untouched: dynamic latency ships and the deactivate/reactivate is still the accepted cost — untouched: dynamic latency ships and the deactivate/reactivate is still the accepted cost —
it is simply a much cheaper one. it is simply a much cheaper one.
**What "cheaper" does NOT mean: skipping the bank fold.** `reloadInstrument` also runs the
recapture sync and the `rsusage_` prune-protection publish, and with no editor open the
activation is the only place either happens (`pollBankSync` runs off the editor's sync tick and
nothing else). Both are a `GetProjExtState` plus a parse — neither disk nor decode — so both run
on the resume path too, and a fold that moves the loaded capture's decode source hands back to
the full reload rather than resuming PCM the bank has superseded. A resume that refreshed the
refs without re-decoding would be the worst of the three: the table would name a recapture's new
file while the voices played the old one.
--- ---
## 8. Forks — SEVEN ruled (one later reversed), NONE open ## 8. Forks — SEVEN ruled (one later reversed), NONE open
@@ -1484,7 +1493,7 @@ than just counting:
4. **The verification requirements survive unchanged**, because they were always about the 4. **The verification requirements survive unchanged**, because they were always about the
`kIoChanged` scar (a dual-mono capture panned hard right by a prior mid-session `kIoChanged` scar (a dual-mono capture panned hard right by a prior mid-session
`restartComponent`), not about this flag. `restartComponent`), not about this flag.
5. **The reduction is filed**, with a trigger condition, in `docs/TODO.md`. 5. **The reduction was filed with a trigger condition and has since LANDED** (Γ-W3 — §7.11).
### 8.3 Γ-F7 — RULED: signal flow. The parameter order ### 8.3 Γ-F7 — RULED: signal flow. The parameter order
+18 -15
View File
@@ -1,8 +1,8 @@
// meter_accumulate.h — the master meter's ACCUMULATE half: the audio thread's block-rate fold // meter_accumulate.h — the master meter's ACCUMULATE half: the audio thread's block-rate fold
// into the two windows the UI drains, and the drain that starts the next window. The ballistics // into the two windows the UI drains, and the drain that starts the next window. The ballistics
// that run on what comes out are meter_ballistics'. Header-only — the folds sit on the audio // that run on what comes out are meter_ballistics'. Header-only — the folds sit on the audio
// thread's per-block path. Templated on the accumulator ONLY so the drain-inside-the-fold // thread's per-block path. The folds are templated on the accumulator ONLY so the
// interleave below can be pinned deterministically instead of raced for. // drain-inside-the-fold interleave below can be pinned deterministically instead of raced for.
#pragma once #pragma once
@@ -30,36 +30,39 @@ inline constexpr float kMeterGainIdentity = 1.f;
// would then drop the block outright: it decided against storing by comparing with a window the // would then drop the block outright: it decided against storing by comparing with a window the
// UI has since taken, so that block's reading enters neither the old window nor the new one. // UI has since taken, so that block's reading enters neither the old window nor the new one.
// The CAS retries against whatever the consume left, which makes `acc >= blockPeak` hold on // The CAS retries against whatever the consume left, which makes `acc >= blockPeak` hold on
// exit however the two interleave. Bounded the audio thread is this accumulator's only other // exit however the two interleave. STRONG, so the loop is bounded by the interference it is
// writer, so one interfering consume costs one retry. Relaxed throughout: the accumulators are // written against: the audio thread is the only writer besides the UI's single consume, and
// advisory and order no other state. Block rate, never per frame. // weak's permitted spurious failure would make an unbounded retry count reachable with no
// interference at all. Three calls per block, so the strong form costs nothing measurable.
// Relaxed throughout: the accumulators are advisory and order no other state. `Accumulator` is
// templated only so a test can pin the interleave; it must behave as std::atomic<float>.
template <class Accumulator> template <class Accumulator>
inline void foldPeak(Accumulator& acc, float blockPeak) { inline void foldPeak(Accumulator& acc, float blockPeak) {
float seen = acc.load(std::memory_order_relaxed); float seen = acc.load(std::memory_order_relaxed);
while (!acc.compare_exchange_weak(seen, seen > blockPeak ? seen : blockPeak, while (!acc.compare_exchange_strong(seen, seen > blockPeak ? seen : blockPeak,
std::memory_order_relaxed, std::memory_order_relaxed,
std::memory_order_relaxed)) { std::memory_order_relaxed)) {
} }
} }
template <class Accumulator> template <class Accumulator>
inline void foldMinGain(Accumulator& acc, float blockMinGain) { inline void foldMinGain(Accumulator& acc, float blockMinGain) {
float seen = acc.load(std::memory_order_relaxed); float seen = acc.load(std::memory_order_relaxed);
while (!acc.compare_exchange_weak(seen, seen < blockMinGain ? seen : blockMinGain, while (!acc.compare_exchange_strong(seen, seen < blockMinGain ? seen : blockMinGain,
std::memory_order_relaxed, std::memory_order_relaxed,
std::memory_order_relaxed)) { std::memory_order_relaxed)) {
} }
} }
// Takes what the window accumulated and reinstalls the identity element, which IS what starts // Takes what the window accumulated and reinstalls the identity element, which IS what starts
// the next window — so exactly one reader may consume (the shell's MasterBusMeter states who). // the next window — so exactly one reader may consume (the shell's MasterBusMeter states who).
template <class Accumulator> // Concrete: only the folds have the interleave a test seam buys, and a template over one
inline float consumePeak(Accumulator& acc) { // instantiation models nothing.
inline float consumePeak(std::atomic<float>& acc) {
return acc.exchange(kMeterPeakIdentity, std::memory_order_relaxed); return acc.exchange(kMeterPeakIdentity, std::memory_order_relaxed);
} }
template <class Accumulator> inline float consumeMinGain(std::atomic<float>& acc) {
inline float consumeMinGain(Accumulator& acc) {
return acc.exchange(kMeterGainIdentity, std::memory_order_relaxed); return acc.exchange(kMeterGainIdentity, std::memory_order_relaxed);
} }
+6
View File
@@ -104,6 +104,12 @@ void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson,
} }
} }
bool sameDecodeSource(const SelectedSample& a, const SelectedSample& b) {
return a.relativePath == b.relativePath && a.rootNote == b.rootNote &&
a.channelCount == b.channelCount && a.loop.hasLoop == b.loop.hasLoop &&
a.loop.start == b.loop.start && a.loop.end == b.loop.end;
}
LegacyLiftDecision legacyLiftDecision(const std::optional<std::string>& banksJson, LegacyLiftDecision legacyLiftDecision(const std::optional<std::string>& banksJson,
const std::vector<std::string>& ids) { const std::vector<std::string>& ids) {
if (!banksJson || banksJson->empty()) return LegacyLiftDecision::Retry; if (!banksJson || banksJson->empty()) return LegacyLiftDecision::Retry;
+7
View File
@@ -85,6 +85,13 @@ std::vector<std::string> referencedSampleIds(const std::string& selectionId);
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);
// True when two refs would build the same SampleData: path plus every intrinsic
// resolveCapture folds. displayName is excluded on purpose — it is a label, never a decode
// input. Exists so a caller holding an ALREADY-DECODED sample can ask whether a refresh moved
// what that sample was decoded from; comparing the fields at the call site instead would go
// stale the first time this struct gains one.
bool sameDecodeSource(const SelectedSample& a, const SelectedSample& b);
// Legacy-lift terminating decision: can a refs lift make progress against this bank blob // Legacy-lift terminating decision: can a refs lift make progress against this bank blob
// for the ids the instance references? // for the ids the instance references?
// * Retry — blob absent/empty/unparseable: not readable yet, keep retrying. // * Retry — blob absent/empty/unparseable: not readable yet, keep retrying.
+4 -2
View File
@@ -91,8 +91,10 @@ bool meterDrawEqual(const MasterMeterUi& a, const MasterMeterUi& b);
// The lamp reports "the limiter is working", not "a sample grazed the threshold", so it needs // The lamp reports "the limiter is working", not "a sample grazed the threshold", so it needs
// a floor rather than a bare non-zero test. 0.5 dB is a CHOSEN floor, not a measurement — the // a floor rather than a bare non-zero test. 0.5 dB is a CHOSEN floor, not a measurement — the
// spec asks only for "a small floor". Raising it hides genuine catches, since the limiter's // spec asks only for "a small floor". It is bounded on BOTH sides: raising it hides genuine
// ceiling is only 0.3 dBTP. // catches, since the limiter's ceiling is only 0.3 dBTP; lowering it turns the lamp into a
// "some sample crossed the ceiling" light, because the gain law is ceiling/peak and so reports
// an arbitrarily small reduction for a peak arbitrarily close to the ceiling.
inline constexpr double kGrLampFloorDb = 0.5; inline constexpr double kGrLampFloorDb = 0.5;
bool grLampLit(const MasterMeterUi& m); bool grLampLit(const MasterMeterUi& m);
+1 -1
View File
@@ -107,7 +107,7 @@ declared ahead of the instrument slots at that member in `reasampler_processor.h
## Modules ## Modules
- `reaper_bridge` — READ-ONLY bank consumer: receives bank snapshots from the extension and exposes them as a read-only view. **Never writes to the extension's bank** — this is a load-bearing invariant; no mutation path exists in this module. It owns TWO prefix-guarded ext-state write entry points, `writeUsageExtState` (`rsusage_`) and `writeBakeExtState` (`rsbake_`), each refusing every other key; neither weakens the read-only-*bank* invariant, because neither payload is bank state and `banks`/`view`/`tail`/`assign` stay structurally unwritable. Both PROVE the write by reading the key back (`wire::extStateWriteLanded`) — `SetProjExtState`'s own return cannot speak for one key, so testing it was a guard that could never fire, and the bake's "could not publish" refusal was consequently unreachable. It also owns the bake crossing — `extensionActionAvailable` / `invokeExtensionAction` (`NamedCommandLookup` + `Main_OnCommandEx` with `getReaperParent(3)`, the instance's OWN project tab, as `proj` — a request, not a DAW-verified guarantee; see the header) and `projectTempoBpm`. - `reaper_bridge` — READ-ONLY bank consumer: receives bank snapshots from the extension and exposes them as a read-only view. **Never writes to the extension's bank** — this is a load-bearing invariant; no mutation path exists in this module. It owns TWO prefix-guarded ext-state write entry points, `writeUsageExtState` (`rsusage_`) and `writeBakeExtState` (`rsbake_`), each refusing every other key; neither weakens the read-only-*bank* invariant, because neither payload is bank state and `banks`/`view`/`tail`/`assign` stay structurally unwritable. Both PROVE the write by reading the key back (`wire::extStateWriteLanded`) — `SetProjExtState`'s own return cannot speak for one key, so testing it was a guard that could never fire, and the bake's "could not publish" refusal was consequently unreachable. It also owns the bake crossing — `extensionActionAvailable` / `invokeExtensionAction` (`NamedCommandLookup` + `Main_OnCommandEx` with `getReaperParent(3)`, the instance's OWN project tab, as `proj` — a request, not a DAW-verified guarantee; see the header) and `projectTempoBpm`.
- `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no cut to ringing tails. **Activation and decoding are separate lifetimes:** `setActive(false)` parks the decoded `SampleData` and destroys the voice state (a surviving `live_` would be displaced into the drain slot and resurrect stale sustained voices), and `setActive(true)` rebuilds the voices around the parked sample through that same swap — so a host-driven cycle costs no disk read and no decode. Nothing parked means nothing was decoded, which routes the activation back through the full reload; that is also where the pre-v10 legacy lift lives. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_<instanceGuid>` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish. **The master bus:** the summed output runs `voice mixer → master gain → limiter (core/instrument/engine/limiter) → output bus`, with the meter tapped at the bus output POST-limiter and published as relaxed atomics — per-channel peak and smallest limiter gain ACCUMULATED across every block since the UI last read, plus the latched clip (the meter Gotcha below owns why). The limiter's enable is persisted in the parameter set (params payload v15) and mirrored onto the audio thread by `setInstrumentParams`, the single funnel every writer already goes through. That mirror is also what `getLatencySamples()` answers from — the plugin's FIRST latency reporting: 0 bypassed, the lookahead engaged. The host's `restartComponent(kLatencyChanged)` is issued by `flushLatencyRestart` alone, UI thread only and never from `process()`; it is a LATENCY restart with the bus untouched, NOT the retired per-mode `kIoChanged` bus renegotiation the invariant above forbids. Why it is split from the commit is the Gotcha below. - `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no cut to ringing tails. **Activation and decoding are separate lifetimes:** `setActive(false)` parks the decoded `SampleData` and destroys the voice state (a surviving `live_` would be displaced into the drain slot and resurrect stale sustained voices), and `setActive(true)` rebuilds the voices around the parked sample through that same swap — so a host-driven cycle costs no disk read and no decode. The resume still folds the live bank blob into the refs and republishes usage (a `GetProjExtState` plus a parse each, and with no editor open the activation is the only place either happens), and hands back to the full reload when that fold moved the loaded capture's decode source. Nothing parked means nothing was decoded, which routes the activation back through the full reload too; that is also where the pre-v10 legacy lift lives. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_<instanceGuid>` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish. **The master bus:** the summed output runs `voice mixer → master gain → limiter (core/instrument/engine/limiter) → output bus`, with the meter tapped at the bus output POST-limiter and published as relaxed atomics — per-channel peak and smallest limiter gain ACCUMULATED across every block since the UI last read, plus the latched clip (the meter Gotcha below owns why). The limiter's enable is persisted in the parameter set (params payload v15) and mirrored onto the audio thread by `setInstrumentParams`, the single funnel every writer already goes through. That mirror is also what `getLatencySamples()` answers from — the plugin's FIRST latency reporting: 0 bypassed, the lookahead engaged. The host's `restartComponent(kLatencyChanged)` is issued by `flushLatencyRestart` alone, UI thread only and never from `process()`; it is a LATENCY restart with the bus untouched, NOT the retired per-mode `kIoChanged` bus renegotiation the invariant above forbids. Why it is split from the commit is the Gotcha below.
- `reasampler_editor` — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; the Sample face is home and Browse is a modal picker over it. Split on the Sample face's BAND axis, mirroring the pure `sample_bands` allocator: `editor_session` (session/bridge state, caches, commit-and-reload), `editor_controls` (the ONE `faceLayout` band resolve every paint and hit-test path shares, the node-drag bounds, the value labels, and the per-instance controls the parameter set does not carry — the parameter-set binding itself is the pure `core/instrument/ui/deck_values` module this only adapts int ids onto), `editor_models` (the orthogonal half: which stored struct each transient editor selection names — the staged-envelope pack/unpack, the drawn contour, and the three velocity curves), then matching paint and input sets — `editor_paint`/`editor_input` (dispatch + drag router + hover dispatch), `_chrome`, `_waveform`, `_deck` — plus the two band-independent surfaces (`_browse` for the modal picker, `_curve` for the velocity-curve popup) and `editor_platform` (IPlugView/Win32 window plumbing). Shared internals in `editor_internal.h`, no TU of its own. Drop-onto-editor ingest is NOT shipped (deferred). - `reasampler_editor` — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; the Sample face is home and Browse is a modal picker over it. Split on the Sample face's BAND axis, mirroring the pure `sample_bands` allocator: `editor_session` (session/bridge state, caches, commit-and-reload), `editor_controls` (the ONE `faceLayout` band resolve every paint and hit-test path shares, the node-drag bounds, the value labels, and the per-instance controls the parameter set does not carry — the parameter-set binding itself is the pure `core/instrument/ui/deck_values` module this only adapts int ids onto), `editor_models` (the orthogonal half: which stored struct each transient editor selection names — the staged-envelope pack/unpack, the drawn contour, and the three velocity curves), then matching paint and input sets — `editor_paint`/`editor_input` (dispatch + drag router + hover dispatch), `_chrome`, `_waveform`, `_deck` — plus the two band-independent surfaces (`_browse` for the modal picker, `_curve` for the velocity-curve popup) and `editor_platform` (IPlugView/Win32 window plumbing). Shared internals in `editor_internal.h`, no TU of its own. Drop-onto-editor ingest is NOT shipped (deferred).
- `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout to `embed_strip`. A read-only readout: the loaded capture across the keyboard span with its root marked, plus the activity level. It takes no mouse input (there is nothing on the strip to select). - `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout to `embed_strip`. A read-only readout: the loaded capture across the keyboard span with its root marked, plus the activity level. It takes no mouse input (there is nothing on the strip to select).
- `editor_stroke` — the editor's LICE side of the analytic stroker: builds a coverage mask with the pure `core/ui/stroke_aa` and blends it into the bitmap ONCE, writing straight to the bitmap's bits (the arithmetic matches LICE's own mode-0 combine, so a stroke composites identically to every other kit draw). Every radial and spline stroke on the editor routes through `strokeArcAA` / `strokePolylineAA` / `strokeLineAA`. Holds the draw-thread-only scratch mask and arc point list — reuse, not a hidden dependency: threading a canvas through the eight paint sites would grow those signatures to carry an allocation detail. Deliberately does NOT touch `shell/panel/draw_kit`: the waveform stroke, the docked bank panel and the browse cards are out of this seam's blast radius. - `editor_stroke` — the editor's LICE side of the analytic stroker: builds a coverage mask with the pure `core/ui/stroke_aa` and blends it into the bitmap ONCE, writing straight to the bitmap's bits (the arithmetic matches LICE's own mode-0 combine, so a stroke composites identically to every other kit draw). Every radial and spline stroke on the editor routes through `strokeArcAA` / `strokePolylineAA` / `strokeLineAA`. Holds the draw-thread-only scratch mask and arc point list — reuse, not a hidden dependency: threading a canvas through the eight paint sites would grow those signatures to carry an allocation detail. Deliberately does NOT touch `shell/panel/draw_kit`: the waveform stroke, the docked bank panel and the browse cards are out of this seam's blast radius.
+39 -4
View File
@@ -268,8 +268,7 @@ void ReaSamplerProcessor::publishUsage(const SampleRefs& refs,
} }
void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr<LoadedInstrument> built) { void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr<LoadedInstrument> built) {
// REQUIRES reloadMutex_ held. Shared by reloadInstrument and rebuildVoiceEngine — the // REQUIRES reloadMutex_ held (see the header's drain-slot proof).
// one safety-critical swap dance (see the header's drain-slot proof).
const std::uint64_t seen = processGeneration_.load(std::memory_order_acquire); const std::uint64_t seen = processGeneration_.load(std::memory_order_acquire);
graveyard_.erase( graveyard_.erase(
std::remove_if(graveyard_.begin(), graveyard_.end(), std::remove_if(graveyard_.begin(), graveyard_.end(),
@@ -347,9 +346,45 @@ bool ReaSamplerProcessor::resumeDormantInstrument() {
// park. Rebuilding here would displace a correct instrument into the drain slot. // park. Rebuilding here would displace a correct instrument into the drain slot.
if (live_.load(std::memory_order_acquire)) return true; if (live_.load(std::memory_order_acquire)) return true;
if (!dormantSample_) return false; if (!dormantSample_) return false;
// The activation is still where a bank change made with NO EDITOR OPEN is picked up:
// pollBankSync, the only other route to either of the two calls below, runs off the
// editor's sync tick and nothing else. Both are a GetProjExtState plus a parse — no disk
// and no decode, which is what lets them stay on a path whose whole point is skipping
// those two.
const std::string selId = selectedSampleId();
const std::vector<std::string> ids = referencedSampleIds(selId);
SampleRefs refs;
bool sourceMoved = false;
{
std::optional<std::string> banksJson =
bridge_.readReasamplerExtState(kProjExtBanksKey);
std::lock_guard<std::mutex> rl(refsMutex_);
if (banksJson) {
const SelectedSample* before = findRef(sampleRefs_, selId);
const std::optional<SelectedSample> was =
before ? std::optional<SelectedSample>(*before) : std::nullopt;
refreshRefsFromBank(sampleRefs_, *banksJson, ids);
const SelectedSample* now = findRef(sampleRefs_, selId);
sourceMoved = !was || !now || !sameDecodeSource(*was, *now);
}
refs = sampleRefs_;
}
// A recapture that landed while this instance was inactive makes the park the WRONG audio,
// and refreshing the refs without re-decoding would leave the table naming one file while
// the voices play another. Hand back to the full reload, which decodes the new one.
if (sourceMoved) return false;
// MOVED, not copied: the park exists for this one handoff, and keeping it would hold a // MOVED, not copied: the park exists for this one handoff, and keeping it would hold a
// second copy of the PCM for the whole active lifetime. // second copy of the PCM for the whole active lifetime. Disengaged BEFORE the build so a
publishBuiltLocked(buildInstrumentLocked(std::move(*dormantSample_))); // throwing build leaves nothing to resume — the next activation then takes the reload
// rather than publishing an empty sample as permanent silence.
SampleData resumed = std::move(*dormantSample_);
dormantSample_.reset();
publishBuiltLocked(buildInstrumentLocked(std::move(resumed)));
// The prune-protection republish reloadInstrument owes on every publish: it is also what
// heals an rsusage_ key whose write failed when this instance last set its state.
publishUsage(refs, ids);
return true; return true;
} }
+12 -5
View File
@@ -87,8 +87,8 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
legacyLiftConcluded_.store(false, std::memory_order_relaxed); legacyLiftConcluded_.store(false, std::memory_order_relaxed);
reloadInstrument(); reloadInstrument();
// This caller has no editor to flush for it. At the TAIL on purpose: a host that services the // This caller has no editor to flush for it. At the TAIL on purpose: a host that services the
// restart synchronously deactivates/reactivates, and our setActive(true) reloads — from the // restart synchronously deactivates/reactivates, and our setActive(true) resumes or reloads
// refs above, which are only fully restored once this function has run to here. // against the refs above, which are only fully restored once this function has run to here.
flushLatencyRestart(); flushLatencyRestart();
return kResultOk; return kResultOk;
} }
@@ -183,13 +183,20 @@ void ReaSamplerProcessor::flushLatencyRestart() {
// Latched BEFORE the call: a host that services the restart synchronously re-enters this // Latched BEFORE the call: a host that services the restart synchronously re-enters this
// object inside it, so the next commit must compare against the value the host is about to // object inside it, so the next commit must compare against the value the host is about to
// read, not against the one it held before. // read, not against the one it held before.
latencyAnnounced_.store(limiterEnabled_.load(std::memory_order_relaxed), const bool previouslyAnnounced = latencyAnnounced_.exchange(
std::memory_order_relaxed); limiterEnabled_.load(std::memory_order_relaxed), std::memory_order_relaxed);
// The SDK requires this on the UI thread and answers getLatencySamples only after the host's // The SDK requires this on the UI thread and answers getLatencySamples only after the host's
// own deactivate/reactivate — so the flag is long committed by the time the host asks. This // own deactivate/reactivate — so the flag is long committed by the time the host asks. This
// is a kLatencyChanged restart with the bus untouched, NOT the retired per-mode kIoChanged // is a kLatencyChanged restart with the bus untouched, NOT the retired per-mode kIoChanged
// bus renegotiation (see initialize()); do not conflate. // bus renegotiation (see initialize()); do not conflate.
componentHandler->restartComponent(kLatencyChanged); if (componentHandler->restartComponent(kLatencyChanged) == kResultOk) return;
// A refused restart leaves the host's delay compensation on the OLD value, so the latch has
// to come back off it: announcing a value the host never took would let a later toggle BACK
// to that value arm nothing, stranding the host's view permanently. Re-armed instead, which
// costs one retry per drain in a host that always refuses. The SDK documents no refusal
// semantics, so whether any host returns non-kResultOk here is `[verify — DAW]`.
latencyAnnounced_.store(previouslyAnnounced, std::memory_order_relaxed);
latencyRestartPending_.store(true, std::memory_order_release);
} }
void ReaSamplerProcessor::publishLimiterEnabled(bool on) { void ReaSamplerProcessor::publishLimiterEnabled(bool on) {
+11 -4
View File
@@ -87,11 +87,18 @@ tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool state) {
// Activation governs ONE thing — whether the audio thread may run. The decoded // Activation governs ONE thing — whether the audio thread may run. The decoded
// SampleData has its own lifetime and survives the cycle (dormantSample_); only the // SampleData has its own lifetime and survives the cycle (dormantSample_); only the
// voice state is built and destroyed here. Main/UI-thread call. // voice state is built and destroyed here. Main/UI-thread call.
//
// Neither branch is idempotent on its own — a repeated deactivate would park an empty
// optional over a still-valid sample, and a repeated activate would reset the limiter over
// a live delay line — and the SDK base is an empty stub that guards neither.
// `[verify — DAW]` whether any host actually repeats the call.
if (static_cast<bool>(state) == active_) return kResultOk;
active_ = static_cast<bool>(state);
if (state) { if (state) {
// Rebuild the voices around the sample the deactivate parked — no bridge read, no WAV // Rebuild the voices around the sample the deactivate parked — no WAV decode — so a
// decode — so a host-driven cycle (a kLatencyChanged restart, an offline-render // host-driven cycle (a kLatencyChanged restart, an offline-render bracket) costs no
// bracket) costs no I/O. With nothing to activate from, the full reload runs: it // disk read. With nothing to activate from, the full reload runs: it resolves +
// resolves + decodes from the instance-owned refs (no bank read, so it plays regardless // decodes from the instance-owned refs (no bank read required, so it plays regardless
// of PROJEXTSTATE parse state) and doubles as the non-editor legacy-lift trigger for a // of PROJEXTSTATE parse state) and doubles as the non-editor legacy-lift trigger for a
// pre-v10 blob, whose opportunistic refreshRefsFromBank copies refs in when the bank // pre-v10 blob, whose opportunistic refreshRefsFromBank copies refs in when the bank
// blob is readable by now. Nothing can shadow that lift: a pre-v10 blob resolves // blob is readable by now. Nothing can shadow that lift: a pre-v10 blob resolves
+22 -14
View File
@@ -139,9 +139,9 @@ public:
// What the audio thread published since the LAST call: peaks maxed and minGain minimised // What the audio thread published since the LAST call: peaks maxed and minGain minimised
// across every block in that window. CONSUMING — it resets the accumulators as it reads, so // across every block in that window. CONSUMING — it resets the accumulators as it reads, so
// exactly one reader may call it, and that reader is the editor's meter tick. Two live // exactly one reader may call it, and that reader is the editor's meter tick. Two live
// editors would each consume half the windows and both meters would read low; what makes // editors would each consume half the windows and both meters would read low; the single
// that unreachable is the HOST calling createView once per instance, not anything this // reader rests on the HOST calling createView once per instance `[verify — DAW]`, not on
// plugin enforces — createView allocates a new editor on every call. UI thread. // anything this plugin enforces — createView allocates a new editor on every call. UI thread.
MasterBusMeter masterBusMeter(); MasterBusMeter masterBusMeter();
void clearMasterBusClip(); void clearMasterBusClip();
@@ -289,10 +289,12 @@ private:
void rebuildVoiceEngine(); void rebuildVoiceEngine();
// The reactivation half of the activation/decode lifetime split (see dormantSample_): // The reactivation half of the activation/decode lifetime split (see dormantSample_):
// rebuilds the voice state around the parked sample and publishes it through the same // folds the live bank blob into the refs, rebuilds the voice state around the parked
// drain-slot swap. True also when a publish landed while inactive, which needs no rebuild. // sample, publishes it through the same drain-slot swap, and republishes usage. True also
// False when there is nothing to activate from — the caller then falls back to a full // when a publish landed while inactive, which needs no rebuild. False when there is nothing
// reload, which is where the pre-v10 legacy lift lives. Off the audio thread only. // to activate from, or when that fold moved what the park was decoded from — the caller
// then falls back to a full reload, which decodes the new file and is also where the pre-v10
// legacy lift lives. Off the audio thread only.
bool resumeDormantInstrument(); bool resumeDormantInstrument();
// Builds a playable snapshot around `sample` at the current voice-system parameters and // Builds a playable snapshot around `sample` at the current voice-system parameters and
@@ -307,7 +309,8 @@ private:
// Publishes `built` (null = install silence) into live_: prunes the graveyard by the // Publishes `built` (null = install silence) into live_: prunes the graveyard by the
// last process()-published generation, swaps `built` into live_, displaces the previous // last process()-published generation, swaps `built` into live_, displaces the previous
// live into the drain slot, and parks the evicted drain instrument in the graveyard. // live into the drain slot, and parks the evicted drain instrument in the graveyard.
// Requires reloadMutex_ held — shared by reloadInstrument and rebuildVoiceEngine. // Requires reloadMutex_ held — shared by reloadInstrument, rebuildVoiceEngine and
// resumeDormantInstrument.
void publishBuiltLocked(std::unique_ptr<LoadedInstrument> built); void publishBuiltLocked(std::unique_ptr<LoadedInstrument> built);
// Publishes a silent block. EVERY process() path that emits no audio calls this. It clears // Publishes a silent block. EVERY process() path that emits no audio calls this. It clears
@@ -391,6 +394,10 @@ private:
// reloadMutex_. // reloadMutex_.
std::optional<SampleData> dormantSample_; std::optional<SampleData> dormantSample_;
// Whether the host has us active, so setActive can treat a repeat of the state it already
// holds as a no-op (it owns why). Main/UI thread only, like setActive itself.
bool active_ = false;
// The loaded capture's id ("" = no pick -> silence). Off-thread only, not read on the // The loaded capture's id ("" = no pick -> silence). Off-thread only, not read on the
// audio thread. // audio thread.
std::mutex selectionMutex_; std::mutex selectionMutex_;
@@ -488,13 +495,14 @@ private:
instrument::engine::Limiter limiter_; instrument::engine::Limiter limiter_;
std::atomic<bool> limiterEnabled_{false}; std::atomic<bool> limiterEnabled_{false};
// Raised (and lowered) by the commit funnel from the difference between the enable and // Raised (and lowered) by the commit funnel from the difference between the enable and
// latencyAnnounced_, cleared by flushLatencyRestart on delivery. A bool and not a count on // latencyAnnounced_, cleared by flushLatencyRestart on delivery and re-raised there if the
// purpose: the host is being told to re-ASK, so N changes before one flush need exactly one // host refuses. A bool and not a count on purpose: the host is being told to re-ASK, so N
// restart, and whatever getLatencySamples answers at that moment is the truth announced. // changes before one flush need exactly one restart, and whatever getLatencySamples answers
// at that moment is the truth announced.
std::atomic<bool> latencyRestartPending_{false}; std::atomic<bool> latencyRestartPending_{false};
// The enable the host was last told about — false initially, which is what an instance // The enable the host has ACCEPTED — false initially, which is what an instance that has
// that has announced nothing reports. Every arm is judged against this, so a change that // announced nothing reports. Every arm is judged against this, so a change that returns to
// returns to the announced state costs no restart. // the announced state costs no restart.
std::atomic<bool> latencyAnnounced_{false}; std::atomic<bool> latencyAnnounced_{false};
// What the audio thread publishes about the output bus each block, relaxed. The peaks and // What the audio thread publishes about the output bus each block, relaxed. The peaks and
+4 -3
View File
@@ -20,7 +20,8 @@ static int g_fail = 0;
// Stands in for std::atomic<float> with ONE scripted interference: the first compare-exchange // Stands in for std::atomic<float> with ONE scripted interference: the first compare-exchange
// runs the UI's consume (identity reinstalled, the window taken) and reports failure exactly as // runs the UI's consume (identity reinstalled, the window taken) and reports failure exactly as
// the real CAS does — expected updated to what the consume left. Everything after is ordinary. // the real CAS does — expected updated to what the consume left. Everything after is ordinary,
// which is what makes the retry count assertable: a strong CAS fails only under interference.
struct ConsumingAccumulator { struct ConsumingAccumulator {
float value; float value;
float identity; float identity;
@@ -29,8 +30,8 @@ struct ConsumingAccumulator {
float load(std::memory_order) const { return value; } float load(std::memory_order) const { return value; }
bool compare_exchange_weak(float& expected, float desired, std::memory_order, bool compare_exchange_strong(float& expected, float desired, std::memory_order,
std::memory_order) { std::memory_order) {
if (casCount++ == 0) { if (casCount++ == 0) {
consumed = value; consumed = value;
value = identity; value = identity;
+38
View File
@@ -531,6 +531,43 @@ static void testRefreshRefsFromBankUpsertAndOwnership() {
CHECK(refs.size() == 1 && refs[0].ref.rootNote == 40); CHECK(refs.size() == 1 && refs[0].ref.rootNote == 40);
} }
static void testSameDecodeSourceTracksEveryDecodeInput() {
// The predicate a resumed (already-decoded) instrument is gated on: every field that
// changes what buildSampleData produces must read as different, and the display-only
// name must not.
SelectedSample a;
a.relativePath = "b/a.wav";
a.rootNote = 36;
a.channelCount = 2;
a.loop.hasLoop = true;
a.loop.start = 100;
a.loop.end = 900;
CHECK(sameDecodeSource(a, a));
SelectedSample recaptured = a;
recaptured.relativePath = "b/a2.wav"; // the recapture case: a new file behind one id
CHECK(!sameDecodeSource(a, recaptured));
SelectedSample reRooted = a;
reRooted.rootNote = 40;
CHECK(!sameDecodeSource(a, reRooted));
SelectedSample reChanneled = a;
reChanneled.channelCount = 1; // drives the channel-mode auto-default, hence the decode
CHECK(!sameDecodeSource(a, reChanneled));
SelectedSample loopOff = a;
loopOff.loop.hasLoop = false;
CHECK(!sameDecodeSource(a, loopOff));
SelectedSample loopMoved = a;
loopMoved.loop.start = 101;
CHECK(!sameDecodeSource(a, loopMoved));
loopMoved = a;
loopMoved.loop.end = 901;
CHECK(!sameDecodeSource(a, loopMoved));
}
static void testRetainRefsFiltersToPlayedSet() { static void testRetainRefsFiltersToPlayedSet() {
// getState hygiene: only the entries the instance currently plays persist — the table // getState hygiene: only the entries the instance currently plays persist — the table
// cannot grow with browsing history. Order of survivors is preserved. // cannot grow with browsing history. Order of survivors is preserved.
@@ -1015,6 +1052,7 @@ int main() {
testReferencedSampleIdsIsTheLoadedCapture(); testReferencedSampleIdsIsTheLoadedCapture();
testFindRefLooksUpTheOwnedCopy(); testFindRefLooksUpTheOwnedCopy();
testRefreshRefsFromBankUpsertAndOwnership(); testRefreshRefsFromBankUpsertAndOwnership();
testSameDecodeSourceTracksEveryDecodeInput();
testRetainRefsFiltersToPlayedSet(); testRetainRefsFiltersToPlayedSet();
testLegacyLiftDecision(); testLegacyLiftDecision();
testResolvePlayConvertsWallClockAtTheRate(); testResolvePlayConvertsWallClockAtTheRate();