2531 lines
178 KiB
Markdown
2531 lines
178 KiB
Markdown
# 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
|
||
> M0–M11 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
|
||
> M0–M11 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 M0–M11 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 B1–B4, 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 B1–B4),
|
||
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 0–1 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 0–1
|
||
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 today** — `SampleData::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^((note−root)/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 transpose** — *cleaner*
|
||
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 amount** — `set_shift(2^((note−root + 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 **S10–S13 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 S1–S6 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 `#ifdef`s
|
||
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 0–1's build shape. **Note:**
|
||
S7 stereo is *not* a Tier-2 feature — it is a channel-count dimension on the existing
|
||
Tier 0–1 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 0–1 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 0–200% 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 (0–127),
|
||
Y = amp scalar (0–1)**, 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
|
||
*linearly* — `velocityGain_ = 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 0–127) → amp scalar 0–1` — a bezier through the control points,
|
||
clamped to the 0–127 × 0–1 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 0–127, Y = amp 0–1), 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 (~44–52px 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**
|
||
(0–200%, 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 mechanism** — `getSize`/`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-FX** — `TrackFX_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 0–127×0–1 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 slab* — `bg/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:1–7: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 REAPER* — `bg/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:1–7: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** L4–L6 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 held** → **REPLACE** 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 **subsystem** — `common/` `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.
|
||
|
||
## Settled decisions (Q-1 settled; Q-2..Q-6 recommended — see `docs/product/code-organization.md` §6)
|
||
|
||
- **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
|
||
mislead** — `BankIndex`→`BankModel` (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
|
||
`Parser`s)
|
||
|
||
**`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.cpp` → `app/`, 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 `Parser`s**.
|
||
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):** `BankIndex`→`BankModel` (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_out`↔`drag_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-W0** — **pre-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 `Parser`s) + 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_panel` →
|
||
`main.cpp` → `actions.cpp` → `persist.cpp`). Q-W4 depends on Q-W2 (`panel_bank_ops` dedupe
|
||
target); Q-W5 best after Q-W4 (`prune_action` → `prune_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.
|