Files
reasampler/docs/TODO.md
T
2026-08-01 21:25:37 -04:00

51 KiB
Raw Blame History

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 everywheresample_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.

Intended fix. Not yet proposed. Bringing the staged pass into the shared arbitration slot was the natural first step and has landed; closing the remaining collision needs either a per-affordance priority rule for genuinely coincident precision targets, or accepting the current smallest-area outcome as intended and documenting it as such rather than as an open wart.

The constraint the fix MUST handle. Whatever rule changes must not regress the contour-node/marker and tab/marker arbitration W5 already fixed, and must not make Origin draggable or otherwise touch isDraggable's AHD/AHDSR shape rules.

Priority / risk. Low. Pre-existing, not introduced by W5; the common case (nonzero attack) is unaffected, and the collision requires both a zero-attack stage and a coincident marker/tab to be reachable at all.

Done looks like. A zero-attack AttackEnd node coincident with the start marker (or, on a loop starting there, the crossfade tab) no longer silently claims the click ahead of the marker/tab — either by an explicit priority rule or by a recorded decision that the current behavior is intended.

Active-bank indicator placement (B4 polish)

Context. CONTEXT-ARCHIVE.md's "Open questions to resolve during build" (B4 panel section): forks 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.

A realtime capture interrupted by a project switch leaves an untracked file behind

Context (found by the tracking-consolidation review, 2026-07-30). DriveRealtimeCapture detects that the active project is no longer the one the in-flight capture belongs to, aborts the backend, and drops the handle. On a Done abort the backend has already moved the recorded WAV into the original project's bank folder (capture_realtime_finalize), so a file the tool created exists with no bank entry and no ledger record.

The wart. This is the one hole in "no silent gaps": a system-created file that is never recorded. It is in the safe direction — an untracked file is foreign, so prune will never reclaim it — but it is permanent, and the bank folder grows by one orphan per interrupted record.

Intended fix. Record the birth against the project the capture belongs to. Neither half is available at the switch point: session's ledger and saveToActiveProject both target the active project, which is by definition the wrong one here.

The constraint the fix MUST handle. Writing the record into the now-active project would attribute another project's file to it — a worse error than the gap, since prune would then consider deleting a file it does not own the folder for. Deleting the stranded file instead was considered and rejected: it is the user's just-recorded audio, and prune is the system's only deletion authority over bank-folder bytes (shell/persist/CLAUDE.md) — a shell self-cleanup exemption covers transient scratch, not a finished recording. The fix therefore needs a deferred write against a named project (or a re-entry into the original project on the next poll), not a change at the abort site.

Priority / risk. Low / deferred. Mitigated in the meantime: the console message names the stranded file's project-relative path, so the operator can recover or remove it rather than discovering it later as an unexplained orphan.

Done looks like. Switching projects mid-record leaves the recorded file with a ledger record in the project it belongs to, so a later prune of that project can reclaim it normally.

Raise the stage-time ceiling above 2 s for long-decay sound design — SCHEDULED, no longer deferred

This entry is discharged into docs/PLAN.md at Γ-W1-T1 and is retained only as a pointer. Daniel reversed Γ-F3 the same day he ruled it (2026-08-01): "extend the stage lengths to 10s." kEnvTimeMaxSeconds / kGateStageMaxSeconds move 2.0 → 10.0 in Γ-W1-T1, beside the taper work rather than after it.

Why the reversal, since the deferral's reasoning was sound. The deferral said the right time to judge a 5× range change is with the new taper in the DAW under the hand. What changed is not that judgement but the cost of waiting: Ruling 1 schedules VST3 parameters inside the same phase (Γ-W4-T1), and a range endpoint is part of the host-facing normalization exactly as much as the curve between the endpoints is. Raising the ceiling is free this wave and permanently expensive four waves later — the same one-way door docs/product/parameter-automation.md §4 states for the taper itself, and §8 sweeps for exhaustively.

What this entry contributed, and where it now lives. Its two prerequisites (the log taper; resetDeckParam bypassing the taper, since 2.0 is a power of two and 10.0 is not) were already in Γ-W1-T1 and are now load-bearing rather than incidental. Its named hard part — "the constant change is trivial; keeping the drawing legible is not" — is now in-scope design work, specified at docs/product/instrument-control-surface.md §4.3.1: at 10 s a 30 ms attack is 0.3 % of the AHDSR schematic's stage domain, and the answer is to make the schematic axis be the taper, so a node's position within its stage slot is its knob's needle position.

Nothing here is actionable as a TODO. Delete this entry when Γ-W1-T1 lands.

Decouple the instrument reload from VST3 activation

Context (Daniel, 2026-08-01 — Phase Γ fork Γ-F6, ruled closed). Γ-W1-T2 ships the plugin's first latency reporting: getLatencySamples() returns 0 with the limiter off and the lookahead with it on, and the toggle calls IComponentHandler::restartComponent(kLatencyChanged). The vendored SDK defines that flag as a host deactivate/reactivate (pluginterfaces/vst/ivsteditcontroller.h:105-108). Dynamic latency reporting is routine for VST3 instruments and REAPER handles it as a matter of course — the deactivate/reactivate is the normal contract, and for a typical plugin setActive only allocates and frees buffers. Γ-F6 was originally posed as "is this SDK cost acceptable?"; Daniel's answer relocated it: "you have to have missed something, I used plenty of VST3s inside of REAPER that report PDC dynamically... Toggling the limiter killing the voices isn't a deal breaker though, the limiter will either be on or off on its instance, toggling during playback is not a use case."

The wart — and it is ours, not the SDK's. ReaSamplerProcessor::setActive(true) calls reloadInstrument() (src/shell/instrument/reasampler_processor.cpp:89-97) — a bridge read plus a full WAV re-decode plus a fresh engine. setActive(false) frees live_, draining_ and the graveyard (:98-107). So every host-driven activation cycle — a latency-change restart, an offline-render bracket, any host that deactivates around transport state — pays a disk read and a decode that nothing about activation requires. Activation currently means two things at once: "the audio thread may run" and "the decoded SampleData is (re)built." Dynamic latency is simply the first feature that makes the cycle user-triggerable.

Intended fix. Separate the two lifetimes: keep the decoded SampleData alive across a deactivate and rebuild only the voice state on reactivate. The mechanism already exists in this file — rebuildVoiceEngine performs exactly that shape (drain-slot swap around the already-decoded SampleData, no bank re-read, no WAV re-decode) for voice-count and voice-mode edits. This is a lifetime split, not a new mechanism.

The constraint the fix MUST handle. The deactivate's destruction is deliberate and its reason is documented at the call site: a surviving live_ would be displaced into the drain slot on reactivate and "resurrect stale sustained voices as ghosts." Voice state must still die across the cycle — only the decoded PCM survives, and those are two different lifetimes currently collapsed into one. Second constraint: setActive(true) is also the non-editor legacy-lift trigger for a pre-v10 blob (its opportunistic refreshRefsFromBank copies refs in once the bank blob is readable), so a path that skips the bridge read must keep that lift reachable — the comment at :90-96 records the residual load-order race it exists to cover.

Priority / risk. Low; deferred by ruling. Nothing is incorrect today, only wasteful, and Daniel has explicitly accepted the user-visible consequence (held notes cut on a limiter toggle). Trigger conditions — revisit when any one of these holds: (a) a second latency-changing control appears, so the cycle stops being a once-per-patch event; (b) the limiter enable is ever wanted automatable, which docs/product/parameter-automation.md §3.8 currently forbids because of this cost; or (c) the re-decode is observed to be perceptible in REAPER — Γ-W1-T2's review records that observation for exactly this purpose.

Done looks like. A host-driven deactivate/reactivate cycle costs no disk I/O and no WAV decode; sounding voices are still destroyed across it, with no ghost-resurrection regression; a pre-v10 blob still lifts; and getLatencySamples() still derives from persisted state rather than from a transient the deactivate cleared.

Sample::sourceMode has no value meaning "produced by the instrument"

Context (what shipped — Ξ-W2-T1, resample-bake-chain). A resample bake's landed Sample entry (bake_land.cpp) never sets sourceMode; it is left at the struct default (SourceMode::MasterMix) rather than recording that the entry's audio came from the instrument's own offline render, not from a capture backend.

The wart. A baked capture is indistinguishable, by sourceMode, from a master-mix render — the bank has no way to tell "this file was produced by ReaSampler 9000" from "this file was rendered off the master bus."

Intended fix. Add a SourceMode value for instrument-produced audio and set it at the one landing site.

The constraint the fix MUST handle. bank_model.cpp's deserializer rejects any sourceMode value outside MasterMix(0)..Realtime(5) by failing the whole bank blob's parse (parseSample returns false), not just that one field — so appending a new enumerator is a forward-incompatible bank-format change: an older extension build reading a newer project's bank would refuse to load it entirely. This needs its own decision (a version-gated field, or accepting the compatibility cost) rather than a one-line enum append.

Priority / risk. Low / deferred. Logged at Ξ-W2-T1's review rather than folded in.

Done looks like. A baked capture's sourceMode reads as instrument-produced, and the compatibility question (how an older build reads a bank containing the new value) is answered rather than left to fail closed by accident.

instrument_bake doubles peak memory on the WAV build

Context (what shipped — Ξ-W2-T1, resample-bake-chain). runBake (instrument_bake.cpp) copies the render's interleaved float buffer (BakeAudio::interleaved, AudioSample = float) into a std::vector<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 1190 × 680 floor — is the table in docs/product/instrument-control-surface.md §1.2. Do not resurrect the old numbers. The unresolved 864-vs-872 px VELOCITY↔VOICE adjacency-threshold discrepancy is retired with them; it was measured against a layout that no longer exists.

The one live loose end. Θ-W4-T2's acceptance criterion "VELOCITY sits immediately to the left of the VOICE group" is not met at the default window size today. Under the new layout it is met by construction — row 1 is PITCH/RATE, FILTER, VELOCITY, VOICE, in that order, at every window width — so the criterion is satisfied rather than retired. Confirm it when Phase Γ-W3 lands and remove this entry.

Done looks like. Phase Γ-W3 (deck-reflow) has landed; the VELOCITY↔VOICE adjacency criterion is confirmed met at the floor width; this entry is removed.

The AA waveform stroke's cost on the docked bank panel's card thumbnails

Context (what shipped — Θ-W6-T1, legibility-and-antialiasing). The antialiasing audit fixed the min/max waveform column plot by adding an AA LICE_FLine stroke across each column's extremes, on top of the existing fill (draw_kit.cpp drawWaveform). drawWaveform is shared by the editor's hero waveform lanes, the docked bank panel's card thumbnails, and the browse cards — the stroke lands on all three.

The wart. Measured cost (Release, MSVC 14.44, real LICE, 24 stereo cards × 136 columns = 6528 columns): fill alone 0.070 ms per full-grid repaint, fill+stroke 0.48 ms — the stroke adds ~0.41 ms, about 2.5% of a 60 Hz frame. At card-thumbnail scale the added smoothness is far less visible than on the editor's hero lanes, so the cost is paid on every repaint of every card for a benefit concentrated in one consumer.

Intended fix. The identified cheap lever: skip the stroke below a card-sized box and keep it only on the editor's hero lanes.

The constraint the fix MUST handle. Not done, because it is a product call about where the comb artifact — the min/max column plot's jagged outline — actually reads badly enough to matter, not a performance-forced decision (2.5% of a frame on hover/scroll/drag repaint, not a continuous cost, is not itself disqualifying).

Priority / risk. Low. The measurement is a one-off scratchpad number (docs/product/visual-design-language.md §8), not a standing regression guard — re-measure before relying on it again.

Done looks like. A size threshold (or explicit per-consumer flag) below which drawWaveform skips the AA stroke, with the panel/browse cards confirmed still readable and the editor's hero lanes unchanged.

High-DPI host scaling is unverified (distinct from the antialiasing audit)

Context (what shipped — Θ-W6-T1, legibility-and-antialiasing). The antialiasing audit (item 13) confirmed every drawn surface renders smooth at 100% scale — the disposition table in docs/product/visual-design-language.md §8 is the record. That audit is about rasterization quality at the pixel level the plugin already draws at; it says nothing about what happens when a host scales the plugin window itself.

The wart. Nothing in the instrument implements IPlugViewContentScaleSupport. A host that applies DPI scaling to the plugin window resamples the already-rasterized output rather than asking the plugin to redraw at the target resolution — every AA guarantee the audit just confirmed (and the piano-key uniform-width guarantee, §8.1) holds only at the client-pixel level the plugin itself draws, not above it.

Intended fix. Not proposed. Implementing IPlugViewContentScaleSupport (or confirming the host compositor's resampling is acceptable without it) is the shape of a fix, not yet scoped.

The constraint the fix MUST handle. Not yet known — no design work has started.

Priority / risk. Not stated. Recorded as a gap, not a defect: no host behavior has been observed to be wrong, only unverified.

Done looks like. Either IPlugViewContentScaleSupport is implemented and the AA/uniform-width guarantees are re-verified at a scaled client size, or a decision is recorded that host-side resampling of the rasterized output is an accepted tradeoff.

The analytic stroker's scaled fallback path is unexercised

Context (what shipped — Θ-W7-T1, arc-and-spline-aa). blendCanvas (shell/instrument/editor_stroke.cpp) guards against LICE_EXT_GET_SCALING being active by falling back to a per-pixel LICE_PutPixel path, because the primary raw-bits path derives its geometry from logical width/height while writing through getRowSpan() — under an active scale that would misplace the stroke or write past the DIB allocation.

The wart. Nothing calls SET_SCALING today, so the fallback path never runs. Under an active scale it would rasterize the coverage mask at logical resolution with each logical pixel expanded to a scale-sized block — geometrically correct but blocky rather than resolution-independent. This connects to the already-filed high-DPI host-scaling deferral above; cross-referenced here rather than duplicated.

Intended fix. Not proposed — same shape as the host-scaling deferral above: implementing (or verifying) genuine scale-aware rasterization is the shape of a fix, not yet scoped.

The constraint the fix MUST handle. Not yet known — no design work has started, and none can usefully start before the host-scaling deferral above is resolved, since that is what would first exercise this path.

Priority / risk. Low / deferred. Recorded as a gap, not a defect: the fallback is guarded, correct-but-blocky rather than wrong, and unreached by anything in the tree today.

Done looks like. Either the fallback path is exercised under a genuinely scaled bitmap and confirmed to place the stroke correctly, or it is redesigned to rasterize at physical rather than logical resolution once IPlugViewContentScaleSupport (or equivalent) makes scaling real.

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.

A multi-track TRACK capture renders one file per track and lands one of them

Context (surfaced by Ψ-W1-T1, capture-range-exactness). Track scope has always rendered through RENDER_SETTINGS &128 ("selected tracks via master"), and the ranged item capture now joins it there. The SDK header (~3041) documents the single-file bit &(4<<16) for "rendering selected items or razor edits" only, so it does not apply to &128: the reading is that N selected tracks produce N files. capture.cpp sets RENDER_PATTERN to one literal stem and detects success by std::filesystem::exists, so N stems collapse onto one name and whichever file survives lands as a successful capture carrying one track's audio.

The wart. renderOffline refuses this shape for the ranged ITEM capture (render_settings::isMultiTrackRangedItemRender) because that path was newly routed into it. Track scope with two or more tracks selected has the same exposure and is deliberately untouched — changing a shipped action from "produces a file" to "refuses" is a behavioral-contract change, and it was out of that track's surface.

Intended fix. Not proposed. Three shapes exist and the choice is a product call, not a mechanical one: refuse (matching the item path), render each track and land N bank entries (that is batch capture's meaning, not this action's), or sum the selected tracks into one file (needs a summing render source &128 does not offer).

The constraint the fix MUST handle. The per-track-output reading of &128 is INFERRED from the header's single-file wording, never observed in a DAW. Verify it first — including what REAPER actually writes when N stems share one literal RENDER_PATTERN — because the answer decides whether this is a defect at all.

Priority / risk. Unknown until the DAW check above runs. If the reading is right, a silently-wrong capture on an ordinary two-track selection; if wrong, nothing.

Done looks like. The &128 multi-track output shape is DAW-confirmed, and track scope either produces defined correct output for a multi-track selection or refuses it with a message naming the reason.

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.