# TODO Forward-looking follow-ups. Deferred by decision, not oversight — each entry records why it was deferred and what "done" looks like. ## The per-voice filter is solved against the WAV's sample rate, not the render rate **Context (what shipped — Θ-W2-T1, the filter in the voice path).** `Voice::start` sets `filterRate_ = sample.sampleRate` — the rate read off the **decoded WAV header** — and hands it to `VoiceFilter::prepare()` and every later `setCutoffNorm()`. But the voice emits exactly one frame per **host** frame, so the rate the corner should be solved against is the project/render rate the processor already latches in `setupProcessing` (`ReaSamplerProcessor::sampleRate_`), not the file's. The rate enters the DSP only through `g = tan(pi*fc/sr)` (`engine/filter/CLAUDE.md`), so a wrong `sr` scales the realized corner by exactly the ratio of the two rates. **The wart.** When capture rate ≠ project rate, the corner lands at the wrong frequency, by that ratio. A 44.1 kHz capture in a 48 kHz project puts the corner roughly **1.5 semitones sharp** (48000/44100 ≈ 1.088×); the same capture in a 96 kHz project is roughly **13.5 semitones off**. The Nyquist clamp (`kFilterNyquistFraction`) measures against the wrong Nyquist for the same reason. This falsifies the guarantee `filter_params.h` states in its own words — that the persisted value is a normalized knob position precisely so one preset does not sound different at 44.1k and 96k. The control law honors that; the solve defeats it. **Intended fix.** Thread the host render rate onto `SampleData` and set `filterRate_` from it. The processor already holds `sampleRate_` from `setupProcessing` and already guards on it being non-zero before building, so the value is available at exactly the point `SampleData` is constructed — this is a plumbing change, not a new mechanism. **The constraint the fix MUST handle.** The engine **already conflates the two rates everywhere** — `sample_map` resolves the AHDSR's stored seconds at the WAV's own rate, and nothing resamples the source — so a cross-rate capture already plays back sharp *and* short by the same ratio. This is an inherited assumption, not a defect introduced by the filter; the filter is simply the first module where it lands as an audible **frequency** error rather than a timing one. A fix that corrects only the filter leaves the filter rate-correct while envelope timing stays rate-wrong. That is strictly less wrong and defensible, but it splits one assumption into two, and the split must be a deliberate choice rather than a side effect of fixing the loudest symptom. Second constraint: `filterRate_ <= 0` must keep meaning **bypass** — the filter module forbids a reference, calibration, or fallback rate anywhere in itself, and a plumbing fix must not smuggle one in as a default. **Priority / risk.** Deferred by ruling — Daniel, 2026-07-30: *"record this and proceed."* Inaudible whenever capture rate == project rate, which is the common case for captures this tool made in the project they belong to. Audible and large on an imported or cross-rate capture, and worse the further the two rates diverge. **Done looks like.** The realized filter corner matches `filterCutoffHzFromNorm(pos)` within measurement tolerance at every combination of capture rate and project rate; the Nyquist clamp measures against the render rate; and the decision about whether envelope timing follows the same correction is recorded rather than left implicit. ## Filter ring-out is truncated on the source-exhaustion path **Context (what shipped — Θ-W2-T1).** The per-voice filter runs between the pitch stage and the amp multiply. When `readPos_` runs past the end of the sample with no usable loop, `Voice::advanceFrame` latches `active_ = false` and returns 0 — the voice stops feeding, and whatever energy remains in the filter's two integrators is discarded rather than rung out. **The wart.** The filter's tail is cut at source exhaustion instead of decaying to the filter's own denormal floor. **Why the common case is unaffected.** A released Gate note's filter tail is shaped to silence by the **amp release** before the read head reaches the end — that is the pipeline ordering (pitch → filter → amp) working exactly as designed. Trigger's fade-out has already taken the amp to ~0 at `playEnd`, so the discarded state is multiplied by ~0 regardless. The exposed case is a voice that reaches source exhaustion with the amp envelope still open. **Intended fix.** Let a voice keep rendering the filter past source exhaustion — zero input, filter ringing — until `VoiceFilter::isSilent()`. **The constraint the fix MUST handle (why deferred).** Extending a voice past source exhaustion changes `active()` and `soundingNote()`, and those two predicates feed `VoiceEngine`'s oldest-first stealing policy and the Preserve-voice tally. A ring-out voice would hold an allocation slot and could suppress or be stolen by a note-on that today would be routed differently — a materially larger blast radius than the track that found the defect, which is why it is deferred rather than patched at the call site. The existing takeover declick already carves out an `active() && !soundingNote()` ring-out state; a filter ring-out would be a second occupant of that state and must compose with it rather than fight it. **The caveat both reviewers recorded.** The discarded state can be roughly `2Q` larger than the source that produced it, so at high Q the cut **amplifies** the step that already existed at source exhaustion rather than merely preserving it. The defect gets worse the more resonance is dialled in — it is not a uniformly small residual. **Priority / risk.** Low / deferred. Recorded during Θ-W2-T1 review and left for a track that can own the voice-lifetime predicates. **Done looks like.** A high-Q filtered voice that reaches source exhaustion with the amp envelope still open decays to the filter's denormal floor rather than cutting, with no change to voice-stealing behavior, the Preserve tally, or the takeover-declick ring-out state. ## Persist ReaSampler 9000 instance identity to let prune reclaim de-referenced captures after reopen **Context (what shipped — Phase S usage-detection).** Each ReaSampler 9000 instance publishes the captures it holds to project ext-state (`rsusage_` keys, ComponentState v11). The extension's prune reads those records and unions every live instance's held captures into the referenced-set, so a capture any live instance holds can never be pruned. Fail-safe: unreadable/ambiguous usage state aborts prune (deletes nothing). Airtight on safety. **The wart.** The per-instance identity token is minted fresh each incarnation and is NOT persisted. After save→reopen, an instance cannot recognize its OWN prior-session usage record — it looks foreign, so the instance defensively unions and marks the record append-only (poisoned). Net effect: after any reopen, prune stops reclaiming captures an instance once loaded but no longer uses. Safe (never deletes a used capture), but the bank folder grows without bound. **Intended fix.** Persist the instance identity in ComponentState so an instance recognizes its own last-session record and does a clean-replace instead of union/poison → prune reclaims de-referenced instance-touched captures normally. **The constraint the fix MUST handle (why deferred).** VST3 provides no stable per-instance identity, and Ctrl+D / in-place FX duplication clones plugin state. A persisted identity is inherited by an in-place duplicate → two live instances in one project share one `rsusage_` key. Harmless while both hold the same capture; the risk is a divergent clone — the copies load DIFFERENT captures, and last-writer-wins drops the other's held capture from the record, exposing it to prune. The fix must detect a genuine live same-identity collision and protect the union in that case, WITHOUT reintroducing the sibling-drop bug the fresh-per-session token was originally added to prevent. (Whole-project copies are a non-issue — bank files are cloned with the project and ext-state is per-project.) **Priority / risk.** Low / deferred. Current behavior is safe; the only cost is unbounded bank-folder growth after reopens. Decided 2026-07-28 to ship the safe version and defer this. **Re-examined 2026-07-30 by the tracking consolidation, and DELIBERATELY NOT absorbed.** The consolidation's mandate is a *safety* claim (no destructive act follows from ambiguity); this wart is a *completeness* one (nothing is lost, the folder grows). They do not conflict, and folding a fix in would have widened a safety-critical review surface with a mechanism that can under-protect. The strongest candidate examined was a **session epoch**: the extension mints a fresh epoch value at each project load and an instance stamps it into its record, so a record carrying a previous epoch is known-stale and may be clean-replaced regardless of nonce. It fixes exactly the reopen case — but a divergent same-key clone pair reopening together gives the first publisher a clean replace that drops the second's holds until the second republishes, i.e. a narrow revival of the sibling-drop bug. Any future attempt must close that window (e.g. by making the epoch rollover a union that clears the sticky poison only once both siblings have republished) before it is worth taking. **Done looks like.** Save → reopen → de-reference a capture from an instance → prune reclaims it. And: in-place-duplicate + diverge + delete-from-bank never deletes a capture a live instance holds, with no window between the two publishes in which a hold is unprotected. ## Isolate capture from out-of-scope aux/parallel sends, not just FX/gain/pan **Context (carried from PLAN.md's "Open questions to resolve during build").** The FX-scope capture neutralizes out-of-scope FX, gain, and pan for both item and track scope — root `CLAUDE.md`'s "Capture FX scope" precision invariant states this precisely: the out-of-scope chain (ancestors + master track, plus the item's own track for item scope) has its FX, gain, and pan/width/pan-law/mode neutralized to unity. **Aux/parallel sends are conspicuously absent from that enumerated list** — the invariant as currently written does not cover them, which is the gap this item exists to close. **The wart.** A downstream coloring send (e.g. a folder → reverb-track send) still routes and blends into an item/track capture, past the intended isolation point. Repro from PLAN.md: folder F1; T1 (MIDI) sends MIDI to T2 (synth); T1+T2 → F1; F1 sends to reverb T3; capturing the MIDI item on T1 currently includes the reverb, but should be isolated to T2's synth output pre-F1 with the MIDI send preserved and the reverb send excluded. **Intended fix.** Likely approach (PLAN.md): snapshot + mute out-of-scope tracks' aux sends during the render while preserving the main/source signal path. **The constraint the fix MUST handle.** Distinguish **source routing that must be preserved** (e.g. a MIDI send T1→T2 where T2's synth is where a MIDI item's audio is actually produced — the "item level" for that MIDI item is T2's synth output) from **coloring sends that must be excluded** (folder→reverb). PLAN.md notes this is "the hard part" and that a rule is needed for which sends are load-bearing. **Settled rule (Daniel, 2026-07-29).** The classification rule for which sends are load-bearing: (1) **ancestor sends are excluded** from the capture — the folder parent is *up* the folder tree but *downstream* in signal flow, so this item deliberately says "ancestor," not "upstream," which would read backwards to anyone applying signal-flow convention; (2) **sibling sends are preserved and captured** — a sibling is a track sharing the capture scope's parent; (3) **if the send's destination has a different parent, ignore it in the capture** — it isn't a sibling, so it isn't captured. Applied to the repro above: the T1→T2 MIDI send is a sibling send (T1 and T2 share parent F1) and is preserved — T2's synth is where the item's audio is actually produced; the F1→T3 reverb send is an ancestor send and is excluded. This resolves the repro. **Still open.** The rule above settles *which sends to mute*, but the repro is only fully resolved together with a second, unspecified lever — *where the capture taps*. PLAN.md's own framing of this open question says a true item-level capture should be taken "at the isolated graph point — the target scope's output before out-of-scope track FX/gain/pan and before out-of-scope aux/parallel sends." How the mute-rule above and the tap-point interact is not yet specified. **Priority / risk.** Not stated in PLAN.md (recorded there as an open `(TODO)` question, not yet triaged to a priority). Flagged here as mattering more than the other four carried-over items because it exposes a live gap in a stated precision invariant, not just a deferred feature. **Done looks like.** Capturing the MIDI item on T1 in the repro above is isolated to T2's synth output pre-F1 — the T1→T2 MIDI send is preserved and the F1→reverb send is excluded — and the CLAUDE.md "Capture FX scope" invariant's neutralization list is extended to name sends explicitly. ## Confirm REAPER's VST3 UID-vs-filename instance rebind behavior (Phase S compat verification) **Context.** PLAN.md, under "Phase S — product name (ReaSampler 9000)": the working assumption is that REAPER rebinds a saved instance by its VST3 class UID, not by the module filename, so a filename rename with an unchanged UID keeps saved projects working (existing instances still resolve). **The wart.** This is not yet confirmed from source — PLAN.md records that a web check surfaced a JUCE/VST3-replace-VST2 case suggesting REAPER's binding is more nuanced than "UID only" (it can involve an FXID match), so UID-rebind is to-verify, not asserted fact. **Intended fix / required check.** DAW-verify: save a project with a ReaSampler 9000 instance under the old filename, rename the module, reopen — confirm the instance rebinds and restores its state. **The constraint the fix MUST handle.** If REAPER does key partly on filename, the fallback is to keep the current filename (display-strings-only) and record that as the shipped choice. **Priority / risk.** Marked "must-DAW-verify before shipping the rename" in PLAN.md; no explicit priority level stated beyond that. **Done looks like.** The save→rename→reopen DAW check is performed and its result (rebind confirmed, or filename-revert fallback taken) is recorded. ## S13 — cross-artifact ingest relay (deferred, spike verdict DEGRADED) **Context.** PLAN.md records the ps-w12 (2026-07-27) spike verdict: DEGRADED — relay deferred. The instrument's REAPER bridge (`reaper_bridge`) is deliberately READ-ONLY; a relay would need a new instrument WRITE seam into ext-state and an extension-side timer poller servicing a drop-ingest inbox key with a claim/clear nonce — the same cross-process handshake race the S17 spec rejected for alternative (A). The shipped ingest gesture stays drop-onto-docked-panel (S8); the editor shows a "drop files onto the ReaSampler bank panel to add them" affordance as the degrade path. **The wart.** Dropping a file directly onto the editor/instrument does not ingest it into the bank — only drop-onto-docked-panel does. **Intended fix.** The editor hands the dropped path + this instance's identity to the extension as a bank-ingest request over an agreed seam. **The constraint the fix MUST handle.** Requires (a) a new instrument WRITE seam into ext-state (breaking the current read-only-bridge invariant) and (b) an extension-side timer poller + claim/clear nonce — both are load-bearing design calls that need to be made deliberately, not as a call-site patch. Both the read-only-instrument boundary and the new poller were judged load-bearing enough that the relay is deferred to a future wave rather than pushed through now. **Priority / risk.** PLAN.md marks this DEFERRED, awaiting a future wave, with no priority assigned. **Done looks like.** Not stated in PLAN.md beyond "a future wave when the design is ready." ## Phase D2 — per-track lane/mode-state panel indicator (deferred) **Context.** PLAN.md: Phase D2 is functionally complete (D2-W1, D2-W2, D2-W3-A, D2-W3-B all landed). One item was deferred out of that completion: a panel UI indicator for per-track lane/mode state (a per-track lane-split marker). **The wart.** The mode switch already shows the active mode, but there is no per-track indicator; PLAN.md records that no natural cheap home for one was found in the bank panel. **Intended fix.** Not specified in PLAN.md beyond the goal (a per-track lane-split marker in the bank panel) — the design is unresolved, which is part of why it was deferred rather than built. **The constraint the fix MUST handle.** Finding a home for the indicator in the bank panel's existing layout, which PLAN.md notes doesn't currently have a natural cheap spot for it. **Priority / risk.** Not stated in PLAN.md. PLAN.md's own framing: "Explicitly deferred — not silently dropped. Can be picked up later if wanted." **Done looks like.** Not stated in PLAN.md. ## FX-GUID keying for `restoreFxOffline` (Design View park/restore) **Context.** CONTEXT.md's "Open questions to resolve during build" (Design View section): the bulk of reconcile residuals shipped (`ViewModeModel::reconcile(liveGuids)` prunes orphaned snapshots on every toggle/load; folder restructure is self-healing because the tree is rebuilt each toggle; membership is intentionally kept so undo-delete preserves the tag). Two sub-items were left deferred out of that; this is the first. **The wart.** `restoreFxOffline` currently restores per-FX offline state by slot index. If the FX chain is reshuffled while a track is parked, restore lands on whatever plugin now occupies that slot rather than the plugin it was originally captured from. **Intended fix.** FX-GUID keying — key the per-FX offline snapshot entries by FX identity rather than slot index. **The constraint the fix MUST handle.** The keying change requires a snapshot-schema migration; CONTEXT.md names this alongside the keying change as the reason the fix was deferred rather than folded into the reconcile-residuals work. **Priority / risk.** Not stated in the source. **Done looks like.** Not stated in the source beyond the fix description above. ## Dormant membership entries in persisted `view_state` **Context.** CONTEXT.md's "Open questions to resolve during build" (Design View section), the second of the two sub-items left deferred after the reconcile-residuals ship described above. **The wart.** Truly-deleted tracks accumulate stale entries in persisted `view_state`. **Intended fix.** A future user-initiated "compact" action to remove the stale entries. **The constraint the fix MUST handle.** Must NOT be automatic pruning — automatic pruning would reintroduce the undo-delete tag-loss that the deliberate membership-retention was designed to prevent. **Priority / risk.** Not stated as a priority level; the source characterizes the wart itself as "harmless and bounded." **Done looks like.** Not stated in the source beyond "a future user-initiated 'compact' action." ## Confirm no fight between Design View flags and screenset recall **Context.** CONTEXT.md's "Open questions to resolve during build" (Design View section): Design View drives the same track flags a screenset recall would drive, and last writer wins between the two. **The wart.** Not a defect — this is a verification task, not a code change. The open concern is confirming there is no surprising interaction between Design View's flag-driving and an active screenset recall. **Intended fix.** N/A — no fix is proposed; the task is to confirm no surprising fight between the two mechanisms. **The constraint the fix MUST handle.** N/A — verification only. **Priority / risk.** Not stated in the source. **Done looks like.** Not stated in the source beyond "confirm no surprising fight." ## Spline overlay's drag-off delete margin may be too generous for its box **Context (what shipped — Θ-W5-T1, spline-egs).** `kCurveDragOffMargin = 24` (`editor_internal.h`) was sized for the velocity-curve popup, whose editing box floats with slack on all sides — the popup's own sheet border sits well outside the box, so 24px of overshoot before a drag-off delete arms is comfortably inside the sheet. The Spline EG overlay reuses the same constant and the same drag-off-delete logic verbatim (`editor_paint_waveform.cpp`), but its box abuts the deck directly with no equivalent slack. **The wart.** Dragging an overlay contour node toward the bottom of the waveform band and overshooting roughly 24px past the box floor carries the drag into the deck below and arms a delete — a gesture that reads as "drag toward the deck" rather than "delete this point." Mitigations already in place: a WARN paint cue while the drag is armed-to-delete, and `VelocityCurve::deletePoint` unconditionally refuses the two endpoints regardless of margin. **Intended fix.** Not yet proposed — likely a smaller, overlay-specific margin (or a margin derived from the actual gap between the overlay box and the deck) rather than sharing the popup's constant. **The constraint the fix MUST handle.** Whatever margin the overlay uses must still comfortably permit an intentional delete-by-drag-off gesture (the design's stated point-removal path) without shrinking it into a hair-trigger; the popup's own margin and delete behavior must be left untouched. **Priority / risk.** Low, pending Daniel's hands-on assessment. Flagged by code review as an unmeasured UX judgment, not a confirmed defect — whether the overshoot is a real hazard in practice is Daniel's call. **Done looks like.** Daniel has used the Spline EG overlay hands-on and either confirms the current margin is fine as shared, or a separate overlay margin is chosen and the WARN cue's trigger point is verified to match it. ## Pre-existing staged-envelope-node shadow at zero-attack (AttackEnd on Origin) **Context (what shipped — Θ-W5-T1, spline-egs).** The staged envelope-node hit-test (`nodeAtPoint`, `envelope_edit.cpp`) and the drawn contour's node hit-test now feed the SAME `WaveformClaim` arbitration slot in `resolveWaveformClaim` (`spline_edit.h`), which resolves competing waveform-band claims — node, crossfade tab, marker column — by smallest nominal target area among the candidates that actually hit. This is the same defect class as the contour-node/marker collision W5 fixed by replacing check-order resolution with that arbitration. **The wart.** A zero-attack `AttackEnd` vertex is drawn at the same pixel as `Origin` (the envelope's non-draggable start anchor), which for an AHD envelope sits at the start marker's frame. Because a node's nominal pick-box area is smaller than the marker's full-height grab-column area, and `resolveWaveformClaim`'s rule is "smallest area among hit candidates wins," the draggable `AttackEnd` node still claims the click over the start marker when the two coincide — and, at a loop starting there, over the crossfade tab. Folding the staged pass into the shared arbitration slot did not change this specific outcome, since the rule that decides node-vs-marker priority is unchanged from what the contour-node fix established. `Origin` itself is excluded from `nodeAtPoint`'s candidate set entirely (never draggable, never a hit), so the common case — attack > 0, no coincidence — is unaffected. **Intended fix.** Not yet proposed. Bringing the staged pass into the shared arbitration slot was the natural first step and has landed; closing the remaining collision needs either a per-affordance priority rule for genuinely coincident precision targets, or accepting the current smallest-area outcome as intended and documenting it as such rather than as an open wart. **The constraint the fix MUST handle.** Whatever rule changes must not regress the contour-node/marker and tab/marker arbitration W5 already fixed, and must not make `Origin` draggable or otherwise touch `isDraggable`'s AHD/AHDSR shape rules. **Priority / risk.** Low. Pre-existing, not introduced by W5; the common case (nonzero attack) is unaffected, and the collision requires both a zero-attack stage and a coincident marker/tab to be reachable at all. **Done looks like.** A zero-attack `AttackEnd` node coincident with the start marker (or, on a loop starting there, the crossfade tab) no longer silently claims the click ahead of the marker/tab — either by an explicit priority rule or by a recorded decision that the current behavior is intended. ## Active-bank indicator placement (B4 polish) **Context.** CONTEXT-ARCHIVE.md's "Open questions to resolve during build" (B4 panel section): forks 1–5 are all settled; one panel-polish detail remains open. Fork 4 already settled that the active-bank indicator must be "visually unmistakable" — only its placement is undecided. **The wart.** No placement chosen yet among three candidates: per-region headers, a single header readout, or a lit-tab treatment. **Intended fix.** Not stated in the source beyond the three candidate placements above — the choice among them is the open item. This is explicitly a panel-polish detail. **The constraint the fix MUST handle.** Not stated in the source. **Priority / risk.** Not stated as a priority level; the source characterizes this as a "panel-polish detail." **Done looks like.** Not stated in the source beyond choosing one of the three placement options. ## Confirm the card name strip reads legibly at the shipping cell size (Ψ-W2-T1 DAW verification) **Context.** Ψ-W2-T1 (`capture-naming`) put the capture's label on the docked panel card, across the top of the cell, drawn OVER the waveform thumbnail. Review found the strip's text/primary was measured at ~1:1 contrast against the accent-lime waveform fill at the shipping 140×84 cell size — a loud capture's peak reaches into the strip on 12 of its 13 rows — and remediated it with a bg/base scrim behind the name (`kCardNameScrimAlpha`, `core/ui/theme.h`) sized so the composite clears the WCAG 4.5:1 body floor against both the bare fill and bare bg/cell (pinned in `test_theme.cpp`). **The wart.** The floor math is verified; the actual on-screen read is not. No `[verify — DAW]` deferral was filed for this track's acceptance criterion ("the panel card shows the name") when it landed, unlike the sibling Ψ tracks. **Intended fix.** N/A — no code change. Daniel views the docked panel with real captures (quiet and loud material, long and short names) and confirms the name reads over the waveform at the shipping cell size. **The constraint the fix MUST handle.** N/A — verification only. **Priority / risk.** Not stated. The math clears its floor with real margin (see `testCardNameScrimClearsBodyFloorOnItsWorstBackground`), so this is a confirmation step, not a suspected defect. **Done looks like.** Daniel confirms the card name reads legibly over both quiet and loud waveform material at the shipping 140×84 cell size, or a follow-up adjusts the scrim alpha and this entry is re-filed against the new value. ## A realtime capture interrupted by a project switch leaves an untracked file behind **Context (found by the tracking-consolidation review, 2026-07-30).** `DriveRealtimeCapture` detects that the active project is no longer the one the in-flight capture belongs to, aborts the backend, and drops the handle. On a `Done` abort the backend has *already* moved the recorded WAV into the **original** project's bank folder (`capture_realtime_finalize`), so a file the tool created exists with no bank entry and no ledger record. **The wart.** This is the one hole in "no silent gaps": a system-created file that is never recorded. It is in the safe direction — an untracked file is foreign, so prune will never reclaim it — but it is permanent, and the bank folder grows by one orphan per interrupted record. **Intended fix.** Record the birth against the project the capture belongs to. Neither half is available at the switch point: `session`'s ledger and `saveToActiveProject` both target the *active* project, which is by definition the wrong one here. **The constraint the fix MUST handle.** Writing the record into the now-active project would attribute another project's file to it — a worse error than the gap, since prune would then consider deleting a file it does not own the folder for. Deleting the stranded file instead was considered and rejected: it is the user's just-recorded audio, and prune is the system's only deletion authority over bank-folder bytes (`shell/persist/CLAUDE.md`) — a shell self-cleanup exemption covers transient scratch, not a finished recording. The fix therefore needs a deferred write against a *named* project (or a re-entry into the original project on the next poll), not a change at the abort site. **Priority / risk.** Low / deferred. Mitigated in the meantime: the console message names the stranded file's project-relative path, so the operator can recover or remove it rather than discovering it later as an unexplained orphan. **Done looks like.** Switching projects mid-record leaves the recorded file with a ledger record in the project it belongs to, so a later prune of that project can reclaim it normally. ## ~~Raise the stage-time ceiling above 2 s for long-decay sound design~~ — SCHEDULED, no longer deferred **This entry is discharged into `docs/PLAN.md` at Γ-W1-T1 and is retained only as a pointer.** Daniel reversed Γ-F3 the same day he ruled it (2026-08-01): *"extend the stage lengths to 10s."* `kEnvTimeMaxSeconds` / `kGateStageMaxSeconds` move **2.0 → 10.0 in Γ-W1-T1**, beside the taper work rather than after it. **Why the reversal, since the deferral's reasoning was sound.** The deferral said the right time to judge a 5× range change is with the new taper in the DAW under the hand. What changed is not that judgement but the **cost of waiting**: Ruling 1 schedules VST3 parameters inside the same phase (Γ-W4-T1), and a range endpoint is part of the host-facing normalization exactly as much as the curve between the endpoints is. Raising the ceiling is free this wave and permanently expensive four waves later — the same one-way door `docs/product/parameter-automation.md` §4 states for the taper itself, and §8 sweeps for exhaustively. **What this entry contributed, and where it now lives.** Its two prerequisites (the log taper; `resetDeckParam` bypassing the taper, since 2.0 is a power of two and 10.0 is not) were already in Γ-W1-T1 and are now load-bearing rather than incidental. Its named hard part — *"the constant change is trivial; keeping the drawing legible is not"* — is now in-scope design work, specified at `docs/product/instrument-control-surface.md` §4.3.1: at 10 s a 30 ms attack is 0.3 % of the AHDSR schematic's stage domain, and the answer is to make the schematic axis **be** the taper, so a node's position within its stage slot is its knob's needle position. **Nothing here is actionable as a TODO.** Delete this entry when Γ-W1-T1 lands. ## Decouple the instrument reload from VST3 activation **Context (Daniel, 2026-08-01 — Phase Γ fork Γ-F6, ruled closed).** Γ-W1-T2 ships the plugin's first latency reporting: `getLatencySamples()` returns 0 with the limiter off and the lookahead with it on, and the toggle calls `IComponentHandler::restartComponent(kLatencyChanged)`. The vendored SDK defines that flag as a host **deactivate/reactivate** (`pluginterfaces/vst/ivsteditcontroller.h:105-108`). **Dynamic latency reporting is routine for VST3 instruments and REAPER handles it as a matter of course** — the deactivate/reactivate is the normal contract, and for a typical plugin `setActive` only allocates and frees buffers. Γ-F6 was originally posed as "is this SDK cost acceptable?"; Daniel's answer relocated it: *"you have to have missed something, I used plenty of VST3s inside of REAPER that report PDC dynamically... Toggling the limiter killing the voices isn't a deal breaker though, the limiter will either be on or off on its instance, toggling during playback is not a use case."* **The wart — and it is ours, not the SDK's.** `ReaSamplerProcessor::setActive(true)` calls `reloadInstrument()` (`src/shell/instrument/reasampler_processor.cpp:89-97`) — a bridge read plus a **full WAV re-decode** plus a fresh engine. `setActive(false)` frees `live_`, `draining_` and the graveyard (`:98-107`). So every host-driven activation cycle — a latency-change restart, an offline-render bracket, any host that deactivates around transport state — pays a disk read and a decode that nothing about activation requires. **Activation currently means two things at once**: "the audio thread may run" and "the decoded `SampleData` is (re)built." Dynamic latency is simply the first feature that makes the cycle user-triggerable. **Intended fix.** Separate the two lifetimes: keep the decoded `SampleData` alive across a deactivate and rebuild only the voice state on reactivate. The mechanism already exists in this file — `rebuildVoiceEngine` performs exactly that shape (drain-slot swap around the already-decoded `SampleData`, no bank re-read, no WAV re-decode) for voice-count and voice-mode edits. This is a lifetime split, not a new mechanism. **The constraint the fix MUST handle.** The deactivate's destruction is deliberate and its reason is documented at the call site: a surviving `live_` would be displaced into the drain slot on reactivate and *"resurrect stale sustained voices as ghosts."* **Voice state must still die across the cycle** — only the decoded PCM survives, and those are two different lifetimes currently collapsed into one. Second constraint: `setActive(true)` is also the non-editor legacy-lift trigger for a pre-v10 blob (its opportunistic `refreshRefsFromBank` copies refs in once the bank blob is readable), so a path that skips the bridge read must keep that lift reachable — the comment at `:90-96` records the residual load-order race it exists to cover. **Priority / risk.** Low; deferred by ruling. Nothing is incorrect today, only wasteful, and Daniel has explicitly accepted the user-visible consequence (held notes cut on a limiter toggle). **Trigger conditions — revisit when any one of these holds:** (a) a second latency-changing control appears, so the cycle stops being a once-per-patch event; (b) the limiter enable is ever wanted automatable, which `docs/product/parameter-automation.md` §3.8 currently forbids *because* of this cost; or (c) the re-decode is observed to be perceptible in REAPER — Γ-W1-T2's review records that observation for exactly this purpose. **Done looks like.** A host-driven deactivate/reactivate cycle costs no disk I/O and no WAV decode; sounding voices are still destroyed across it, with no ghost-resurrection regression; a pre-v10 blob still lifts; and `getLatencySamples()` still derives from persisted state rather than from a transient the deactivate cleared. ## `Sample::sourceMode` has no value meaning "produced by the instrument" **Context (what shipped — Ξ-W2-T1, resample-bake-chain).** A resample bake's landed `Sample` entry (`bake_land.cpp`) never sets `sourceMode`; it is left at the struct default (`SourceMode::MasterMix`) rather than recording that the entry's audio came from the instrument's own offline render, not from a capture backend. **The wart.** A baked capture is indistinguishable, by `sourceMode`, from a master-mix render — the bank has no way to tell "this file was produced by ReaSampler 9000" from "this file was rendered off the master bus." **Intended fix.** Add a `SourceMode` value for instrument-produced audio and set it at the one landing site. **The constraint the fix MUST handle.** `bank_model.cpp`'s deserializer rejects any `sourceMode` value outside `MasterMix(0)..Realtime(5)` by failing the whole bank blob's parse (`parseSample` returns `false`), not just that one field — so appending a new enumerator is a forward-incompatible bank-format change: an older extension build reading a newer project's bank would refuse to load it entirely. This needs its own decision (a version-gated field, or accepting the compatibility cost) rather than a one-line enum append. **Priority / risk.** Low / deferred. Logged at Ξ-W2-T1's review rather than folded in. **Done looks like.** A baked capture's `sourceMode` reads as instrument-produced, and the compatibility question (how an older build reads a bank containing the new value) is answered rather than left to fail closed by accident. ## `instrument_bake` doubles peak memory on the WAV build **Context (what shipped — Ξ-W2-T1, resample-bake-chain).** `runBake` (`instrument_bake.cpp`) copies the render's interleaved `float` buffer (`BakeAudio::interleaved`, `AudioSample = float`) into a `std::vector` before handing it to `buildFloat32Wav`, which takes doubles and narrows back to float for the bank's 32-bit-float WAV contract. **The wart.** The copy roughly doubles peak memory for the bake — an 8-byte double holding a value that started and ends as a 4-byte float — for the duration of the WAV build on a large bake. **Intended fix.** Either give `buildFloat32Wav` (or a sibling entry point) a `float`-input overload so the bake path narrows nothing it doesn't already own in `float`, or narrow lazily during the WAV build instead of pre-copying the whole buffer. **The constraint the fix MUST handle.** `buildFloat32Wav`'s `double` parameter is shared with every other caller in `core/capture/wav_codec`; a fix must not change those callers' contract or add a second WAV-building code path to maintain. **Priority / risk.** Low / deferred. Logged at Ξ-W2-T1's review; correctness is unaffected, only peak memory on a large bake. **Done looks like.** A bake's peak memory no longer includes a full double-precision copy of the rendered buffer, with `buildFloat32Wav`'s other callers unchanged. ## The deck layout rework — SPECCED, and the original shape SUPERSEDED **Status (2026-08-01): no longer a deferral. The design notes Daniel owed this entry have arrived, and they change the shape.** The rework is specced in `docs/product/instrument-control-surface.md` §1 and sequenced as **Phase Γ** in `docs/PLAN.md`. This entry is retained only until that work lands, because one loose end below (the Θ-W4-T2 acceptance criterion) still needs an explicit disposition. **What was superseded, and confirmed superseded by Daniel.** The original entry recorded a directive of Daniel's for **one row of much *taller* decks with knobs stacked *within* a deck** (his example: the filter's static knobs above its envelope knobs). **The new framing replaces that.** The decks stay **single-height with knobs side-by-side**; what becomes one row is the **sound** category (PITCH/RATE, FILTER, VELOCITY, VOICE), with the three envelope decks on a second **contour** row and MASTER as a double-height deck spanning both. The within-deck stacking idea is retired, not deferred. **The measured-geometry block that used to live here has been deleted, not moved.** It was taken at the 840 px floor with `kDeckCellW = 48` and is wrong twice over — Θ-W6-T1 changed both the floor (980) and the cell metrics (60 × 74). The current, re-derived geometry — every group's width, both row totals, and the resulting 1190 × 680 floor — is the table in `docs/product/instrument-control-surface.md` §1.2. **Do not resurrect the old numbers.** The unresolved 864-vs-872 px VELOCITY↔VOICE adjacency-threshold discrepancy is retired with them; it was measured against a layout that no longer exists. **The one live loose end.** Θ-W4-T2's acceptance criterion *"VELOCITY sits immediately to the left of the VOICE group"* is not met at the default window size today. Under the new layout it **is** met by construction — row 1 is PITCH/RATE, FILTER, VELOCITY, VOICE, in that order, at every window width — so the criterion is satisfied rather than retired. Confirm it when Phase Γ-W3 lands and remove this entry. **Done looks like.** Phase Γ-W3 (`deck-reflow`) has landed; the VELOCITY↔VOICE adjacency criterion is confirmed met at the floor width; this entry is removed. ## The AA waveform stroke's cost on the docked bank panel's card thumbnails **Context (what shipped — Θ-W6-T1, legibility-and-antialiasing).** The antialiasing audit fixed the min/max waveform column plot by adding an AA `LICE_FLine` stroke across each column's extremes, on top of the existing fill (`draw_kit.cpp` `drawWaveform`). `drawWaveform` is shared by the editor's hero waveform lanes, the docked bank panel's card thumbnails, and the browse cards — the stroke lands on all three. **The wart.** Measured cost (Release, MSVC 14.44, real LICE, 24 stereo cards × 136 columns = 6528 columns): fill alone 0.070 ms per full-grid repaint, fill+stroke 0.48 ms — the stroke adds ~0.41 ms, about 2.5% of a 60 Hz frame. At card-thumbnail scale the added smoothness is far less visible than on the editor's hero lanes, so the cost is paid on every repaint of every card for a benefit concentrated in one consumer. **Intended fix.** The identified cheap lever: skip the stroke below a card-sized box and keep it only on the editor's hero lanes. **The constraint the fix MUST handle.** Not done, because it is a product call about where the comb artifact — the min/max column plot's jagged outline — actually reads badly enough to matter, not a performance-forced decision (2.5% of a frame on hover/scroll/drag repaint, not a continuous cost, is not itself disqualifying). **Priority / risk.** Low. The measurement is a one-off scratchpad number (`docs/product/visual-design-language.md` §8), not a standing regression guard — re-measure before relying on it again. **Done looks like.** A size threshold (or explicit per-consumer flag) below which `drawWaveform` skips the AA stroke, with the panel/browse cards confirmed still readable and the editor's hero lanes unchanged. ## High-DPI host scaling is unverified (distinct from the antialiasing audit) **Context (what shipped — Θ-W6-T1, legibility-and-antialiasing).** The antialiasing audit (item 13) confirmed every drawn surface renders smooth at 100% scale — the disposition table in `docs/product/visual-design-language.md` §8 is the record. That audit is about rasterization quality at the pixel level the plugin already draws at; it says nothing about what happens when a host scales the plugin window itself. **The wart.** Nothing in the instrument implements `IPlugViewContentScaleSupport`. A host that applies DPI scaling to the plugin window resamples the already-rasterized output rather than asking the plugin to redraw at the target resolution — every AA guarantee the audit just confirmed (and the piano-key uniform-width guarantee, §8.1) holds only at the client-pixel level the plugin itself draws, not above it. **Intended fix.** Not proposed. Implementing `IPlugViewContentScaleSupport` (or confirming the host compositor's resampling is acceptable without it) is the shape of a fix, not yet scoped. **The constraint the fix MUST handle.** Not yet known — no design work has started. **Priority / risk.** Not stated. Recorded as a gap, not a defect: no host behavior has been observed to be wrong, only unverified. **Done looks like.** Either `IPlugViewContentScaleSupport` is implemented and the AA/uniform-width guarantees are re-verified at a scaled client size, or a decision is recorded that host-side resampling of the rasterized output is an accepted tradeoff. ## The analytic stroker's scaled fallback path is unexercised **Context (what shipped — Θ-W7-T1, arc-and-spline-aa).** `blendCanvas` (`shell/instrument/editor_stroke.cpp`) guards against `LICE_EXT_GET_SCALING` being active by falling back to a per-pixel `LICE_PutPixel` path, because the primary raw-bits path derives its geometry from logical width/height while writing through `getRowSpan()` — under an active scale that would misplace the stroke or write past the DIB allocation. **The wart.** Nothing calls `SET_SCALING` today, so the fallback path never runs. Under an active scale it would rasterize the coverage mask at *logical* resolution with each logical pixel expanded to a scale-sized block — geometrically correct but blocky rather than resolution-independent. This connects to the already-filed high-DPI host-scaling deferral above; cross-referenced here rather than duplicated. **Intended fix.** Not proposed — same shape as the host-scaling deferral above: implementing (or verifying) genuine scale-aware rasterization is the shape of a fix, not yet scoped. **The constraint the fix MUST handle.** Not yet known — no design work has started, and none can usefully start before the host-scaling deferral above is resolved, since that is what would first exercise this path. **Priority / risk.** Low / deferred. Recorded as a gap, not a defect: the fallback is guarded, correct-but-blocky rather than wrong, and unreached by anything in the tree today. **Done looks like.** Either the fallback path is exercised under a genuinely scaled bitmap and confirmed to place the stroke correctly, or it is redesigned to rasterize at physical rather than logical resolution once `IPlugViewContentScaleSupport` (or equivalent) makes scaling real. ## The loop intrinsic is folded twice: the bank blob and the instance ref can skew **Context (what shipped).** Two call sites answer the same question — "does this capture have a sustain loop, and where?" — by different routes, and both are load-bearing: - `ReaSamplerEditor::pickedMarkers` (`shell/instrument/editor_session.cpp`) resolves the intrinsic from the **live bank blob** first (`selectSample`), falling back to the instance-owned `SampleRefs` only when the blob is unreadable, then lets `params_.loopOverride` supersede it. - `ReaSamplerProcessor::reloadInstrument` (`shell/instrument/processor_reload.cpp`) resolves it from the **instance ref** via `resolveCapture`, which is the one override-beats-intrinsic fold, and that is what the bake renders and what `bakeWindowNeedsHold` is ultimately asked about. **The wart.** The two can disagree whenever the bank blob's loop for a capture differs from the copy in the instance's own refs table — a recapture that moved the loop points, a hand-edited blob, or an instance that predates the current bank state. The face then draws (and the Hold predicate answers about) one loop while the engine plays another. **Pre-existing.** This split predates the derived-bake-window work; the bake-Hold predicate is only a new *consumer* of `pickedMarkers`, not the origin of the divergence. **Intended fix.** Route `pickedMarkers` through `resolveCapture` so both sites share the one fold, as the bank/refs paths already do elsewhere. **The constraint the fix MUST handle.** `pickedMarkers` runs on the editor's mouse-down arbitration path (every waveform click, not just marker grabs) and deliberately skips its bridge read once an override is set; a unified fold must not put a bank read back on that path. It must also keep the browser-source semantics: the bank is where a *new* capture's intrinsics come from, the refs table is where the *loaded* one's live. **Priority / risk.** Low. Needs a recapture-moved-the-loop scenario to observe, and the failure is a mis-drawn marker or a spuriously shown/hidden Hold knob, not bad audio. **Done looks like.** One fold answers the intrinsic for both the editor's markers and the engine's reload, with a test that moves the bank's loop out from under a loaded instance and shows the two agreeing. ## `ingestHandleSectionCommand` has no unit test **Context (what shipped — Ψ-W1-T3, media-explorer-section).** The Media-Explorer import now dispatches through two hooks — `ingestHandleCommand` (Main, `"hookcommand"`) and `ingestHandleSectionCommand` (Media Explorer, `"hookcommand2"`). Both live in `ingest.cpp`, which compiles straight into the `reaper_reasampler` MODULE target. **The wart.** No `shell/` translation unit in this repo has a test target — every `_tests` executable is a `core/` pure-module target. `ingestHandleSectionCommand` is a two-line command-id comparison; correctness here rests on code review, not CTest. Review verified this constraint is real and the deferral correct. **Intended fix.** Make `action_registry` a linkable library and give it the repo's first `shell/` test target, driven by a fake `reaper_plugin_info_t`. Its own header (`reaper_plugin.h:153-172`) shows `Register` is a plain member-function pointer on the struct, not a REAPER API pointer resolved through `REAPERAPI_LoadAPI` — a fake instance needs no live REAPER process to exercise `rec->Register(...)` calls. Once `action_registry` is test-covered, move the Media-Explorer section registration into it. **The constraint the fix MUST handle.** The extraction alone buys nothing: `action_registry` has no test target today either, so lifting `ingestHandleSectionCommand` into it without also standing up the test target just relocates the untested code. The same follow-up could collapse `ingest.cpp:466-472`'s hand-rolled `command_id`+`gaccel` pair onto `action_registry::registerAction`, which already does exactly that dance for the Q-W6 table. **Priority / risk.** Low / deferred. `ingestHandleSectionCommand` is a two-branch comparison, reviewed and correct at this scope; the gap is the missing test seam, not a known defect. **Done looks like.** `action_registry` is a linkable library with its own `shell/`-first CTest target driven by a fake `reaper_plugin_info_t`; the Media-Explorer section registration and `ingestHandleSectionCommand` move into it and gain unit coverage; and `ingest.cpp`'s own `command_id`+`gaccel` registration collapses onto `action_registry::registerAction` where the shapes match. ## The `&128` multi-track output shape is still DAW-unobserved, and a refusal now rests on it **Context.** The multi-track TRACK capture no longer lands one track's audio under an `Ok`: `renderOffline` refuses every selected-tracks render covering more than one track, both scopes, naming the way out (`render_settings::isMultiTrackStemRender` / `multiTrackRefusalMessage`). What did NOT change is the evidence: the per-track-output reading of `&128` is still INFERRED from the SDK header documenting the single-file bit `&(4<<16)` for item/razor sources only. It has never been observed in a DAW. **The wart.** The refusal is therefore as unverified as the defect it closes. If REAPER in fact sums a multi-track `&128` render into the single literal `RENDER_PATTERN`, the refusal costs a working capture — a user who selects two tracks and captures gets a message where a correct summed file used to land. **Intended fix.** Run the observation in `docs/verify-track-scope-multitrack.md` §3 (a hand-driven Render dialog, source "selected tracks via master", one literal filename, two tracks selected — then count the files REAPER writes). If it comes back "one file per track", nothing to do and the inference is retired into fact. If it comes back "one summed file", the refusal is over-strict for the TRACK scope and should be narrowed back — and the ITEM-scope half is then an OPEN question, not settled: a full-extent item capture already sums a multi-track item selection via `&32|single-file` (`tests/test_render_settings.cpp:262`), so if `&128` also sums, a ranged item capture routed through it sums too, and keeping the item refusal in that branch would make item scope inconsistent with itself across the range boundary (full-extent sums, ranged refuses, same scope). Whether that inconsistency is acceptable or the item refusal should narrow too needs its own look at that point — not decided here. **The constraint the fix MUST handle.** Narrowing the refusal must keep the ITEM scope refusing, must keep `renderOffline` the single seam (so a recipe replay cannot diverge from a fresh capture), and must not re-open the collapse for any caller that reaches `&128` later — the predicate is keyed on the render source precisely so new callers inherit it. **Priority / risk.** Low and bounded either way: the current behavior refuses rather than lands wrong audio, so the cost of being wrong here is a refused capture, not a bad one. **Done looks like.** The `&128` multi-track output shape is DAW-observed and written into `src/shell/capture/CLAUDE.md` as fact rather than inference, and the refusal is either kept as-is or narrowed to the item scope with that observation cited. ## A `SelectedItems` recipe replays against whatever items are selected then **Context (surfaced by Ψ-W1-T1, capture-range-exactness).** `RunRecaptureFromSource` rebuilds a `CaptureRequest` from the recorded `CaptureRecipe` and resolves its source tracks by GUID. `renderOffline` engages `RenderTrackSelection` only when the recipe's source mode is `SelectedTracks`, which is what makes a ranged item capture and a track capture replay against their recorded tracks rather than the live selection. **The wart.** A recipe whose source mode is `SelectedItems` — every pre-fix item-scope capture, and every post-fix full-extent one — renders `&32`, which prints whatever items happen to be selected when the replay fires. The recorded recipe therefore does not fully determine the audio it reproduces, which is what "recapture from source" promises. **Intended fix.** Not proposed. The recipe stores tracks and a range; it carries no item GUIDs, so no guard on the shell side can reconstruct the item selection from what is recorded. Closing it means widening `CaptureRecipe` (a wire-format change with a version rung) or re-sourcing full-extent item captures through the tracks render too, which would drag them onto the isolation path for no gain. **The constraint the fix MUST handle.** Widening the recipe must keep every already- persisted recipe readable, and must not make a replay depend on items that no longer exist — a deleted source item has to degrade to a stated refusal, not a silent substitution. **Priority / risk.** Pre-existing; not introduced or worsened by the range-exactness work. Harmless when the user re-runs a recapture with the same items still selected, wrong when they do not. **Done looks like.** A `SelectedItems` recapture either reproduces its recorded audio from the recipe alone, or refuses with a message naming what the recipe cannot pin down. ## An overlapping item on the source track itself is not isolated from a ranged item capture — DECIDED, not deferred **Context (surfaced by Ψ-W1-T1, capture-range-exactness).** The re-source to the selected-tracks render (`&128`) needed transient upstream silencing so an item capture did not also print folder children and receives; `render_isolation` (`UpstreamIsolation`) covers both. A third widening exists in the same shape: a non-selected item on the SAME track that overlaps the requested range is now audible in the render, where the pre-fix `&32` selected-items source excluded it by construction (that source only ever prints the selected items). **This is a decision, not a gap.** `src/shell/capture/CLAUDE.md` states the reasoning in full and it is not repeated here: `UpstreamIsolation`/`render_selection` silence and select TRACKS because the recipe that replays a capture stores tracks and a range, never item GUIDs — a mute plan keyed to today's overlapping item could not be recomputed at replay time, so muting items would make the capture stop reproducing itself. The named candidate (a) in `docs/PLAN.md` §Ψ-W1-T1 carried exactly this semantic edge; it was weighed against candidate (b) (an item-bounds render with a derived start time) and (a) shipped with the edge accepted rather than closed. **Priority / risk.** Low in the common case (one item per track over the captured range is the normal shape); a project with deliberately overlapping items on one track is the one that surfaces it, and the practical mitigation is unchanged from before this track: select/move the neighbour, or capture at track scope instead. **Done looks like.** Nothing to do — recorded so a future reviewer does not read the non-isolation as an oversight and re-propose closing it against the recipe's stated tracks-and-range-only shape. ## Resample-bake landings don't apply the lossless mono collapse to a dual-mono render **Context (surfaced by Ψ-W2-T2, mono-collapse).** The collapse (`collapseCapturedFileToMono` / `core/capture/wav_codec::collapseToMono`) ships for every extension capture path — offline, realtime, batch, recapture — but not for `bake_land.cpp`'s `landOne`, the resample bake's landing function. A dead-center instrument render (the common case that motivated Ψ.6 in the first place) is exactly the dual-mono shape the predicate collapses, so an un-collapsed bake keeps paying for the second channel it doesn't need. **Not deferred for the reason once given.** `landOne` reads the staged file into `bytes` once (`bake_land.cpp:101`), parses its layout (`:105`), hashes it (`:126`), derives the channel count twice (`:131`, `:178`), and writes it (`:165`) — all from that same one buffer, so collapsing `bytes` right after the layout parse would keep the hash, the channel count, and the written file consistent by construction; there is no ordering hazard here to defer around. **The real reason.** `bake_land.cpp` is Phase Ξ's freshly-landed surface (Ξ-W2-T1, the resample bake chain) and another team is actively remediating it. Landing a mutation there now would cross tracks mid-remediation for no urgent gain — the mono propagation this item would add is a size win, not a correctness one. **A mono capture already propagates through the bake for free**, so this item is scoped to the dual-mono-*render* case only: `runBake` / `instrument_bake.cpp` already renders however many channels the dialed sound has, and `bake_render.cpp:38` reads `sample.channelCount()` off that render rather than hardcoding 2 — a mono-programmed sound already bakes to a mono file today, with no change needed. **Intended fix.** Once `bake_land.cpp` is quiet, call `collapseToMono` on the staged `bytes` in `landOne` right after the layout parse (`:105`) and before the hash (`:126`), matching the offline/realtime insertion point (post-parse, pre-identity-read). **Priority / risk.** Low — a size optimization on an already-correct path, not a precision-invariant gap; the bake's dual-mono case still lands as a valid (if larger) stereo file today. **Done looks like.** A dead-center instrument bake lands as a 1-channel file with `Sample::channelCount` matching, the same way an offline dead-center capture does; a true-stereo bake is byte-identical to today's output. ## A 0-byte render can still pass every gate under Auto/Manual tail (narrowed, not closed) **Context (surfaced by Ψ-W3 review).** `OfflineRenderBackend::capture`'s exists-check passes for a 0-byte file, and the bounds gate used to fire only when `expectedFrames > 0` — an invalid/empty layout read `expectedFrames == 0` and skipped the gate rather than refusing, so a 0-byte render reached `stampCaptureSample` and landed as `CaptureStatus::Ok` with an empty `contentHash` and `channelCount == 0`. **Narrowed.** `shell/capture/render_bounds_gate` now refuses an unmeasurable render (invalid layout, or a layout declaring no sample rate) instead of skipping it. That covers `TailMode::None` only — the gate does not judge Auto/Manual, which add frames by design, so a 0-byte render under either of those still lands as `Ok`. The refusal reuses `CaptureStatus::BoundsMismatch` rather than minting its own status; the earlier note here preferred a distinct status, and that preference is unresolved, not withdrawn. **Intended fix.** Reject a 0-byte / unparseable render right after the exists-check, on every tail mode, before anything downstream reads it. ## An offline capture can be refused for a short render — root cause open **Symptom (live, 2026-08-02).** A capture over [0.000000s, 4.067797s) at 48 kHz was refused: `Render produced 195216 frames but the requested range is 195254`. 38 frames short — two orders of magnitude outside the gate's one-frame tolerance, so the tolerance is not what refused it. **Hypothesis A — the render bounds itself to the media it can see.** REAPER's selected-items render source (`&32`) derives its bounds from the selected items' own extents (`src/core/capture/CLAUDE.md` §Gotchas — itself an inference from an observed defect, not a header fact). If a time-bounded selected-tracks render (`&128`) does the same thing against content extent, a range running past the end of its material comes up exactly as short as the material is. **Hypothesis B — a trailing-silence trim fires anyway.** `TailMode::None` sets `RENDER_NORMALIZE = &(4<<16)` (disable all postprocessing) and `RENDER_TRIMEND = 0`. If REAPER trims regardless of that bit, a range whose material decays before its end loses exactly the decayed frames. **Not excluded — the gate itself.** `renderHonoredBounds`' one-frame tolerance is empirical, not proven (`src/core/capture/render_window.h`): a renderer that resolves the window's two edges by DIFFERENT conventions can sit two frames from `frameCountFor`'s answer on a correctly-honored render. That cannot account for 38 frames, so it is not this refusal — but it means a future one- or two-frame refusal may be ours, which is why the tolerance was not widened on speculation. Widening it is a precision-invariant decision, not a bug fix. **How it gets decided.** `docs/VERIFICATION.md` §Capture range and bounds, the three numbered blocker steps: step 1 separates A's `&32` path from the shared `&128` path (and says how to tell when it failed to), step 2 asks whether the render is short at all, step 3 reads the retained refused render to place the missing frames. Nothing here should be "fixed" before that comes back.