65 KiB
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.
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.
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 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.
The editor's drag state machine has no seam, and reasampler_editor.h is near the ceiling
Context (Γ-W3, meter re-review). reasampler_editor.h stands at 563 lines against the
~600-line ceiling — 37 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), and
every input TU both writes it and branches on it. 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.
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-ownedSampleRefsonly when the blob is unreadable, then letsparams_.loopOverridesupersede it.ReaSamplerProcessor::reloadInstrument(shell/instrument/processor_reload.cpp) resolves it from the instance ref viaresolveCapture, which is the one override-beats-intrinsic fold, and that is what the bake renders and whatbakeWindowNeedsHoldis 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: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 landing, the
resample bake's prepareLanding / commitLanding pair. 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. prepareLanding reads the staged file into
prep.bytes once, parses its layout, hashes it and derives the channel count from that
same one buffer, and commitLanding writes that buffer — so collapsing it 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 prep.bytes in
prepareLanding right after the layout parse and before the hash, 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 (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.
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 — 38x 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.
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:343, capture_batch.cpp:266 and
:333, and realtime_lifecycle.cpp:39.
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:450 — 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:364-365 states that the action follows the bank-op
family's discard-on-unsaved pattern.
The wart. It does not: :382-383 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:24-31 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.