Files
reasampler/CONTEXT.md
T

178 KiB
Raw Blame History

ReaSampler — implementation briefing

A native C++ REAPER extension that captures any arbitrary audio source into a per-project sample bank (cached files + a docked grid), decoupled from the arrange view, with keyboard/MIDI-bindable capture and placement. Built as a precision tool: deterministic, non-destructive, no clutter.

This document is the spec. Read the existing scaffold first (src/main.cpp is the REAPER<->extension contract; the pure/testable-core split in src/bank_model.* is the pattern to preserve — the discipline of a pure testable core split from REAPER-facing shells runs throughout the codebase). Verify every REAPER API name and signature against vendor/reaper-sdk/sdk/reaper_plugin_functions.h before use — the API names in this brief are correct-by-intent but treat them as hints, not gospel, and check argument order/types.

The load-bearing principle

Capture and placement are separate acts. Capturing audio writes a file to the bank and adds an index entry — it NEVER puts an item in the arrange. Placement is a distinct, on-demand action (insert / drag). Any code path that auto-inserts a capture into the timeline violates the entire point of the tool and must be rejected in review. This single rule is why the tool exists.

Settled decisions

  • Capture modes: offline render (deterministic, the default) AND realtime record (for hardware / performed FX). Both sit behind one capture interface and produce identical bank entries.
  • Bank scope: per-project, travels with the .rpp. Files live in a project-relative subfolder; the index persists in project ext state. No absolute paths anywhere in the index.
  • Material: must handle full-mix/stem bounces, chops/one-shots, and single-cycle/wavetable grabs equally. That means exact sample-accurate bounds, explicit tail control, channel-count preservation, and loop/zero-crossing handling all matter from day one.

Build detail for this phase (module architecture, data model sketch, API surface, build order) moved to CONTEXT-ARCHIVE.md.

Precision invariants (enforce, test)

  • Null test: a dry offline capture of a range, re-inserted at its source position, nulls to silence against the source. Ship this as a verification action; it is the tool's trust anchor. (Verification action cut per docs/product/provenance.md — manual verification only.)
  • Bit-identical repeats: identical offline requests produce identical files.
  • Non-destructive: capture never mutates source items or tracks (realtime's temp track is created and removed cleanly; source routing is restored).
  • Exact bounds: no rounding of the requested range; no added silence unless a tail is explicitly requested; channel count preserved (no silent stereo fold).
  • Relative paths only in the persisted index.

Non-goals / guardrails

  • No auto-insertion of captures into the arrange (see the load-bearing principle).
  • Native OS drag-out is deferred to the final milestone — InsertMedia-driven placement is the primary path and must work first.
  • Do not depend on REAPER's peak API for thumbnails; compute from the captured file.
  • Do not silently time-stretch on insert; conform is opt-in.
  • Do not trust this brief's API names blindly — verify against the SDK header.

Open questions to resolve during build

  • Exact no-dialog render command/flag on the current REAPER build.
  • Realtime record routing that captures wet master output without altering the user's monitoring. Resolved: realtime taps the selected track (track scope), not the master — a post-fader track→temp send is chain-independent by construction, so there is no wet-master-routing problem and monitoring is untouched (see the Realtime record API surface above).
  • Audio format for the bank (wav bit depth default; allow float for wavetable fidelity).
  • Thumbnail cache: recompute vs store peak bins alongside the index.

Design View — additive phase spec

Additive section. This is a standalone phase parallel to — not part of — the M0M11 capture roadmap above. Nothing above changes. Product framing (workflow narrative, screenset differentiation, N-mode reasoning, design-direction calls) lives in docs/product/design-view.md; this section is the authoritative technical spec, matching the house style of the capture spec. Same standing discipline applies: verify every REAPER API name/signature against vendor/reaper-sdk/sdk/reaper_plugin_functions.h before use.

What it is

A track-visibility-plus-processing "mode" system, toggled from the ReaSampler window. Tracks used purely for sound design (scratch oscillators, FX mangling, resampling sources) are tagged into Design mode; the arrangement's real tracks are Arrange mode (the default). Toggling to a mode hides and disables the tracks that don't belong to it. Workflow value: mental separation + clutter elimination — be in Design view, resample into the bank, flip to Arrange, place it.

Design View is a mode projection over REAPER's single arrange timeline — reaching both tracks (parked per mode) and items (lane-split per mode on shared tracks). It approximates two canvases without a second surface: same project, same timeline, but each stance sees its own tracks and its own items. It never duplicates the project or opens a second window.

Two-canvas reach — settled (2026-07-23). The original single-canvas framing here was derived from a REAPER constraint, not chosen as a product stance. Daniel reopened it — "as close as possible to two separate design and arrange canvases, same project, different items and leaves" — and has now signed off on the mechanism and resolved all forks. The "different leaves" half is delivered by track-parking (D1). The "different items" half is delivered by per-item mode membership via REAPER fixed lanes (track-side I_FREEMODE=2, I_NUMFIXEDLANES, C_LANEPLAYS:N; item-side I_FIXEDLANE, C_LANEPLAYS, B_FIXEDLANE_HIDDEN): each mode owns a lane on a shared track, and toggling shows/plays only the active mode's lane. This is an item-visibility projection over the one timeline — the exact analog of today's track-visibility projection — with still no literal second surface, window, or duplicate project. It is an additive sub-phase (Phase D2 / Phase E) on top of D1, spec'd in §Two-canvas sub-phase below. Full framing and the rejected alternatives (timebase-offset, subproject) live in docs/product/design-view.md §Two-canvas direction.

It is the visibility/processing analog of the capture pillar's load-bearing rule: designing and arranging are separate stances on one timeline, and the tool enforces the separation without ever destroying the user's real state.

Settled decisions

  • Membership. Default = Arrange; every untagged leaf belongs to it. Leaves opt in to Design (or any mode). No track appears in two modes at once except (a) via an explicit show-both toggle, or (b) parent/folder derivation.
  • Parents are derived, never tagged. A parent/folder track is visible in mode M if either (a) any descendant leaf is visible in M (descendant-derived), or (b) the parent belongs to M by its own membership — and an untagged parent is an Arrange member by default. So an untagged folder carrying its own FX/media above all-Design leaves shows in both Arrange (its own default) and Design (derived from its children). A parent is never parked in any mode it is visible in. Rule of thumb: tag leaves; parents follow — the common case, since a content-bearing folder still surfaces wherever its own membership places it. Master track is always visible and never touched.
  • N-mode model, two-mode UI. The data model carries arbitrarily many modes; the UI ships Arrange + Design. A mode is (stable id, display name, ordinal). Arrange is special only as the default home for untagged leaves.
  • Parking a track (inactive-mode leaf): B_SHOWINTCP=0, B_SHOWINMIXER=0 (hide both panels), B_MAINSEND=0 (out of mix), I_FXEN=0 (FX bypassed), and TrackFX_SetOffline(track, fx, true) for each FX (reclaim CPU). Full CPU-park is the deliberate choice over mix-removal-only.
  • Never touches B_MUTE / I_SOLO. The tool owns only visibility, B_MAINSEND, I_FXEN, and per-FX offline — on every managed leaf, tagged or untagged. User mute/solo survives every toggle untouched.
  • Persistence. Membership index + last-active mode + per-track flag snapshots ride in the existing "reasampler" project ext-state namespace and travel with the .rpp. On project open, reapply the active mode's visibility + processing.

Precision invariants (enforce, test)

  • Non-destructive restore. For every flag the tool drives, snapshot the prior value before parking; on toggle-back restore from the snapshot, never to a hardcoded "on." Round-trip (snapshot → park → restore) returns every driven flag to its captured value. This is the phase's trust anchor — the analog of the capture null test — and is enforced in the pure layer.
  • Mute/solo untouched. No toggle ever reads or writes B_MUTE / I_SOLO.
  • Master untouched. The tool never drives the master track's visibility flags (the SDK forbids B_SHOWINTCP/B_SHOWINMIXER on master; the invariant agrees).
  • GUID-keyed, reorder-safe. Membership keys on track GUID (GetTrackGUID), never track index; tolerates unknown/stale GUIDs (prune on reconcile).
  • Relative/portable state only in the persisted view section (GUID strings, mode ids — no absolute paths, no index positions).

Documented caveat

Offlined FX re-instantiate when a track returns to the active mode. Stateful plugins (convolution, loaded samplers, tail-holding effects) re-initialize on return — possible load hitch, un-persisted internal state lost. Accepted cost of the CPU reclaim; surface it at the toggle affordance (tooltip).

Show-both semantics

A per-track "pin visible across modes" flag that re-enables processing whenever shown. A show-both leaf appears in every mode's visible set and is never parked — its driven flags stay at snapshot/restored values, FX online, in the mix. ("Show but keep parked" is not offered — a visible-but-silent-and-offline track is clutter with a thumbnail.) Stored on the membership record; persists; togglable per selection.

Build detail for this phase (module architecture, API surface) moved to CONTEXT-ARCHIVE.md.

Non-goals / guardrails

  • No literal second canvas. A literal second arrange surface, a second window, or a duplicated project stays rejected — reject any such path in review. This guardrail was narrowed (2026-07-23), not lifted: per-item mode separation via REAPER fixed lanes on shared tracks (an item-visibility projection over the one timeline) is now allowed and specified in §Two-canvas sub-phase below. Paths that remain rejected: subproject / second-project-file approaches, and overloading item D_POSITION with mode semantics (timebase-offset regions) — the latter collides with the capture null test. See docs/product/design-view.md §Two-canvas direction for why those were rejected.
  • Never touch mute/solo. Any code path reading/writing B_MUTE / I_SOLO is a bug.
  • Never drive a manual lane (Phase D2). A mode toggle touches only managed lanes (minted by the mode system, keyed in the lane-ownership index). Any code path that shows, hides, silences, or re-lanes a manual lane — a user-created comp/take lane outside the mode system — is a bug. The tool drives only lanes it created.
  • Every leaf is managed. The mode system owns all leaf tracks: an untagged leaf is an Arrange member, and when the active mode is not Arrange it is fully parked and snapshot-restored exactly like a tagged leaf out of its mode. show-both is the only way to opt a leaf out of parking. (Parents stay visibility-only, master is never touched — see below.)
  • Restore from snapshot, never to a default. No hardcoded "on" restores.
  • Verify API names against the SDK header before use.

Open questions to resolve during build

  • Reconcile residuals (bulk behavior 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 remain deferred:
    • FX-GUID keying for restoreFxOffline: currently restores by slot index; a reshuffled FX chain while parked will restore to the wrong slot. Fix requires FX-GUID keying + snapshot-schema migration — deferred.
    • Dormant membership entries: truly-deleted tracks accumulate stale entries in persisted view_state (harmless and bounded); natural home is a future user-initiated "compact" action, not automatic pruning (which would reintroduce undo-delete tag-loss).
  • Interaction with the user having a screenset active (Design View drives the same flags a screenset recall would; last writer wins — confirm no surprising fight).

Two-canvas sub-phase (Phase D2 / Phase E) — additive to D1

Additive sub-phase, settled 2026-07-23. Extends D1's track-level mode projection to item level so each stance owns its own items as well as its own tracks. Nothing in D1 changes; this wraps it. Runtime floor rises to REAPER 7 for this sub-phase (fixed lanes shipped in v7). Product framing in docs/product/design-view.md §Two-canvas direction.

What it adds

On a track present in both stances (a show-both track, or a folder carrying its own media), each mode owns a fixed lane: the active mode's lane shows and plays; the inactive mode's lane is hidden and silent. A Design take and an Arrange take can then live on the same track, same time position, without colliding on the view. Track-only-in-one-mode content is still handled by D1 track-parking, unchanged.

Settled decisions

  • Mechanism: fixed item lanes. Map mode → lane; toggle drives per-lane play/show so only the active mode's lane is present. Items keep their real position and real track — nothing is moved in time or deleted. SDK surface (verified present in vendor/reaper-sdk): track-side I_FREEMODE = 2, I_NUMFIXEDLANES, C_LANEPLAYS:N; item-side I_FIXEDLANE, C_LANEPLAYS, B_FIXEDLANE_HIDDEN. I_FREEMODE changes require UpdateTimeline() to take visible effect.
  • Membership: adoption rule for new items; active mode for new tracks. New tracks are tagged to the active mode at creation. New items follow an adoption rule: if the item's track has pre-existing managed-eligible content spanning exactly one mode, the item adopts that mode — the track stays single-mode, no lane split, nothing stranded. The active-mode fallback applies only when the track is empty (no prior content) or already spans multiple modes. Items in manual lanes are excluded from the prior-mode computation and are not auto-tagged at all (manual/managed boundary unchanged). Deliberate multi-mode splits occur only via the explicit tag/move-item actions, never via auto-tag. Pre-existing content defaults to Arrange. Membership is exclusive per item: an item lives in exactly one mode, except via the existing show-both escape hatch. (New-track tagging follows the same active-mode rule as D1 track membership; the adoption rule is a refinement for items on shared tracks.)
  • Inactive-mode content is hidden AND silenced. The off-mode lane is set C_LANEPLAYS = 0 — neither shown nor played — consistent with exclusive membership and with D1's "flipping modes is a real change, not cosmetic." Show-both is the deliberate opt-out for a lane that must stay audible across modes.
  • Managed vs. manual lanes — indexed and distinct. Fixed lanes are also REAPER's native comping surface: a user may keep their own manual lanes (comp takes, alternate reads) on a track alongside the mode system's lanes. The tool maintains a lane-ownership index — per (track GUID, lane): managed (which mode owns it) vs manual (user-minted, outside the mode system). Mode operations touch only managed lanes; manual lanes are never shown, hidden, silenced, or re-laned by a toggle, and their C_LANEPLAYS stays exactly as the user set it. Items a user adds to a manual lane are not auto-tagged (auto-tag governs normal timeline content, not hand-managed lanes). The ownership index rides in "reasampler" view_state alongside the membership index, GUID-keyed and portable.
  • Capture placement is mode-aware. An explicit placement while in Design mode — including capture-and-place — lands the item in the Design lane; the same rule governs manual insertion. The capture load-bearing principle is untouched: capture still writes a file + index entry and never auto-inserts; this governs only where an explicit placement lands.
  • REAPER floor: v7. No version-gate branch — below v7 this sub-phase is simply unavailable.

Precision invariants (unaffected — called out explicitly)

The capture precision invariants — null test, bit-identical repeats, non-destructive, exact bounds, relative-paths-only — are entirely unaffected by this sub-phase. No capture path changes; lanes are a placement/view concern downstream of the written file. Lane assignment and C_LANEPLAYS are reversible flags: the item is never relocated in time or deleted, so the sub-phase extends the D1 non-destructive snapshot/restore contract to a new (item-lane) flag family rather than introducing any destructive operation.

New invariant — mode operations touch only managed lanes. A mode toggle drives only lanes the mode system minted (managed lanes, keyed in the lane-ownership index). Manual lanes — user-created comp/take lanes outside the mode system — are never shown, hidden, silenced, or re-laned by a toggle; their C_LANEPLAYS is left exactly as the user set it. This is the fixed-lane analog of never touch B_MUTE/I_SOLO and never touch master: the tool drives only what it created. Enforceable and testable in the pure layer — the "which lanes may this toggle touch" decision is a pure query over the ownership index; only reading REAPER's live lane state is shell.

Build detail for this sub-phase (module architecture, new-content detection, API surface) moved to CONTEXT-ARCHIVE.md.


Multi-bank — additive phase spec

Additive section. This is a standalone phase parallel to — not part of — the M0M11 capture roadmap and Phase D above. Nothing above changes. Product framing (workflow narrative, pool-privilege reasoning, movement semantics, UI-direction calls) lives in docs/product/multi-bank.md; this section is the authoritative technical spec, matching the house style of the capture and Design View specs. Same standing discipline applies: verify every REAPER API name/signature against vendor/reaper-sdk/sdk/reaper_plugin_functions.h before use.

What it is

The single per-project bank (bank_model / BankIndex) is generalized into a multi-bank system. The existing bank becomes the pool — a default, always-present bank that every capture lands in unless another bank is the active target. On top of the pool the user creates named banks ("Drums", "1-Shots", "Synth Hits") that group samples for a purpose. Samples move freely between any banks, including to and from the pool. Exactly one bank is the active bank — the capture target — the pool by default.

This is the container generalization of the capture pillar's bank. The pool is to banks what Arrange is to modes: structurally one member of an N-collection, but privileged as the default home. The capture pillar's load-bearing rule is untouched — capture still writes a file + an index entry and never inserts into the arrange; the only change is which index the entry lands in.

Settled decisions

  • The pool is privileged, not special-cased. Structurally the pool is one BankIndex among many in the container (mirror of "Arrange is just another mode"). Semantically it is privileged: it always exists, is un-deletable, and is un-renamable (fixed id + fixed display name "Pool"). New projects and migrated single-bank projects start with the pool and zero named banks. This keeps the data model uniform (no pool-shaped special type) while the rules layer enforces the three privileges.
  • Container in the pure core; a BankIndex per bank. A new pure module bank_book owns an ordered registry of banks, each bank = { stable bank id, display name, ordinal, BankIndex }. BankIndex is untouched — the multi-bank layer wraps it, it does not modify it (additive; no bank-id field on Sample). Bank id is the stable key (GUID-style, minted on bank create); display name and ordinal are mutable (rename / reorder). Display names are unique, enforced in the pure model on create and rename: createBank / renameBank reject a name that duplicates an existing bank's (renaming a bank to its own current name is a no-op success). The comparison is trimmed + case-insensitive (ASCII), so "Drums", "drums", and " Drums " cannot coexist; the pool's reserved name "Pool" is protected by the same check. Uniqueness makes by-name resolution in the action shell unambiguous by construction. The pool is the first, seeded, fixed-id member. bank_book is the mirror of bank_model and view_mode_model: pure, no REAPER types, unit-tested outside the DAW, JSON round-trip.
  • Active bank lives in the model, routes through the capture path. bank_book carries the active bank id (defaults to the pool). The capture action family resolves "which bank does this capture land in?" by asking the session for the active bank's BankIndex, then adds exactly as today. No capture backend changes; only the add-target is selected upstream. Activating a bank is a model mutation + a persist write; it never touches the timeline.
  • Movement moves the index entry, not the file. Moving a sample from bank A to bank B is an index-only operation: remove the Sample from A's BankIndex, add it to B's. The underlying file stays in the project bank folder — banks are logical groupings over one shared file pool, not separate folders on disk. This keeps movement cheap, non-destructive, and immune to path-rewrite bugs. (Per-bank subfolders on disk are an explicit non-goal — see guardrails.)
  • Dedup-by-hash is per-bank. Each BankIndex dedups within itself, unchanged. Moving a sample whose hash already exists in the destination bank collapses onto the existing entry there (the move is a no-op add on the destination side, and the source entry is still removed) — the same collapse semantics BankIndex::add already has, now observed across a move. Cross-bank dedup is not enforced: the same hash may exist in the pool and in a named bank simultaneously (that is the point — copy lets a sample be grouped into "Drums" while still living in the pool).
  • Move vs. copy are distinct acts; move is the default. Move removes from source, adds to destination (one logical sample, regrouped) — it is the primary, low-friction gesture, so a sample lives in exactly one bank at a time. Copy adds to destination and leaves the source entry intact (same file, two index entries, two banks) — the deliberate secondary act for the "in two places at once" case. Both are index-only; both share the destination-collapse rule. Under move-as-default the pool is the default home and staging ground, not a permanent superset: moving a sample into a named bank takes it out of the pool. (See product notes for the mental-model reconciliation.)
  • Delete drops members; evacuate returns them. Deleting a named bank drops its member index entries (files are not deleted — file lifecycle stays owned by the capture/prune path). A separate evacuate operation moves all of a bank's members back to the pool (index-only, same destination-collapse-by-hash as move), leaving the bank empty. Intended workflow: evacuate then delete to keep the samples, plain delete to drop the grouping and its members. A plain delete of a non-empty bank orphans those members out of every index — their files persist on disk until prune, referenced by no bank — so the UI confirms on non-empty delete and offers evacuate as the alternative. Evacuate cannot be applied to the pool.
  • Persistence: a new ext-state key; the pool folds in and the legacy key is retired. The multi-bank state serializes to a new key banks in the existing "reasampler" namespace, alongside view_state and project_guid. The pool's index rides inside the banks blob as bank-zero — persisted identically to any named bank (one blob, one section, one JSON shape). Migration: on load, if a banks key is absent but a legacy bank_index key is present, the legacy index is promoted into the pool inside a freshly-minted banks blob and the book is { pool } with zero named banks — a one-way, lossless promotion. After migration the banks blob is authoritative; the legacy bank_index key is retired (not written or read back going forward). The one-way retirement trades pre-multi-bank backward-read compatibility for the clean single-blob shape — an accepted, forward-only migration consistent with how M4 project state already moves forward.
  • Vertical-split UI, pool on top. The bank window splits vertically: pool on top, the named-banks region below (a tab-page strip, one tab per named bank, empty when none exist). Two full-height toggles collapse the split: pool full-height (hide the named-banks region) and banks full-height (hide the pool). The Design View segmented mode switch already in the window header is orthogonal and stays — it governs timeline visibility, not bank grouping; the two coexist in the header/body without interaction.

Precision / invariant implications

  • Relative-paths-only survives unchanged. Every BankIndex in the book keeps the relative-path invariant at its add boundary — the book adds no new path handling, because movement is index-only and files never relocate. The invariant is enforced N times (once per bank) by the exact code that enforces it today.
  • Non-destructive. Bank create / rename / delete / activate / evacuate and sample move / copy mutate only index + ext-state; no file is written, moved, or deleted, and no timeline item is touched. Deleting a named bank drops its member index entries but does not delete their files; file lifecycle stays owned by the capture/prune path, not the bank container. A file referenced only by the deleted bank becomes an orphan on disk — present but indexed by no bank — until the capture/prune path reclaims it. That orphaned-until-prune window is designed, not accidental; the evacuate verb and the confirm-on-non-empty-delete guardrail exist to keep the user out of it unintentionally.
  • Travels-with-the-.rpp preserved. The banks blob rides the same ext-state namespace and the same GUID-primary project-identity / Save-As-relocation machinery as the bank index does today (M4). One shared physical bank folder, one ext-state namespace, now three logical sections (banks + view + identity).
  • Determinism / null-test / bit-identical are untouched — they are properties of the capture path and the file, and the multi-bank layer sits above the file entirely.

Build detail for this phase (module architecture, API surface) moved to CONTEXT-ARCHIVE.md.

Non-goals / guardrails

  • No per-bank folders on disk. Banks are logical groupings over one shared project bank folder. Do not create a subfolder per bank or move files on bank-move — reject any such path in review (it reintroduces the path-rewrite bug class M4 closed).
  • No cross-bank dedup enforcement. The same hash may exist in multiple banks (that is what copy is for). Do not add a global dedup that collapses across banks.
  • Pool privileges are inviolable. No action path may delete or rename the pool, leave a project with zero banks, or evacuate the pool (the pool is evacuation's destination, not a source). Enforce in the pure rules layer, not just the UI.
  • Delete drops members; files are never deleted by a bank op. Deleting a named bank removes its member index entries only. No bank operation writes, moves, or deletes a file — file lifecycle stays with capture/prune. The UI confirms on non-empty delete and offers evacuate; do not silently orphan members.
  • Capture still never inserts into the arrange. The load-bearing principle is unchanged; multi-bank only redirects which index the capture lands in.
  • Additive only. Do not alter BankIndex, the M0M11 capture roadmap, or Phase D semantics. bank_book wraps; it does not modify.
  • Verify API names against the SDK header before use.

Sample removal — additive spec (Phase B, point B5)

Additive section, part of the Multi-bank pillar. The sample-level companion to move/copy/evacuate/delete-bank: a verb that drops a Sample's index entry from a bank (or the pool). Index-only, non-destructive to the file — it sits on the same side of the index/file line as every other Phase B op. Product framing: docs/product/removal-and-prune.md §Sample-remove. Same verify discipline: verify every REAPER API name/signature against the SDK header before use.

What it is

Move, copy, and evacuate all keep a sample somewhere; there was no verb to drop a sample outright. Sample-remove is that verb: it removes one Sample entry from one BankIndex. It exposes the remove primitive bank_model's BankIndex already has — B5 wires it to an action + a panel affordance, it does not add a model capability.

Settled decisions (spec-level)

  • Remove is index-only. It removes the Sample from a BankIndex and mutates only index + ext-state. No file is written, moved, or deleted; no timeline item is touched. Identical non-destructive posture to move/copy/evacuate/delete-bank.
  • Remove can orphan a file — the same designed orphaned-until-prune state a non-empty delete-bank produces. When remove drops the last index reference to a file (no other bank holds its hash), that file becomes an orphan on disk, referenced by no bank, reclaimed later by prune (Phase R) — never by remove. This is not a new hazard class; it is the existing "files persist until prune" window, reached by a sample-level verb instead of a bank-level one.
  • Collapse-by-hash is unaffected. Remove targets a specific entry in a specific bank. Because cross-bank dedup is deliberately not enforced, removing a sample from one bank leaves any same-hash entry in another bank intact — the same coexistence copy relies on.
  • The pool's contents are removable; the pool container is not. Pool privileges (un-deletable, un-renamable, un-evacuable) govern the pool as a container. Individual samples can be removed from the pool — otherwise the pool would be a one-way trap. Remove-from-pool is the pool's own "drop this sample" verb and is allowed.
  • Remove scope (fork R-A, SETTLED 2026-07-24 — this-bank). Remove drops the entry from this bank only, leaving copies in other banks untouched — the core and only shipped verb. The action carries a scope: this-bank | all-banks seam, but this-bank is the settled default and the only surfaced affordance; all-banks stays a latent parameter (promotable later behind the seam without a rewrite), never a surfaced verb now. See product notes §Fork R-A.

Precision / invariant implications

  • Non-destructive extends to remove verbatim: index + ext-state only, no file touched, no timeline item touched.
  • Relative-paths-only is unaffected — remove deletes an entry, it adds no path handling.
  • Determinism / bit-identical / null-test (capture) untouched — remove sits above the file, same as all of multi-bank.

Guardrails

  • Removes are silent — no confirm dialog. Recoverability is provided by the batched REAPER undo (R-B): one Ctrl-Z restores the index entry, whether or not the sample was a last reference. Files are never deleted by remove (orphaned-until-prune is unchanged). hashReferencedElsewhere is a tested model API retained for Phase R prune; it has no shell caller in the remove path.
  • Undo (fork R-B, SETTLED 2026-07-24 — batched REAPER undo points, Phase-B-wide). Bank/index mutations integrate into REAPER's undo system as batched undo points (Undo_BeginBlock / Undo_EndBlock): the related index mutations of one bank operation are batched into a single undo point, so one bank operation is one Ctrl-Z. This is a Phase-B-wide decision — it applies to create/rename/reorder/delete-bank, move, copy, evacuate, and remove, retro- touching B1B4, not just B5. Must-verify before build: confirm against vendor/reaper-sdk that "reasampler" ext-state mutations participate correctly in Undo_BeginBlock/Undo_EndBlock undo blocks — the whole approach depends on it. Surfaced with remove because remove is the first verb whose only effect is index-entry destruction with no relocation, so it is where the gap first bit; the fix is shared. See product notes §Fork R-B.

Build detail for this phase (module architecture, API surface) moved to CONTEXT-ARCHIVE.md.

Settled forks (Daniel, 2026-07-24)

  • Fork R-A — remove scope. Settled: this-bank (this-bank-primary, all-banks a latent seam-only parameter). Folded into Settled decisions above.
  • Fork R-B — undo model for index mutations. Settled: batched REAPER undo points (Undo_BeginBlock/Undo_EndBlock), Phase-B-wide (retro-touches B1B4), with the ext-state-participation SDK check as a must-verify-before-build. Folded into Guardrails above and the Phase B / B1 plan points.

Prune — file-lifecycle spec (Phase R — Reclaim)

New pillar, its own lettered phase. Prune is the file-lifecycle path the capture and multi-bank specs forward-reference throughout ("files persist on disk until prune", "the capture/prune path reclaims it") but that had no phase, module, or point until now. It is the only operation in ReaSampler that deletes bytes off disk. Namespaced R (Reclaim) alongside M (capture), D (Design View), B (Banks) — it is a distinct pillar, not a Multi-bank sub-step, because it serves every orphan-producing path (delete-bank, sample-remove, re-capture) and carries a new risk class (file deletion) with its own invariants. Product framing and the phase-placement justification: docs/product/removal-and-prune.md §Prune. Same discipline: verify every REAPER/SWELL/filesystem API name/signature against the SDK/SWELL headers before use.

What it is

Over a project's life, delete-bank and sample-remove (and, potentially, M10 re-capture superseding an old file) leave .wav files on disk that no bank index references — the "orphaned-until-prune" state the specs design in on purpose. Prune is the reclaim pass: reconcile the physical bank folder against the union of every bank's index, and reclaim the files nothing references. It makes good on the promise the rest of the spec keeps making.

The load-bearing rule

Remove creates orphans; prune reclaims them. Sample-remove and delete-bank drop index entries and may leave a file referenced by nothing. Prune is the single path that turns such an orphan back into free disk space. No other operation deletes a file; prune deletes only files that no index references. A bank op that deletes a file is still a bug — prune is not a bank op, it is the file-lifecycle op.

This asymmetry is deliberate and must be stated loudly: every other invariant says "no operation deletes a file." Prune is the sole, explicit exception, and its entire job is deletion — so it must be the only file-deleting authority in the system, with the strongest guardrails.

Mirror of reconcile — the pure pattern one level down

Prune reuses the shape Design View already shipped. view_mode_model's ViewModeModel::reconcile(liveGuids) reconciles membership entries against live tracks and returns the residuals to drop. Prune reconciles files on disk against referenced files (the union of every bank's index) and returns the orphan set to delete. Same pure pattern, one level down (files instead of GUIDs).

The decision is pure and unit-tested: given the set of files present in the bank folder and the set of files referenced by the book, compute the orphan set. Only the two ends touch the shell — enumerating the bank folder and deleting the orphans are filesystem I/O. Keep the "which files are orphans" core REAPER-free and hard-tested (this is the safety-critical part); keep the I/O thin. Same pure/shell split as bank_model / view_mode_model / bank_book.

Settled decisions (spec-level)

  • Referenced-set is the union across ALL banks, pool included. A file is an orphan iff no bank in the book references it. Because copy lets one file be referenced by several banks, prune must union references across the whole book before deciding. This is the safety-critical computation — the prune null test is prune never deletes a file that any index references.
  • Project-relative resolution, current folder. Prune enumerates and deletes within the project bank folder using the same M4 project-relative path resolution the index uses, against the resolved current folder — never a stale absolute path — so a Save-As relocation cannot cause it to mis-identify or mis-target orphans.
  • Dry-run first, always. Prune reports before it deletes: the orphan count, reclaimed size, and (for a small set) the files. The dry-run — compute-and-report, the pure core with no deletion — is the primary surface; actual deletion is the confirmed second step. A prune that silently sweeps is unacceptable for an irreversible file-delete.
  • Scope is the bank system's own leavings, not the folder at large. Prune reclaims files that were bank files and are now unreferenced — never a file a user hand-dropped into the folder. Prune is a reclaimer of ReaSampler's own orphans, not a general folder cleaner.
  • Orphan attribution is an owned-file manifest (fork R-D, SETTLED 2026-07-24). The book tracks the set of files it has created (an owned-file manifest); prune reclaims (owned ∩ on-disk) referenced. This is the honest encoding of "reclaim only our own leavings" and rejects folder-sweep (which would delete hand-dropped files). The seam lands early: because the manifest is cheap to maintain from capture onward but a backfill cliff to reconstruct later, capture writes each file it creates into the owned-file manifest starting in Phase B, even though prune consumes it only in Phase R. R1/R2 consume the manifest; they do not build it. The manifest is persisted in the "reasampler" ext-state; the exact persistence shape (a sibling key vs. folded into the banks blob) is a small build-time residual, but the manifest-now decision is firm.

Precision / invariant implications

  • The single intentional exception to "no operation deletes files." Stated above; called out again here so the invariant table is honest: prune is destructive-to-files by design and by exclusive authority.
  • Relative-paths-only / Save-As machinery reused — prune resolves paths the same way the index does (M4), so it inherits relative-path correctness and Save-As survival; it introduces no new path handling.
  • Determinism / bit-identical / null-test (capture) untouched — prune sits below the capture path entirely.
  • Prune null test (new invariant): a prune of a folder whose every file is referenced by some bank deletes nothing; a prune deletes exactly the present referenced orphan set and nothing else. Ship as a tested property of the pure core.

Guardrails — the genuinely destructive act

  • Dry-run + confirm-with-manifest (above): the user approves a specific deletion (count + size + files), never an abstract "clean up."
  • Never a referenced file; never a non-bank file. The union-across-all-banks rule protects referenced files; the ownership-attribution rule (fork R-D) protects hand-dropped files.
  • Safest platform deletion available (fork R-C, SETTLED 2026-07-24 — trash- preferred, unlink fallback). Route deletions to the platform recycle bin / trash wherever a portable move-to-trash is available (recoverable outside the app); fall back to unlink — behind the dry-run + confirm guardrail — only where the platform affords no portable trash. "Delete where possible" means recoverable-trash- preferred, never plain unlink-by-default. The move-to-trash surface is an explicit per-platform to-verify (see REAPER/platform API surface). (R3 verified: Windows SHFileOperationW + FOF_ALLOWUNDO confirmed against SDK 10.0.26100; macOS/Linux unlink fallback — no portable SWELL trash surface.)
  • Manual, explicit trigger (fork R-E, SETTLED 2026-07-24 — manual action + panel button). Prune runs via a bindable manual action (dry-run-first, confirm-to- delete) and a bank_panel button that fires that same action — never a silent background sweep. The earlier optional "…and prune now at the delete-bank confirmation" convenience was not selected and is out of scope; a periodic background sweep remains rejected (silent irreversible file-deletion violates the guardrails).

Build detail for this phase (module architecture, API surface) moved to CONTEXT-ARCHIVE.md.

Non-goals / guardrails

  • Prune is the ONLY file-deletion authority. No bank op, no capture op, no Design View op deletes a file. If any path other than prune deletes a bank file, reject it in review.
  • No general folder cleaning. Prune reclaims the bank system's own unreferenced leavings, not arbitrary files a user placed in the folder (fork R-D governs the attribution).
  • No silent deletion. Dry-run + explicit confirm always; no background sweep.
  • No file deleted while any index references it. The referenced-set union across all banks is the safety-critical invariant — enforce and test it in the pure core, not just the UI.
  • Additive only. Prune reads the book and the folder; it does not modify BankIndex, bank_book, the capture roadmap, or Design View semantics.

Settled forks (Daniel, 2026-07-24)

  • Fork R-C — deletion mechanism. Settled: trash-preferred, unlink fallback. Route to the OS trash where a portable move-to-trash is available (recoverable), else unlink behind the dry-run/confirm guardrail. Per-platform trash surface is a must-verify. Folded into Settled decisions + Guardrails + API surface above. Product notes §Fork R-C.
  • Fork R-D — orphan attribution. Settled: owned-file manifest, (owned ∩ present) referenced; folder-sweep rejected as unsafe. The seam lands early — capture writes each created file to the manifest starting in Phase B, prune consumes it in Phase R. Persistence shape (sibling key vs. banks blob) is a build-time residual. Folded into Settled decisions + Module architecture + API surface above, and added as an up-front Phase B / capture plan point. Product notes §Fork R-D.
  • Fork R-E — trigger. Settled: manual action + bank_panel button, dry-run- first, confirm-to-delete; no background sweep. The delete-time "…and prune now" convenience was not selected (out of scope). Folded into Guardrails + Module architecture above and the R3 plan points. Product notes §Fork R-E.

Build-time residual (not a fork): the owned-file manifest's exact persistence shape (sibling "reasampler" ext-state key vs. folded into the banks blob).


MIDI-playback instrument — additive phase spec (Phase S — Sampler)

New pillar, its own lettered phase, and — uniquely — its own build artifact. Every prior phase (M / D / B / R / V) ships inside the one reaper_reasampler extension binary. Phase S does not: a REAPER extension cannot be a MIDI-triggered instrument (it is not a node in any track's signal chain), so the instrument is a second, separate binary — a native VST3 plugin the user instantiates on an instrument track — that reads ReaSampler's banks and plays them MIDI-triggered. Namespaced S (Sampler) rather than "D" (which would collide with Design View). The M/D/B/R/V extension pillars are untouched. Product framing, the plugin-format reasoning, the bare-VST3-vs-JUCE assessment, and the settled decision record: docs/product/midi-playback.md. Same standing discipline: verify every Steinberg VST3 SDK and REAPER/SWELL API name/signature against the vendored headers before use.

What it is

A native VST3 sampler instrument — a separate product/artifact from the extension — that maps ReaSampler's captured bank samples across a MIDI keyboard and plays them back with a real voice engine (polyphony, velocity, envelopes). The extension stays the sole owner of capture + organization; the instrument is the playback surface. The two are tightly integrated but distinct acts: the extension captures and organizes; the instrument plays. Neither crosses into the other's role — the instrument never captures, the extension never becomes an instrument.

Why a VST3 and not the extension (load-bearing, settled — see D1/D5/D6 below). An instrument track's "read live MIDI, emit audio per-voice, in REAPER's routing/record/render path" contract belongs to VST/VST3/CLAP/JSFX plugins, hosted through an entirely different mechanism than the extension API. The extension SDK's audio-adjacent surfaces (Audio_RegHardwareHook, kbd_OnMidiEvent, PlayPreview, pcmsrc subclassing) are each the wrong tool for a live-MIDI instrument — the full reasoning is in docs/product/midi-playback.md §1. The instrument is therefore a standard VST3 plugin; this is not an engineering-around-able limitation, it is what the plugin format is.

The three locked decisions this spec assumes

Settled by Daniel (2026-07-26); everything below assumes them. Reasoning preserved in docs/product/midi-playback.md §4.

  • D1 — native VST3. Not JSFX. Full sampler sophistication, clean integration, and access to the REAPER VST-host bridge. JSFX retired (cross-platform-for-free is worthless under D5, and JSFX gets no bridge).
  • D5 — Windows-only, VST3-only, REAPER-only. No cross-platform DSP/build/signing matrix, no multi-format wrapper, no standalone-in-other-hosts concern. REAPER-coupling via the bridge is intended. This is the single biggest simplifier — it deletes most of what makes VST3 painful.
  • D6 — two products, tightly integrated. A separate artifact, but not a divorced file-only companion: via the VST-host bridge it reads the live "reasampler" project ext-state and is project-aware.

The VST-host bridge (the integration mechanism, stated once)

A VST3 hosted inside REAPER can call back into REAPER's own API by resolving function pointers by name over the host callback (hostcb(&effect, 0xdeadbeef, 0xdeadf00d, 0, "FunctionName", 0.0) — the same string-keyed API table the extension uses; verified in video_processor.h and the reaper_plugin_functions.h GetProjExtState/SetProjExtState/EnumProjExtState entries). The plugin can also fetch its host context — the track/take/project it was instantiated in (opcode 0xdeadf00e). Consequence: the instrument reads the same live "reasampler" ext-state that persist writes, follows the active project, and needs no "point me at the bank folder" wiring — it asks REAPER which project it is in. This capability exists because the plugin is hosted in REAPER; it is the technical affordance D6 leaned on. Must-verify before build: confirm the bridge opcodes and the by-name resolution against the vendored vendor/reaper-sdk/sdk/ headers (reaper_plugin.h, video_processor.h, reaper_plugin_functions.h) — the framing doc's opcode citations are verified from those headers but the exact call marshalling should be confirmed at the spike.

The two seams (audio via files, mapping via live state)

  • File seam (audio, permanent). The sample audio is the on-disk 32-bit-float WAVs — project-relative, travelling with the .rpp via the M4 machinery. The instrument resolves those paths the same way persist does (a shared convention, not a re-implementation). There is no live PCM stream across the bridge, by design.
  • Live-state seam (the mapping, via the bridge). For everything that is not raw audio — the bank index, the mapping, which project is active — the instrument reads the live "reasampler" ext-state via the bridge. It sees what persist last wrote and follows the active project.

The seam fields — what becomes a bank intrinsic (D-B, settled 2026-07-26)

The split model (option iii) is the settled answer. It mirrors the capture/placement separation:

  • Bank intrinsics (facts about the captured file) live on Sample. Root note (the MIDI note the sample was recorded at, so it can be repitched across the keyboard — distinct from the existing optional musical key field) and loop points (sustain-loop start/end for held notes; sample-accurate, zero-crossing-aware) are facts about the file, analogous to sample rate, length, and peaks. They are added to Sample as an additive field extension — the same shape as how provenance was added in M1: new optional fields with JSON round-trip, populated at/after capture, defaulting cleanly for pre-existing samples. This keeps the bank a clean, tool-agnostic library (WAVs + facts, readable by anything).
  • The performance map (a creative arrangement) lives in the instrument. Key zones (low/high note per sample), velocity layers, round-robin groups, amplitude envelopes (ADSR), and per-sample tuning/gain trim are a performance choice, not a fact about a file — they belong to the instrument, not the bank. Under the live-state seam the instrument may still read performance-map data out of shared "reasampler" state, so "who owns which field" is a data-ownership decision, not a transport one.

Why the intrinsic fields are added early (D-B, the backfill-cliff reasoning). The Sample field addition is scheduled as an early Phase S point even though the instrument that consumes them lands later. Rationale (the design-the-seam-even-if-you- defer-the-feature instinct, same as Fork R-D's owned-file manifest): if the fields are added only when the instrument needs them, every sample captured before then lacks a root note / loop points and must be backfilled by hand. Adding the fields now — so capture starts populating them (or at least defaulting them cleanly) — costs almost nothing and closes the cliff. The field addition touches the extension codebase (bank_model + capture + persist), is independently shippable, and lands before the instrument build leans on it.

Scope tiers (D-C, settled 2026-07-26 — Tier 01 committed, Tier 2 held, Tier 3 optional-forever)

Tiers are minimal → sophisticated; Tier 0 delivers the core promise and each tier above is optional depth, not a prerequisite for the one below.

  • Tier 0 — "the bank plays" (committed). One sample mapped chromatically across the keyboard from its root note; basic polyphony; a simple amp envelope; velocity → volume. The honest MVP: point a bank sample at a MIDI track and play it repitched. On the native path this is the SingleComponentEffect skeleton plus a single-voice core, editor deferrable behind a parameters-only default view.
  • Tier 1 — "a keymap" (committed). Multiple samples zoned across the keyboard (key ranges), each with its own root note — a captured kit or a multisampled instrument plays correctly. This is where the root-note + key-range seam fields earn their place. One sample per key-region.
  • Tier 2 — "expressive" (HELD — noted, not specified). Velocity layers, round-robin (the anti-machine-gun feature), full ADSR, per-sample tuning/gain trim, sustain loops. Where it becomes a tool people reach for. Explicitly a follow-on — its points are not drawn up in this spec; it is recorded as the next depth increment once Tier 01 proves the instrument belongs in ReaSampler's world.
  • Tier 3 — "instrument polish" (optional-forever). Filters, filter/pitch envelopes, LFOs, per-voice pan, choke groups, a modest FX slot. A direction to leave room for, never a commitment. Do not let a Tier-3 feature list inflate the build-shape decisions.

The build shape (D-A, settled 2026-07-26 — bare Steinberg VST3 SDK + LICE editor)

Settled: bare Steinberg VST3 SDK, no JUCE, with the editor drawn in the same LICE/SWELL stack bank_panel already uses. Reasoning (full assessment in docs/product/midi-playback.md §1a and §4 D-A):

  • The audio-processing scaffolding is bounded. Using SingleComponentEffect (the SDK's combined processor+controller base — sanctioned for a non-distributable, REAPER-only plugin under D5/D6) plus the SDK's factory macros, a silent-but-loading VST3 instrument skeleton is order-of-magnitude a few-hundred lines of adapt-from-example ceremony, written once. The AGain / Note Expression Synth SDK examples are the copy-source. Not a tar pit.
  • D5 deletes JUCE's biggest justification. JUCE exists largely for multi-format / cross-platform, both of which D5 removed. Its one genuine remaining pull is the editor UI — and ReaSampler is the atypical case where even that is weak, because it already has a working, docked, custom-drawn LICE UI (bank_panel) and a house style. Drawing the editor in a VST3 IPlugView that hosts a LICE surface reuses that muscle, keeps the look house-consistent, and avoids JUCE's AGPL-or-pay license posture (the Steinberg SDK is permissive, no revenue gate).
  • The one real edge — the IPlugView↔LICE bridge (window lifecycle, sizing, event routing from the host into the draw/hit-test loop) — is the same class of work ReaSampler already did to dock bank_panel, not a new competence, but it is less trodden than dropping in a JUCE editor. It is therefore the phase's opening spike (below), which also converts §1a's experienced-estimates (Windows module-export symbol names, factory-macro spellings, exact bridge marshalling) into verified fact before the engine build leans on them. VSTGUI (the SDK's bundled toolkit) is the noted fallback rung only if the LICE bridge proves gnarlier than the panel work suggests; JUCE is the last resort behind that.

The pure core (D3 — the load-bearing split, transplanted)

The sampler's voice engine, envelope math, key/velocity mapping, repitch/interpolation, and keymap resolution are a pure, REAPER-free, DAW-free, unit-tested module — the mirror of bank_model / peaks / view_mode_model / bank_book, tested in the CTest harness outside any host. This is the heart of the phase; test it hard. The VST3 wrapper — the SingleComponentEffect subclass, bus setup, the process call marshalling MIDI→core and core→audio-buffer, the IPlugView LICE editor, and the bridge calls that read "reasampler" ext-state — is the thin shell, the only part that touches VST3 or REAPER at all. Critically, this split is invariant under the build-shape choice: whether the shell is bare-SDK or (hypothetically) JUCE, the pure core is identical, REAPER-free, and tested the same way. The format choice is a shell choice; the core is invariant.

Build detail for this phase (module architecture, WDL API surface, build sequencing, the superseded S10 workflow hierarchy, Steinberg API surface, the S17 drop-and-load spec) moved to CONTEXT-ARCHIVE.md.

Precision / invariant implications

  • The bank is one source; the instrument is another view of it (never a fork). The instrument is a pure consumer of the bank — it does not copy samples, does not own a private sample store, and does not mutate the bank. The bank stays the single authoritative artifact (the one-source-multiple-views instinct). Any instrument path that writes back into the bank or keeps its own sample copies is a bug.
  • Capture/placement/playback stay distinct acts. The instrument reads and plays; it never captures and never inserts into the arrange. The capture load-bearing principle is untouched — Phase S adds a third distinct act (playback) without weakening the capture↔placement separation.
  • Sample field addition is additive and lossless. Root note + loop points are new optional fields with JSON round-trip, defaulting cleanly for samples captured before the addition — the same additive, backward-compatible shape as provenance (M1). No existing Sample field changes; no BankIndex behavior changes.
  • Relative-paths-only survives. The instrument resolves audio via the M4 project-relative machinery; it introduces no absolute paths.

Embedded TCP/MCP UI (D-D, settled 2026-07-26 — SCHEDULED as a later Phase S point)

A REAPER-hosted VST3 can draw its own UI inline in the track/mixer control panel via reaper_plugin_fx_embed.h (the plugin implements IReaperUIEmbedInterface; the same Cockos surface REAPER's own embedded FX use). A ReaSampler instrument can render a compact keymap/level strip inline in the TCP/MCP, not only in its own window. Because this uses the same LICE-class drawing as the D-A editor path, it composes naturally with the bare-SDK-plus-LICE build — the groundwork is the groundwork.

Settled: scheduled, not deferred. This is a real, in-phase later point — it lands after the main IPlugView editor exists (it composes with that LICE path), not a someday-note. It is polish, not a Tier-0 need, so it sequences last in the phase; but it is on the roadmap. Must-verify before build: the IReaperUIEmbedInterface contract and embed message/lifecycle against vendor/reaper-sdk/sdk/reaper_plugin_fx_embed.h.

Channel mode — mono | stereo (D-E, decided 2026-07-26; PLAN.md S7)

Decided direction: the instrument gets a per-instance channel-mode toggle — 1 (mono) or 2 (stereo) — that negotiates the REAPER audio bus automatically. Captures are often stereo; the current mono downmix is a Tier-0 simplification, not a permanent shape.

  • Mono mode keeps today's path. The decode-side downmix stands: a stereo source in mono mode downmixes (the existing policy), a mono source plays as-is. No engine change for mono.
  • Stereo mode is an S3-core extension, not a shell hack (honest). The S3 core is mono-per-sample by design todaySampleData::frames is one mono stream, Voice::renderFrame returns a single value, VoiceEngine::render writes one channel. Stereo mode grows the core a channel dimension: 2-channel decoded PCM, per-voice stereo render (per-channel fractional read + linear interpolation + loop), and a per-channel mix in the engine. Mono stays the degenerate (single-channel) case, so existing mono behavior is unchanged. This is why S7 sequences first after the editor/embed work: it touches the engine Daniel smoke-tests.
  • Where the toggle lives. Per-instance component state (setState/getState), alongside the selected sample — a performance choice the instrument owns, never written to the bank (D-B: not a file fact). Default preserves current behavior (mono).
  • Cross-mode policy (settled). Mono source + stereo mode → dual-mono (same signal both channels, centered). Stereo source + mono mode → downmix (the existing decode-side policy). The bank's per-sample channel-count intrinsic (already on Sample) tells the shell how many channels to decode into SampleData.
  • Bus negotiation (the "works with the REAPER bus automatically" requirement). The VST3 implements setBusArrangements so the output bus reports mono or stereo per the instance's channel mode, and REAPER's routing follows without manual channel wiring. Must-verify before build: the setBusArrangements / getBusArrangement contract and REAPER's mono/stereo instrument-bus expectations against the vendored Steinberg SDK + reaper_vst3_interfaces.h.

Sampling modes — Trigger vs Gate + pitch envelope (S15/S16; core + editor)

Daniel's directive (2026-07-26, verbatim): "Sampling mode: Trigger vs Gate. Gate has an AHDSR envelope. Trigger has fade in, % length, and fade out. Both modes have modifiable start point, Gate has modifiable loop points too. In addition to amp env, there will be a pitch envelope/curve (AD?) which is off by default." The feature set is settled; two forks (S15-F1 choke, S15-F2 param granularity) are flagged with leans below.

Daniel's S16 correction (2026-07-26, verbatim): "isn't that ratio stuff going to change the playback rate? I want duration-preserving repitching." Correct — the readPos_ += ratio_ path is varispeed (pitch and duration coupled). S16 is revised from "pitch envelope only" into a pitch-engine mode (Varispeed vs Preserve) + pitch envelope (see §Pitch engine modes below). Two new S16 forks are flagged: S16-F1 (the engine default — lean Preserve) and S16-F2 (the Preserve implementation — lean WDL_SimplePitchShifter first, hand-rolled held). The prior WDL finding that dismissed WDL_SimplePitchShifter is corrected in place below (duration-preserving is now the requirement, so that shifter is the Preserve candidate).

Play mode — Gate vs Trigger (S15)

Each played sample carries a play mode — a per-sample/per-zone performance choice (D-B, instrument-owned, never a bank fact). Two modes, precisely:

  • Gate — classic held note (grows the current path). Note-on enters the amp envelope; note-off enters release; a sustain loop applies for held notes (S11's draggable loop markers are Gate-mode UI). The current core envelope is ADSR; Gate adds a Hold stage → AHDSR: 0→1 over attack, hold at 1 over holdFrames, 1→sustain over decay, hold sustain until note-off, level→0 over release. holdFrames == 0 is exactly today's ADSR — a back-compat degenerate, no behavior change for existing Gate play. Segment math is the existing linear-ramp idiom (AdsrEnvelope::tick) with one new stage inserted between Attack and Decay.
  • Trigger — one-shot drum-pad. Note-on fires playback of a defined % of sample length with a fade-in and fade-out ramp; note-off is ignored (the voice plays through); no sustain loop. Envelope math (distinct from AHDSR): play the frame span [startFrame, playEnd) where playEnd = startFrame + round(lengthFraction·(frames startFrame)), lengthFraction ∈ (0,1]; amplitude ramps 0→1 over fadeInFrames (fade-in) at the head and 1→0 over fadeOutFrames anchored to playEnd (fade-out), unity between; fades clamp so fadeInFrames + fadeOutFrames ≤ play length. The voice frees when readPos_ ≥ playEnd (mirror of the current run-off-end idle). Fade curve default: equal-power (constant-power sin/cos — click-free on one-shots); linear is a build-time residual. Note-off in Trigger is a no-op (choke is held — fork S15-F1).

Both modes: modifiable start point. Playback begins at startFrame (a frame offset into the sample, clamped 0 ≤ startFrame < frames), not always frame 0. This is the voice's initial readPos_; the existing per-frame readPos_ += ratio_ read and linear-interp/loop machinery are otherwise unchanged. Gate additionally has modifiable loop points (already the S2 loop intrinsic + S11 override); Trigger has none (it is a one-shot).

Voice-stealing interaction (unchanged). The S3 stealing policy (oldest-in-release, else oldest-overall) is mode-agnostic — a Trigger one-shot is a normal active voice until it runs off playEnd; it can be stolen like any voice. No new stealing rule.

Confirmed from the core (sampler_core.cpp): the read loop advances readPos_ by an arbitrary ratio_ per frame with 2-point linear interpolation, and the amp is a per-frame env_.tick() multiply — so both the AHDSR hold stage and the Trigger fade/%-length envelope are per-frame amplitude functions over the existing read machinery, and the start point is just a non-zero initial readPos_. No resampler or voice-lifecycle rewrite is needed.

Parameter ownership (D-B). The play mode + its params (Gate: AHDSR; Trigger: %-length + fade-in + fade-out; both: start point) attach to the capture selection / zone and live in the instrument's performance map (component state, version-bumped, back-compat: a truncated/older blob defaults to Gate, hold=0, start=0, no fades = exactly today). Start point joins rootOverride / loop-override as another per-PerformanceZone optional override; a per-zone PlayMode + param struct is added additively. Fork S15-F2 (flagged): per-capture-selection and per-zone, or per-zone only with the single-capture case as a one-zone map? Lean: per-zone only — the single capture is already a one-zone map (S10-Z's back-compat lift), so one storage site serves both; flagged because it touches S10's single-capture setup surface shape.

Editor (mode-aware, on the S11 waveform surface). Gate shows draggable start + loop markers; Trigger shows start + %-length end + fade-in/out handles — same waveform, same pure frame↔pixel + marker-grab geometry module (S11), mode switches which markers draw. A mode toggle per capture/zone sits in the S10 guided setup / S10-Z Zones panel. Every edit commits off-thread via commitAndReload; the instrument stays a read-only bank consumer (mode/params are performance map, never written to Sample or the bank).

Pitch engine modes — Varispeed vs Preserve (S16)

Daniel's correction (2026-07-26, verbatim): "isn't that ratio stuff going to change the playback rate? I want duration-preserving repitching." Correct: the readPos_ += ratio_ resampling path is Varispeed — pitch and duration are coupled (an octave up halves the note's duration). Daniel wants duration-preserving repitch. So S16 grows a per-voice/ per-zone pitch-engine mode, not just a pitch envelope:

  • Varispeed engine (current path). ratio_ = pitchRatio(note,root), readPos_ += ratio_ with 2-point linear interp — resampling that couples pitch and duration. This is the classic sampler / RS5K behavior and today's shipped S3/S5 output. Cheap, zero-latency. Musically right for drums / one-shots (pitch-down-lengthens-the-hit is a feature there).
  • Preserve engine (duration-preserving). The read advances at the source rate (duration held) while a pitch shifter transposes the output by 2^((noteroot)/12). Musically right for tempo-locked loops and phrases — a transposed loop still lines up to the bar. Since captured banks are project slices (loop/phrase-heavy), this is the default lean (fork S16-F1).

Mode is per-PerformanceZone performance state (D-B) — instrument-owned, never a bank fact — additive/version-bumped (absent/older blob → the S16-F1 default). A per-zone Varispeed/Preserve toggle surfaces in the S10 guided setup / S10-Z Zones panel.

Preserve engine implementation (fork S16-F2). Two RT-disciplined routes behind the PitchEngine::Preserve seam (identical contract either way):

  • (a) WDL_SimplePitchShifter (vendor/WDL/WDL/simple_pitchshift.h) — a per-voice time-domain OLA shifter. Under the duration-preserving directive this is the right category (see the corrected WDL finding below). set_shift(2^(semi/12)) for pitch, set_tempo(1.0) to hold duration — pitch and duration are separately controllable. Lean: route (a) first (low-cost proof), with two costs owned in the build: an inherent onset latency (~half-window, ~25 ms @ the 50 ms quality-0 window; pre-warm at voice- allocation, and it lands on sustained/loop material where least harmful) and a queue-growth allocation hazard in BufferDone (WDL_Queue::Add) that is settled by a silence pre-warm at voice-allocation so no process-thread allocation occurs in steady state.
  • (b) hand-rolled pure pitch_shift OLA/granular module (house pattern — CTest-testable, no REAPER/VST3/WDL type at the boundary) — held as the quality/latency upgrade if the SimpleWindowed warble or onset lag proves musically unacceptable.

WDL_Resampler is not a Preserve engine — it is a resampler (couples duration); it remains a held Varispeed-quality upgrade only. elastique is NOT available (licensed zplane, not vendored — restated). JUCE / rubberband / signalsmith are new-dependency forks carrying D-A weight (bare-VST3-no-framework is the locked D-A) — not proposed.

S15 × S16 interaction (Preserve consumes S15's source-frame read). S15's amplitude semantics are defined over the voice's source-frame timeline; the Preserve engine wraps that read and transposes the output, so:

  • Trigger %-length stays a source-frame fact (playEnd = start + round(lengthFraction· (frames start))); under Preserve its wall-clock is stable under transposecleaner than Varispeed, where transposing a Trigger also scales its audible length.
  • Gate sustain loop — under Preserve, loop the source read (the [loopStart, loopEnd) source-frame region) and feed the looped stream into the shifter, which transposes the output. Contract: loop the source, shift the output; loop points stay source-frame facts (S11 markers unchanged). Under Varispeed the loop read itself carries the pitch.
  • Start point is a source-frame offset in both engines (engine-independent).

Pitch envelope — AD, off by default, engine-aware (S16)

A per-voice pitch modulation curve riding on top of whichever engine — a short AD (attack-decay) envelope that biases pitch over time. Off by default (so existing playback is bit-identical under the same engine). The classic use is a percussive pitch drop.

  • Shape (lean, build-time residual): two-segment AD — at note-on the pitch offset rises to peakSemitones over attackFrames, then falls to 0 (base pitch) over decayFrames. A zero attack gives the pure "start high, drop to base" percussive drop.
  • Range: semitones (±). peakSemitones is signed; default depth range noted at build.
  • Applied per engine. Under Varispeed the offset is a per-frame multiply of ratio_ by 2^(pitchEnvSemitones(frame)/12) (the effective read increment varies frame- by-frame at no structural cost — the same per-frame tick() idiom as the amp envelope, RT-safe, no process allocation). Under Preserve the offset is added to the shifter's shift amountset_shift(2^((noteroot + pitchEnvSemitones(frame))/12)) — bending pitch without touching duration. Per-voice (polyphonic notes each run their own).
  • Ownership + editor. Per-zone instrument performance-map state (D-B), additive/version- bumped (absent → disabled). Editor exposure folds into the S12 ADSR-editor tier: attack + decay + a ±semitone depth control, default-off (discoverable but inert until enabled).

Ingest through the bank — the extension owns ingest (decided "option 1", 2026-07-26; PLAN.md S8)

Decided: loading a sample into the sampler is ONE gesture — capture/import-into-bank AND auto-assign to the active sampler instance — and the extension owns it. The instrument stays a read-only bank consumer; it never captures and never imports. The extension is the right owner: it has arrange access, Media-Explorer access, and the drop-target surface on its own docked panels. Ingest lives in the extension codebase (actions + bank_panel + capture/import add-path), routing through the existing capture add-path and the live "reasampler" seam the instrument already reads.

The three ingest surfaces, with the honest SDK reality (verified against the vendored headers):

  • Arrange capture → bank → assign. A one-click action captures the selected item / time-selection into the bank (reusing the existing capture request path — CountSelectedMediaItems / GetSelectedMediaItem + GetSet_LoopTimeRange are already the capture inputs) and assigns the resulting Sample id to the target instance. It never inserts a timeline item — the capture/placement separation is load-bearing; assignment is a bank-index + instance-selection act, not a placement.
  • Media Explorer import → bank → instrument on selected track. The Media-Explorer surface is thin: OpenMediaExplorer (open/select a file) and MediaExplorerGetLastPlayedFileInfo (read the one last-played/selected file path + its selection range/pitch/vol/rate) are the whole contract. There is no enumerate-selected-files and no register-a-drop-handler-on- the-Media-Explorer API. So ME import is single-file, pull-on-action — an action fired while a file is selected in the ME — not a push/drop from inside the ME. Shipped behavior: after importing the file into the active bank, the action adds a ReaSampler 9000 instrument to the user's currently selected track via loadInstrumentOntoTrack in instrument_drop_win — no new track is created and no routing is changed ("new sound, existing track"). If no track is selected the sound still lands in the bank but no instrument is placed and a console message explains why. No assignment_request is written; it never touches a live instance's selection. Undo-wrapped: persist + FX-add + inject = one Ctrl-Z.
  • Drag-and-drop onto ReaSampler surfaces. REAPER exposes no drag-drop registration API. Drop handling is on ReaSampler's own HWNDs via SWELL/Win32 (WM_DROPFILES / IDropTarget on the docked bank_panel HWND — the surface the panel already owns) → ingest (bank-fill only). Dropped files are imported into the active bank; no assignment_request is written and no live instance's selection is affected. The bank-generation bump is retained so open instances' browsers refresh to show newly available sounds. Assess-and-flag (spike, not promised): a drop onto the VST3 editor window — whether the IPlugView HWND can accept an OS file drop and relay it to the extension as a bank-ingest request (the instrument does not ingest; it forwards a request over an agreed seam). This crosses the two-artifact boundary and the relay is unproven; if gnarly, drop-onto-panel is the shipped path and drop-onto-editor is deferred.

The assign seam. The ingest action names the target instance (lean: the active/ last-focused instance, discovered via the host context the bridge already resolves) and hands it the new sample id — the same instance-owned selection state S4 already persists, so a reload picks it up. With the change-detection seam (below) the assignment refreshes hands-free; without it the ingest action pokes the target instance's reload directly.

Guardrail (load-bearing, restated): ingest is an extension act. Any instrument code path that captures, imports, inserts a timeline item, or writes back into the bank is a bug — the instrument reads and plays only.

Bank-generation change-detection — hands-free refresh (decided 2026-07-26; PLAN.md S9)

Instances reference sample ids. So a recapture (M10) landing under the same id — or an ingest (S8) touching the active bank — should refresh playing instances hands-free, without re-opening each editor. The missing trigger: a bank-generation counter in "reasampler" ext-state.

  • Writer (extension). A monotonic bank-generation counter, stamped into "reasampler" ext-state under a new forever-stable ext_keys.h constant, bumped on every bank-content mutation that changes what an instance would play (capture add, recapture-in- place, sample-remove, move/copy affecting the active bank). Additive to the persist blob; defaults to 0 for projects saved before the stamp exists.
  • Reader (instrument). Poll the generation over the bridge on a safe off-audio-thread cadence (a UI/timer tick, never process), compare to the last-seen value, and call the existing off-thread reloadFromBank() on change — reusing S4's atomic pointer-swap handoff (graveyard-reclaim) so a mid-play refresh does not glitch. No new audio-thread work; no allocation in process.
  • Cadence + safety. A low-frequency UI timer, coalescing multiple bumps between polls into one reload (build-time residual). The read already tolerates a stale value by design (it reloads on the next poll). Must-verify before build: no torn-read hazard on the single integer generation key for a bridge read on the instrument's UI/timer thread concurrent with an extension write.

This seam serves both S8 ingest and M10 recapture; the writer side is extension-only and independent of S8, so it can land alongside either.

Design-system foundation — moved to Phase L (2026-07-26)

The visual design language moved out of Phase S into its own Phase L. The design-system content that stood here (the toolkit assessment, the shared LICE drawing kit S0-DS, and the dock-panel refresh S14) has been lifted into Phase L (Look-and-feel) on dev, taken up by a parallel team so Phase S feature work proceeds ungated. S0-DS is now Phase L point L1 (the shared kit); S14 is now L2 — and, per Daniel's DS-3 call, expanded from a light re-skin into a thorough dock-panel layout redesign that lays out the full M11-aware button inventory before applying the kit; the VST editor + embed-strip restyle is now the explicit L3 point (gated on Phase S landing on dev). The design-system forks DS-1 (LICE + WDL free game, no external frameworks), DS-2 (Direction B "Neon Console" + Direction C's spectral keyboard strip), and DS-3 (thorough panel layout redesign) are all SETTLED (Daniel, 2026-07-26).

Authoritative from here: PLAN.md §Phase L + CONTEXT.md §Phase L on dev, and docs/product/visual-design-language.md (on dev). Phase S's S10S13 build their interaction UX with the current drawing and adopt the Phase L kit when it lands — they are not gated on Phase L. LICE/SWELL-only, the pure-geometry-module discipline, RT discipline, and the read-only-over-bank / VST3-class-UID-unchanged guardrails all hold exactly as before — a visual refresh is not a data-ownership or compat event.

Product name — ReaSampler 9000 (Daniel, 2026-07-26)

The MIDI-playback instrument's product name is ReaSampler 9000. The extension stays ReaSampler (capture + organization); the instrument is ReaSampler 9000 (playback). Set by Daniel on DAW-testing the S1S6 instrument, alongside the UX-overhaul directive.

  • Propagate the display name across user-visible surfaces: the VST3 class display name string in the factory registration, the factory vendor/name strings, the IPlugView editor title band (currently "ReaSampler Instrument"), the S6 embed-strip label, and the Phase S docs.
  • Do NOT change the VST3 class UID. Instances in already-saved projects key off the class UID; changing it orphans every existing instance in every saved project. The UID is a forever-stable contract (mirror of the command-id / ext-state-namespace forever-stable strings).
  • S-NAME-1 SETTLED (Daniel, 2026-07-26): rename the binary filename too. The on-disk module name is renamed to match the product (e.g. reasampler_9000.vst3), not just the display strings. Full rename surface: CMake OUTPUT_NAME on the second VST3 target, the factory vendor/name strings, the editor title, and the embed label. The class UID stays locked as the compat anchor.
  • Compat verification (must-DAW-verify before shipping the rename). 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. This is a to-verify assumption, not a confirmed fact: a web check surfaced a JUCE/VST3-replace-VST2 case suggesting REAPER's binding can be more nuanced than "UID only" (an FXID match is involved), so it is not safe to assert UID-only rebinding from source. DAW-verify: save a project with an instance under the old filename, rename the module, reopen, and confirm the instance rebinds and restores its state. If REAPER keys partly on filename, fall back to keeping the current filename (display-strings-only) and record that as the shipped choice.

VST3 channel identity — the UID pair + the pairing surface (S18; extends Phase V V4)

Decided (Daniel, 2026-07-26): the beta/stable channel split Phase V V4 gave the extension extends to the ReaSampler 9000 VST3 instrument — a beta-built VST pairs with the beta extension only, a stable VST with stable only, both installable side-by-side in one REAPER. This is the instrument-side companion to V4 and mirrors its philosophy exactly: one channel per binary; all channel identity derives from the ONE REASAMPLER_CHANNEL_IS_BETA bit via the pure app_version module — no scattered #ifdefs in the VST shell.

What is already isolated (structural, not added by S18). The wire/data pairing is already done and needs no per-key work: ext_keys.h's kProjExtNamespace() delegates to app_version::extStateNamespace(), so a beta-compiled VST's bridge reads "reasampler_beta". Every wire key — banks, assign_request, S9's bank-generation key (in-flight), S17's component-state contract, and any future key — is a plain constant under that namespace, so channel data-isolation is structural: no per-key opt-in, and a future key that forgets to isolate is impossible by construction (it keys off the namespace accessor, not a raw literal). What S18 adds is only the missing plugin identity layer.

  • The UID-pair invariant (the permanent commitment). The VST3 class UID is the plugin's identity — a saved REAPER project records it and rebinds a saved instance by it. Today reasampler_vst.h holds one forever-locked UID (kReaSamplerProcessorUID, REASAMPLER_PROC_UID_1..4, S-NAME-1). A beta VST with the same UID cannot coexist with stable in one install (same UID = identity collision / arbitrary rebind). So beta needs its own forever-stable UID: a second constant, minted once, locked exactly as the first. Invariant: BOTH UIDs are frozen forever once shipped; the channel bit selects which is compiled into this binary (one DEF_CLASS2, one class per binary — not both classes in one binary; that mirrors V4's fully-isolated-binary philosophy and keeps a beta build from ever presenting the stable identity). Saved-project isolation follows directly: a project saved with beta instances rebinds only to the beta VST; a stable-saved instance opened where only the beta extension has banks resolves the stable UID and shows a clean empty "pick a capture" state (S10 policy), not an error.
  • Binary + display identity, channel-derived. Mirror the extension's OUTPUT_NAME fork (reaper_reasampler / reaper_reasampler_beta): the VST3 module's on-disk name forks reasampler_9000 / reasampler_9000_beta, its factory display name "ReaSampler 9000" / "ReaSampler 9000 beta", and its editor title band + S6 embed-strip label are channel-aware — all sourced from app_version channel accessors (a VST-name accessor beside binaryName()/dockTitle()), never a literal in reasampler_vst.h/vst_entry.cpp. The factory version string carries the -beta render where V4's appVersion() already does; vendor/url/email stay shared unless V4 qualified the equivalent (V4 kept the lane-name prefix shared — shared-where-V4-shares is the default).
  • The complete pairing surface (the guarantee to state, not new code). A channel's VST talks to that channel's extension only, because (1) plugin identity — UID + filename + display — is channel-forked (above), and (2) all wire keys live under the channel-derived kProjExtNamespace(). The two together make pairing complete and structural: identity keeps the plugins distinct; the namespace keeps the data distinct. No per-key or per-seam isolation work is ever needed — S8's assignment key, S9's generation key, and S17's blob-injection key all inherit it. Verify all identity/factory wiring against the vendored Steinberg SDK (DEF_CLASS2 / INLINE_UID / FUID from pluginfactory.h + funknown.h); the pure app_version name accessors are CTest-tested.
  • Fork S18-F1 (flagged — Daniel's call): mint the beta UID now vs. at first beta release. Lean mint now — mirrors the stable UID (minted at the S1 spike, locked long before ship), removes a "remember to mint before shipping beta" landmine, zero cost for an unused-until-beta constant. The alternative (a locked-once placeholder replaced before the first beta VST ships) defers the commitment but adds a release-gate step. Flagged only because the UID is a forever commitment.

Non-goals / guardrails

  • The instrument never captures and never inserts into the arrange. Playback is a read-only act over the bank. Any instrument path that captures, places a timeline item, or writes back into the bank is a bug — reject in review.
  • The instrument keeps no private copy of the samples. It consumes the one authoritative bank; per-instance sample stores are a non-goal (they refork the source the one-source-multiple-views instinct keeps single).
  • The instrument never ingests (S8). Capture, import, and drop-ingest are extension acts; the instrument only reads and plays. A drop onto the editor window (if the spike proves it viable) is relayed to the extension as an ingest request — the instrument never writes the bank itself.
  • Channel mode is a performance choice, not a bank fact (S7). The mono/stereo toggle is per-instance component state, never written to Sample or the bank (D-B). The bank's per-sample channel-count intrinsic is a file fact; the play mode is the instrument's.
  • No cross-platform / multi-format. Windows-only, VST3-only, REAPER-only (D5). Do not add an AU/AAX/VST2/CLAP wrapper, a mac/Linux build, or a standalone host target.
  • The pure core stays REAPER-free and VST3-free. The voice engine / envelope / keymap / repitch module takes no VST3 or REAPER type at its boundary — the shell marshals. Any VST3 or REAPER type leaking into the core is a bug (the D3 split).
  • Additive to the extension. The Sample intrinsic-field addition is additive (new optional fields; no existing field or BankIndex behavior changes); everything else in Phase S lives in the second artifact and does not alter the extension's M/D/B/R/V pillars.
  • Do not spec Tier 2/3. Tier 2 is held (noted, not specified); Tier 3 is optional-forever. Do not let their feature lists drive Tier 01's build shape. Note: S7 stereo is not a Tier-2 feature — it is a channel-count dimension on the existing Tier 01 engine, orthogonal to Tier 2's velocity-layers / round-robin / per-sample trim. (S7's stereo loop read is the same loop the core already has, extended per-channel — not the Tier-2 "sustain loops" feature.) Likewise S15/S16 (Trigger/Gate modes + pitch envelope) are Daniel-directed engine features on the Tier 01 core, not Tier 2/3 — the AHDSR hold, Trigger one-shot, start point, and AD pitch envelope are orthogonal amplitude- shape / read-rate dimensions, not the held velocity-layers / round-robin / filter work.
  • S15/S16 params are performance choices, not bank facts. Play mode, start point, %-length, fades, AHDSR, and the pitch envelope are per-instance performance-map state (component state), never written to Sample or the bank (D-B). The bank carries file facts (root note, loop intrinsic, channel count); the instrument owns how they are played.
  • S15/S16 stay channel-count-agnostic (S7 interplay). The mode/envelope logic is per-frame amplitude and read-rate, independent of the S7 channel dimension. Any S15/S16 code that assumes a fixed channel count (mono) — rather than operating per-frame pre-mix — is a bug that would collide with S7. Spec and build them channel-agnostic.
  • Trigger ignores note-off; choke is out of scope (S15). In Trigger mode note-off is a no-op and the one-shot plays to playEnd. Choke-on-note-off / choke-groups are held (fork S15-F1, Tier-3-adjacent) — do not add a choke path in S15.
  • Pitch envelope is off by default (S16). Default-disabled → offset always 0 → the engine's un-modulated output → playback bit-identical to the same engine pre-envelope. A regression that applies pitch modulation when the envelope is off is a bug.
  • Pitch engine is a per-zone performance choice, not a bank fact (S16). Varispeed vs Preserve is per-PerformanceZone component state (D-B), never written to Sample or the bank. The engine default is fork S16-F1 (lean Preserve — Daniel's call), with a prominent per-zone toggle so drum/one-shot zones opt into Varispeed cheaply.
  • Preserve engine is RT-disciplined (S16). The WDL_SimplePitchShifter (or hand-rolled) Preserve path pre-warms at voice-allocation and does no allocation in process — a WDL_Queue::Add or Resize on the audio thread in steady state is a bug. Preserve's onset latency (shifter window) is an accepted property, not a defect; a note-onset click or smear from a cold-started (un-pre-warmed) shifter is a bug.
  • WDL_Resampler is not a Preserve engine (S16). It is a resampler (couples duration) — a held Varispeed-quality option only. Do not wire it as the duration-preserving path.
  • Drop-and-load must not regress the two existing drags. S17 inserts a new middle case (InstrumentDrop) between the M11 OS drag-out and the internal bank-to-bank drag; both existing gestures stay byte-for-byte unchanged in their own regions. Drop-and-load never inserts a media item into the arrange and never copies sample bytes into the instance — it hands the new instance a reference to an already-captured bank sample.
  • Verify Steinberg SDK, bridge, embed, and LICE-view surfaces against the vendored headers before use — several §1a claims are experienced estimates until the spike confirms them.

Editor view-model redesign — three views: Sample / Browse / Zone (S-VIEW; Daniel, 2026-07-27, r9)

Additive sub-phase of Phase S — an editor redesign, not an engine change. Re-partitions the ReaSampler 9000 editor from today's two-view toggle (Browser + Zones) into a three-view model where the loaded sample is the home, adds three new performance parameters (key-tracking, preview velocity, and the r10 velocity→amp transfer curve) and three visual components (envelope overlay, real piano-key pattern, and the r10 velocity-curve editor), and frames two engineering prerequisites (drop-to-FX bug, default window size). The S3 voice engine, keymap resolution, and read-only-over-bank contract are unchanged; the component-state format extends additively (key-tracking + the r10 velocity curve on the zones-payload axis, preview velocity on the envelope axis). Product framing: docs/product/ midi-playback.md §Addendum r9 + r10. Same standing discipline: LICE/SWELL drawing only, all layout/hit-test in pure geometry modules, RT-safe, VST3 class UID unchanged, verify every API name/signature against the vendored headers before use.

The view model — Sample is home; Browse is modal; Zone is a dedicated surface

The three views are not a flat three-way toggle (today's Browser|Zones segmented switch is retired). The model is document-with-modal-picker (mirror: Ableton Simpler — the device face is the loaded sample; loading a new one is a distinct act):

  • Sample (the home / default face). What the editor shows on open with a capture loaded. The hero waveform, the envelope overlay, all per-sample tuning controls, the preview-trigger. This is where the user lives.
  • Browse (a modal page layered over Sample). Summoned by a Browse button (and, when nothing is loaded, the empty-state's primary affordance — Browse must be very easy to open when there is no capture selected). It renders as a full-window overlay over the Sample face — filters + captures grid + select-then-confirm to change the loaded sample, then dismisses back to Sample. It is a picker sheet, not a peer tab.
  • Zone (a dedicated editing surface). Opened by its own Zone button when the user wants to map the capture(s) across the keyboard. Not shown by default ("most of the time zones won't be used" — the S10 reframe). Returns to Sample on close.

Navigation contract. From Sample: a Browse button opens the Browse overlay; a Zone button opens the Zone surface. From Browse: select a card + confirm (or cancel) returns to Sample with (or without) a new loaded capture. From Zone: a close/back affordance returns to Sample. The empty state (no capture loaded) surfaces Browse as its dominant call-to-action. The fresh instance stays silent with a "pick a capture" empty state (S10 reversal, unchanged).

View 1 — Sample (the new main view)

The home face. Composition follows the reference devices (Simpler / Phase Plant): a hero waveform up top with the envelope drawn over it at accurate time, a dense labelled value-strip beneath, root fenced as its own affordance, and a preview cluster. Bands, top to bottom:

  • Title band. Plugin name (channel-derived vstPluginName()) + live readout (loaded capture name / [pick a capture] / [bank empty] / [host: no bridge]) + the Browse and Zone view buttons. (Inherits today's title-band text logic.)
  • Hero waveform band (enlarged) with the ENVELOPE OVERLAY (new). The picked capture's full-resolution envelope (from peaks over the cached mono PCM — no new decode), given real vertical space (the Simpler/Phase-Plant hero, materially taller than today's 72px strip). Drawn over it, at accurate wall-clock time: (a) the S11 markers — start (teal/secondary), loop start/end (purple/tertiary), the faint loop-region fill — moved here from Browse unchanged; and (b) the NEW amplitude-envelope overlay — the AHDSR shape (Gate) or the fade-in/%-length/fade-out shape (Trigger) traced as a curve across the sample at the same time base the voice engine uses, in an accent hue. This is the "reads like a real sampler, not a spreadsheet" move: the envelope becomes a shape over the sound, not four abstract sliders.
  • Root + preview cluster (fenced, new preview-trigger). Root note shown as a first-class, always-visible control (fenced like Phase Plant's "Root" box), draggable/typeable — this is the keyboard-strip root-drag from today's Browse setup, promoted to a fenced control. Adjacent: the NEW preview-trigger button (fires the sampler at the loaded capture's root note through the live voice engine, off the audio-thread commit path — no MIDI controller needed) with an adjacent velocity knob setting the preview velocity level. The Mono/Stereo toggle moves here from Browse (it is a per-capture output-mode tuning concern, not a choosing concern).
  • The "Modes-and-down" control strip (moved from Zone). Every per-sample control that lives on today's Zones param panel for the single-capture case — Mode (Gate/Trigger), Pitch engine (Varispeed/Preserve), the AHDSR sliders (Gate) or Length%/Fade-in/Fade-out (Trigger), and the AD pitch envelope (Off/On + P.Attack/P.Decay/P.Depth) — renders here as the Sample view's control strip, laid out as a dense labelled value-row (bold micro-caps over a value/slider, the reference grammar). For a single loaded capture this is the same one-zone storage site the S15-F2 lean already established (the empty-map single-capture face reads/writes the same PerformanceZone defaults) — no new storage.

Browse elements that MOVE to Sample (inventory — nothing silently dropped): the large waveform preview, the S11 start/loop markers + loop fill, the keyboard-strip root affordance (now the fenced root control), and the Mono/Stereo toggle.

View 2 — Browse (reduced to choosing, laid over Sample as a modal)

Browse's only job is pick a capture. Today's Browser view is close but overloaded; it is cut to the choosing essentials and rendered as a modal overlay:

  • Kept (the excellent core, unchanged): the type-to-filter search box, the bank filter tabs (All + one per named bank), the captures grid (cards: peak thumbnail + name + root/key badge), scroll (wheel + thumb), and card selection.
  • Added: a confirm/cancel affordance (select a card, confirm to load it into Sample and dismiss; cancel to dismiss unchanged) — the modal-picker close semantics. Double-click-to-load is the natural accelerator.
  • Removed from Browse (moved to Sample or cut): the large waveform preview (redundant — the grid thumbnails already show every capture's waveform; moved to Sample as the hero); the Mono/Stereo toggle (moved to Sample); the root-note keyboard-strip in the setup band (moved to Sample as the fenced root control); the loop-point labels and the track-root message (cut — they waste space and add nothing to choosing).

Rationale (why cut, not keep-and-hide): Browse is a picker. Every tuning affordance on it is a mode error — you tune what you have, you browse for what you want. Concentrating tuning on Sample and choosing on Browse makes each view do one job, and makes the modal overlay light enough to summon and dismiss without ceremony.

View 3 — Zone (dedicated keyboard-mapping surface + key-tracking + real piano pattern)

Zone is the multi-zone keymap editor — RS5K structurally lacks a multi-zone-in-one-instance view, so this is a genuine capability, kept as the deliberate secondary surface. Retains today's Zones view wholesale, with two changes:

  • Kept (inventory — nothing silently dropped): + Add Zone / Delete buttons, the keyboard strip with one bar per zone (selected zone lit accent-primary + static glow, others categorical), the selected-zone legend with the three Low/High/Root numeric-entry fields (click-to-type, parseNoteEntry), and the per-zone param control panel (Mode, Pitch engine, AHDSR/Trigger, AD pitch envelope) — the same panel Sample now also hosts for the single-capture case (one storage site, two surfaces).
  • NEW — key-tracking parameter (0%200%, default 100%). A per-PerformanceZone scalar on how sample pitch tracks the keyboard around the root note. 100% = standard 12-tone-ET tracking (today's behavior, bit-identical); 0% = no tracking (the sample plays at root pitch on every key — a fixed one-shot); 200% = double-rate tracking. Additive on PerformanceZone, version-bumped, defaulting to 100% so pre-existing zones are unchanged. Applies inside the repitch math in both engines: under Varispeed it scales the semitone offset feeding pitchRatio(note, root) (effectiveSemis = (note root) · keyTrack); under Preserve it scales the semitone offset feeding set_shift(2^(effectiveSemis/12)). Surfaces as a control in the Zone param panel (and, for the single capture, on the Sample control strip). The pure sampler core owns the key-track math (unit-tested: a known note/root/keyTrack triple asserts the expected ratio); the shell only maps the 0200% control to the scalar.
  • NEW — real piano-key pattern on the keyboard strip. Today's spectral strip is pretty but does not read as a keyboard (Daniel's note). Keep the pastel spectral hue as the backdrop, but overlay the actual alternating white/black key pattern — bright cells for naturals, dark cells for accidentals (C#/D#/F#/G#/A#), per the palette (bright ≈ a light neutral, dark ≈ bg/base/hairline) — so the strip is instantly identifiable as a keyboard. It need not be shaped like a keyboard (no protruding black keys); it carries the pattern as an overlay, so a glance reads pitch position without counting. The pure keyboard_strip geometry gains a isBlackKey(note)/per-key-natural query (12-tone pattern, pure + unit-tested); the shell draws the two-tone overlay over the spectral fill. This same strip serves the Sample view's fenced root affordance (one keyboard grammar everywhere).

New parameters — ownership and persistence (D-B; instrument-owned, never bank facts)

  • Key-tracking — per-PerformanceZone, additive/version-bumped component state, default 100%. A performance choice, never written to Sample or the bank. Back-compat: an absent field on an older blob → 100% (bit-identical playback).
  • Preview velocity — a utility setting for the Sample view's preview-trigger button, not a musical parameter of the capture. SETTLED (S-VIEW-F1, Daniel 2026-07-27): it PERSISTS across reloads. The seam is VST3 component state, not the extension's persist ext-state module. This distinction is load-bearing and was verified against the existing VST source, not recalled:
    • Wrong seam — the extension's persist module. persist writes REAPER project ext-state (SetProjExtState, namespace "reasampler") and is owned by the extension, not the instrument. Preview velocity is a per-instance instrument-editor setting; putting it in project ext-state would (a) make it project-global rather than per-instance (two ReaSampler 9000 instances would share one preview level), (b) route an instrument-owned setting through a seam the instrument only reads from (bank state), violating the read-only-over-bank contract, and (c) leak an instrument concern into the extension's key space. Rejected.
    • Right seam — the instrument's own VST3 component state, the same blob the processor already round-trips via ReaSamplerProcessor::getState/setState over IBStream, whose format is the envelope-versioned ComponentState in src/vst/sample_map.h (currently v5: version tag
      • channel-mode byte + last-consumed-assignment marker + selection-id + zones payload). Preview velocity is a top-level instance concern — a sibling of channelMode and lastConsumedAssignGeneration, not a per-PerformanceZone field (it is one setting per instance, not per zone). It therefore lands as a new field on ComponentState added by bumping the envelope version to v6 (a new float previewVelocity after the assignment marker, before the selection-id), leaving the zones payload untouched — exactly the independent-version-axes composition the header already documents (envelope grows a field; the zone-record payload version does not move). Round-trip: serializeComponentState appends it, deserializeComponentState reads it; a v6 blob restores it directly, and every older blob (v5 and down) lifts to a sensible default (e.g. 100/127 ≈ 0.79 or a chosen mid level), so already-saved instances are unchanged and no compat surface is broken. This is the same additive envelope-bump discipline S7 (v4→v5) already used — a well-trodden move in this codebase, not new machinery.

The envelope overlay — visual component (new)

The amp envelope drawn as a curve over the Sample view's hero waveform at accurate wall-clock time (the Simpler/Phase-Plant grammar). Gate → the AHDSR shape (attack ramp, hold, decay to sustain, release tail); Trigger → the fade-in/unity/%-length/fade-out shape anchored to playEnd. The time base is the same the voice engine resolves (seconds → frames at the live rate), so the drawn shape lines up with the waveform under it. Pure geometry: an envelope_overlay module (mirror of waveform_view / param_slider) maps the AHDSR/Trigger params + the sample's frame-length to a polyline in the waveform rect (param↔pixel at the shared time base), unit-tested against known param sets; the shell traces it via kit line draws in an accent hue.

The overlay is directly editable — DRAGGABLE NODES (SETTLED, S-VIEW-F2, Daniel 2026-07-27). The envelope is not a read-only informative curve — its breakpoints are draggable handles that set the envelope parameters directly. Dragging a node and the existing sliders are two surfaces onto one model: the sliders stay as the precise numeric-entry surface, node-drag is the direct- manipulation surface, and both read and write the same PerformanceZone envelope fields — a drag updates the params, the sliders reflect them live, and a slider edit re-lays the nodes. There is exactly one source of truth (the zone's envelope params); the two surfaces never diverge. This is the same one-source-multiple-views instinct the whole system runs on.

  • Which nodes, and what each axis means. The AHDSR shape (Gate) exposes handles at the segment breakpoints — attack-end (top of the attack ramp), hold-end, decay-end / sustain-level (the corner where decay settles to the sustain plateau), and release-end. The Trigger shape exposes fade-in-end, %-length (the playEnd anchor), and fade-out-end. For each node, the horizontal axis maps to time (the segment duration — attack/hold/decay/release seconds, or fade/length fractions) and the vertical axis maps to level where the node is a level breakpoint (the sustain node's vertical drag sets sustain 0..1; the peak nodes sit at unity). Time-only nodes (attack-end, hold-end, release-end) drag horizontally; the sustain node drags on both axes (its X sets decay time, its Y sets sustain level) — the standard ADSR-editor grammar (Ableton Simpler, Phase Plant, Serum all use exactly this).
  • Constraints. Nodes are monotonic in time — a node cannot be dragged left of its predecessor or right of its successor (attack-end can't pass hold-end, etc.); each segment stays ≥ 0. Level drags clamp to 0..1 (sustain) / unity (peaks). Times clamp to the same per-param min/max the sliders already enforce (so node-drag can never produce a param the slider couldn't). The playEnd/%-length node additionally clamps to the sample's frame-length. No snapping by default (continuous drag, matching the sliders' resolution); a fine-drag modifier (drag with a held modifier for reduced sensitivity) is an optional polish, not required.
  • Pure module owns the geometry/hit-test math (house pattern). A pure REAPER/LICE-free module — name it envelope_edit (sibling to envelope_overlay; mirror of card_drag / mode_switch / param_slider) — owns node hit-testing (point → which node, with a pick radius) and the drag→param mapping (a pixel delta on a given node → the resulting clamped envelope param set, respecting the monotonic + range constraints above). It is unit-tested: a known node + a known pixel delta asserts the expected param delta and the clamp/monotonic behavior at the boundaries. envelope_overlay keeps the params→polyline forward map (draw); envelope_edit owns the pixel→params inverse map (edit) + hit-test. The shell (reasampler_editor.cpp) does the LICE handle draw (small node markers at each breakpoint, hover/drag-lit via kit states) and routes mouse events through envelope_edit, then commits the resulting params to the zone through the same off-audio-thread path the sliders use (no new RT surface — a node-drag is a param edit, same as a slider drag). Zones and the single-capture Sample face share this one edit surface (one storage site, S15-F2).
  • Sync with sliders (load-bearing). Because both surfaces write the same PerformanceZone envelope fields, keeping them in sync is structural, not a listener chain: the editor re-reads the zone's params every paint, so a slider edit re-lays the nodes and a node-drag re-positions the sliders with no explicit cross-wiring. The single source of truth makes divergence impossible by construction.

The real piano-key pattern — visual component (new)

Covered under View 3 above. Pure: keyboard_strip gains the natural/accidental predicate; shell draws the bright/dark overlay over the existing pastel spectral fill. Shared by the Zone strip and the Sample root affordance.

The velocity → amp transfer-curve editor — visual component (new; Daniel, 2026-07-27, r10)

A visual transfer-curve editor mapping MIDI velocity to an amp scalar: X = velocity (0127), Y = amp scalar (01), an editable bezier from a flat default to an arbitrary multi-point curve. It gives fully shapeable velocity dynamics per sound. Today the engine maps velocity to gain linearlyvelocityGain_ = velocity / 127.0, computed once at note-on in Voice::start() (src/vst/sampler_core.cpp:261); this replaces that fixed line with an editable curve evaluated at the same point.

State home — per-PerformanceZone (instrument-owned, D-B). Velocity response is a per-sound performance characteristic — a sibling of the AHDSR amp envelope, the pitch engine, and the r9 keyTrack scalar, all of which already live on PerformanceZone. A drum and a pad want different velocity curves, so the curve varies per zone, not per instance. This is deliberately not ComponentState (per-instance): that is where preview velocity correctly lives, because preview velocity is a utility setting (one per instrument, like a metronome level), whereas the transfer curve is a musical setting (one per sound). The curve is an additive field on PerformanceZone riding the zones-payload version axis (contrast preview velocity's envelope-v6 bump — a top-level per-instance field on the envelope axis; the two version axes are independent, as the header documents). The single-capture Sample face reads/writes the same one-zone storage site (S15-F2), so Sample and Zone views share one curve store per zone.

Default — flat y=1, a deliberate behavior change (fork R10-F1, Daniel's call). Daniel's verbatim default is "any velocity plays at full level" — a flat curve at y=1. This is not bit-identical to today's shipped linear velocity/127 map: today soft hits are quieter; under a flat-y=1 default every hit plays at unity, so every existing zone's felt dynamics change. This is the one genuine fork the feature carries:

  • Option A (Daniel's stated default): flat y=1. Honors the directive; velocity is inert until a curve is drawn. Con: NOT back-compat — already-saved instances and new captures get flatter dynamics than today until a curve is drawn.
  • Option B: default = today's linear ramp (y = x/127). Bit-identical to the shipped engine; the editor's flat y=1 is one drawn state, not the default. Con: contradicts the verbatim "by default … full level" (the default line is a diagonal, not flat).
  • Lean: Option A (flat y=1) — it is what Daniel asked for and the feature's point is opt-in velocity dynamics — but flagged loudly as a shipped-behavior change, not a silent regression. Option B is the safe fallback and costs only the default curve's seeded control points. Whichever wins, the stored default is a curve the editor draws and the core evaluates; the options differ only in which curve is seeded.

Pure module — velocity_curve (REAPER/LICE-free, unit-tested). Mirror of envelope_edit / card_drag. Two responsibilities:

  • Evaluation: eval(velocity 0127) → amp scalar 01 — a bezier through the control points, clamped to the 0127 × 01 box, monotonic in x by construction (each velocity has exactly one output). Called at note-on, never per frame.
  • Editing: add / move / delete control points, each clamped into the box and x-ordered (a point cannot cross its neighbours in x — the same monotonic grammar as the envelope nodes); a point hit-test (point → which control point, pick radius) and a pixel-delta → clamped-point inverse map (mirror of envelope_edit's inverse map). The flat identity curve (per R10-F1) is a named constructor.

Both halves are unit-tested at the boundaries: a known curve + known velocity asserts the eval output; a known drag asserts the clamped point set + the box/order constraints; add/delete assert the point count and ordering invariants.

Voice-engine application point — Voice::start(), off the per-frame path. Confirmed from source: Voice::start(int note, int velocity, …) (sampler_core.cpp:252) computes velocityGain_ = velocity / 127.0 once at note-on (line 261); the per-frame render then just multiplies the cached scalar (advanceFrame, line 408: gain = amp * velocityGain_). The transfer curve slots in at exactly that line — velocityGain_ = curve.eval(velocity) at note-on — off the audio-thread-hostile per-frame path (evaluated once per voice, no new process-thread work, no allocation). The curve reaches the voice the same way the AHDSR/keyTrack params do: on the zone's SampleData::play bundle (resolved from the stored PerformanceZone at keymap build), read by the voice at start(). The pure velocity_curve core owns the eval; the voice reads it.

The curve-editor UI — Sample view, adjacent to the envelope overlay. Lives on the Sample view (the r9 home face), a compact band next to the hero-waveform envelope overlay — the two are the same grammar (a drawn 2-D curve with draggable handles), and amp-over-time beside amp-over-velocity reads naturally. Draws through the L1 kit like every S-VIEW surface: a bordered box (X = velocity 0127, Y = amp 01), the bezier traced in an accent hue, small draggable node markers per control point (hover/drag-lit via kit states), add-point on empty-space click, delete on modifier-click / drag-off. The shell (reasampler_editor.cpp) does the LICE draw + mouse routing; all geometry/hit-test/clamp math lives in the pure velocity_curve module. On the Zone view the same editor appears in the per-zone param panel (one curve per zone). Additive and bit-identical for existing projects only under R10-F1 Option B; under Option A (the lean) it is additive-but-behavior-changing.

Wave-plan slot (concurrency-aware). The curve lands on PerformanceZone, so it is blocked by Wave 1 track T-KEYTRK (which owns the PerformanceZone schema + zones-payload version bump). The velocityCurve field must sequence as a LATER additive payload bump AFTER T-KEYTRK merges — the two are sequential additive extensions of the same zones-payload record, not simultaneous ones, so they never collide on one payload version. It is not blocked by T-STATE (that owns the per-instance ComponentState v5→v6 envelope bump for preview velocity — a different struct on the independent envelope version axis). Concretely: a follow-on foundation track (pure velocity_curve + the Voice::start application point + the additive PerformanceZone field + payload bump), gated on T-KEYTRK; plus a Wave 2 shell-integration item (the Sample-view + Zone-panel curve-editor UI through the L1 kit), gated on the foundation track and composing with the S-VIEW-2 Sample face + S-VIEW-3 envelope overlay.

The Sample-face recomposition — full-width hero, knob deck, curve popup (r11; Daniel, 2026-07-27)

Additive S-VIEW revision (r11) — the layout spec for the Wave B editor rebuild. Recomposes the landed S-VIEW-1..10 Sample face after Daniel's DAW pass: every linear slider becomes a small radial knob grouped into a fenced knob deck, the two mode toggles shrink from full-width control rows to compact group-header segments, and the inline velocity-curve box is replaced by a miniature curve preview button that summons a full-size popup editor — freeing the hero waveform to run full width. The view recomposition is otherwise parameter- preserving — existing params, their persisted seams, and the VST3 class UID are all unchanged. Exception (landed in FB1): r11 added a post-mixer master gain control (MASTER group, −∞…+24 dB dB-taper, per-sample ramped in the processor) backed by a new masterGainLinear field in ComponentState, bumping the envelope version v7→v8; pre-v8 blobs lift to unity gain. The knob primitive itself (minimal arc ~6→4 o'clock, needle indicator, vertical drag) is a separate in-flight track; this section specs the layout that consumes it. Directives (Daniel, 2026-07-27): radial knobs replacing all sliders, grouped "in a reasonable way" with the envelope controls grouped together intuitively; pitch-engine and Gate/Trigger toggles not full-width; the velocity curve as a small square preview button right of the preview-velocity control opening a popup with the full-size editor, right-click removing a control point in the popup; the waveform preview full-width. Product framing: docs/product/midi-playback.md §Addendum r11.

Inventory contract — every landed Sample-face element has a named home (nothing silently dropped):

Landed element (S-VIEW-1..10) r11 home
Title band: name + live readout, Browse + Zone nav buttons Unchanged
Hero waveform + S11 markers (start/loop/loop-fill) + "(decoding…)" placeholder Unchanged, now full width (the velCurve carve-out is gone)
Envelope overlay polyline + draggable nodes (S-VIEW-3/F2) Unchanged, over the full-width hero
Inline velocity-curve box (trace, node drag, click-add, Alt-click delete, drag-off delete) Popup editor — all interactions preserved, + new right-click delete; summoned from the mini preview button
Fenced root spectral strip + root marker drag Unchanged (cluster left, now remainder-width)
Preview-trigger button Unchanged (cluster)
Preview-velocity horizontal slider Radial knob in the cluster ("Vel"), same persisted previewVelocity seam (envelope-v6, untouched)
Mono | Stereo toggle Unchanged (cluster, right-anchored)
Mode row (Gate|Trigger, full-width toggle) Compact toggle in the AMP ENVELOPE group caption row
Pitch eng row (Varisp|Preserve, full-width toggle) Compact toggle in the PITCH group caption row
Attack / Hold / Decay / Sustain / Release sliders (Gate) Knobs in the AMP ENVELOPE group
Length % / Fade in / Fade out sliders (Trigger) Knobs in the AMP ENVELOPE group (time-ordered: Fade In · Length % · Fade Out)
Pitch env row (Off|On, full-width toggle) Compact toggle in the PITCH ENV group caption row
P.Attack / P.Decay / P.Depth sliders Knobs in the PITCH ENV group
Key track slider Knob in the PITCH group
Empty state ("pick a capture") + S13 drop-hint banner Unchanged

Band order (top → bottom) — hero becomes the elastic band. Title (26, unchanged) → hero (full width between kPad margins, ELASTIC: absorbs all height left after the fixed bands, floor 150px) → cluster (52, unchanged height) → knob deck (fixed-height, bottom-anchored; ~92px per deck row). The old fixed-150 hero + rest-of-window slider stack inverts: the control surface is now the fixed band and the waveform grows with the window. At the unchanged 840×620 default this yields a ~430px hero (see fork R11-F1 for the height call).

The knob deck — three fenced groups, left → right. Each group is a hairline-bordered bg/panel box with a caption row (~20px: micro-caps caption left; the group's compact mode toggle right-anchored in the caption row — this is where the not-full-width toggles live) over a knob row of fixed cells. Knob cell: 48w × 58h — 28px knob centered, 12px Font::Micro label beneath in text/dim; the label swaps to the live value during hover/drag (no third line, no permanent value clutter). Toggle segments reuse the Mono/Stereo grammar (~4452px per segment, 18px tall, Active segment accent-primary). Groups:

  • AMP ENVELOPE — caption toggle: Gate | Trigger (kPlayMode). Knobs, Gate: Attack · Hold · Decay · Sustain · Release; Trigger: Fade In · Length % · Fade Out (a deliberate time-order reorder of today's row order — left-to-right matches the drawn envelope). The group reserves the 5-cell Gate width so a mode flip repopulates in place and never reflows the neighboring groups (Trigger simply leaves two cells blank). This is the "envelope controls grouped together as a unit" directive: the group IS the envelope, its toggle picks the shape, and the hero's envelope overlay is the same params drawn large (two surfaces, one model — unchanged from S-VIEW-F2).
  • PITCH — caption toggle: Varisp | Preserve (kPitchEngine). Knob: Key Track (0200%, the S-VIEW-6 scalar). Key tracking is repitch math, so it lives with the engine that applies it.
  • PITCH ENV — caption toggle: Off | On (kPitchEnvEnable). Knobs: P.Attack · P.Decay · P.Depth. Its own fenced envelope unit, mirroring AMP ENVELOPE's grammar at smaller scale. When Off, the three knobs draw Disabled (kit state) rather than vanish — stable geometry.

Group gaps 12px; deck side margins kPad. Sum at these metrics ≈ 610px wide; below that width the pure module wraps whole trailing groups to a second deck row deterministically (the deck grows, the elastic hero shrinks toward its floor — the 560px checkSizeConstraint minimum forces PITCH ENV onto row two, which is acceptable at the floor).

The cluster band (root + preview + curve button). Root strip keeps the left side but becomes remainder-width (min ~200px) instead of a fixed 55%; the right side is a fixed-width right-anchored run: Preview button (64w) · Vel knob cell (48w, the radial preview-velocity knob) · curve preview button (28×28, immediately right of the Vel knob — per the directive) · Mono | Stereo (right-anchored, unchanged). The curve preview button is a hairline-bordered bg/cell square with the zone's live velocity curve traced in miniature (1px secondary-accent trace, no node markers at this scale); Hover lifts it, and it draws Active (accent-primary border) while its popup is open. It re-renders live as the popup edits the curve.

The popup curve editor. Summoned by left-click on the curve preview button. A centered sheet over the Sample face: a 0.50-alpha bg/base wash over the whole window (lighter than Browse's 0.82 — this is a focused sub-editor, the Sample face stays legible behind it), then a bg/panel + hairline sheet, width clamp(60% of window, 360..520), height clamp(55%, 260..380). Inside: a ~22px title row ("VELOCITY → AMP" micro-caps left, a Close (×) button 18×18 right), and the full-size curve box filling the remainder using the existing curveBoxFromRect inset/mapping-box grammar (one coordinate formula, as landed). Interactions are identical to the landed inline editor — node drag with mouse-up commit, empty-space click adds + grabs a point, Alt-click delete, drag-off delete (the box+24px drag-off margin stays inside the sheet, so it cannot collide with dismissal) — plus the NEW right-click delete (issue 3c): right-click on a node removes it, committing immediately through the same path as Alt-click, with the existing deletePoint endpoint guard making endpoint right-clicks a safe no-op. Right-click becomes the primary delete affordance; Alt-click and drag-off remain as landed alternates (nothing dropped). Dismiss: Close click, click on the wash outside the sheet (only when no drag is in flight), or Esc. Popup state (open flag + target zone) is editor-local, never persisted. The dragStartMap_ rollback contract is unchanged.

Module architecture (r11 delta — house pattern preserved).

  • Pure NEW knob_deck — group boxes, caption rows, compact-toggle rects, knob-cell rects, deterministic group wrap, and hit-test (point → control id + element kind). Mirror of action_bar / param_slider; consumes the same shell-owned control-id descriptors; engine-free, LICE-free, unit-tested (layout at reference widths, wrap at the 560 floor, hit-test at cell/toggle boundaries).
  • Pure NEW curve_popup — sheet/close/box geometry from the window size + the outside-sheet dismissal test. Mirror of overflow_menu; unit-tested at the size clamps.
  • The knob primitive (value↔needle-angle map, arc geometry, vertical-drag delta→value) is the separate in-flight track; knob_deck treats a knob cell as a rect and defers value↔angle to the primitive. Layout-level contract on it: vertical drag with resolution at least matching the retired sliders; a Shift fine-drag is optional polish (mirroring the envelope-node note), not required.
  • param_slider retires from the Sample face (and from the Zone panel under fork R11-F2); its toggle-segment helpers may be reused for the compact toggles or subsumed into knob_deck — implementer's call at build. The preview-velocity control becomes a knob cell bound to the same persisted previewVelocity (seam untouched).
  • Shell (reasampler_editor.cpp): re-lays computeSampleBands (elastic hero, fixed deck), draws groups/knobs/mini-button/popup through the L1 kit by palette role, routes right-click (verify WM_RBUTTONDOWN reaches the child wndproc — see must-verify) and Esc. Knob drags commit on mouse-up exactly as slider drags did (live invalidate, commitAndReload on release); the knobs and the hero's envelope nodes stay two surfaces on one param model.

Spec'd aesthetic defaults (decided here; Daniel can veto at smoke test): label↔value swap-in on knob cells; Trigger knob time-order (Fade In · Length % · Fade Out); popup wash 0.50; group captions AMP ENVELOPE / PITCH / PITCH ENV; disabled-not-hidden PITCH ENV knobs. The two genuinely open aesthetic calls are forks R11-F1 and R11-F2 in the fork ledger below.

Engineering prerequisite 1 — drop-to-FX bug (routed to implementation, NOT a design call)

Symptom (Daniel): dropping a capture onto a track's FX chain does not instantiate + init ReaSampler 9000 — the audio-to-arrange drop works, but the "instrument init never fires."

RESOLVED (GA post-launch DAW-fix pass, 2026-07-28). The root cause was the injection mechanism, not the gesture wiring: TrackFX_SetNamedConfigParm(..., "vst_chunk", <base64>) is silently unappliable for VST3 — REAPER's VST3 wrapper cannot apply unframed raw component-state bytes there (the write parm returns true but the instance stays at defaults). The fix: instrument_drop builds a Steinberg-format .vstpreset file image (header + 'Comp' chunk = serialized component state, class ID from reasampler_uid.h); instrument_drop_win writes a transient temp file and applies it via TrackFX_SetPreset(track, fx, path), which the SDK documents as accepting full .vstpreset paths for VST3 plug-ins. FX hotspot prefix-set extended to tcp.fx*/mcp.fx*/fx_* (embed strip tokens excluded). reasampler_uid.h split out of reasampler_vst.h as an SDK-free header so instrument_drop (pure module) can derive the class-ID hex string without the VST3 SDK. Verify in DAW: drag a capture onto a track's FX button → a ReaSampler 9000 instance appears on that track already playing that capture (one Ctrl-Z removes it). VERIFIED.

Engineering prerequisite 2 — default window size for 1080p (routed to implementation)

Symptom (Daniel): the editor window is too small by default; assume a 1080p minimum screen.

SDK sweep (done this pass — mechanism verified): the VST3 editor size is set by the IPlugView/CPluginView contract. getSize() returns the view's rect (set via setRect — the default the host opens at); checkSizeConstraint() is where a minimum is enforced; onSize() handles host resizes; canResize() already returns kResultTrue. Verified in vendor/vst3sdk/public.sdk/source/common/pluginview.h. Today ReaSamplerEditor's constructor sets ViewRect(0, 0, 560, 400) — the undersized default. The fix is a one-line default change (a larger initial ViewRect sized for the new three-band Sample face on a 1080p display) plus an optional checkSizeConstraint minimum so the host cannot shrink the window below a usable floor. This is not a platform limitation — the mechanism exists and is trivial. The exact default dimensions are a build-time value to set against the Sample face's band heights (hero waveform + control strip want materially more than 400px tall; a ~840×560 or larger default is the starting point, tuned at build). Routing: flagged for staff-engineer; no product fork. Verify in DAW: the editor opens at the new default on a 1080p screen showing the full Sample face without scrolling, and cannot be resized below the constraint floor.

Build detail for this sub-phase (module architecture) moved to CONTEXT-ARCHIVE.md.

Precision / invariant implications

  • Read-only bank consumer (unchanged). Key-tracking, preview velocity, envelope-node edits, and every marker/mode control are the instrument's performance map / editor state (D-B) — held in the instrument's own VST3 component state, never written to Sample or the bank.
  • Additive, back-compat component state. Three additive fields land: keyTrack (per- PerformanceZone, default 100%) and — sequenced after it on the same zones-payload axis — velocityCurve (per-PerformanceZone, r10, default the R10-F1 curve) inside the zones payload, and previewVelocity (per-instance) as a new top-level ComponentState field via an envelope bump to v6 on the independent envelope axis. Every older blob lifts on read (absent keyTrack → 100%, absent velocityCurve → the R10-F1 default curve, absent previewVelocity → the chosen mid default), so already-saved instances restore cleanly. Playback back-compat carries a caveat for the velocity curve: under R10-F1 Option A (flat y=1 default) an already-saved zone with no stored curve now plays every velocity at unity — not bit-identical to the linear velocity/127 it played before; under Option B (linear default) it is bit-identical. This is the one non-back- compat surface in S-VIEW and is the substance of fork R10-F1. keyTrack and previewVelocity remain fully bit-identical on lift. No existing field changes.
  • RT discipline (unchanged). The preview-trigger fires a note through the existing voice engine via the off-audio-thread commit path (commitAndReload idiom); no new process-thread work, no allocation on the audio thread.
  • VST3 class UID unchanged. A view reorganization + additive param is not a compat event; saved instances rebind and restore. The UID stays the S-NAME-1/S18 forever-locked identity.
  • Capture ≠ placement ≠ playback (unchanged). The preview-trigger plays; it never captures, never inserts a timeline item. The three acts stay distinct.

Open questions / forks (Daniel / Phase S team)

(S-VIEW-F1 and S-VIEW-F2 are SETTLED — Daniel 2026-07-27 — and folded into the spec above: preview velocity persists via envelope-v6 ComponentState; the envelope overlay's nodes are draggable via the pure envelope_edit module. They are no longer open questions.)

  • S-VIEW-F3 — Browse modal presentation. SETTLED (2026-07-27): full-window overlay — landed in S-VIEW-1/S-VIEW-5 (Browse renders as a full-window modal over Sample). Recorded here so the ledger matches PLAN.md; no longer open.
  • R10-F1 — velocity-curve default (Daniel, 2026-07-27). Option A: flat y=1 (Daniel's verbatim default — every velocity plays at full level; NOT back-compat with today's linear velocity/127, so existing zones' dynamics change) vs. Option B: linear y = x/127 (bit- identical to the shipped engine; contradicts the verbatim "full level" default). Lean A, flagged as a deliberate shipped-behavior change, not a silent regression. This is the only non-back-compat surface the velocity-curve feature introduces. Daniel's call.
  • R11-F1 — hero height vs. default window (r11; Daniel, 2026-07-27). With the knob deck collapsing ~312px of slider rows into ~92px, the elastic hero at the unchanged 840×620 default runs ~430px tall — waveform-dominant (Simpler-like; envelope-node drags gain vertical precision). Alternative: shrink the default to ~840×520 (hero ~330px, a tighter face). Lean: keep 840×620 + elastic hero — the freed space going to the waveform is the point of the recomposition, and no default-size churn. Purely aesthetic. Daniel's call.
  • R11-F2 — Zone-panel parity (r11; Daniel, 2026-07-27). Convert the Zone param panel to the same knob deck + curve-preview-button/popup (one control grammar everywhere; retires param_slider's slider rows outright) vs. leave Zone on the landed slider rows (smaller Wave B, but the same params render as knobs on Sample and sliders on Zone — a grammar fork). Lean: parity — one grammar, and the two surfaces already share one storage site (S15-F2). Cost: Wave B scope grows by the Zone panel re-lay. Daniel's call.

Must-verify before build (S-VIEW)

  • Editor size mechanismgetSize/setRect/checkSizeConstraint/onSize/canResize verified present in vendor/vst3sdk/public.sdk/source/common/pluginview.h; confirm the exact min-size enforcement point (checkSizeConstraint) behaves under REAPER's host at build.
  • Drop-to-FXTrackFX_AddByName / TrackFX_SetPreset / GetThingFromPoint all verified present in reaper_plugin_functions.h; injection via TrackFX_SetPreset + .vstpreset image (not vst_chunk) — RESOLVED, GA DAW-fix pass 2026-07-28.
  • Preview note through the voice engine off-thread — confirm the existing commitAndReload / off-thread reload idiom is the right seam to fire a one-shot preview note without touching process on the UI thread; no torn state on the atomic voice-engine pointer.
  • Preview-velocity persistence seam (S-VIEW-F1) — the envelope-v6 ComponentState field is the prescribed seam (verified against src/vst/sample_map.h + reasampler_processor.cpp getState/setState this pass); confirm at build that appending a float previewVelocity after the assignment marker keeps the v5→v6 lift clean and the zones-payload byte offsets unchanged.
  • Envelope node-edit inverse map (S-VIEW-F2) — confirm the pure envelope_edit pixel→param inverse map produces params the sliders' own min/max already permit (so the two surfaces can never diverge), and that the monotonic time-node constraint holds at the segment boundaries.
  • Envelope-overlay time base — confirm the seconds→frames resolution the overlay draws against matches the voice engine's live-rate resolution so the drawn shape lines up with the waveform.
  • Velocity-curve application point (r10) — confirm Voice::start() (sampler_core.cpp:252) is the sole velocity→gain site and that replacing velocityGain_ = velocity / 127.0 (line 261) with velocityGain_ = curve.eval(velocity) keeps the eval at note-on only, off the per-frame render path (line 408 gain = amp * velocityGain_ unchanged). No new process-thread work or allocation.
  • Velocity-curve payload sequencing (r10) — confirm the additive velocityCurve field on PerformanceZone lands as a zones-payload bump AFTER T-KEYTRK's keyTrack bump (not simultaneous), so the two sequential extensions of the same record never collide on one payload version number.
  • Velocity-curve eval monotonicity (r10) — confirm the pure velocity_curve bezier is monotonic in x over the 0127×01 box (one output per velocity) and that control-point edits stay clamped + x-ordered at the boundaries.
  • Right-click routing (r11) — confirm WM_RBUTTONDOWN/WM_RBUTTONUP reach the editor's child wndproc (today only left-button + move are handled) before committing to right-click node delete; same sweep for Esc/VK_ESCAPE key routing while the curve popup is open (the Browse search box already takes keyboard input, so the focus path exists — confirm the popup sees it).
  • Deck wrap + hero floor (r11) — confirm the pure knob_deck wrap is deterministic at the 560×460 checkSizeConstraint floor (PITCH ENV onto row two) and the elastic hero's 150px floor holds with a two-row deck at minimum height.
  • Knob primitive contract (r11) — confirm the separately-built knob primitive and knob_deck agree on the knob-cell rect and the value↔angle map (one formula each side of the seam), so the drawn needle and the drag hit-test can never drift — the same one-formula discipline as curveBoxFromRect.

Look-and-feel — visual design language (Phase L)

New pillar, own lettered phase, taken up by a parallel team. Phase L is the whole-system look-and-feel effort — a shared LICE drawing kit and the surfaces that adopt it — that replaces the flat "temple os" drawing (opaque LICE_FillRect blocks + raw GDI DrawTextA) with a modern, sleek 2026 dark synth look across ReaSampler (the extension's docked bank panel) and ReaSampler 9000 (the Phase S VST editor + embed strip). Namespaced L (Look-and-feel), orthogonal to and ungated by the M/D/B/R/V/S pillars. Product framing, the settled decision record (DS-1/DS-2/DS-3 all SETTLED 2026-07-26), palette, the three visual directions, and the toolkit assessment: docs/product/visual-design-language.md. Same standing discipline: verify every LICE/ WDL/SWELL API name/signature against vendor/WDL before use. When a point lands, doc-keeper moves it to COMPLETED.md.

What it is

A shared LICE-based drawing kit (palette + type scale + component-draw layer) and the surfaces that consume it. The kit is the one source of drawing for the whole system — a button, row, slider, or waveform looks identical in the bank panel, the embed strip, and the VST editor because it is the same kit function (the one-source-multiple-views instinct, applied to drawing). The current "temple os" look is the system drawing at the floor of LICE (flat fills, GDI text, no gradients/AA/rounded/hover); the kit lifts every surface to LICE's actual ceiling — which REAPER's own themed UI and SWS prove is a modern dark UI.

Settled decisions (Daniel, 2026-07-26 — reasoning in docs/product/visual-design-language.md §6)

  • DS-1 — toolkit: LICE + WDL free game, no external frameworks. Draw with LICE directly (gradients via LICE_GradRect, AA rounded via LICE_RoundRect/LICE_Line, cached AA text via LICE_CachedFont). Reuse any useful WDL/vwnd piece — skin/image helpers, draw idioms, a specific control (e.g. virtwnd-listbox for a long scroll list) — where it beats re-deriving; "don't reinvent the wheel." Reject external frameworks (iPlug2 / JUCE / VSTGUI — they re-open the settled bare-SDK+LICE build shape to solve a look problem that is not a toolkit-ceiling problem). Caution, not a ban: keep hit-test geometry in pure CTest-covered modules — do not import vwnd's retained-mode object model wholesale (its controls own their hit-test internally, which would move geometry into untestable shell code and undercut the pure/shell split).
  • DS-2 — visual direction: Direction B ("Neon Console") + Direction C's spectral keyboard strip. SETTLED 2026-07-26, REVISED 2026-07-26 (Daniel) — three-accent pastel accents AND REAPER-grey neutrals (palette-only). Neutral surfaces (revised): the neutral ladder moved from near-black up into REAPER's mid-grey theme family so the dock reads as part of REAPER, not a black slabbg/base#2b2b2b (REAPER chrome), bg/panel#333333, bg/cell#3a3a3a (REAPER track bg), line/hairline#4a4a4a, text/primary#dcdcdc, text/dim~#a0a0a0+. Elevation-ladder discipline unchanged (base < panel < cell by a few %, micro-gradient + inner highlight/shadow carry elevation, not hard borders). Accent layer (revised): changed from one electric cyan to three pastel accents: accent/primary = pastel lime green (~176,224,152, the live/active/selected signal), accent/secondary = pastel teal (~132,214,208, categorical role A), accent/tertiary = pastel purple (~194,170,232, categorical role B); accent/hot is a lighter pastel-lime tint (~200,236,178). These are starting values — the implementer locks final hex against the theme module's WCAG tests, staying within the pastel intent ("soft-side" floor: the most pastel value that still clears, not re-saturated toward neon). The spectral (hue-mapped) keyboard strip — signature surface, glow-on-active as a static drawn state — is now a pastel sweep anchored on the three accents (pastel-lime low → pastel-teal mid → pastel-purple high). A stylish/bundled font upgrade was considered and DECLINED (Daniel, 2026-07-26) — no font bundling or redistribution; the kit keeps its current cached-font face and no new typeface is specified (§3.1). The kit palette stays abstract (role→color, one constants block — now three accent roles), so the direction is a single-file change. Tight pairs flagged for the implementer (re-verify against the GREY ladder, not near-black): text/dim on grey (mid-grey-on-mid-grey — lands ~4:1 at start, must be lifted toward ~#a8a8a8+ to clear AA 4.5:1 body on #333/#3a3a3a, the classic floor failure); the three pastels as state indicators / active fills on bg/cell (the pastel cushion shrank from ~15:1 on near-black to ~6:17:1 on grey — still clears 3:1 but must be re-checked; if any drops below floor, nudge that hue slightly deeper within the pastel intent); body text on a pastel fill (light-grey on a light pastel can drop below AA 4.5:1 — use dark labels on pastel fills or darken the fill); and secondary vs. tertiary distinguishability (both cool/desaturated — verify they read as distinct categories). Full reasoning: docs/product/visual-design-language.md §2.1 + §4 + §6.
  • DS-3 — dock-panel scope: a thorough layout redesign, not a light re-skin. L2 lays out the full button/affordance inventory — including M11's action-trigger buttons + keybinding-help labels — intuitively, uncluttered, and useful, then applies the kit. Sequenced after M11 merges so it designs against the actual landed button set.

Palette + the "punch" rule (Daniel's standing taste)

Dark, modern, visual punch over conservative contrast — now pastel, on REAPER-grey. The palette is defined by role, not hardcoded hue: bg/base, bg/panel, bg/cell, line/hairline, text/primary, text/dim, accent/primary, accent/secondary, accent/tertiary, accent/hot, warn. Neutral surfaces (DS-2 revised, 2026-07-26): the neutral ladder sits in REAPER's mid-grey theme family, not near-black, so the dock reads as part of REAPERbg/base#2b2b2b, bg/panel#333333, bg/cell#3a3a3a, line/hairline#4a4a4a, text/primary#dcdcdc, text/dim~#a0a0a0+. A modern dark UI is built from elevation layers, not borders — surfaces gain a micro-gradient (LICE_GradRect, a few percent lighter at the top) + a 1px inner top-highlight / bottom-shadow (the vwnd trick) instead of flat fills; the grey ladder keeps this discipline (base < panel < cell by a few %, subtle steps like REAPER, not hard outlines). The three accents carry categorical meaning: primary (pastel lime) = live/active/selected, secondary (pastel teal) + tertiary (pastel purple) = supporting categorical distinctions (kinds, not intensity — primary is always "what's live now"). WCAG-floor discipline (pastel + grey re-read): moving neutrals up into grey makes two pairs harder — (1) text/dim on bg/panel/bg/cell is the classic mid-grey-on-mid-grey floor failure (must be lifted light enough to clear AA 4.5:1 body on the greyest surface it draws on), and (2) the three pastels as state indicators on grey have a shrunken contrast cushion (~6:17:1 vs. ~15:1 on near-black — still clear 3:1 but re-verify; nudge a hue slightly deeper within the pastel intent if it drops below floor). Text-on-a-pastel-fill (AA 4.5:1) remains a tight pair. Take the most pastel value that still clears the floor on grey — approached from the soft side, never re-saturated toward neon "to be safe," except where the grey floor forces a small deepening. The warn role (red/amber) is reserved only for byte-deleting or clip states (prune, delete). Because the palette is role-based in one constants block, the settled-and-revised B+pastel+grey+spectral direction (DS-2) is one file.

"Speed is the selling point" — a design constraint, not a tagline

The UI must feel instant, and no decoration may cost that. Sub-frame hover/press/drag feedback repainted immediately on the input message (instant acknowledgment is the perception of speed); zero-jank via the preserved double-buffer discipline (draw to LICE_SysBitmap, single BitBlt); region-scoped InvalidateRect during a drag (build-time residual). No decorative animation — no tweens/fades/pulses; the only permitted motion is a level/meter readout following the audio directly (as the embed strip already does). Direction C's glow/bloom is a static drawn state, never a pulse. The "fast" feeling is typography + hover + no-jank, not motion.

Build detail for this phase (kit architecture, L2/L4/L5 dock-panel layout contracts, L7 implementation detail, the L3 gate, LICE/WDL API surface) moved to CONTEXT-ARCHIVE.md.

L7 capture ordering · card metadata · selection styling (ordering model · overlay · tertiary-border selection)

L7 is the next ungated dock-panel pass over the same bank_panel grid L4L6 built. Unlike L4/L5 (pure layout over existing actions), L7 (1) is a persisted-model change — it adds an explicit per-sample display order with gap-preserving sparse placement to the persisted bank state and its JSON round-trip. (2) and (3) are draw-only. No new capture/placement behavior; the "capture ≠ placement" principle is untouched. Drawn through the L1 kit in the DS-2 grey-neutral + three-accent-pastel palette; no palette/font decision is re-opened. Ungated by Phase S; independent of the L3 gate. Sequences AFTER L6.

Research baseline (dev, confirmed at spec time). The grid draws in BankIndex insertion order: BankIndex holds std::vector<Sample> samples_ in insertion order (all()), and bank_grid::computeCellRects tiles exactly one contiguous rect per sample left-to-right, top-to-bottom — no explicit ordinal, no sparse slots today. Sample already carries lengthSeconds, lengthBeats, and captureTempo (BPM at capture) but no time-signature field. The selected cell is drawn (bank_panel::drawThumbnail) as InteractionState::Active (accent-fill surface) + an accent/primary border + an inverted waveform (bg/base), with focus as a text/primary double-line ring. Drag today: press on a selected cell arms a drag; crossing a threshold begins it; leaving the client rect hands off to OS drag-out (drag_out::decideGesture); a drop on a tab / the other region moves (or copies on Ctrl) the samples to that bank. There is no same-bank in-grid reorder today.

Drag disambiguation (F3 — SETTLED 2026-07-27)

In-grid reorder must coexist with the existing internal bank-move/copy drag and OS drag-out. Precedence (one clean rule, evaluated live during the drag):

  1. Pointer leaves the client rect → OS drag-out (unchanged; drag_out::decideGesture wins first — the existing invariant #4 boundary).
  2. Else drop lands on a tab / the OTHER region's bank → move/copy (unchanged; Ctrl = copy).
  3. Else drop lands within the SAME bank's own grid → reorder-to-slot (new).
    • Onto an empty slot → place there.
    • Onto an occupied slot, no modifier → insert-before-and-shift-tail (the default, above).
    • Onto an occupied slot, Alt heldREPLACE the occupant (below).

So: leave-client wins → else other-bank wins → else same-bank-grid = reorder. The precedence is encoded in a pure decision helper (mirror drag_out::decideGesture); the shell reads the live pointer + focused region + client rect + modifier state (Alt) and calls it. This keeps the reorder gesture from ever stealing an intended bank-move or OS-drag, and keeps a same-bank in-grid drag from being mis-read as a no-op (today a same-bank drop is a no-op; L7 gives it reorder meaning).

Alt+drop-onto-occupied = REPLACE (SETTLED 2026-07-27). Holding Alt at drop over an occupied slot replaces the occupant instead of inserting-and-shifting. Replace semantics, precisely:

  • The replaced sample is removed from THAT bank's index only — same semantics as the existing remove-from-bank verb (BankBook::removeSample index-only). The file stays on disk; the owned-manifest and Phase R prune govern its bytes. If the replaced sample's last reference disappears, that is exactly the existing cross-bank-reference story (hashReferencedElsewhere reports whether the hash is still referenced elsewhere; prune later reclaims a now-orphaned owned file). Replace introduces no new deletion authority — it never touches the disk.
  • The dragged sample then takes the vacated slot (the slot's position is preserved; only its occupant changes).
  • Pool case (un-evacuable pool — SETTLED rule). The pool's privileges (un-deletable, un-renamable, un-evacuable, never zero banks) are enforced in bank_book. Alt+Replace is allowed in the pool only when it does not violate a pool privilege. Concretely: replacing a pool entry is an index-only removal of that pool sample; it is permitted as long as it does not empty the pool below the pool's floor and does not remove the last reference in a way the pool's rules forbid. The consistent rule the model enforces: the replace's index-removal step is the same operation as remove-from-bank, and it must pass the same pool-privilege guard that remove already applies — if remove-from-pool would be rejected for that sample, Alt+Replace over it is rejected too (the drop falls back to a no-op; the pool-privilege guard is reused as-is — confirmed at build). No special pool-only replace path; one rule, guarded by the existing pool invariants.

Drop-result cursor cues (SETTLED 2026-07-27 — REAPER-idiomatic special cursors). During a drag the cursor must indicate what the drop will do, following REAPER's idiomatic use of distinct action cursors. The cue set:

  • Reorder-to-slot (within the same bank's grid) — a move/reorder cursor.
  • Move/copy to another bank or tab — the move (or copy, when Ctrl is held) cursor, matching the existing internal-drag semantics.
  • OS drag-out (pointer left the client rect) — the OS copy/drag cursor (owned by the OS drag loop once handed off).
  • Replace — a distinct replace cursor, shown only when Alt is actually held over an occupied slot (i.e. only when precedence resolves to the Alt+replace case). It must not appear over an empty slot or when Alt is not held.

Shell mechanism: the shell sets the cursor via Win32/SWELL SetCursor. SWELL stock cursors were chosen at build (Reorder→IDC_SIZEALL, Move→IDC_HAND, Copy→IDC_UPARROW, Replace→IDC_SIZEWE; no custom cursor load/synthesis required). The decision of which cue applies stays in the pure gesture/disambiguation helper — the same helper that resolves precedence returns the resolved gesture (reorder / move / copy / os-drag-out / replace), and the shell maps that pure result to a cursor. No cue logic in the shell; the shell only owns the SetCursor call and the cursor resources.

Precision / invariant implications (what Phase L does NOT change)

  • The pure/shell split holds. All layout/hit-test stays in pure CTest-covered geometry modules; the kit's draw half is shell, its geometry half is pure — even where a WDL piece is reused (DS-1). No hit-test math moves into untestable code.
  • RT discipline untouched. The kit is draw-thread only; nothing here touches process or any off-thread reload handoff (a Phase S concern surfaced at L3).
  • Read-only-over-bank untouched. This is look-and-feel; no data-ownership change.
  • The capture/placement load-bearing principle is untouched. Phase L draws; it does not capture, place, or mutate the bank.
  • VST3 class UID / component-state contract unchanged (Phase S concern; noted for L3).
  • Windows-only (D5). Font/GDI/HFONT choices assume Windows; no cross-platform font fallback concern.

Non-goals / guardrails

  • No external UI framework. iPlug2 / JUCE / VSTGUI are rejected (DS-1). LICE + reused WDL pieces are the toolkit; reject any path that pulls in a new framework.
  • No hit-test geometry in untestable shell code. Even when reusing a WDL piece, layout/ hit-test math stays in pure CTest-covered modules (DS-1 caution). Reject a control whose adoption would move geometry into the shell without a pure test seam.
  • L2 does not restructure the panel bones. The vertical-split / grid / tab structure is sound and stays; L2 designs the layout of the button inventory around it (DS-3). A ground-up structural rework of landed Phase-B panel structure is out of scope.
  • L3 does not build on dev until Phase S lands there. The gate is explicit; do not chase Phase S files on dev.
  • A visual refresh is not a compat event. VST3 class UID, command-id strings, ext-state namespaces, and component-state contracts are unchanged by Phase L.
  • Verify LICE/WDL/SWELL surfaces against vendor/WDL before use.

Structural reorganization — reorg spec (Phase Q — Quality)

New pillar, own lettered phase, and the LAST structural pillar. Phase Q is a pure structural refactor of src/ — more encapsulation, granular namespaces, core//shell// app/ subdirectories — against a stated quality bar ("mtytel Vital is my code reference for quality"), to bring the codebase "into the realm of something I can stand to look at." It ships no feature and changes no behavior: the test suite passing unchanged is the proof of correctness. Namespaced Q (Quality) — M/D/B/R/V/S/L taken; Q names the end (the quality bar), the reorg being the means. Product framing, the Vital-grounded target shape, the grep-verified SOLID audit (the evidence base), and the fork record (Q-1..Q-6): docs/product/code-organization.md. When a point lands, doc-keeper moves it to COMPLETED.md.

Phase Q opens with a pre-restructure audit (Q-W0 — added 2026-07-27). Before any structural point (Q-W1+), Phase Q runs a functional + DSP quality audit that produces a written, triaged findings report — a functional-correctness/algorithm-quality complement to the SOLID/naming audit below. Q-W1 is gated on Q-W0's triage being complete and Daniel signing off on each finding's disposition (fix-now vs. document-and-defer). Spec: §"The pre-restructure audit wave (Q-W0)" below.

What it is

A directory + namespace + file-split reorganization that makes ReaSampler's already-real, CMake-enforced architecture legible in the code's shape. The pure/shell split exists (30 pure static libs, each with its own test executable, none linking a REAPER SDK) but is invisible: all 45 files sit in one flat src/, all 37 headers in one flat reasampler namespace, four modules have grown into god-modules, and the JSON parser is copy-pasted across four models. Phase Q gives the existing subsystem grouping — model / view / capture / audio / ui / reclaim / version — a structural home (directories + namespaces), splits the four god-modules along validated seams, and extracts the duplicated JSON into one pure module. Nothing about the architecture changes; it becomes visible. This is why the phase can be zero-runtime-cost and CTest-green throughout: the seams already exist in the link graph; Phase Q draws them where a reader sees them.

The pre-restructure audit wave (Q-W0)

Phase Q opens with Q-W0 — a thorough static/functional audit that runs before any structural point (Q-W1+) moves a single file. It is the functional-correctness and algorithm-quality complement to the grep-verified SOLID audit (§"The evidence base" in docs/product/code-organization.md §2) and naming audit (§2b): those ground where responsibilities live and what things are called; Q-W0 grounds does the code do the right thing, and does it do it well. It exists because the structural reorg is the wrong moment to discover a bad algorithm — a reinvented wheel or a numerically-fragile DSP path should be eliminated or consciously documented before it is relocated, re-namespaced, and split, not carried forward untouched into a tidier tree. Bringing the code "into the realm of something I can stand to look at" is not only a matter of shape; it is also a matter of the code being functionally sound.

Audit scope — the named surfaces:

  1. DSP / audio, close eye on pitch. Assess algorithm quality — correctness, artifacts, numerical robustness, interpolation quality, and reinvented-wheel-vs.-established-technique — on:
    • src/vst/pitch_shift — the hand-rolled OLA pitch-preserve engine: window/overlap choice, phase coherence, transient and formant behavior, buffer-edge handling. The highest-priority DSP surface (Daniel: "a close eye on the Pitch stuff").
    • sampler_core — repitch ratio math, interpolation order/quality, loop-point-aware sustain crossfade, and voice-stealing correctness (clicks/discontinuities on steal).
    • peaks — envelope min/max binning correctness.
    • wav_trim — the realtime-tail decay-scan threshold + truncate plan.
    • the capture / tail paths — any DSP-adjacent arithmetic in capture range/tail handling.
  2. Architecture smells. Duplicate code, reinvented wheels, poor abstractions, and leaky pure/shell boundaries (a core/ file reaching a REAPER/host type; geometry or algorithm math sitting untestable in a shell instead of a pure module). This overlaps the SOLID audit's territory but targets the functional smell, not the responsibility-placement smell — Q-W0 reports what §2/§2b did not.
  3. Env-coupled-constant domain-modeling smells (explicit category). ANY value stored in a frame / rate / DPI / tick-coupled domain that should instead be stored rate-free and resolved at the point of use is a domain-modeling smell — store rate-free, resolve at use, not "rescale by the rate." This is grounded in the load-bearing invariant that wall-clock times are stored as rate-free SECONDS resolved against the live project rate (sample_map), with NO hardcoded sample rates in src/ (Daniel's standing ruling). There was a prior incident on exactly this — envelope times stored in the frame domain — which is why it is a first-class audit category, not a footnote. Sweep at least: envelope times, loop points, fade lengths, tail lengths, and any UI geometry constant that silently bakes in a DPI or rate.

Deliverable + acceptance: a written findings report exists covering the named surfaces; every finding is triaged into eliminate-before-restructure (fix-now) or document-and-defer (with a one-line rationale so the deferral is a decision, not an omission). Fix-now findings are remediated in Q-W0, or folded into the downstream wave that already opens the file (recorded per finding) — they are not silently deferred into the structural waves. Any behavior-changing remediation lands with the module's CTest executable green, and where a DSP path changes audibly, a stated before/after listening or null check. The gate to begin Q-W1 is: triage complete and Daniel signed off on every disposition.

Relationship to the structural waves: Q-W0's findings may add or reshape downstream Q-W1..Q-W6 points (e.g. an algorithm rewrite that changes a module's shape, or a domain-modeling fix that changes a payload). Those reshapes are folded in before Q-W1 begins. Q-W0 is thus both the entry point and a scoping input to the rest of the phase.

Report home (Q-10 — SETTLED, Daniel 2026-07-27): a committed doc. The findings report lives as a committed doc under docs/product/code-quality-audit.md, alongside the SOLID/naming audit that already lives in docs/product/code-organization.md. It sits beside the existing audit, travels with the tree, and each finding's disposition is reviewable in one place. A tracked issue list was set aside — the audit is a one-shot pre-reorg sweep, not an ongoing backlog. The Q-W0 "Triage + report" step writes this file. See docs/product/code-organization.md §2c.3.

Pitch-remediation depth (Q-11 — SETTLED, Daniel 2026-07-27): defer to findings. How deep the OLA pitch-preserve remediation goes is decided by what Q-W0 finds, not pre-committed. Default is document-and-defer; only if the audit surfaces artifacts that matter is a bounded fix (tune window/overlap/edge handling) weighed before a technique replacement (phase-vocoder / WSOLA). A technique replacement reshapes pitch_shift, would spill a downstream Q-wave point, and is therefore a Daniel decision at triage time, not an automatic Q-W0 action. See §2c.4.

The quality bar — Vital (read from its actual src/ tree)

Vital (github.com/mtytel/vital) groups its synth by subsystemcommon/ synthesis/ interface/ plugin/ — with synthesis/ further subdivided by function (synth_engine/ modulators/ filters/ effects/ producers/ framework/ lookups/ utilities/). The grouping principle is layered functional architecture: the directory tree is the architecture diagram; you read the subsystem map off the folders. ReaSampler adopts the pattern (directory = architecture), adapted to its own most load-bearing invariant — the pure/shell split — as the top level (see below). Vital is GPLv3; the borrowed artifact is the structural pattern, not code.

  • Q-1 — namespace letter. SETTLED: Q (Quality). Point-id family Q1..Qn, wave prefixes Q-W0 (the pre-restructure audit) then Q-W1..Q-W6 (the structural reorg). O (Organization) was set aside: the glyph reads ambiguously against zero in point ids, and "Organization" undersells a phase measured against a quality bar.
  • Q-2 — JSON extraction in scope + first. REC: yes. The 4× duplicated Parser is the largest DRY+SRP violation and is entirely off the hot paths — the ideal safe, high-leverage opener (Q-W1).
  • Q-3 — directory shape. REC: core//shell//app/ top-split, subsystem dirs beneath. Top level by the pure/shell discipline (so the invariant is structural, not merely conventional), subsystem grouping one level down. Preferred over pure-Vital subsystem-first because ReaSampler has a pure/host split Vital lacks and that split is the invariant most worth protecting structurally.
  • Q-4 — sub-namespace to match sub-directory. REC: both. reasampler::model/view/capture/ audio/ui/reclaim/version/json. Directory and namespace agree; a symbol's home is unambiguous from either.
  • Q-5 — god-module split granularity. REC: to the audit's named seams, no finer. Well-factored, not atomized.
  • Q-6 — OCP registration-table. REC: in scope, last (most droppable if narrowing).
  • Q-7 — naming rides the relocation waves, no dedicated naming wave. REC: yes (forced once Q-3/Q-4 settle — a rename is near-free during relocation, near-pure-churn standalone).
  • Q-8 — class/module renames beyond the free namespace fix. REC: fix the two that actively misleadBankIndexBankModel (the bank_model.h/BankIndex file↔class word-mismatch) and the unified JSON parser → json::Reader/json::Writer (or json::Parser) — leave the merely-quirky (Book/Bank/Index, Sample/AudioSample, MinMax, KitBox). Daniel's to call.
  • Q-9 — align the capture_realtime (shell) / realtime_record (pure) word-order inversion. REC: yes, during Q-W3 (a free rider — W3 already hoists the realtime lifecycle).

The directory + namespace map (Q-3 / Q-4)

Top-level by the pure/shell discipline; subsystem dirs beneath core/; namespaces mirror directories.

core/ (pure, no REAPER types, unit-tested — reasampler::<subsystem>):

  • core/model/ (::model) — bank_model, bank_book, owned_manifest, provenance
  • core/view/ (::view) — view_mode_model, view_tree, lane_keys, mode_switch
  • core/capture/ (::capture) — render_settings, batch_capture, tail_control, capture_paths, wav_trim, insert_plan
  • core/audio/ (::audio) — peaks
  • core/ui/ (::ui) — theme, component_geometry, bank_grid, tab_strip, action_buttons, prune_button
  • core/reclaim/ (::reclaim) — prune_reconcile
  • core/version/ (::version) — app_version
  • core/json/ (::json) — NEW — extracted parser/serializer (replaces the 4 duplicate Parsers)

shell/ (REAPER-facing — subdir by subsystem, namespace as house style prefers):

  • shell/capture/capture, capture_realtime, provenance_shell, track_guid, item_read; post-Q-W3 capture_orchestrator, scope_resolve, realtime_lifecycle
  • shell/panel/draw_kit; post-Q-W2 panel_render, panel_thumbnails, panel_audition, panel_input, panel_bank_ops, panel_window (from bank_panel)
  • shell/view/view
  • shell/persist/post-Q-W5 session, ext_state_io, prune_fs (from persist)
  • shell/actions/drag_out_win; post-Q-W4 design_view_actions, bank_actions, prune_action (from actions)

app/: main.cpp (post-Q-W3: API-pointer ownership + ReaperPluginEntry + dispatch only).

Collision sweep (before W1): Sample (::model) vs AudioSample (::audio alias) vs the unified Parser (::json) must not collide once flattened into granular namespaces; resolve by subsystem home.

The god-module split seams (Q-5)

Split each to the audit-validated seams, no finer:

  • bank_panel.cpp (2424 LOC → shell/panel/, Q-W2): panel_render (draw/paint) / panel_thumbnails (compute+cache) / panel_audition (preview engine — hot path, direct call-through) / panel_input (mouse/key/wheel + new-content detection) / panel_bank_ops (bank CRUD — the single owner W4 dedupes against) / panel_window (lifecycle + OS drag-out/drop-target). Split the fat bank_panel.h per seam (I).
  • main.cpp (1762 LOC → hoist to shell/capture/, Q-W3): capture_orchestrator (RunCapture/captureAndIndexOne/renderOffline/batch/recapture/realtime Run*) / scope_resolve (range/razor/track resolution + provenance assembly inputs) / realtime_lifecycle (state machine + globals + selection guards). FxBypassGuard moves out but stays stack RAII (precision-critical). main.cppapp/, reduced to pointers + entry
    • dispatch.
  • actions.cpp (981 LOC → shell/actions/, Q-W4): design_view_actions / bank_actions / prune_action (doBankPruneFolder — the single file-deletion action). Dedupe promptText/mintBankId + bank verbs against panel_bank_ops.
  • persist.cpp (766 LOC → shell/persist/, Q-W5): session (lifecycle/poll + BeginLoadProjectState reload hook) / ext_state_io (serialization bridge + GUID minting + folder relocation) / prune_fs (prune scan + deleteOrphanFile via SHFileOperationW — the isolated single file-deletion authority).

The JSON extraction (Q-2 / Q-W1)

Extract one pure core/json (::json): parser (parseString/parseInt/parseKey/ skipValue + escape) + serialize/emit helpers. Rewire bank_model, bank_book, view_mode_model, owned_manifest onto it and delete their four hand-rolled Parsers. Round-trip output must be byte-identical to before — this is a structural dedupe, not a format change. Off all hot paths (serialization runs at save/load, never per frame) — safe to abstract freely.

The OCP registration-table (Q-6 / Q-W6)

Replace the ~350-line hand-written non-table action registration blocks (isolated in app/main.cpp after Q-W3) with a data-driven registration table, so adding an action edits one place, not four parallel Register("command_id"/"gaccel"/"hookcommand") sites. Unload mirror-unregisters from the same table. Command-id + display strings stay byte-identical (FOREVER-STABLE, per-channel — the Phase V V4 contract). Also split residual fat headers (capture.h/persist.h) alongside their TU splits (I).

The naming dimension (Q-7 / Q-8 / Q-9 — grep-verified audit in docs/product/code-organization.md §2b)

Alongside giving symbols a directory + namespace home (Q-3/Q-4), Phase Q gives inconsistently/poorly-named symbols a consistent name — same Vital bar, orthogonal to the SOLID focus. The audit (2026-07-27) is grep-verified; the load-bearing findings:

  • Already consistent — preserve verbatim: the geometry-mirror verb vocabulary (compute<Thing>Rects/compute<Thing> + hitTest<Thing>, verified across bank_grid / mode_switch / action_buttons / action_bar / tab_strip / prune_button / overflow_menu / footer_bar / card_drag / component_geometry) and the uniform <module>_tests CTest suffix.
  • Collisions — resolved by the Q-4 sub-namespaces for free: four class Parser (bank_model.cpp / bank_book.cpp / owned_manifest.cpp / view_mode_model.cpp) collapse to one json::Parser in Q-W1; the shared pure-UI rect types FooterRect / ButtonRect (defined in prune_button.h, reused by footer_bar.h under an explicit hand-collision "NAME NOTE") get one ui:: owner; Sample (model::) vs AudioSample (audio::) de-collide by home.
  • Genuine renames (Q-8/Q-9 — Daniel's call): BankIndexBankModel (the bank_model.h file↔class word-mismatch — the worst legibility wart, rec: rename the class so the model family reads BankModel/BankBook/ViewModeModel); the unified JSON parser named json::Reader/ json::Writer at W1 mint; align capture_realtime(shell)/realtime_record(pure) to the house shell↔core convention (drag_outdrag_out_win is the model) during Q-W3.
  • Sequencing (Q-7): renames ride the wave that already relocates/splits the file — no dedicated naming wave. Rule: no rename lands on a file the wave isn't otherwise touching. W1 carries the collision + model-class renames; W2 the panel_* names; W3 the realtime word-order fix. Zero-behavior-change like the rest of Phase Q; verified by the module's own test executable.
  • Out of scope (never renamed): the FOREVER-STABLE contract strings are not C++ symbols — command_id strings, action display names, ext-state namespace ("reasampler"/"reasampler_beta") and keys, the reasampler: lane prefix, the VST3 class UID. Renaming a C++ class is orthogonal to these literals (audit §2b.5).

Performance guardrails (HARD CONSTRAINT — Daniel's non-negotiable)

The reorg must cost zero runtime. The two hot paths must keep their exact call/inline shape; the following are acceptance criteria on every point:

  • peaks envelope compute (computeEnvelope / lastFrameAboveThreshold over full PCM): NO virtual dispatch, NO peaks interface, NO added header→TU indirection. computeEnvelope stays a free function on const std::vector<float>& so it inlines as today. Relocate + namespace only; never wrap in an abstraction.
  • Audition / preview: panel_audition may be its own TU (Q-W2), but the call stays a direct call-through, not virtual.
  • Realtime-capture tick: keep the idle fast-path a single pointer test; realtime_lifecycle (Q-W3) must not change the tick's branch shape.
  • JSON extraction is off all hot paths — safe to abstract freely (why Q-W1 is the safe opener).
  • FxBypassGuard runs per-capture, not per-frame — keep it stack RAII when it moves out of main.cpp (Q-W3); never heap-allocate or virtualize it.

Net: every recommended split falls on a cold path or preserves call/inline shape on the two hot ones. A split that would add a hot-path indirection is out of scope — rework it or drop it.

The GATE (load-bearing — Phase Q is last)

Phase Q is gated on the tree being otherwise quiescent. Daniel's plain readiness target: "when Phase S and L3 are finished." As of 2026-07-27 the outstanding work is precisely (1) Phase S merged to dev (the large second-artifact branch, currently on the phase-s worktree) and (2) Phase L L3 merged to dev (the VST restyle, itself gated on Phase S). L1/L2/L4/L5/L6/L7 have already landed (see COMPLETED.md) — the earlier "L2 + L3" wording was stale and is corrected to L3 only. D2 is functionally complete (D2-W1..W3-B landed; its lone open item, a per-track lane-split panel indicator, is explicitly deferred, not a blocking residual). M9 (slots) is abandoned (Daniel, 2026-07-27) — will not be built. D2 is named here only so that reactivating its deferred panel indicator re-arms the quiescence condition; neither D2 nor M9 blocks the gate today. Why: Phase Q touches nearly every file in src/ (relocate, re-namespace, split the four largest TUs, plus the §2b renames); every large in-flight branch (Phase S on its worktree, L3 once it lands) is diffed against the current flat layout, so landing a rename-and-relocate-everything reorg mid-flight forces every open branch through the worst conflict class — a combinatorial re-resolution, not a linear one. Phase Q is last precisely because it reshapes the ground every other pillar stands on: landing it early taxes every subsequent phase; landing it last taxes nothing. Re-confirm quiescence against dev before Q-W1.

M9 disposition — resolved (Daniel, 2026-07-27): abandoned. M9 is out; it will not be reactivated. The gate remains satisfied; no re-arm condition applies.

Wave sequencing (each independently landable, CTest-green at every step)

Big-bang is rejected — the CMake per-module static-lib + per-module test-executable seams make a file move + namespace change mechanically verifiable (ctest --test-dir build green or not, at every commit), a property only an incremental reorg uses. Risk-ordered:

  • Q-W0pre-restructure functional + DSP quality audit (entry point; see §"The pre-restructure audit wave (Q-W0)"). Produces a written, triaged findings report; runs first and gates Q-W1 — no structural point begins until the triage closes and Daniel signs off on every disposition. Fix-now findings are remediated here or folded into the wave that opens the file; the report may add/reshape downstream Q-W1..Q-W6 points before they start.
  • Q-W1 — safe opener: core/json extract (delete 4 Parsers) + impose the directory/ namespace layout on the 30 clean pure libs + clean shells (pure relocation, no logic change). All later waves assume this layout. Carries the naming collision fixes + the model-class renames (Q-8), which are free during this relocation.
  • Q-W2..Q-W5 — the four god-module splits, one per wave, risk-ordered (bank_panelmain.cppactions.cpppersist.cpp). Q-W4 depends on Q-W2 (panel_bank_ops dedupe target); Q-W5 best after Q-W4 (prune_actionprune_fs routing); otherwise parallel-safe. Q-W2 carries the panel_* names; Q-W3 carries the capture_realtime/realtime_record word-order fix (Q-9).
  • Q-W6 — OCP registration-table + residual fat-header (I) splits. Depends on Q-W3 (registration code isolated first). Sequenced last; most droppable if narrowing.
  • Naming (Q-7): no dedicated wave — every rename rides the wave already relocating/splitting its file; a rename that would touch an otherwise-untouched file is deferred.

Precision / invariant implications (what Phase Q does NOT change)

  • The pure/shell split is strengthened, never dissolved. Top-level core/ vs shell/ makes it structural; no file crosses the boundary; no core/ file gains a REAPER type; the CMake per-module test executables stay green.
  • Every precision invariant holds. Null test, bit-identical repeats, non-destructive capture, exact bounds — none is code Phase Q rewrites. FxBypassGuard moves but stays stack RAII with identical behavior.
  • Capture ≠ placement. No hoisted Run* / capture_orchestrator path may gain an InsertMedia call during the split.
  • Single file-deletion authority is concentrated, never spread. prune_fs (Q-W5) is the one module that deletes bytes; the reorg improves this invariant.
  • Relative-paths-only in the persisted index — untouched.
  • On-the-wire/on-disk contract strings unchanged. Command-id strings, action display names, ext-state namespaces, VST3 class UID are byte-identical (per-channel, per Phase V V4). Re-namespacing C++ symbols is orthogonal to these.
  • No behavior change. Phase Q is a pure structural refactor; the test suite passing unchanged is the proof of correctness. A point that changes observable behavior has exceeded its charter.

Non-goals / guardrails

  • No feature, no behavior change. If a point ships anything user-visible, it is out of scope.
  • No hot-path indirection. No virtual dispatch or header→TU indirection on peaks/audition/ realtime-tick — ever (a hard acceptance bar, not advice).
  • No design-level (D-letter SOLID) rework. main/bank_panel depending on concrete capture backends is a low-priority Dependency-Inversion concern — out of scope (a design change, not a reorg). Phase Q reorganizes; it does not re-architect interfaces.
  • No peaks data-ownership change. peaks forcing a whole-file std::vector<float> copy on the thumbnail path is noted but not touched — reworking it risks the hot path.
  • No big-bang commit. Every wave is independently landable and CTest-green; reject a change set that cannot be verified at each step.
  • Do not begin before the GATE. Re-confirm the tree is quiescent (Phase S + L + D2 merged/closed; M9 abandoned) before any Q point. And do not begin any structural point (Q-W1+) before the Q-W0 sub-gate: the audit's triage is complete and Daniel has signed off on every finding's disposition.
  • Verify the CMake src/ path updates and the SWELL/LICE surfaces still resolve after relocation, as the existing build already requires.