diff --git a/src/core/wire/CLAUDE.md b/src/core/wire/CLAUDE.md index 97f74f7..eb059bc 100644 --- a/src/core/wire/CLAUDE.md +++ b/src/core/wire/CLAUDE.md @@ -80,7 +80,7 @@ This directory owns two cross-artifact contracts specifically: - `wire` (`core/wire`) — the ONE length-prefixed ext-state wire codec (Q-W1): `putField`/`parseUnsignedDecimal` + the bounds-checked `Cursor` (`field`/`fieldInt`/`fieldInt64`/`fieldSizeT`/`fieldDouble`), replacing four near-identical copies (`provenance` / `assignment_request` / `sample_usage` / `bank_sync`). `core/wire/bytes.h` is the sibling little-endian byte codec (`putLE`, `ByteReader`, `doubleToBits`/`bitsToDouble`) that `component_state_io` is the biggest consumer of. `core/wire/ext_state_read.h` owns the `GetProjExtState` grow-loop retry policy (Absent/Complete/Overflow) shared by `persist`, `usage_scan`, and `reaper_bridge`. `core/wire/reasampler_uid.h` (the FOREVER-FROZEN VST3 class-UID macros) also lives in this directory. - `reasampler_uid.h` — SDK-free header owning the FOREVER-FROZEN VST3 class-UID integer macros (stable + beta pairs, `REASAMPLER_PROC_UID_*` / `REASAMPLER_PROC_UID_BETA_*`) and the `REASAMPLER_ACTIVE_UID_*` channel-selector macros. Split out of `reasampler_vst.h` so the pure extension side (`instrument_drop`) can derive the `.vstpreset` class-ID hex string without pulling in the VST3 SDK. Both `reasampler_vst.h` (runtime `FUID`) and `instrument_drop` (preset hex string) source from this single header — the binary identity and the preset-file identity cannot diverge. - `assignment_request` — pure ingest-assign wire: typed request record carrying the drop payload from the `ingest` shell through to the VST3 bridge. -- `bake_wire` — the resample bake's request/outcome pair on ONE per-instance key (`rsbake_`): the instrument writes a `BakeRequest`, invokes the extension's action synchronously, and reads the extension's `BakeOutcome` back over the same key inside that one call. Not a handshake — a call and a return, and it must not grow a claim protocol. Also the ONE home of the bake action's command-id suffix and of the leading underscore `NamedCommandLookup` needs but `rec->Register("command_id", …)` does not, so both artifacts name one action. `BakeStatus` values are WIRE INTEGERS: never renumber, only append, and an unrecognized value decodes as `Failed` rather than as the numeric default `Ok`. +- `bake_wire` — the resample bake's request/outcome pair on ONE per-instance key (`rsbake_`): the instrument writes a `BakeRequest`, invokes the extension's action synchronously, and reads the extension's `BakeOutcome` back over the same key inside that one call. Not a handshake — a call and a return, and it must not grow a claim protocol. Also the ONE home of the bake action's command-id suffix and of the leading underscore `NamedCommandLookup` needs but `rec->Register("command_id", …)` does not, so both artifacts name one action. `BakeStatus` values are WIRE INTEGERS: never renumber, only append, and an unrecognized value decodes as `Failed` rather than as the numeric default `Ok`. It owns BOTH ends' reading of that key, since the key's contents are the only evidence either side gets: `classifyBakeAnswer` (instrument side — six kinds, of which `Unanswered`, the request still sitting there untouched, is what separates an extension that never ran the landing from one that refused) and `classifyBakeScan` + `kMaxRequestAgeSeconds` (extension side — the per-key Land / RefuseWrongProject / ClearStale / IgnoreUnreadable / IgnoreNotARequest verdict over every open tab, stated without a REAPER type so the multi-tab matrix is unit-provable). `BakeScanTally` + `describeBakeScan` are that same reading counted and spoken — the rationale lives at the type. The report is empty whenever a pass answered anybody, which makes its ABSENCE from the console evidence too: no line means the landing action never ran. - `instrument_drop` — pure FX-drop payload builder: constructs a Steinberg-format `.vstpreset` image (channel-active class ID + the instrument's own component state, capture pre-selected) the shell applies via `TrackFX_SetPreset`; owns `classifyReaperSurface`, the prefix classifier mapping a `GetThingFromPoint` (info token, track-present) pair onto `core/ui/drag_out`'s `ReaperSurface`. Classifier ordering is load-bearing: the embed strip is matched before the `tcp`/`mcp` panel family, which now claims the WHOLE track panel rather than just its FX sub-elements. All-or-nothing contract — caller rolls back via `TrackFX_Delete` on any failure. - `sample_usage` — instance-usage wire: `UsageRecord`, `planUsagePublish` (fresh/heal/clean-replace/union/remint publish plan), `foldUsageRecords`/`usageHeldPaths` (liveness fold — protect-all when records exist but no instance is live; abort→protect-all on unreadable record; `counted` carries key-attributed live records), `identityMatches` (ReaSampler 9000 FX identity). REAPER-free, unit-tested. The mirror of `assignment_request` on the instrument→extension direction: the wire format and the two safety-critical decisions (what to write on publish, which records count at prune time) are pure so they are provable without a DAW. It lives here because it is a *wire format* with an instrument-side writer; the fold's output is consumed by `core/tracking`'s authority, which owns every consumer-facing decision built on it. diff --git a/src/core/wire/bake_wire.cpp b/src/core/wire/bake_wire.cpp index 4617066..24f7283 100644 --- a/src/core/wire/bake_wire.cpp +++ b/src/core/wire/bake_wire.cpp @@ -2,6 +2,8 @@ #include "core/wire/bake_wire.h" +#include + #include "core/version/app_version.h" #include "core/wire/wire.h" @@ -32,6 +34,10 @@ BakeStatus statusFromWire(int raw) { return BakeStatus::Failed; } +std::string countOf(int n, const char* noun) { + return std::to_string(n) + " " + noun + (n == 1 ? "" : "s"); +} + } // namespace std::string bakeActionLookupName() { @@ -122,4 +128,89 @@ std::optional decodeBakeOutcome(const std::string& wire) { return outcome; } +BakeAnswer classifyBakeAnswer(const std::optional& raw, + const BakeRequest& sent) { + BakeAnswer answer; + // `raw` folds three distinct bridge outcomes into one — absent, explicitly + // cleared, and an unreadable/oversized read — because the bridge cannot label + // which occurred. A caller's message for `Cleared` must not claim more than that. + if (!raw || raw->empty()) { + answer.kind = BakeAnswerKind::Cleared; + return answer; + } + if (std::optional outcome = decodeBakeOutcome(*raw)) { + answer.kind = outcome->generation == sent.generation + ? BakeAnswerKind::Answered + : BakeAnswerKind::ForeignOutcome; + answer.outcome = std::move(outcome); + return answer; + } + // Still a request: whether it is OURS is what separates "nothing read this key" from + // "another instance overwrote it" — a persisted instanceGuid is copyable, so two + // instances CAN name one key. + if (const std::optional req = decodeBakeRequest(*raw)) { + answer.kind = + *req == sent ? BakeAnswerKind::Unanswered : BakeAnswerKind::ForeignRequest; + return answer; + } + answer.kind = BakeAnswerKind::Undecodable; + return answer; +} + +const BakeOutcome* answeredOutcome(const BakeAnswer& answer) { + if (answer.kind != BakeAnswerKind::Answered || !answer.outcome) return nullptr; + return &*answer.outcome; +} + +BakeScanVerdict classifyBakeScan(const BakeScanContext& session, const BakeScanKey& key, + std::int64_t nowSec) { + // Listed by the enumerator but not returned whole by the reader. Distinct from the + // next case even though both leave the key alone: this one is the extension failing + // to read a request that may well be there, and it is invisible from the other end. + if (!key.readable) return BakeScanVerdict::IgnoreUnreadable; + + // Not a request: an outcome the writing instance has not collected yet, or a value + // from a build we do not read. The writer owns clearing its own key. + if (!key.decoded) return BakeScanVerdict::IgnoreNotARequest; + + // Either direction, so a clock moved backwards is caught too. + const std::int64_t age = nowSec - key.generation; + if (age > kMaxRequestAgeSeconds || age < -kMaxRequestAgeSeconds) + return BakeScanVerdict::ClearStale; + + // Two facts have to agree before anything may land: the loaded project is the one a + // persist would write into (loadedProjectIsActive), and this key's tab IS that loaded + // project (matchesLoadedProject — false by construction whenever no project is + // loaded, which is what folds the former three-way check into two). + const bool landable = session.loadedProjectIsActive && key.matchesLoadedProject; + return landable ? BakeScanVerdict::Land : BakeScanVerdict::RefuseWrongProject; +} + +std::string describeBakeScan(const BakeScanTally& t) { + if (t.answered > 0) return {}; + + std::string s = "ReaSampler resample: the landing action ran and scanned " + + countOf(t.tabsScanned, "project tab") + ", and answered nothing. "; + if (t.keysFound == 0) { + s += "No pending bake request was visible to it at all -- if ReaSampler 9000 " + "reported publishing one, the plugin and the extension are not reading the " + "same project's ext state."; + return s + "\n"; + } + + s += "It found " + countOf(t.keysFound, "pending request key") + ", " + + std::to_string(t.activeTabKeys) + " of them in the active tab"; + if (t.unreadable > 0) + s += "; " + std::to_string(t.unreadable) + " could not be read back"; + if (t.notARequest > 0) + s += "; " + std::to_string(t.notARequest) + " held something other than a request"; + if (t.staleCleared > 0) + s += "; " + std::to_string(t.staleCleared) + + " were past the age bound and were cleared unanswered"; + s += "."; + if (t.activeTabKeys == 0) + s += " The tab this bake was fired against held none of them."; + return s + "\n"; +} + } // namespace reasampler::wire diff --git a/src/core/wire/bake_wire.h b/src/core/wire/bake_wire.h index 3c501cc..0ff5daf 100644 --- a/src/core/wire/bake_wire.h +++ b/src/core/wire/bake_wire.h @@ -79,4 +79,102 @@ std::string encodeBakeOutcome(const BakeOutcome& outcome); std::optional decodeBakeRequest(const std::string& wire); std::optional decodeBakeOutcome(const std::string& wire); +// --- Reading the shared key, from either end ----------------------------------------- +// +// One key carries both records, so what it holds after the action returned is the ONLY +// evidence either side gets. The two classifiers below are that reading, stated once. + +// What the instrument found under its own key once invokeExtensionAction returned. The +// distinction that matters: `Unanswered` is the extension never having read the key at +// all, which is a DIFFERENT fault from every refusal — a refusal is an outcome. +enum class BakeAnswerKind { + Answered, // a decodable outcome echoing this request's generation + ForeignOutcome, // a decodable outcome, but for another generation + Unanswered, // this request, unchanged: nothing on the extension side read it + ForeignRequest, // a request that is not ours — another instance shares this key + Cleared, // the key holds nothing — absent, explicitly cleared, or a read + // failure at the bridge; these are not distinguishable from here + Undecodable, // neither record — a build whose wire this one does not read +}; + +struct BakeAnswer { + BakeAnswerKind kind = BakeAnswerKind::Cleared; + std::optional outcome; // set iff Answered or ForeignOutcome +}; + +// `raw` is the key's value after the invocation (nullopt = absent/empty). +BakeAnswer classifyBakeAnswer(const std::optional& raw, + const BakeRequest& sent); + +// The outcome iff `answer.kind == Answered`; nullptr in every other state. The ONE place +// the Answered-implies-outcome-is-set contract is enforced, so a caller can fail closed +// on a state this switch does not (yet) name instead of dereferencing `answer.outcome` on +// the strength of switch exhaustiveness alone — exhaustiveness a future BakeAnswerKind +// enumerator (BakeAnswerKind is documented append-only, like its BakeStatus neighbor) +// would silently break with no compiler diagnostic (no -Wswitch/-Werror configured). +const BakeOutcome* answeredOutcome(const BakeAnswer& answer); + +// The whole chain is a call and a return inside ONE editor tick, so a request older than +// this has no reader left. Landing one would bank it for nobody and leave an outcome +// nobody collects in the .rpp forever. The bound is only meaningful because both ends +// read the same wall clock (std::time) AND the instrument stamps `generation` +// immediately before publishing the request — after staging the WAV, so that write +// never eats into the budget. +inline constexpr std::int64_t kMaxRequestAgeSeconds = 30; + +// The extension's per-key verdict on one scanned `rsbake_*` key. +enum class BakeScanVerdict { + Land, // land it into the loaded project's bank + RefuseWrongProject, // answer WrongProject — the book in memory belongs to another tab + ClearStale, // no reader left: clear the key, never answer it + IgnoreUnreadable, // the enumerator listed it, the reader could not return it whole + IgnoreNotARequest, // read whole, but an uncollected outcome or a wire we do not read +}; + +// The session's side of the verdict: whether the loaded project is the one REAPER will +// persist into. Whether a project is loaded at ALL is folded into +// BakeScanKey::matchesLoadedProject below rather than carried as a second flag here — a +// key can only ever match a project that is loaded, so a standalone "session has a +// project" flag on this side could disagree with the key's and describe a state the +// shell can never actually produce. +struct BakeScanContext { + bool loadedProjectIsActive = false; // the loaded project is REAPER's active tab +}; + +// The scanned key's side — per-TAB, which is what makes a request found in a background +// tab decidable without any REAPER type crossing into this module. +struct BakeScanKey { + bool readable = false; // the enumerated key's value came back WHOLE + bool decoded = false; // that value decoded as a BakeRequest (implies readable) + std::int64_t generation = 0; + bool matchesLoadedProject = false; // this key's tab IS the session's loaded project + // — false whenever the session has no loaded + // project, by construction (see BakeScanContext) +}; + +BakeScanVerdict classifyBakeScan(const BakeScanContext& session, const BakeScanKey& key, + std::int64_t nowSec); + +// What ONE scan pass actually saw, accumulated by the shell as it applies the verdicts +// above. Counts only, so the report below is provable without a DAW. It exists because +// every non-answering verdict leaves the asking instance with the same evidence — its own +// request, untouched — and only these counts say which of them happened. +struct BakeScanTally { + int tabsScanned = 0; + int keysFound = 0; // `rsbake_*` keys enumerated across every open tab + int activeTabKeys = 0; // of those, in the tab the action was fired against + int unreadable = 0; // IgnoreUnreadable + int notARequest = 0; // IgnoreNotARequest + int staleCleared = 0; // ClearStale + int answered = 0; // an outcome was written back (a landing OR a refusal) + int landed = 0; // of `answered`, the ones that reached the bank +}; + +// The console sentence for a pass that answered NOTHING — the one state in which the +// asking instance reports a no-answer and has nothing to go on. Empty string when +// `answered > 0`, so the caller prints unconditionally and stays quiet on a pass that +// spoke for itself. The line's absence is itself evidence: no line means the landing +// action never ran. +std::string describeBakeScan(const BakeScanTally& tally); + } // namespace reasampler::wire diff --git a/src/shell/capture/CLAUDE.md b/src/shell/capture/CLAUDE.md index 37bbea9..cb1ef86 100644 --- a/src/shell/capture/CLAUDE.md +++ b/src/shell/capture/CLAUDE.md @@ -57,7 +57,7 @@ detail not covered there: - `render_selection` (`shell/capture`) — the transient track selection a selected-tracks render (`&128`) requires, as a stack RAII guard: REAPER prints whatever tracks are selected, so `renderOffline` makes the request's own tracks BE the selection for the render's duration and restores the user's set on every exit path. Engaged ONLY for that source mode, which leaves a stated residual: a `&32` selected-items render still prints whatever ITEMS the user has selected. Live captures are unaffected (that selection is the source), but a recipe replay of a `SelectedItems` capture renders against whatever happens to be selected then — the recipe stores tracks and a range, never item GUIDs, so this guard cannot close it. Filed in `docs/TODO.md`. - `render_isolation` (`shell/capture`) — the transient upstream silencing a ranged ITEM render needs, as a stack RAII guard alongside the two above: the selected-tracks source prints everything flowing INTO the track, so each direct folder child's `B_MAINSEND` and each of the track's receives' `B_MUTE` are cut for the render and restored on every exit path. Direct children only — a grandchild reaches the track through the child that owns it. The child-set walk is pure (`core/capture/track_topology`). - `capture_orchestrator` (`shell/capture`) — single-capture orchestration + the realtime/insert action bodies (Q-W3 hoist, T4-02): `renderOffline` (one offline render under the scope's FX-bypass guard), `captureAndIndexOne` (render + provenance stamp + bank add + tracking-ledger record, unpersisted), `RunCapture`/`RunCaptureItemAssign`, `RunCaptureRealtimeTrack`/`RunCancelRealtime` (the realtime action bodies — the in-flight state lives in `realtime_lifecycle`), and `RunInsertSelected` (the ONE deliberate exception to capture-never-places). -- `bake_land` (`shell/capture`) — the EXTENSION's half of the resample chain: scans every open project tab for pending `rsbake_*` requests, lands the ones belonging to the project this session has loaded, and refuses the rest with `WrongProject` — one undo point for the batch, each answered over its own key inside the invoking instance's synchronous action call. It RENDERS NOTHING — the instrument already did, through its own engine in its own process, which is what makes the baked audio the sound the user approved and what keeps the voice engine out of the extension's link graph. Replace-vs-add comes from `tracking::resampleLanding`; a replace keeps the entry's id and slot and never deletes the superseded file. Hash-dedup applies on the add path only, before the disk write, matching `updateSampleInPlace`'s "an in-place refresh is not an insert". A refused index withdraws the bytes this call had just written — the self-cleanup carve-out from prune's deletion authority, stated in `prune_fs.cpp`'s header. +- `bake_land` (`shell/capture`) — the EXTENSION's half of the resample chain: scans every open project tab for pending `rsbake_*` requests, lands the ones belonging to the project this session has loaded, and refuses the rest with `WrongProject` — one undo point for the batch, each answered over its own key inside the invoking instance's synchronous action call. The per-key verdict itself is NOT this TU's: it is `core/wire`'s pure `classifyBakeScan`, so this shell only enumerates, reads, and applies — counting every verdict into a `wire::BakeScanTally` as it goes, and printing `wire::describeBakeScan` when the pass answered nobody. Each key is materialized before any answer is written, so no `SetProjExtState` in this action mutates a set the enumerator is still walking. It RENDERS NOTHING — the instrument already did, through its own engine in its own process, which is what makes the baked audio the sound the user approved and what keeps the voice engine out of the extension's link graph. Replace-vs-add comes from `tracking::resampleLanding`; a replace keeps the entry's id and slot and never deletes the superseded file. Hash-dedup applies on the add path only, before the disk write, matching `updateSampleInPlace`'s "an in-place refresh is not an insert". A refused index withdraws the bytes this call had just written — the self-cleanup carve-out from prune's deletion authority, stated in `prune_fs.cpp`'s header. - `capture_batch` (`shell/capture`) — the batch-capture family + re-capture-from-source (Q-W3 hoist, T4-02): `RunBatchCaptureItems` (one sample per selected item), `RunBatchCaptureRazor` (one sample per razor area), `RunRecaptureFromSource` (regenerate a provenanced sample from its recorded source's current state, bank-only). Every unit routes through `capture_orchestrator` so every precision invariant holds; persist is batched to one ext-state write per action. - `realtime_lifecycle` (`shell/capture`) — the in-flight realtime-capture state machine + globals (Q-W3 hoist): the action starts it, `OnTimer` drives it per tick via `DriveRealtimeCapture` (a single-pointer-test idle fast path — load-bearing hot-path guardrail), `CommitRealtimeResult` lands a finished capture in the bank, `AbortRealtimeCaptureForUnload` tears down cleanly on extension unload. - `capture_realtime_shell` (`shell/capture`) — the async realtime-record backend surface (Q-W6 split of the former fat `capture.h`): `RealtimeRecordBackend::begin`/`tick`/`abort`, transport-driven across timer ticks (a realtime record cannot block REAPER's UI for its own duration). Deliberately shares NO interface with the offline backend — the lifecycles genuinely differ (the former `ICaptureBackend` interface was deleted in Q-W3, T4-26). diff --git a/src/shell/capture/bake_land.cpp b/src/shell/capture/bake_land.cpp index 4e0ad3e..d08bf1b 100644 --- a/src/shell/capture/bake_land.cpp +++ b/src/shell/capture/bake_land.cpp @@ -6,7 +6,6 @@ #include "shell/capture/bake_land.h" #include -#include #include #include #include @@ -53,13 +52,6 @@ using wire::BakeOutcome; using wire::BakeRequest; using wire::BakeStatus; -// The whole chain is a call and a return inside ONE editor tick, so a request older than -// this has no reader left: it is a crash leftover, and it is CLEARED rather than landed. -// Without the guard a stranded request would be banked on the next unrelated instance's -// click, and the outcome written back to a key nobody will collect would persist into the -// .rpp forever. -constexpr std::int64_t kMaxRequestAgeSeconds = 30; - BakeOutcome refuse(BakeStatus status, std::string message, std::int64_t generation) { BakeOutcome out; out.status = status; @@ -268,55 +260,69 @@ struct Answer { } // namespace void RunResampleBake(ReaSamplerSession& session) { - // Three projects have to agree before anything may land: the tab the request was found - // in, the tab whose book/ledger poll() last loaded, and the tab saveToActiveProject - // will persist into. Land on a disagreement and one tab's bake is written into - // another's bank. Whether REAPER makes Main_OnCommandEx's `proj` current for the - // action's duration is unverified in the DAW; this holds either way, and a request it - // cannot land is told why rather than silently ignored. + // Whether REAPER makes Main_OnCommandEx's `proj` current for the action's duration is + // unverified in the DAW; the per-key verdict holds either way, and a request it cannot + // land is told why rather than silently ignored. const void* loaded = session.loadedProject(); const void* active = EnumProjects(-1, nullptr, 0); - const bool sessionUsable = loaded != nullptr && loaded == active; + // `loaded == active` alone is enough: every OpenProject::proj enumerated below is a + // real, non-null tab, so an unloaded `loaded` (nullptr) can never equal one and + // matchesLoadedProject (below) falls out false regardless of this flag's value. + const wire::BakeScanContext scanContext{loaded == active}; const std::int64_t nowSec = static_cast(std::time(nullptr)); std::vector answers; - int landedCount = 0; + wire::BakeScanTally tally; // every verdict below is counted, skips included Undo_BeginBlock2(nullptr); for (const OpenProject& open : openProjects()) { + ++tally.tabsScanned; + const bool activeTab = static_cast(open.proj) == active; for (const std::string& key : pendingBakeKeys(open.proj)) { + ++tally.keysFound; + if (activeTab) ++tally.activeTabKeys; const std::optional raw = readKey(open.proj, key); - if (!raw) continue; - const std::optional request = wire::decodeBakeRequest(*raw); - // Not a request: an outcome this instance has not yet collected, or a value from - // a build we do not read. Leave it — the writing instance owns clearing its key. - if (!request) continue; + const std::optional request = + raw ? wire::decodeBakeRequest(*raw) : std::nullopt; + const wire::BakeScanKey scanKey{ + raw.has_value(), request.has_value(), request ? request->generation : 0, + static_cast(open.proj) == loaded}; + const wire::BakeScanVerdict verdict = + wire::classifyBakeScan(scanContext, scanKey, nowSec); - // Stale (either direction, so a clock moved backwards is caught too): clear the - // key, never answer it. The instance that could read an answer is gone. - if (std::llabs(nowSec - request->generation) > kMaxRequestAgeSeconds) { + // Leave it — the writing instance owns clearing its own key. + if (verdict == wire::BakeScanVerdict::IgnoreUnreadable) { + ++tally.unreadable; + continue; + } + if (verdict == wire::BakeScanVerdict::IgnoreNotARequest) { + ++tally.notARequest; + continue; + } + if (verdict == wire::BakeScanVerdict::ClearStale) { + ++tally.staleCleared; answers.push_back(Answer{open.proj, key, std::string{}, std::string{}}); continue; } BakeOutcome outcome; - if (!sessionUsable || static_cast(open.proj) != loaded) { + if (verdict == wire::BakeScanVerdict::RefuseWrongProject) { outcome = refuse(BakeStatus::WrongProject, "this bake's project tab is not the one the extension has " "loaded -- focus that tab and try again", request->generation); } else { outcome = landOne(session, open.dir, *request); - if (outcome.status == BakeStatus::Ok) ++landedCount; + if (outcome.status == BakeStatus::Ok) ++tally.landed; } + ++tally.answered; // A WrongProject request left sitting in a tab this call did not come from // prints again on every OTHER tab's bake, since the scan revisits every open - // project each time. `active` is this call's own proxy for "the invoking tab" - // (see the three-projects comment above) — only that tab's own refusal is fresh - // feedback to a user who just clicked bake; every other one is a rescan repeat. - const bool ownRequest = static_cast(open.proj) == active; + // project each time. `active` is this call's own proxy for "the invoking tab" — + // only that tab's own refusal is fresh feedback to a user who just clicked + // bake; every other one is a rescan repeat. std::string console; if (outcome.status != BakeStatus::Ok && - (outcome.status != BakeStatus::WrongProject || ownRequest)) { + (outcome.status != BakeStatus::WrongProject || activeTab)) { console = "ReaSampler resample: " + outcome.message + ".\n"; } answers.push_back( @@ -324,7 +330,7 @@ void RunResampleBake(ReaSamplerSession& session) { } } - if (landedCount > 0) { + if (tally.landed > 0) { // A bake changes what a live instance would play, so the generation bump rides the // persist — every other open instance refreshes hands-free. session.bumpBankGeneration(); @@ -344,7 +350,10 @@ void RunResampleBake(ReaSamplerSession& session) { answer.wire.c_str()); if (!answer.console.empty()) ShowConsoleMsg(answer.console.c_str()); } - if (landedCount > 0) bankPanelRefresh(); + // Empty unless the pass answered nobody, so a bake that spoke for itself stays quiet. + const std::string report = wire::describeBakeScan(tally); + if (!report.empty()) ShowConsoleMsg(report.c_str()); + if (tally.landed > 0) bankPanelRefresh(); } } // namespace reasampler::capture diff --git a/src/shell/instrument/CLAUDE.md b/src/shell/instrument/CLAUDE.md index 2432850..de46232 100644 --- a/src/shell/instrument/CLAUDE.md +++ b/src/shell/instrument/CLAUDE.md @@ -108,7 +108,7 @@ declared ahead of the instrument slots at that member in `reasampler_processor.h - `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). - `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. -- `instrument_bake` — the instrument's half of the resample chain, on the UI thread: render the dialed sound through the pure `core/instrument/bake` modules at the instance's PERSISTED PREVIEW VELOCITY (the velocity the user has been auditioning at — three velocity curves are live, so it is a property of the sound and not a render detail), stage the WAV OUTSIDE the bank folder, publish one `rsbake_` request, invoke the extension's landing action SYNCHRONOUSLY, read the outcome back over the same key, then adopt + reset in one act. Two stack-RAII guards mirror `FxBypassGuard`'s discipline: the staged file and the request key are both cleared on every exit path, so a failed bake leaves no temp, no bank entry and no parameter reset. `bakeAvailable` is the affordance's paint gate. +- `instrument_bake` — the instrument's half of the resample chain, on the UI thread: render the dialed sound through the pure `core/instrument/bake` modules at the instance's PERSISTED PREVIEW VELOCITY (the velocity the user has been auditioning at — three velocity curves are live, so it is a property of the sound and not a render detail), stage the WAV OUTSIDE the bank folder, publish one `rsbake_` request, invoke the extension's landing action SYNCHRONOUSLY, read the outcome back over the same key, then adopt + reset in one act. What that key holds afterwards is classified by `core/wire`'s pure `classifyBakeAnswer`, and each of its five non-answers gets its OWN sentence — a silent no-answer stays a failure, but the user is told whether the extension never ran the landing, answered a stale generation, answered in a wire this build cannot read, cleared the request, or refused it. Two stack-RAII guards mirror `FxBypassGuard`'s discipline: the staged file and the request key are both cleared on every exit path, so a failed bake leaves no temp, no bank entry and no parameter reset. `bakeAvailable` is the affordance's paint gate. A cloned `instanceGuid` (two instances sharing one `rsbake_` key) is NOT handled here — the residual is contained by pre-existing tracking machinery instead: `planUsagePublish`'s sticky `unioned` poison plus `tiedUsageExists` (`core/tracking/tracking_authority.cpp`) force a clone's bake to `AddDistinct` rather than silently replacing a sibling's entry. - `vst_entry` — VST3 entry point: `GetPluginFactory` export, class registration, channel-forked class UIDs. - `editor_internal.h` — INTERNAL shared helpers for the `reasampler_editor` TU family, included only by the editor's own shell TUs (`editor_session` / `editor_controls` / `editor_paint_*` / `editor_input_*` / `editor_platform`), never a public seam: the `Rect`↔kit adapters, small draw primitives (knob face / title band), label helpers, and the velocity-curve box derivation — the helpers more than one band TU needs. The deck's control ids, group ids and group composition are the pure `deck_groups` module's, not this file's. The piano-strip and root-key draws live in `editor_paint_chrome`, their only consumer, not here. - `reasampler_vst.h` — shared identity constants for the ReaSampler VST3 instrument (Phase S): the plugin's class UID (the channel-selected `Steinberg::FUID`, built from the FOREVER-FROZEN macros in `core/wire/reasampler_uid.h`), vendor name/URL/email, so the processor, factory, and editor agree. A class UID is FOREVER-STABLE once shipped — minted once, never regenerated. *(Newly authored per this dispatch's brief — no existing root-CLAUDE.md bullet; verified by reading `src/shell/instrument/reasampler_vst.h` directly.)* diff --git a/src/shell/instrument/instrument_bake.cpp b/src/shell/instrument/instrument_bake.cpp index fc8d2ab..0764f41 100644 --- a/src/shell/instrument/instrument_bake.cpp +++ b/src/shell/instrument/instrument_bake.cpp @@ -154,14 +154,18 @@ BakeChainResult runBake(ReaSamplerProcessor& processor) { static_cast(audio.frameCount()), interleaved); const std::string instanceGuid = processor.usageInstanceGuid(); - const std::int64_t stamp = static_cast(std::time(nullptr)); + // Named for what it is used for here — the staged file's own name, nothing else. + // `request.generation` below takes its OWN, later timestamp: kMaxRequestAgeSeconds + // is a budget measured from the write, and re-using this one would silently spend it + // on the WAV write that happens in between. + const std::int64_t stageStamp = static_cast(std::time(nullptr)); // OUTSIDE the bank folder, always: the bank holds indexed captures only, and a stray // file there would read as a prune orphan. std::error_code ec; const fs::path stagedPath = fs::temp_directory_path(ec) / - ("reasampler_bake_" + instanceGuid + "_" + std::to_string(stamp) + ".wav"); + ("reasampler_bake_" + instanceGuid + "_" + std::to_string(stageStamp) + ".wav"); if (ec) return fail("no writable temp directory for the staged render"); const std::string staged = stagedPath.string(); StagedFileGuard stagedGuard(staged); @@ -175,7 +179,10 @@ BakeChainResult runBake(ReaSamplerProcessor& processor) { request.sourceDisplayName = sourceEntry->displayName; request.ownUsageKey = usageKeyFor(instanceGuid); request.rootNote = plan.note; - request.generation = stamp; + // Stamped here, immediately before the publish below — AFTER the WAV write above, + // which is what makes kMaxRequestAgeSeconds' budget (bake_wire.h) exclude staging + // time rather than eat into it. + request.generation = static_cast(std::time(nullptr)); const std::string key = bakeKeyFor(instanceGuid); RequestKeyGuard keyGuard(bridge, key); @@ -187,24 +194,56 @@ BakeChainResult runBake(ReaSamplerProcessor& processor) { if (!bridge.invokeExtensionAction(wire::bakeActionLookupName())) return fail("the ReaSampler extension's bake action is not registered"); - const std::optional raw = bridge.readReasamplerExtState(key); - const std::optional outcome = - raw ? wire::decodeBakeOutcome(*raw) : std::nullopt; - // No outcome at all means the action never reached our request — an older extension - // that registers the id but does not read this key, or an invocation REAPER deferred. - if (!outcome) return fail("the extension did not answer the bake request"); - if (outcome->generation != request.generation) - return fail("the extension answered a different bake request"); - if (outcome->status != BakeStatus::Ok) - return fail(outcome->message.empty() ? std::string("the bake was refused") - : outcome->message); + // What the key holds now is the only evidence this side gets, and each of the five + // non-answers is a different thing to go fix — collapsing them into one sentence is + // what made a stale install indistinguishable from a refusal. + const wire::BakeAnswer answer = + wire::classifyBakeAnswer(bridge.readReasamplerExtState(key), request); + switch (answer.kind) { + case wire::BakeAnswerKind::Answered: + break; + case wire::BakeAnswerKind::Unanswered: + return fail( + "the ReaSampler extension did not answer -- the request key still holds " + "this exact request, untouched. Check the REAPER console: if the landing " + "action printed a scan report just now, it DID run and that line says what " + "it saw; if the console is silent, the action never ran at all"); + case wire::BakeAnswerKind::Undecodable: + return fail( + "the extension answered in a format this plugin does not read -- the " + "extension and ReaSampler 9000 are from different builds"); + case wire::BakeAnswerKind::Cleared: + return fail( + "the bake key came back empty -- either the request was cleared before " + "an answer was written, or this build could not read whatever was there. " + "The landing action's console scan report, if one appeared, names which"); + case wire::BakeAnswerKind::ForeignRequest: + return fail( + "the bake key held a different pending request instead of an answer -- " + "unexpected given the bake's single-threaded call flow; if this recurs, " + "note the exact steps and file it"); + case wire::BakeAnswerKind::ForeignOutcome: + return fail("the extension answered a different bake request"); + } + // wire::answeredOutcome is the guard, not switch exhaustiveness alone: the switch + // above has no `default`, so a future BakeAnswerKind enumerator it doesn't yet handle + // would otherwise fall through to a raw `*answer.outcome` deref with nothing set. + const BakeOutcome* outcomePtr = wire::answeredOutcome(answer); + if (!outcomePtr) + return fail( + "the extension's answer was not one this build recognizes -- the extension " + "and ReaSampler 9000 may be from different builds"); + const BakeOutcome& outcome = *outcomePtr; + if (outcome.status != BakeStatus::Ok) + return fail(outcome.message.empty() ? std::string("the bake was refused") + : outcome.message); SampleRefEntry entry; - entry.sampleId = outcome->sampleId; - entry.displayName = outcome->displayName; - entry.ref.relativePath = outcome->relativePath; - entry.ref.rootNote = outcome->rootNote; - entry.ref.channelCount = outcome->channelCount; + entry.sampleId = outcome.sampleId; + entry.displayName = outcome.displayName; + entry.ref.relativePath = outcome.relativePath; + entry.ref.rootNote = outcome.rootNote; + entry.ref.channelCount = outcome.channelCount; // No loop: the loop points shaped the render and are meaningless against the new file // (bake_reset owns that rule for the parameter set; this is its bank-intrinsic peer). @@ -213,7 +252,7 @@ BakeChainResult runBake(ReaSamplerProcessor& processor) { // The extension's own wording, which distinguishes the three landings (replaced, added, // and pointed at an identical existing entry) more precisely than this side can. - return BakeChainResult{true, "resampled -- " + outcome->message}; + return BakeChainResult{true, "resampled -- " + outcome.message}; } } // namespace reasampler::vst diff --git a/tests/test_bake_wire.cpp b/tests/test_bake_wire.cpp index 5d58cdc..6dcc54d 100644 --- a/tests/test_bake_wire.cpp +++ b/tests/test_bake_wire.cpp @@ -6,13 +6,16 @@ // break a mixed-version pair); request + outcome round-trips including bytes that would // break a delimiter-based format; the refusals every house wire record shares (wrong tag, // truncation, trailing garbage, a swapped record kind); an unrecognized status integer -// degrading to Failed rather than to Ok; and the action lookup name's leading underscore. +// degrading to Failed rather than to Ok; the action lookup name's leading underscore and +// its channel fork; and the two key classifiers each end reads the shared key through. #include "../src/core/wire/bake_wire.h" #include "../src/core/version/app_version.h" +#include #include +#include #include using namespace reasampler::wire; @@ -183,6 +186,358 @@ int main() { CHECK(lookup.compare(lookup.size() - suffixLen, suffixLen, kBakeActionSuffix) == 0); } + // --- The action id and the ext-state namespace fork on the SAME channel bit --------- + // Both frozen families are spelled out, so this holds whichever channel the test binary + // was built for. Consequence for a cross-channel pair (stable VST + beta extension or + // the reverse): NamedCommandLookup resolves nothing, bakeAvailable paints the affordance + // Disabled, and the click cannot reach the no-answer path at all. + { + const std::string suffix = kBakeActionSuffix; + const std::string stableLookup = "_CEREBELLUM_REASAMPLER_" + suffix; + const std::string betaLookup = "_CEREBELLUM_REASAMPLER_BETA_" + suffix; + const bool beta = reasampler::version::isBeta(); + + CHECK(stableLookup != betaLookup); + CHECK(bakeActionLookupName() == (beta ? betaLookup : stableLookup)); + CHECK(bakeActionLookupName() != (beta ? stableLookup : betaLookup)); + CHECK(reasampler::version::extStateNamespace() == + (beta ? "reasampler_beta" : "reasampler")); + } + + // --- What the instrument finds under its own key, after the action returned --------- + { + BakeRequest sent; + sent.instanceGuid = "0123abcd"; + sent.stagedFilePath = "C:/Temp/reasampler_bake_0123abcd_1893456000.wav"; + sent.sourceSampleId = "cap-1"; + sent.sourceRelativePath = "reasampler_bank/kick.wav"; + sent.sourceDisplayName = "Kick"; + sent.ownUsageKey = "rsusage_0123abcd"; + sent.rootNote = 36; + sent.generation = 1893456000; + + BakeOutcome answered; + answered.status = BakeStatus::Ok; + answered.sampleId = "bake-1"; + answered.message = "added as a distinct capture"; + answered.generation = sent.generation; + + const BakeAnswer ok = + classifyBakeAnswer(std::optional(encodeBakeOutcome(answered)), sent); + CHECK(ok.kind == BakeAnswerKind::Answered); + CHECK(ok.outcome.has_value() && ok.outcome->sampleId == "bake-1"); + + // A refusal is an ANSWER — it must never fold into a no-answer kind, or the user is + // sent to reinstall a binary that in fact answered them. + BakeOutcome refused; + refused.status = BakeStatus::WrongProject; + refused.message = "this bake's project tab is not the one the extension has loaded"; + refused.generation = sent.generation; + const BakeAnswer refusal = + classifyBakeAnswer(std::optional(encodeBakeOutcome(refused)), sent); + CHECK(refusal.kind == BakeAnswerKind::Answered); + CHECK(refusal.outcome.has_value() && + refusal.outcome->status == BakeStatus::WrongProject); + + BakeOutcome older = answered; + older.generation = sent.generation - 1; + const BakeAnswer foreignOut = + classifyBakeAnswer(std::optional(encodeBakeOutcome(older)), sent); + CHECK(foreignOut.kind == BakeAnswerKind::ForeignOutcome); + CHECK(foreignOut.outcome.has_value()); + + // Nothing on the extension side read the key: the request is still sitting there + // byte-for-byte. THE diagnostic that separates a stale/absent landing from a refusal. + CHECK(classifyBakeAnswer(std::optional(encodeBakeRequest(sent)), sent) + .kind == BakeAnswerKind::Unanswered); + + // A request that is not ours: two instances copied from one another share a + // persisted instanceGuid, so they name one key. + BakeRequest sibling = sent; + sibling.sourceSampleId = "cap-2"; + sibling.generation = sent.generation + 1; + CHECK(classifyBakeAnswer(std::optional(encodeBakeRequest(sibling)), sent) + .kind == BakeAnswerKind::ForeignRequest); + + // Cleared folds two genuinely different bridge outcomes -- key absent, and key + // present but empty -- into ONE kind, because the bridge cannot label which + // occurred. Both must land on the identical kind: a caller's message may claim + // no more than what the fold actually preserves. + const BakeAnswer clearedFromAbsent = classifyBakeAnswer(std::nullopt, sent); + const BakeAnswer clearedFromEmpty = + classifyBakeAnswer(std::optional(""), sent); + CHECK(clearedFromAbsent.kind == BakeAnswerKind::Cleared); + CHECK(clearedFromEmpty.kind == BakeAnswerKind::Cleared); + CHECK(clearedFromAbsent.kind == clearedFromEmpty.kind); + CHECK(classifyBakeAnswer(std::optional("rsbakeout9 whatever"), sent) + .kind == BakeAnswerKind::Undecodable); + + // No non-Answered kind may carry an outcome the caller could read as a landing. + CHECK(!classifyBakeAnswer(std::nullopt, sent).outcome.has_value()); + CHECK(!classifyBakeAnswer(std::optional(encodeBakeRequest(sent)), sent) + .outcome.has_value()); + CHECK(!classifyBakeAnswer(std::optional("garbage"), sent) + .outcome.has_value()); + } + + // --- answeredOutcome: the guarded read, not switch exhaustiveness alone ------------- + { + BakeRequest sent; + sent.instanceGuid = "abcd"; + sent.generation = 42; + + BakeOutcome landed; + landed.status = BakeStatus::Ok; + landed.sampleId = "bake-1"; + landed.generation = sent.generation; + + // The one state that may yield a non-null pointer, and it points at the SAME + // outcome classifyBakeAnswer decoded. + const BakeAnswer answered = + classifyBakeAnswer(std::optional(encodeBakeOutcome(landed)), sent); + const BakeOutcome* ptr = answeredOutcome(answered); + CHECK(ptr != nullptr); + CHECK(ptr->sampleId == "bake-1"); + + // Every other real classification nulls out. + CHECK(answeredOutcome(classifyBakeAnswer(std::nullopt, sent)) == nullptr); + CHECK(answeredOutcome(classifyBakeAnswer( + std::optional(encodeBakeRequest(sent)), sent)) == nullptr); + CHECK(answeredOutcome(classifyBakeAnswer(std::optional("garbage"), sent)) + == nullptr); + BakeOutcome foreign = landed; + foreign.generation = sent.generation + 1; + CHECK(answeredOutcome(classifyBakeAnswer( + std::optional(encodeBakeOutcome(foreign)), sent)) == nullptr); + + // The defensive arm: a hand-built Answered with no outcome set (what a future + // BakeAnswerKind enumerator falling through an un-updated switch would look like + // to this accessor) fails closed rather than being dereferenced. + BakeAnswer malformed; + malformed.kind = BakeAnswerKind::Answered; + CHECK(!malformed.outcome.has_value()); + CHECK(answeredOutcome(malformed) == nullptr); + } + + // --- kMaxRequestAgeSeconds: a regression pin on the bound itself -------------------- + // The bound's meaning depends on the instrument stamping `generation` immediately + // before publishing (AFTER staging the WAV) -- a shell-side ordering fix this pure + // suite cannot observe directly. Pinning the literal at least catches a silent + // widen/narrow of the budget itself. + CHECK(kMaxRequestAgeSeconds == 30); + + // --- The extension's per-key verdict over the open tabs ----------------------------- + { + const std::int64_t now = 1893456000; + // The session has polled a project and that project is REAPER's active tab — the + // steady state a project opened from disk reaches on the next timer tick. + const BakeScanContext loadedAndActive{true}; + + BakeScanKey own{true, true, now, true}; + CHECK(classifyBakeScan(loadedAndActive, own, now) == BakeScanVerdict::Land); + + // A request found in a tab the extension has NOT loaded — the multi-tab case. It is + // REFUSED, which is an answer the asking instance can read; it is never landed into + // the loaded tab's bank, and never dropped silently. + BakeScanKey otherTab{true, true, now, false}; + CHECK(classifyBakeScan(loadedAndActive, otherTab, now) == + BakeScanVerdict::RefuseWrongProject); + + // The loaded tab is no longer the active one, so a persist would write elsewhere: + // even the loaded tab's own request is refused rather than half-landed. + CHECK(classifyBakeScan(BakeScanContext{false}, own, now) == + BakeScanVerdict::RefuseWrongProject); + // The window before any project is loaded: matchesLoadedProject cannot be true for + // ANY key here — a key can only match a project that is loaded — so this is the + // shell-reachable stand-in for "no book yet", not `own` (which the shell could + // never actually pair with an unloaded session). + CHECK(classifyBakeScan(BakeScanContext{false}, BakeScanKey{true, true, now, false}, + now) == BakeScanVerdict::RefuseWrongProject); + + // Stale in EITHER direction (a clock that moved backwards counts too). + BakeScanKey old{true, true, now - kMaxRequestAgeSeconds - 1, true}; + BakeScanKey future{true, true, now + kMaxRequestAgeSeconds + 1, true}; + CHECK(classifyBakeScan(loadedAndActive, old, now) == BakeScanVerdict::ClearStale); + CHECK(classifyBakeScan(loadedAndActive, future, now) == BakeScanVerdict::ClearStale); + // Exactly at the bound is still landable — the ceiling is inclusive. + BakeScanKey atBound{true, true, now - kMaxRequestAgeSeconds, true}; + CHECK(classifyBakeScan(loadedAndActive, atBound, now) == BakeScanVerdict::Land); + // Staleness outranks the project check: a request nobody can read is cleared, not + // answered, wherever it sits. + BakeScanKey oldElsewhere{true, true, now - kMaxRequestAgeSeconds - 1, false}; + CHECK(classifyBakeScan(loadedAndActive, oldElsewhere, now) == + BakeScanVerdict::ClearStale); + + // A value that is not a request (an outcome the writer has not collected) is left + // alone — answering it would clobber an answer in flight. + BakeScanKey notARequest{true, false, 0, true}; + CHECK(classifyBakeScan(loadedAndActive, notARequest, now) == + BakeScanVerdict::IgnoreNotARequest); + CHECK(classifyBakeScan(BakeScanContext{false}, notARequest, now) == + BakeScanVerdict::IgnoreNotARequest); + + // A key the enumerator listed but the reader could not return whole is its OWN + // verdict, not folded into the one above: the extension failing to read a request + // that may well be there is a different fault from a key holding something else, + // and neither is visible from the instrument's end. + BakeScanKey unreadable{false, false, 0, true}; + CHECK(classifyBakeScan(loadedAndActive, unreadable, now) == + BakeScanVerdict::IgnoreUnreadable); + CHECK(classifyBakeScan(BakeScanContext{false}, unreadable, now) == + BakeScanVerdict::IgnoreUnreadable); + // Unreadable outranks even staleness — an age read off a request that was never + // decoded would be a made-up number. + BakeScanKey unreadableOld{false, false, now - kMaxRequestAgeSeconds - 1, true}; + CHECK(classifyBakeScan(loadedAndActive, unreadableOld, now) == + BakeScanVerdict::IgnoreUnreadable); + } + + // --- The scan report: what the action tells a user it actually saw ------------------ + // The report exists because every non-answering verdict leaves the asking instance + // with the identical evidence (its own untouched request), so only these counts + // discriminate them. It must therefore SAY the count that fired, and must stay silent + // whenever an answer was written. + { + BakeScanTally answeredOne; + answeredOne.tabsScanned = 1; + answeredOne.keysFound = 1; + answeredOne.activeTabKeys = 1; + answeredOne.answered = 1; + answeredOne.landed = 1; + CHECK(describeBakeScan(answeredOne).empty()); + // A refusal is still an answer, so it silences the report the same way a landing + // does — the refusal's own sentence has already been printed. + BakeScanTally refusedOne = answeredOne; + refusedOne.landed = 0; + CHECK(describeBakeScan(refusedOne).empty()); + + // Nothing found at all: the state Daniel's repro produces if the request key never + // becomes visible to the extension. + BakeScanTally nothing; + nothing.tabsScanned = 2; + const std::string none = describeBakeScan(nothing); + CHECK(!none.empty()); + CHECK(none.find("2 project tabs") != std::string::npos); + CHECK(none.find("No pending bake request was visible") != std::string::npos); + CHECK(none.back() == '\n'); + + // One tab is singular, not "1 project tabs". + BakeScanTally oneTab; + oneTab.tabsScanned = 1; + CHECK(describeBakeScan(oneTab).find("1 project tab,") != std::string::npos); + + // Found but unreadable — the fact the old scan discarded. + BakeScanTally unreadable; + unreadable.tabsScanned = 1; + unreadable.keysFound = 1; + unreadable.activeTabKeys = 1; + unreadable.unreadable = 1; + const std::string unread = describeBakeScan(unreadable); + CHECK(unread.find("1 pending request key") != std::string::npos); + CHECK(unread.find("1 could not be read back") != std::string::npos); + CHECK(unread.find("held something other than a request") == std::string::npos); + + // Found, readable, but not a request. + BakeScanTally foreign; + foreign.tabsScanned = 1; + foreign.keysFound = 1; + foreign.activeTabKeys = 1; + foreign.notARequest = 1; + const std::string other = describeBakeScan(foreign); + CHECK(other.find("1 held something other than a request") != std::string::npos); + CHECK(other.find("could not be read back") == std::string::npos); + + // Cleared as stale: an answer was never written, so this too must report. + BakeScanTally stale; + stale.tabsScanned = 1; + stale.keysFound = 1; + stale.activeTabKeys = 1; + stale.staleCleared = 1; + const std::string aged = describeBakeScan(stale); + CHECK(aged.find("past the age bound") != std::string::npos); + + // Keys exist, but none in the tab the bake was fired against — the multi-tab + // mis-target, called out explicitly rather than left to be inferred from "0 of them". + BakeScanTally elsewhere; + elsewhere.tabsScanned = 2; + elsewhere.keysFound = 1; + elsewhere.activeTabKeys = 0; + elsewhere.notARequest = 1; + const std::string away = describeBakeScan(elsewhere); + CHECK(away.find("0 of them in the active tab") != std::string::npos); + CHECK(away.find("fired against held none of them") != std::string::npos); + } + + // --- Every outcome bake_land actually emits survives the key round trip ------------- + // The landing writes these and the instrument reads them back; a field the encoder and + // the decoder disagreed about would strand exactly the bake that produced it. + { + BakeOutcome added; + added.status = BakeStatus::Ok; + added.sampleId = "bake-1893456000-a1b2c3d4-kick_1893456000.wav"; + added.relativePath = "reasampler_bank/kick_1893456000-a1b2c3d4.wav"; + added.displayName = "Kick 2"; + added.rootNote = 36; + added.channelCount = 2; + added.replaced = false; + added.message = "added as a distinct capture"; + added.generation = 1893456000; + + BakeOutcome replaced = added; + replaced.replaced = true; + replaced.displayName = "Kick"; + replaced.message = "replaced the bank entry"; + + BakeOutcome deduped = added; + deduped.message = "identical to an existing capture -- pointed at it"; + + BakeOutcome noProject; + noProject.status = BakeStatus::NoProject; + noProject.message = "no saved project, so the bank has no location"; + noProject.generation = added.generation; + + BakeOutcome stagedMissing = noProject; + stagedMissing.status = BakeStatus::StagedMissing; + stagedMissing.message = "the staged render is not a usable WAV"; + + BakeOutcome noSource = noProject; + noSource.status = BakeStatus::NoSource; + noSource.message = "the resampled capture is not in any bank"; + + BakeOutcome indexRejected = noProject; + indexRejected.status = BakeStatus::IndexRejected; + indexRejected.message = "the bank refused the new capture"; + + BakeOutcome wrongProject = noProject; + wrongProject.status = BakeStatus::WrongProject; + wrongProject.message = + "this bake's project tab is not the one the extension has " + "loaded -- focus that tab and try again"; + + BakeOutcome writeFailed = noProject; + writeFailed.status = BakeStatus::Failed; + writeFailed.message = "could not write the bake into the bank folder"; + + for (const BakeOutcome& emitted : + {added, replaced, deduped, noProject, stagedMissing, noSource, indexRejected, + wrongProject, writeFailed}) { + const auto back = decodeBakeOutcome(encodeBakeOutcome(emitted)); + CHECK(back.has_value()); + CHECK(back.has_value() && *back == emitted); + // Field for field as well: operator== is hand-written, so a field missing from + // BOTH the codec and the comparison would pass the aggregate check above. + CHECK(back.has_value() && back->status == emitted.status && + back->sampleId == emitted.sampleId && + back->relativePath == emitted.relativePath && + back->displayName == emitted.displayName && + back->rootNote == emitted.rootNote && + back->channelCount == emitted.channelCount && + back->replaced == emitted.replaced && + back->message == emitted.message && + back->generation == emitted.generation); + } + } + if (g_fail == 0) std::printf("bake_wire: all tests passed\n"); return g_fail ? 1 : 0; }