Files
reasampler/docs/TODO.md
T

908 lines
70 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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_<guid>` 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_<guid>` 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.
## 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.
**RESOLVED — Γ-W2-T2 (`loop-crossfade-ux`), incidentally.** Giving every mark the cap-grip the crossfade already had is what closed it: the start marker now carries an 11x10 cap in the overlay's top strip, whose nominal area (110) is smaller than the node's fixed pick box (169), so the cap wins the coincident pixel and the marker is reachable again. No priority rule was added and `resolveWaveformClaim` is byte-for-byte unchanged — but the cap slot's own nominal area DID move, from the old clipped-actual measure (60 at frame 0) to the new nominal 110 every cap now feeds it (`markerHandleRect`'s own unclipped area). That move leaves the `cap < node < column` ordering unchanged only because 110 is still under the node's fixed 169 — the outcome held, not the area. Below the cap strip the node keeps the click, which is correct: that is where the node is actually drawn for any non-degenerate envelope. Pinned by `testAMarkCapOutranksACoincidentEnvelopeNodeInTheTopStrip` (`tests/test_spline_edit.cpp`). `Origin` was not touched and `isDraggable`'s shape rules are unchanged.
## Active-bank indicator placement (B4 polish)
**Context.** CONTEXT-ARCHIVE.md's "Open questions to resolve during build" (B4 panel section): forks 15 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.
## The editor's drag state machine has no seam, and `reasampler_editor.h` is near the ceiling
**Context (Γ-W3, meter re-review).** `reasampler_editor.h` stands at **564 lines** against the
~600-line ceiling — 36 lines of margin — and it keeps growing because every new surface on the
Sample face adds its transient state there. The obvious seam is the drag state machine: `drag_`
plus the per-gesture anchors it is read against.
**Why it was declined rather than taken.** `drag_` has **42 references across 13 shell TUs**
(measured over `src/shell/instrument/*.cpp`; the declaration in the header is additional). Of
the six input TUs, three write it and branch on it (`editor_input`, `_waveform`, `_curve`) and
three only write it (`_chrome`, `_browse`, `_deck`) — which is what makes the anchor invariant
observed rather than enforced. Extracting it is a real refactor of the editor's input half, not
a header move — and doing it inside a wave whose subject is the MASTER deck would have put an
unrelated high-blast-radius change in the same diff. Declining was right; leaving it unrecorded
was not.
**The shape a fix would take.** A `DragState` type owning the kind plus its anchor payload,
with the input TUs mutating it through named transitions rather than assigning `drag_` and its
anchors independently — which is also what would let the invariant "an anchor is only readable
while its own `DragKind` is in flight" be enforced rather than observed. `editor_interaction.h`
already holds the `DragKind` vocabulary and is the natural home.
**Priority / risk.** Low, but the margin is the clock: the next surface that adds two members to
the header takes it over the ceiling, and at that point the seam gets chosen under time pressure
by whoever is unlucky. Take it before that, not after.
**Done looks like.** `reasampler_editor.h` is back under the ceiling with room; no TU assigns
`drag_` and an anchor as two independent writes; and the transitions are named where the
`DragKind` catalogue already lives.
## `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<double>` 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.
**Re-confirmed still accurate (2026-08-02), after the mono-collapse landing touched
`wav_codec` adjacent to this site.** `applyMonoCollapse` operates on the staged bytes in
`bake_landing.cpp`'s `prepareLanding`, upstream of and unrelated to `runBake`'s
`std::vector<double>` copy in `instrument_bake.cpp`; `buildFloat32Wav`'s signature is
unchanged. The wart stands exactly as described above.
**Current blocker.** Not taken this wave because `instrument_bake.cpp` is being edited
by a live VST3-parameter track.
**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 1198 × 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
`<module>_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`'s `ingestRegisterActions` 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`
(`test_render_settings.cpp`'s `testMultiTrackStemRenderIsNamedForRefusal`), 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.
## A 0-byte render can still pass every gate under Auto/Manual tail (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, then reopened as an asymmetry.** `shell/capture/render_bounds_gate` was
first changed to refuse an unmeasurable render (invalid layout, or a layout declaring no
sample rate) instead of skipping it — but that gate only ever judges `TailMode::None`,
so a 0-byte render under Auto/Manual still landed as `Ok`, while `None` now refused and
quarantined the identical file. The two tail modes disagreed on a defect neither should
accept.
**Closed.** `capture.cpp` now checks `checkRenderedFileNotEmpty` right after the
exists-check, on every tail mode, before the `TailMode::None`-only bounds gate runs —
a 0-byte render is refused and quarantined identically regardless of tail mode. 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.
## `renderHonoredBounds`'s one-frame tolerance is empirical, not proven
**Context.** The millisecond-floor defect that motivated this gate is closed
(`docs/COMPLETED.md`), but the gate itself — `render_window.h`'s
`renderHonoredBounds` — carries a one-frame tolerance that carried through the fix
unchanged and was never itself proven.
**The wart.** 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 the 8- and 38-frame shortfalls
the floor produced (`docs/COMPLETED.md`), so it was not the cause of those
refusals — but it means a future one- or two-frame refusal may be the gate's own
edge convention rather than a real defect.
**Intended fix.** Not proposed. Widening the tolerance is a precision-invariant
decision, not a bug fix, and was deliberately not taken on speculation.
**Priority / risk.** Low. Nothing to date implicates the tolerance itself;
recorded so a future narrow refusal is investigated rather than assumed to be
the same floor.
**Done looks like.** Either the tolerance is confirmed correct by a DAW
observation that isolates edge-convention behavior from bounds-floor behavior,
or it is widened with the reasoning recorded.
## `TailMode::Auto` and `Manual` have no automatic bounds observation
**Context.** `render_bounds_gate.cpp`'s `checkRenderedBounds` returns early for
anything but `TailMode::None`, so the millisecond-floor fix (`docs/COMPLETED.md`)
was measured only against `TailMode::None` — Auto and Manual were never
observed, before the fix or after it.
**The wart.** The inference that Auto/Manual are fixed too is sound — same
bounds path, same floor, same fix — but it is an inference, not a measurement.
`checkRenderedFileNotEmpty` runs on every tail mode and still catches a 0-byte
render, but that is the ONLY automatic bounds signal Auto/Manual get; a
floored or otherwise short-but-nonzero render under either mode would land as
`Ok` with nothing to catch it.
**Intended fix.** Not a code change — a DAW observation. `docs/VERIFICATION.md`'s
"Capture range and bounds" section already carries the manual check: repeat an
off-grid-start capture at Manual over a source loud to the window's end and
check the landed frame count against window + `tailMs`; Auto can't be checked
by count (it trims trailing silence) and needs the null test by ear/inversion
instead.
**Priority / risk.** Low. Both modes share the same bounds path as the
now-fixed `TailMode::None`, so nothing suggests they still floor — but nothing
confirms it either.
**Done looks like.** A DAW-observed Auto and Manual capture, each landing the
window as requested, closes the inference into fact — or surfaces a
mode-specific divergence this entry does not currently know about.
## Floor, ceil and round are not the identity on a millisecond grid point in binary double (caution, not an open question)
A discarded compensation design for the millisecond-floor defect
(`docs/COMPLETED.md`) rested on the premise that a grid-aligned value survives a
bare floor/ceil/round unchanged. That is false in binary double: `1.007 * 1000
== 1006.9999999999999` (floors to 1006, not 1007), and `4.068 * 1000 ==
4067.9999999999995` (floors to 4067, not 4068). The compensation this premise
would have supported is no longer needed — the fix moved the render to a bounds
mode that does not floor at all — so this is not a live open question. Recorded
because it would bite any future millisecond-grid arithmetic that assumes an
on-grid value is safe from a bare floor: `render_window.h`'s own
`isOnMillisecondGrid`/`msFlooredEndFrameCount` already carry the nanosecond
tolerance that handles it correctly on this codebase's side of the boundary; the
trap is for whoever writes the next piece of grid arithmetic without that guard.
## `capture.cpp` is over the ~600-line ceiling — the seam is identified, taking it is blocked
**Context.** Removing the settled bounds experiment's instrumentation (the console
verdict and the three-checkpoint `RENDER_STARTPOS`/`ENDPOS` read-back) brought the file
from 697 to **620 measured lines**, against root `CLAUDE.md`'s ~600-line ceiling. The
seam that entry originally named is gone with the instrumentation; nothing left in the
file is bisectable without cutting load-bearing why.
**The remaining seam is a real responsibility boundary**, and the file header already
names it as two things: `OfflineRenderBackend::capture` (the offline render driver)
versus the four helpers BOTH backends share — `makeUniqueTag`, `captureNameFor`,
`collapseCapturedFileToMono`, `stampCaptureSample` — consumed by `capture_batch`,
`capture_orchestrator`, `capture_realtime_shell`, `capture_realtime_finalize` and
`render_in_place`. Lifting those four into their own TU takes the driver under the
ceiling and gives the cross-backend steps their own home.
**Why not taken.** `src/shell/capture/` has no `CMakeLists.txt` of its own — its sources
are listed in `src/app/CMakeLists.txt`, so a new TU needs an edit there. Forcing the
four helpers into an existing TU instead (orchestrator, realtime finalize) would put
them in a wrong home to dodge one build-file line, which is worse than the overshoot.
## bext TimeReference read-back is not a floor detector (dead end, recorded so it is not re-litigated)
Idea considered and dropped: read a captured file's `BWF:TimeReference` tag back as
independent evidence on the START-edge millisecond-floor question above. `WDL/metadata.h`'s
`WriteMetadataPrefPos` only writes it past its `prefpos > 0.0` guard (`:1301`) — that guard
alone is enough to rule the approach out. One nuance worth recording separately: the
millisecond quantization at `:1382-1383` (`AddMexMetadata`'s `ParseUInt64(val)/1000.0`)
belongs to the MEX caller, not proven to be `WriteMetadataPrefPos`'s own behavior or the
renderer's direct call into it — so even without the guard, a floored bext tag would show
that MEX quantizes, not that the render engine does.
## Split `render_bounds_gate` on the verdict/message vs. filesystem seam
**Context (Ψ-W3 round-two review).** `render_bounds_gate.cpp` mixes pure verdict
composition (frame-count comparison, message text) with filesystem I/O
(`retainRefusedRender`'s `fs::create_directories`/`fs::rename`) in one shell TU. The
verdict half has no REAPER dependency and no filesystem dependency either — it could be
`core/capture`, unit-tested directly instead of only through the pure `render_window`
functions it calls. The reviewer's suggested split: verdict + message composition pure
and testable in `core/capture`, leaving only `retainRefusedRender` (and the two thin
`checkRendered*` entry points that call it) in `shell/capture`.
**Why deferred.** Out of scope for the dispatch that surfaced it — a structural split,
not the bug fix in front of it.
**Filed also because it's already slightly wrong today.** `render_bounds_gate.cpp`
touches no REAPER API (it is `<filesystem>` + the pure `core/capture` modules only), so
`src/shell/capture/CLAUDE.md`'s "this directory is the REAPER API surface only" scope
line no longer describes it — one more small argument for eventually moving the
REAPER-free half to `core/capture`, separate from the untested-filesystem-code gap
above.
**Done looks like.** `core/capture` owns a pure `checkRenderedBoundsVerdict`-shaped
function under a `<module>_tests` target with no REAPER, no VST3 SDK, and no
filesystem includes; `shell/capture/render_bounds_gate` shrinks to the file-move and
the two callers' plumbing.
## The capture path ignores `saveToActiveProject`'s return at four sites
**Context.** `saveToActiveProject()` returns false for exactly two reasons — no active
project, or an unsaved one — and in both cases NOTHING was written. Four capture sites
discard that return outright: `capture_orchestrator.cpp`'s `RunCapture`, `capture_batch.cpp`'s
`RunBatchCaptureItems` and `RunBatchCaptureRazor`, and `realtime_lifecycle.cpp`'s `CommitRealtimeResult`.
**The wart.** A capture on an unsaved project renders the file into the bank folder, adds
the `Sample` to the in-memory book, records a birth record in memory — and loses all three
on reload. The bytes stay on disk with no index entry and no persisted ledger record, so
they are a foreign file prune will never reclaim (an unrecorded file is untouchable by
design — `core/tracking/CLAUDE.md`). Nothing is printed. The bank-op family already reads
this return and discards its undo point on a false; the capture family does not read it at
all.
**Why filed, not fixed.** Pre-existing, and the right answer is a product decision this
dispatch had no mandate for: refuse the capture up front, keep it and warn, or prompt for
a Save-As (the bank ops chose "quiet persist by design, deliberately no Save-As prompt" —
whether capture should follow is a separate call).
**Done looks like.** A capture attempted with no saved project either does not write bytes
at all, or writes them and says so in a sentence naming what will not survive a reload —
and the choice between those two is recorded rather than implicit.
## `panel_input`'s wheel handler persists the whole book per wheel message
**Context.** `panel_input.cpp``handleWheel` calls `markTailDirty()` on every wheel
message that actually moves `manualMs`, while the pointer is over the footer in Manual
mode. (It coalesces sub-notch deltas within ONE message and no-ops at a bound, so the
count is wheel messages that changed the value, not raw notches.)
**The wart.** `markTailDirty` is `saveToActiveProject()` — a full `BankBook` serialize plus
six ext-state value writes on the UI thread — for a setting that is one number. A flick
over the footer is a dozen of them in a few hundred milliseconds. Disproportionate rather
than incorrect: no guardrail is violated (this is nowhere near the two named hot paths),
and the writes are idempotent.
**Intended fix.** Coalesce: mark dirty and let one timer tick flush, the same shape the
panel already uses elsewhere for repaint batching.
**Done looks like.** A continuous wheel gesture over the footer produces one persist, and
the value that lands is the gesture's final one.
## `RunCaptureItemAssign`'s undo point does not follow the pattern its comment claims
**Context.** `capture_orchestrator.cpp`'s `RunCaptureItemAssign` states that the action follows
the bank-op family's discard-on-unsaved pattern.
**The wart.** It does not: `RunCaptureItemAssign` records the undo point unconditionally whenever
`sampleId` is non-empty, and never consults the persist's return at all. So on an unsaved
project it records an undo point for ext-state that was never written — the empty
no-effect entry `persistBankOp`'s guardrail exists to avoid. The comment describes the
intended behavior, not the code.
**Why filed, not fixed.** It is one instance of the capture-family gap filed above, and
fixing it alone would leave the other four sites divergent. Fix them together, or explain
in one place why capture differs from bank ops.
**Done looks like.** The comment and the code agree, and the whole capture family answers
the unsaved-project case one way.
## `core/tracking/CLAUDE.md`'s untracked-file enumeration says "reaches the `.rpp`" too loosely
**Context.** `src/core/tracking/CLAUDE.md` §"Invariants" — "No silent gaps — in memory at
creation, on disk at the next save" — enumerates how a created file can stay
untracked, and describes the ledger as reaching the `.rpp` at the following
`saveToActiveProject()`.
**The wart.** `saveToActiveProject()` writes REAPER's IN-MEMORY project state and marks the
project dirty; REAPER writes the `.rpp` on the project's own save, which may be much later
or never. The sentence was already loose before this branch and is not made wrong by it —
but it is the same over-claim ("a write reached the file on disk") the bake's reporting
pass spent several rounds removing from its own sentences, so it should read the same way.
**Why filed, not fixed.** Editing another layer's own CLAUDE.md from a persist-and-report
dispatch is exactly the boundary crossing the per-directory docs exist to prevent. It is a
doc-keeper edit.
**Done looks like.** The enumeration distinguishes "in the project's state" from "on disk
in the `.rpp`", and does not gain a second home for the distinction.
## `view_mode_model.cpp` is over the ~600-line structural bar, and `view.cpp` is close behind
**Context (surfaced by the FX-GUID keying track).** Root `CLAUDE.md`'s structural
heuristics put an ~600-line ceiling on any one file, with a documented responsibility
seam as the required method for splitting it, not an arbitrary bisection.
`src/core/view/view_mode_model.cpp` measures **815 lines** (verified this pass),
up from 715 before the FX-GUID keying track's v2 schema addition made it worse.
**The named seam.** The JSON codec — `serialize()`/`deserialize()` — wants its own
`view_state_codec` TU in `src/core/view/`.
**Why it was deferred, and this reasoning should survive.** `serialize()` is a
`ViewModeModel` member and `deserialize()` a static factory (confirmed:
`std::string ViewModeModel::serialize() const` and
`std::optional<ViewModeModel> ViewModeModel::deserialize(const std::string&)`), both
reaching private state — so extraction needs either a friend declaration or a new
public accessor surface. Doing that in the same commit that changed the byte format
the golden test literals pin would roll a format change and a codec extraction
together, which is the riskier order.
**Also over the bar, blocked differently.** `src/shell/view/view.cpp` measures
**625 lines** (re-measured after the deferred FX-park split took `fxGuidString`,
`liveFxGuids` and the park/restore FX writes out into `view_fx_park`). Its remaining
seam is blocked not by a private-state/friend question but by a build file another
team owns: `src/shell/view/` has no `CMakeLists.txt` of its own today — a new TU
there costs one `target_sources` line in `src/app/CMakeLists.txt` instead.
**Priority / risk.** Not stated.
**Done looks like.** `view_mode_model.cpp`'s JSON codec is extracted into its own
`view_state_codec` TU (with the friend/accessor question resolved deliberately, not
sidestepped), dropping the file under the ~600-line ceiling; `view.cpp`'s own path is
unblocked once the build-file ownership question is resolved.
## FX-GUID stability for `restoreFxOffline` is unverified in the DAW
**Context.** The Design View park/restore FX keying (`applyRestore`,
`src/shell/view/view_fx_park.cpp`) rests on `TrackFX_GetFXGUID` returning an identity that
survives a chain reorder while a track is parked. SWS issue #802 reports that after
`SNM_MoveOrRemoveTrackFX` reorders a chain, the FXID lines do not follow the plugin
(`SNM_PreObjectState()``RemoveAllIds()`) — if that still holds, an SWS-driven
reorder while parked produces wrong-plugin restores or mass drops, which is the exact
operation this keying targets.
**What must be checked.** Native drag-reorder, an SWS move, save/reload, and two live
instances of the same plugin.
**Already flagged in code — this entry is the tracked home, not a restatement.**
There is a `[verify — DAW]` marker at `fxGuidString` in `src/shell/view/view_fx_park.cpp` and
a note in `src/shell/view/CLAUDE.md`'s Gotchas; point at them rather than restating
them in full.
**Priority / risk.** Not stated.
**Done looks like.** Native reorder, SWS reorder, save/reload, and a
two-instance-of-the-same-plugin case are each observed in a live REAPER session, and
either the identity is confirmed to survive all four, or a degradation is found and
the keying is amended.