Files
reasampler/PLAN.md
T
daniel 3791e6c119 docs: spec capture-tail feature (Milestone T)
Add docs/product/capture-tail.md (authoritative spec) and Milestone T in PLAN.md:
offline auto-trim (-72 dB) + manual tail modes, surgical RENDER_NORMALIZE
(trim-end-only), realtime PCM-decay-scan follow-on, invariant interactions,
and DAW-confirm items.
2026-07-23 15:38:16 -04:00

18 KiB
Raw Blame History

PLAN.md — ReaSampler milestone roadmap

Living milestone roadmap for ReaSampler. Derived from CONTEXT.md's 11-step build order; CONTEXT.md remains the authoritative spec — this file is the tickable checklist, not a re-statement of the spec. When a point lands, doc-keeper removes it here and appends it to COMPLETED.md.

Conventions

  • One checkbox - [ ] = one discrete, independently-landable point.
  • Each milestone opens with a Goal (one line) and a Verify criterion (the acceptance gate; precision invariants pulled in where one applies).
  • Verify-in-DAW points require a manual REAPER run; pure points are gated by CTest.
  • "See CONTEXT.md §…" points at the authoritative detail — do not duplicate it here.

Milestone 8 — RealtimeRecordBackend

Goal: Realtime record behind the same ICaptureBackend, producing identical bank entries. CONTEXT.md §capture (realtime), §Precision invariants. Verify (in DAW): Hidden temp track resamples wet output; recorded file moves into the bank; non-destructive — temp track removed cleanly, source routing and user monitoring restored unchanged.

Note (from M3): The realtime backend captures during playback and does NOT invoke the offline-render path, so it is inherently dialog-free (no render-progress window) — a secondary benefit beyond hardware/performed-FX capture.

  • Hidden-track resample recipe (I_RECMODE/I_RECINPUT/I_RECARM, CSurf_OnRecord/CSurf_OnStop); verify record-mode values against SDK.
  • Resolve wet-master routing that does not alter user monitoring (open question).
  • Move recorded source into bank; populate identical Sample; clean teardown.

Milestone 9 — slots (MPC-style)

Goal: "Capture to slot N" / "insert slot N", MIDI-bindable. CONTEXT.md Build order 9. Verify (in DAW): Slot capture and slot insert fire from MIDI bindings; slot state persists via the index.

  • Slot model + slot↔sample assignment.
  • "Capture to slot N" / "insert slot N" actions, MIDI-bindable.

Milestone 10 — provenance + null-test verify action

Goal: Provenance (parent sample id + FX-chain snapshot) and "re-capture from source"; ship the null-test verification action. CONTEXT.md §Precision invariants, Build order 10. Verify (in DAW): Null test — a dry offline capture of a range, re-inserted at its source position, nulls to silence against the source. This action is the tool's trust anchor and must pass.

  • Provenance fields populated on resample-from-sample (parent id + FX-chain snapshot string).
  • "Re-capture from source" action.
  • Null-test verification action (capture → re-insert at source pos → assert silence sum).

Note (from M7): The null test requires a TRUE pre-FX dry capture, which REAPER offline render cannot produce via RENDER_SETTINGS (there is no pre-FX bit). True dry must be obtained by bypassing the source FX around an offline render (snapshot→bypass→render→restore) OR via the M8 realtime pre-FX path — so the dry-capture mechanism should be designed as part of the M10 null-test work.

Milestone 11 — polish

Goal: Batch capture (per selected item / per razor area), resample-and-mute-source, conform-on-insert, native OS drag-out. CONTEXT.md Build order 11, §Non-goals (drag-out deferred to last). Verify (in DAW): Each polish action works without regressing the precision invariants; drag-out places a valid file in the OS target.

  • Batch capture: per selected item / per razor area.
  • Resample-and-mute-source.
  • Conform-on-insert (explicit).
  • Native OS drag-out (deferred final; InsertMedia path must already work).

Open questions to resolve during build

Carried from CONTEXT.md §Open questions — keep visible until each is closed by a landed milestone.

  • Realtime wet-master routing that captures master output without altering the user's monitoring. (blocks M8)
  • parseInt narrowing hardening: src/bank_model.cpp parseInt casts int64_t → int via static_cast without a range check; integers that fit in int64 but exceed INT_MAX are implementation-defined. Hardening candidate — add bounds check before the cast when integer-field validation is in scope.
  • Capture send/routing isolation (TODO): The FX-scope capture neutralizes out-of-scope FX, gain, and pan — but NOT aux sends. So a downstream coloring send (e.g. a folder → reverb-track send) still routes and blends the reverb into an item/track capture, past the intended isolation point. 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. The hard part: 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). Repro: 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, should be isolated to T2's synth output pre-F1 with the MIDI send preserved and the reverb send excluded. Likely approach: snapshot + mute out-of-scope tracks' aux sends during the render while preserving the main/source signal path — needs a rule for which sends are load-bearing.

Milestone T — capture tail (rider on the offline render path)

Rider, not a new pillar. Tail preservation wires into the already-shipped offline OfflineRenderBackend (M3/M7) — no new backend, no new render trigger. It takes a T tag (not an M-number) because it is an enhancement to landed capture, sequenced independently of M8M11. Authoritative spec: docs/product/capture-tail.md (full RENDER_* values, the surgical RENDER_NORMALIZE, the realtime parallel path, invariant interactions, acceptance criteria, DAW-confirm items). Parameters set by Daniel: auto-trim threshold -72 dB, max-tail cap 8 s. When a point lands, doc-keeper moves it to COMPLETED.md.

T1 — offline tail: auto (default) + manual override

Goal: Preserve decay tails on offline captures. Auto (default): render an 8 s-capped tail, then auto-trim trailing silence to -72 dB via a surgical RENDER_NORMALIZE (only the trim-end bit set) + RENDER_TRIMEND. Manual: a fixed tail length (clamped to the 8 s cap), no trim, keeping today's disable-all normalize. Tail is opt-in; None stays byte-identical to today. See docs/product/capture-tail.md §The offline path. Verify (in DAW): A range ending mid-reverb + Auto tail ends at the -72 dB decay point (not a hard 8 s, not the range end); a non-decaying signal caps at range + 8 s; two identical Auto requests are byte-identical (deterministic trim); a TailMode::None capture is byte-identical to the pre-tail exact-bounds capture; Manual(N ms) yields range + N ms untrimmed, with N clamped to 8000; ScopedRenderSettings restores RENDER_NORMALIZE and every touched setting on every path.

  • Pure layer (render_settings.{h,cpp}): named constants kAutoTrimThresholdDb (-72) + derived RENDER_TRIMEND ratio (≈0.00025119), kMaxTailSeconds/kMaxTailMs (8 s); a TailMode { None, Auto, Manual } → (RENDER_TAILFLAG/RENDER_TAILMS/ RENDER_NORMALIZE/RENDER_TRIMEND) mapping + the manual-tail clamp; unit-tested.
  • Wire the mapping into OfflineRenderBackend (capture.cpp): drive the tail + surgical-normalize (Auto) / disable-all (Manual/None) values; snapshot/restore RENDER_TRIMEND alongside the existing RENDER_* set. RENDER_TAILFLAG = 1 unconditionally (custom bounds — not per range type).
  • Replace/extend CaptureRequest.renderTail(bool)/tailMs with the three-state tail contract (None/Auto/Manual(ms)); default None (exact bounds, null-test-safe).
  • DAW-confirm: RENDER_TRIMEND amplitude curve (0.00025119 ≈ -72 dB); trim-end-only normalize (32768) does not engage fades/normalize/pad; trim never eats pre-ENDPOS body. (See spec §Open questions / DAW-confirm.)

T2 — realtime tail (follow-on to T1)

Goal: The parallel tail path for the M8 realtime backend, which does not drive RENDER_*: record an 8 s-capped tail window past the range end, then trim in a PCM decay-scan to the -72 dB point (Manual = record fixed tail, skip the scan). See docs/product/capture-tail.md §The realtime path. Verify (in DAW): A realtime Auto capture of a decaying source records ≥ the range then trims at the -72 dB decay point (± inherent realtime tolerance); realtime tail is not asserted bit-identical (documented non-determinism). Depends on: T1, M8.

  • Record [start, end + clamp(tail, 8 s)] (extend the record time selection in capture_realtime.cpp); Manual skips the scan, Auto proceeds to it.
  • Pure decay-scan helper alongside peaks: lastFrameAboveThreshold(interleaved, channels, frames, linearThreshold) -> frameIndex (backward scan, per-frame max-abs across channels, no fold); unit-tested with a synthetic decaying ramp. (Spec §realtime path option (a) — recommended over bending computeEnvelope.)
  • Realtime shell: read the recorded wav PCM into a float buffer, find the trim frame, rewrite the file truncated (new I/O the backend does not do today).

Milestone T open questions

  • Auto as the shipped-action default? Whether CAPTURE_ITEM/CAPTURE_TRACK/ CAPTURE_MASTER (currently all TailMode::None) flip to Auto, gain a "…with tail" variant, or take a modifier. Product call for Daniel; leaning paired variant / toggle over silently changing the exact-bounds default. Not blocking T1 (the request-level three-state contract is independent). (touches render_settings.cpp action table + actions.)

Phase B — Multi-bank (parallel to the M0M11 capture roadmap and Phase D)

Separate phase namespace. The M-numbers belong to the capture pillar (M0M11); the D-letters belong to Design View. Multi-bank is a third orthogonal pillar — generalizing the single bank into a pool + named banks — so it takes its own lettered namespace (B1, B2, …). "B" reads for Banks and, like Phase D, keeps the roadmaps from colliding on numbering: Phase B is not "the twelfth capture step," it is a different pillar. Authoritative spec: CONTEXT.md §Multi-bank. Product framing: docs/product/multi-bank.md. When a point lands, doc-keeper moves it to COMPLETED.md.

B1 — bank_book (pure)

Goal: REAPER-free bank registry wrapping N BankIndex instances: pool seeded + privileged, create/rename/reorder/delete named banks, active-bank id, move/copy a sample between banks, JSON round-trip + legacy-migration. The heart of the phase; mirror of bank_model / view_mode_model; BankIndex untouched (additive). CONTEXT.md §Multi-bank (Module architecture — pure). Verify: CTest green. Pool always present, un-deletable, un-renamable, un-evacuable (rules rejected in-model). Active-bank defaults to pool. Move is index-only (source loses entry, destination gains it) and observes destination collapse-by-hash; copy leaves source intact. Delete drops member index entries. Evacuate moves all members to the pool, leaving the bank empty. JSON round-trip lossless across pool-as-bank-zero + named banks + per-bank indices + ordinals + active id. Legacy bank_index JSON parses into { pool } with zero named banks.

  • Bank registry: ordered { bank id, display name, ordinal, BankIndex }; pool seeded with fixed id + fixed name; create / rename / reorder / delete named banks (delete drops the bank's member index entries).
  • Pool-privilege rules enforced in-model: reject delete-pool, reject rename-pool, reject evacuate-pool, never allow zero banks.
  • Active-bank id (get/set; defaults to pool); resolve active bank's BankIndex.
  • Move sample between banks (index-only; destination collapse-by-hash observed; source entry removed).
  • Copy sample between banks (index-only; source entry retained; destination collapse-by-hash observed).
  • Evacuate bank: move every member to the pool (index-only; destination collapse-by-hash observed), leaving the bank empty; pool cannot be evacuated.
  • JSON round-trip: pool-as-bank-zero inside the blob + named banks + per-bank indices + ordinals + active id.
  • Legacy migration: a bare bank_index JSON promotes to the pool's index with zero named banks (one-way, lossless; blob authoritative thereafter).
  • Tests: pool privileges (delete/rename/evacuate rejected); move source-loses/dest-gains; copy source-retained; evacuate empties source into pool with dest collapse; cross-bank same-hash coexistence; dest collapse on move into a bank already holding the hash; JSON lossless; legacy migration.

B2 — persist slice (banks ↔ project ext state)

Goal: Serialize the book under the banks key in "reasampler" alongside the existing sections, with the pool folded in as bank-zero; migrate a legacy bank_index key into the pool on first load and retire the legacy key; reload-on-open and Save-As survival via the existing M4 machinery. CONTEXT.md §Multi-bank (persist). Verify (in DAW): Banks + named banks + active bank + all per-bank samples survive Save / Save As / close+reopen; relative paths only; bank travels with the .rpp; a project saved before this phase (legacy bank_index only) loads as pool + zero named banks with no sample loss, and after save carries banks with no bank_index written. Depends on: B1. (Persistence-key fork settled — fork 1 (a): pool inside the banks blob, legacy key retired after one-way migration.)

  • Serialize/deserialize the book under the banks key (pool-as-bank-zero inside the blob; distinct section from view_state; no bank_index key written going forward).
  • Legacy-migration path on load: absent banks + present bank_index → promote into pool, mint the blob, treat blob as authoritative (legacy key retired).
  • Session exposes the book; the active bank's BankIndex is the capture add target (route the M7 capture family through it — additive to M7, no M7 rewrite).
  • Confirm survival across Save / Save As; confirm legacy-project load path.

B3 — actions

Goal: Bindable action set for the multi-bank workflow. CONTEXT.md §Multi-bank (actions). Verify (in DAW): Each action registered (bindable in Actions list); bank-activate + move/copy + evacuate MIDI-bindable; create/rename/delete/evacuate drive the B1 model via the B2-persisted session. Depends on: B1, B2.

  • Create bank / rename bank / delete bank (delete drops member index entries; confirm-on-non-empty offered at the UI layer in B4).
  • Evacuate bank → pool (move all members back to the pool; refuses on the pool).
  • Activate bank (direct-by-id + cycle).
  • Move selected samples → bank / copy selected samples → bank (move is default).
  • Pool full-height / banks full-height toggles.
  • Register each (command_id/gaccel/hookcommand); bank-activate + move/copy
    • evacuate MIDI-bindable.

B4 — bank_panel vertical split (UI)

Goal: The vertical-split bank window — pool on top, named-banks tab-page region below, full-height toggles — extending the M5 docked grid. CONTEXT.md §Multi-bank (bank_panel). Verify (in DAW): Pool grid renders on top; named-banks tab strip below (empty when no named banks, one tab per named bank); active-bank unmistakably indicated; both full-height toggles collapse the split correctly; sample move/copy affordance works; non-empty delete confirms and offers evacuate; the Design View mode switch in the header is unaffected. Depends on: B1, B2, B3. (Tab rendering + move-affordance mechanics — fork 5 — settled 2026-07-23: LICE-drawn tabs + both move affordances; see Phase B open questions and product notes → Fork 5 — settled.)

  • Vertical split: pool grid region (top) + named-banks tab-page region (bottom).
  • Named-banks tab strip: LICE-drawn (matching the M5 grid + Design View segmented switch, not SWELL-native — fork 5a); one tab per named bank; empty state when none. Verify LICE tab draw against the M5 reference before use.
  • Tab-strip overflow/scroll affordance — in scope from the start (fork 5a): a naive fixed-width LICE strip breaks down at ~812 tabs, so ship scroll/chevron overflow with the strip, do not defer it.
  • Pool full-height / banks full-height toggle affordances wired to B3.
  • Active-bank indicator — visually unmistakable (settled constraint); placement (per-region header / single readout / lit-tab) is the residual polish detail.
  • Create / rename / delete / activate / evacuate affordances driving B3 actions.
  • Delete confirms on a non-empty bank, naming the evacuate alternative.
  • Sample move affordance — both (fork 5b): a "move to bank" menu on the current selection (bindable front-end for the B3 move action) and drag-between-regions. Copy is the deliberate secondary act, offered on the menu.
  • Drag mis-drop mitigation (fork 5b): clear drop-target highlighting on the destination region/tab during a drag; a mis-drop is recoverable by design (move is index-only and reversible). Verify the drag hit-test doesn't collide with the M5 grid's multi-select drag.

Phase B open questions

All five forks settled by Daniel (2026-07-23): persistence key = fold pool into banks, retire legacy key (1a); delete drops members + add evacuate verb (2); move is the default gesture (3); active-bank/shown-tab distinct with an unmistakable indicator (4); LICE-drawn tabs + overflow, and both move affordances with drop-highlighting (5). Folded into CONTEXT.md §Multi-bank + the B1B4 points above. Phase B is fully settled and ready to scope into implementation waves. One polish detail remains:

  • Active-bank indicator placement — per-region headers vs. single header readout vs. lit-tab. "Unmistakable" is settled; only placement is open. Polish detail. (touches B4)