Files
reasampler/CONTEXT-ARCHIVE.md
T
daniel 959019bcf5 docs(context): split landed build detail into CONTEXT-ARCHIVE.md
CONTEXT.md 270k -> 186k chars. Module architecture, API surface, and
landed implementation specs moved verbatim; settled decisions,
invariants, guardrails, and unbuilt Phase Q / S-VIEW Wave B retained.
2026-07-27 20:23:19 -04:00

1232 lines
83 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CONTEXT-ARCHIVE.md — ReaSampler spec provenance
Build-detail sections moved verbatim out of `CONTEXT.md` once their phase
landed. Nothing here is condensed or rewritten — each section is the original
text, preserved for provenance. `CONTEXT.md` retains the settled decisions,
invariants, guardrails, and unbuilt specs that still constrain future work.
Sections appear in their original `CONTEXT.md` document order.
---
## Module architecture
Preserve the scaffold's split: pure, REAPER-free logic in one set of files
(unit-tested outside the DAW via the existing `tests/` + CTest harness), REAPER-
facing shells in another.
Pure (no REAPER types, fully unit-tested):
- `bank_model` — the `Sample` metadata struct and the `BankIndex` (add / remove /
query / tier moves / dedup-by-hash) plus JSON serialize/deserialize to a
`std::string`. This is the heart; test it hard.
- `peaks` — compute waveform min/max bins from raw PCM. Feed it a known signal
(sine, ramp) and assert the envelope. We compute our own thumbnails from the
captured file rather than depending on REAPER's peak API — we own the file
format, so this is simpler, testable, and dependency-free.
REAPER-facing:
- `capture` — the `ICaptureBackend` interface plus `OfflineRenderBackend` and
`RealtimeRecordBackend`. Input: a `CaptureRequest` (capture scope — item or
track, time range, tail, SR/bit-depth/channels, output path). Output: a finished
file + a populated `Sample` handed to `bank_model`. Capture is always wet; the FX
*scope* (not a wet/dry dial) is the control — the pure `render_settings` module
maps a scope to its render-source bits and its FX-bypass plan (see §Capture FX
scope below).
- `insert` — placement via `InsertMedia`; conform-to-project-tempo vs literal,
as an explicit flag (never silent stretching).
- `bank_panel` — the docked grid: LICE-drawn thumbnails, audition, multi-select,
keyboard navigation. Reuses the docking setup already in `mpe_view.cpp`.
- `persist` — project ext state <-> `bank_model` JSON; project-relative path
resolution (resolve bank folder from the current project path).
- `actions` — registers the capture/placement/slot action family and routes each
to the modules above (the `command_id` + `gaccel` + `hookcommand` pattern from
`main.cpp`).
## Data model (sketch — refine in code)
`Sample`: id, display name, relative file path, source mode, source range (start/
end in project time + PPQ), track GUID(s) if applicable, wet/dry, channel count,
sample rate, length (seconds + musical/beats), capture tempo, optional key,
peak/RMS/LUFS, clip flag, tier (scratch | archive), content hash, provenance
(parent sample id + FX-chain snapshot string, when resampled from another sample),
created timestamp.
`BankIndex`: ordered collection of `Sample`, keyed by id; hash lookup for dedup;
tier filtering; JSON round-trip. Scratch tier is auto-prunable; archive is kept.
## REAPER API surface (verify all signatures)
Offline render (the crux — prototype this first):
- Drive render settings with `GetSetProjectInfo` (`RENDER_BOUNDSFLAG`,
`RENDER_STARTPOS`, `RENDER_ENDPOS`, `RENDER_TAILFLAG`, `RENDER_TAILMS`,
`RENDER_SRATE`, `RENDER_CHANNELS`, `RENDER_SETTINGS`) and
`GetSetProjectInfo_String` (`RENDER_FILE`, `RENDER_PATTERN`, `RENDER_FORMAT`).
The `RENDER_SETTINGS` bit choice is driven by capture scope: **item** scope renders
selected media items (single-file), **track** scope renders selected tracks via
master. There is **no master-mix scope** (to capture the master, render a track).
Render is always wet — REAPER has no true pre-FX "dry" render bit; FX scoping is done
by the FX-bypass-around-render mechanism, not a render bit (see §Capture FX scope).
- Trigger a no-dialog render via the appropriate render action / `RENDER_SETTINGS`
bit. Confirm the exact command id and the "render without opening dialog" flag
against current REAPER — do not assume; test that it runs headless.
- Determinism is a hard requirement: two identical requests must produce
bit-identical files (enables the null test below).
Realtime record (**track scope** — item realtime is deferred):
- Taps the **selected track**, not the master. Recipe: create a hidden temp track,
route a **post-fader send from each selected source track into it**
(`CreateTrackSend`, default post-fader/full-stereo — captures each track's own
output *before* the parent/master sums it, so the tap is chain-independent by
construction and needs no FX bypass), set the temp's record mode to record-output
(latency-compensated, `B_MAINSEND=0` so it does not sum back), arm (`I_RECARM`),
`CSurf_OnRecord`, run for the range, `OnStopButtonEx`, then move the recorded file
into the bank and delete the temp track (which drops the sends — no source track is
left mutated). Verify `I_RECMODE` values for output-recording.
- Master→track sends are refused by REAPER as feedback loops — this is *why* the tap
is the selected track's post-fader output, not the master.
Sources & metadata:
- Time selection: `GetSet_LoopTimeRange`. Razor edits:
`GetSetMediaTrackInfo_String(track, "P_RAZOREDITS", ...)`. Tempo:
`Master_GetTempo` / `TimeMap2_timeToBeats` / `GetProjectTimeSignature2`.
Selected items/tracks: `CountSelectedMediaItems` / `GetSelectedTrack`.
Placement:
- `InsertMedia(path, mode)` at edit cursor / new track / replace selection
(verify mode bits). `SetEditCurPos`. Wrap edits in `Undo_BeginBlock2` /
`Undo_EndBlock2`.
Persistence & paths:
- `SetProjExtState` / `GetProjExtState` (namespace e.g. `"reasampler"`) for the
index JSON. Resolve project folder via `EnumProjects` / `GetProjectPathEx`;
store the bank under a project-relative subfolder; keep only relative paths in
the index.
## Build order (each milestone independently testable)
1. `bank_model` + JSON round-trip + unit tests. (pure — no REAPER)
2. `peaks` + unit tests. (pure)
3. Offline capture of the time-selection master mix to a wav in the project bank
folder; add a `Sample`; log it to the console. (the render-driving spike)
4. `persist`: write the index to proj ext state, reload on project open; confirm
it survives Save / Save As. (bank travels with the .rpp)
5. `bank_panel`: docked grid with thumbnails, audition, selection.
6. `insert`: "insert selected sample at edit cursor" action via `InsertMedia`.
7. Capture action family: two FX scopes — **item** (item/take FX only) and **track**
(item FX + the selected track's own track FX), each chain-independent (the
out-of-scope chain is neutralized to unity for the render). No master scope — to
capture the master, render a track. Each scope captures over a **time-or-razor
range** (razor is a range source, not a mode: razor-when-present, else time
selection). Capture is always wet; the scope is the control. All registered as
bindable actions (tail off; a tail-on variant is a later opt-in).
8. `RealtimeRecordBackend` behind the same interface.
9. Slots: "capture to slot N" / "insert slot N", MIDI-bindable (MPC-style).
10. Provenance + "re-capture from source"; null-test verify action.
11. Polish: batch capture (per selected item / per razor area), resample-and-mute-
source, conform-on-insert, drag-out to OS.
---
## Module architecture (preserve the pure/shell split)
Pure (no REAPER types, unit-tested — the mirror of `bank_model`):
- `view_mode_model` — mode registry (id/name/ordinal; Arrange + Design seeded);
membership index (`track GUID → { mode ids }` + per-track show-both flag; add /
remove / retag / query); **folder-tree-aware** visibility derivation (given the
current parent↔child tree supplied by the shell + the active mode, compute the
visible set); the **parking/restore planner** (given active mode + snapshot
record, emit the exact (track, flag, value) operation lists for park and
restore — where the restore invariant is enforced); JSON round-trip of modes +
membership + show-both + snapshots + active mode.
REAPER-facing:
- `view` shell — reads `I_FOLDERDEPTH` across the track list to build the
parent↔child tree and feeds it to `view_mode_model`; applies the planner's operations
via `SetMediaTrackInfo_Value` (`B_SHOWINTCP` / `B_SHOWINMIXER` / `B_MAINSEND` /
`I_FXEN`) and `TrackFX_GetCount` + per-FX `TrackFX_SetOffline`; snapshots prior
flag values before parking; resolves GUIDs via
`GetTrackGUID` / `guidToString` / `stringToGuid`. Never touches master
visibility, never touches `B_MUTE` / `I_SOLO`.
- `persist` (slice) — serialize/deserialize the view section into the
`"reasampler"` namespace alongside the bank; on project open, rebuild the tree
and reapply the active mode.
- `actions` (entries) — toggle active mode; activate mode: Arrange / Design;
tag/untag selected tracks → mode; show-both for selected tracks. Registered with
the `command_id` / `gaccel` / `hookcommand` pattern; toggle + mode-jumps
MIDI-bindable.
- UI (in the ReaSampler / bank_panel window) — a **segmented mode switch**
(`[ Arrange | Design ]`) in the window header, active segment lit; small per-mode
membership count; the offlined-FX caveat as a tooltip. Tag/untag acts on the
current REAPER track selection, not a per-track widget.
## REAPER API surface (verify all signatures)
- Visibility/routing/FX flags via `GetMediaTrackInfo_Value` (snapshot) /
`SetMediaTrackInfo_Value` (apply): `B_SHOWINTCP`, `B_SHOWINMIXER`, `B_MAINSEND`,
`I_FXEN`. (Note: brief cited `B_SHOWINMCP`; verified SDK name is `B_SHOWINMIXER`.)
- Per-FX offline: `TrackFX_GetCount` + `TrackFX_SetOffline(track, fx, offline)`.
- Folder tree: read `I_FOLDERDEPTH` per track to derive parent↔child structure.
- GUID keying: `GetTrackGUID`, `guidToString`, `stringToGuid`.
- Persistence: `SetProjExtState` / `GetProjExtState` under `"reasampler"` (shared
with the bank index — one blob, two logical sections).
- Wrap flag mutations in `Undo_BeginBlock2` / `Undo_EndBlock2` as appropriate.
---
### Module architecture (preserve the pure/shell split)
- **Pure (`view_mode_model` extension).** Lane math — which lane maps to which mode,
which `C_LANEPLAYS` value per mode, the item-lane op family alongside the existing
track-flag op family — is **REAPER-free and unit-tested**, mirroring the D1
planner. The **lane-ownership index** (per (track GUID, lane): managed-which-mode vs
manual) and the **"which lanes may this toggle touch" query** (managed only) are
pure and unit-tested — the planner emits lane ops for managed lanes only and never
for manual lanes. The **auto-tag decision** is pure too: given *a set of new
track GUIDs + active mode* (active-mode rule) and *a set of new items, each
carrying the set of modes already present on its track* (`NewItem::trackModes`)
— plus the manual-lane exemption flag — `autoTagNewContent` produces the
membership writes. The adoption guard runs inside the pure layer: single prior
mode → adopt it; empty / multi-mode track, or new track → fall back to active mode.
- **Shell.** Two shell responsibilities. (1) **Apply** the planner's item-lane ops
(`I_FREEMODE`/`I_FIXEDLANE`/`C_LANEPLAYS`/`B_FIXEDLANE_HIDDEN` via the media-item
info setters, `UpdateTimeline()` after `I_FREEMODE` changes) — for managed lanes
only. (2) **Detect** new content and read live lane state — see below.
- **Persistence.** The tool persists which lane maps to which mode **and the
managed/manual ownership index** (a small addition to the `"reasampler"` view
section); REAPER stores fixed lanes and lane-plays in the `.rpp` natively.
### New-content detection (implementation design point)
REAPER exposes **no clean "item added" / "track added" event callback.** Auto-tagging
therefore requires the shell to **diff project state on the panel's existing timer**
the `bank_panel` already polls and fingerprints the bank; this extends that machinery
to the timeline's tracks and items.
- Each poll, compare the live track/item GUID set against the previous poll's set.
New **track** GUIDs are tagged to the then-active mode. New **item** GUIDs are
passed to `autoTagNewContent` with the pre-existing mode-span of their track
(`NewItem::trackModes`): single-mode track → adopt that mode; empty / multi-mode
track → fall back to the then-active mode. Manual-lane items are excluded.
- Correctness the implementation must handle: the **first poll after project open must
not mass-tag** pre-existing content (pre-existing defaults to Arrange, per the
membership rule).
- **Manual-lane exemption (design point).** An item added to a **manual** lane is
**not** auto-tagged — auto-tag governs normal timeline content, not lanes the user
hand-manages. Distinguishing the two may need a heuristic at detection (e.g., an item
whose `I_FIXEDLANE` is marked manual in the ownership index is exempt; content
outside any managed lane on a mode-managed track is subject to the adoption rule). The
precise rule is an **open implementation design point**; the settled boundary is that
manual-lane content is off-limits to auto-tag.
- **Lane-identity fragility (design point).** `I_FIXEDLANE` is the lane's identity and
is how the ownership index keys to a lane. Whether the index survives lane
reorder/renumber/deletion without going stale is an **implementation design point**
(same class as GUID-keyed reorder-safety for tracks) — flag, don't solve here.
- Pure/shell seam: the **detection** (diffing REAPER's live set each tick, reading live
lane ownership and per-item `trackModes`) is shell; the **tagging decision** and the
**managed/manual lane query** (new track GUIDs + active mode; new item GUIDs +
`trackModes` + active mode + manual-lane exemption ⇒ membership + lane writes) are
pure and unit-tested.
### REAPER API surface (verify all signatures)
- Fixed lanes — track: `I_FREEMODE` (=2), `I_NUMFIXEDLANES`, `C_LANEPLAYS:N` via
`GetMediaTrackInfo_Value`/`SetMediaTrackInfo_Value`; item: `I_FIXEDLANE`,
`C_LANEPLAYS`, `B_FIXEDLANE_HIDDEN` via `GetMediaItemInfo_Value` /
`SetMediaItemInfo_Value`. Call `UpdateTimeline()` after `I_FREEMODE` changes.
- Detection reuses the `bank_panel` timer + GUID fingerprinting already in place;
item GUIDs via the item's `GUID` (`GetSetMediaItemInfo_String` `"GUID"`), track
GUIDs via `GetTrackGUID` as in D1.
- **Verify every name/signature against the SDK header before use** — the surface is
verified *present*, but confirm argument order, types, and flag values.
---
## Module architecture (preserve the pure/shell split)
Pure (no REAPER types, unit-tested — the mirror of `bank_model` / `view_mode_model`):
- `bank_book` — ordered bank registry (`{ bank id, display name, ordinal,
BankIndex }`); pool seeded with fixed id + name; create / rename / reorder /
delete named banks (pool-privilege rules enforced here: reject delete/rename of
pool; delete drops member index entries; **display names unique** — create/rename
reject a name that duplicates another bank's, trimmed + case-insensitive, "Pool"
protected); **evacuate** a bank (move every member to
the pool, index-only, destination-collapse observed; pool cannot be evacuated);
active-bank id (get/set, defaults to pool); **move** and **copy** a sample between
banks (index-only, destination-collapse observed); query a bank's index; JSON
round-trip of the whole book (pool-as-bank-zero inside the blob + named banks +
per-bank indices + active id + ordinals) and legacy-`bank_index`→pool migration on
parse (one-way; blob authoritative thereafter).
REAPER-facing:
- `persist` (slice) — serialize/deserialize the book under the `banks` key in
`"reasampler"` (pool-as-bank-zero inside the blob; no separate `bank_index` key
going forward); migrate a legacy `bank_index` key into the pool on first load
(one-way; blob authoritative thereafter, legacy key retired); reload-on-open and
Save-As survival ride the existing M4 machinery. The session exposes the book the
way it exposes the bank today; the active bank's `BankIndex` is what the capture
layer adds to.
- `bank_panel` (extension) — the vertical split: pool grid on top, named-banks
tab-page region below; two full-height toggles; the active-bank indicator; the
create / rename / delete / activate affordances. The named-banks tab strip is
**LICE-drawn** to match the M5 grid and the Design View segmented switch (not a
SWELL-native tab control), with an **overflow/scroll affordance** so it scales past
the ~812-tab point. Sample move ships **both ways**: a "move to bank" menu on the
current selection (the bindable front-end for the B3 move action) and
drag-between-regions (the direct-manipulation accelerator); copy is the deliberate
secondary act, offered on the menu. Drag carries clear drop-target highlighting on
the destination region/tab, and a mis-drop is recoverable by design (move is
index-only and reversible — the user moves the sample back). Reuses the existing
LICE grid render loop per bank region.
- `actions` (entries) — create bank / rename bank / delete bank (confirm on
non-empty delete); evacuate bank → pool; activate bank (direct + cycle); move
selected samples → bank; copy selected samples → bank; pool/banks full-height
toggles. Registered with the `command_id` / `gaccel` / `hookcommand` pattern;
bank-activate + move/copy + evacuate MIDI-bindable to suit the capture-heavy
workflow.
## REAPER API surface (verify all signatures)
No new REAPER API is invented at spec stage — the multi-bank layer is pure model +
persistence + panel UI over machinery M0M6 already established. Shells will need to
verify against the SDK header where they extend existing surfaces:
- **Persistence:** `SetProjExtState` / `GetProjExtState` under `"reasampler"`, new
key `banks` (shared blob machinery from M4 — no new API, new key only).
- **Panel UI:** the docked-window + LICE-grid surface from M5 (`bank_panel`),
extended to two grid regions + a **LICE-drawn** tab strip (with overflow/scroll) +
toggles. The tab strip, the toggle affordances, and the drag hit-testing are
custom-drawn on the M5 LICE surface (not SWELL-native tabs); the "move to bank" menu
uses a SWELL popup-menu surface. **Verify LICE drawing and any SWELL menu/drag
hit-test usage against the M5 reference / SWELL headers**, and confirm the drag
hit-test does not collide with the M5 grid's multi-select drag. No new REAPER audio
API involved.
- **Actions:** the `command_id` / `gaccel` / `hookcommand` contract from `main.cpp`
(unchanged), new command-id strings under the sampler family prefix.
## Open questions to resolve during build
Forks 15 are all settled (see product notes → *Settled forks* and *Fork 5 — settled*,
and the settled-decision prose above). One panel-polish detail remains open.
- **Fork 5 — tab rendering + move affordance (B4). Settled (2026-07-23).** (5a) The
named-banks region is **LICE-drawn** to match the M5 grid and the Design View
segmented switch — not a SWELL-native tab control — with an **overflow/scroll
affordance in scope from the start** so the strip scales past the ~812-tab
breakdown. (5b) Move ships as **both** a "move to bank" menu (the precise,
MIDI-bindable front-end for the B3 move action) **and** drag-between-regions (the
direct-manipulation accelerator); copy stays the deliberate secondary act via the
menu. Drag mis-drop is mitigated by drop-target highlighting and is recoverable by
design (move is index-only and reversible). Folded into the `bank_panel` prose and
the API surface below. **Verify LICE tab drawing and any SWELL menu/drag hit-test
surface against the M5 reference / SWELL headers before use** (confirm no collision
with the M5 grid's multi-select drag). Analysis in product notes → *Fork 5*.
- **Active-bank indicator placement (B4 polish)** — per-region headers vs. a single
header readout vs. lit-tab treatment. The "visually unmistakable" requirement is
settled (fork 4); only the placement is open. Panel-polish detail.
---
## Module architecture (preserve the pure/shell split)
- `bank_book` / `BankIndex` (pure) — expose remove of a `Sample` from a bank's
index (the existing `BankIndex::remove` primitive, surfaced through the book);
pool contents removable, pool-container privileges unchanged.
- `actions` (entry) — "remove selected sample(s) from bank" (and, under fork R-A,
a scope parameter); registered with the `command_id`/`gaccel`/`hookcommand`
contract; MIDI-bindable to suit the capture-heavy workflow.
- `bank_panel` (affordance) — remove on the current selection (menu entry / key),
reusing the M5 selection model exactly as move/copy do; confirm-on-last-reference
at this layer.
## REAPER API surface
No new REAPER API. Pure model + a new action command-id string under the sampler
family prefix + a panel affordance on the existing M5 LICE surface. Verify the
command-id/gaccel/hookcommand usage against `main.cpp` (unchanged contract).
---
## Module architecture (preserve the pure/shell split)
Pure (no REAPER types, unit-tested — the mirror of `reconcile`):
- **Prune-reconcile core** — given `{ files present in the bank folder }`,
`{ files referenced by the book }`, and `{ files the book owns }` (the owned-file
manifest, R-D), compute the orphan set `(owned ∩ present) referenced`.
REAPER-free, filesystem-free, unit-tested hard (the prune null test lives here).
The referenced-set is unioned across all banks by asking the `bank_book`.
REAPER-facing / filesystem-facing (thin):
- `persist` / session — supplies the referenced-set (union across the book) and the
owned-file manifest (R-D, written from capture onward in Phase B); resolves the
current project bank folder via the M4 project-relative machinery.
- A **prune shell** — enumerates the bank folder (filesystem I/O), feeds the
pure core, presents the dry-run manifest, and on confirmation deletes the orphan
set (via OS trash where portably available — fork R-C — else unlink). Filesystem
I/O only; the decision stays in the pure core.
- `actions` (entry) — "Prune bank folder" (dry-run-first, confirm-to-delete),
registered with the `command_id`/`gaccel`/`hookcommand` contract; **plus a
`bank_panel` button** (R-E) that fires the same action.
## REAPER / platform API surface (verify all signatures)
No new REAPER *audio* API. New surfaces to verify before use:
- **Filesystem enumeration + delete** — directory listing and file removal for the
project bank folder. **Verify** the portable approach against SWELL / the existing
file-handling in `persist` / `capture` (which already resolve and write files);
prefer reusing whatever path/file machinery M4 established.
- **Move-to-trash (fork R-C, settled trash-preferred)** — verify a portable
move-to-trash exists (SWELL, or per-platform: Win `IFileOperation`/
`SHFileOperation`, macOS `NSFileManager trashItemAtURL:`, Linux XDG trash spec).
This is a **must-verify per platform** before use, not an assumed capability;
where it is unavailable, fall back to unlink behind the dry-run/confirm guardrail.
(R3 verified: Windows routes to Recycle Bin via `SHFileOperationW` + `FOF_ALLOWUNDO`,
verified against SDK 10.0.26100. macOS / Linux: no portable SWELL trash surface
found — fall back to `unlink` behind the dry-run/confirm guardrail, as specified.)
- **Owned-file manifest persistence (fork R-D, settled)** — a new tracked set in
the `"reasampler"` ext-state (a sibling key or folded into the `banks` blob —
build-time residual); shared M4 blob machinery, new data only. **Written from
capture onward in Phase B** (the seam lands early), consumed by prune in Phase R.
- **Actions** — the `command_id`/`gaccel`/`hookcommand` contract from `main.cpp`
(unchanged), a new command-id string under the sampler family prefix.
---
## Module architecture (preserve the pure/shell split — in the new artifact)
Pure (no REAPER types, no VST3 types, unit-tested — the mirror of `bank_model`):
- **Sampler core** — voice allocation/polyphony, amplitude envelope (ADSR), key→sample
and velocity→sample mapping (the keymap), repitch/interpolation from root note, and
keymap resolution. REAPER-free *and* VST3-free, unit-tested in CTest against known
signals (mirror of how `peaks` asserts an envelope). This is D3's pure core and the
heart of the phase.
Shell (VST3-facing / REAPER-facing, thin):
- **VST3 wrapper** — `SingleComponentEffect` subclass: `initialize` (declare an event
input bus + an audio output bus, no audio input), `setupProcessing`, `setActive`,
`setState`/`getState`, and the hot-path `process` that reads MIDI off the event bus,
drives the pure core, and writes the core's per-voice audio to the output bus. Plus the
module factory (`GetPluginFactory` + Windows `InitDll`/`ExitDll` — verify exact export
names at the spike).
- **`IPlugView` LICE editor** — hosts a LICE-drawn surface in the VST3 view seat
(window creation/sizing, host→draw/hit-test event routing). Reuses the `bank_panel`
LICE/SWELL competence and house style.
- **Bridge/state reader** — resolves `GetProjExtState`/`EnumProjExtState` by name over
the host callback, fetches the host project context, reads the live `"reasampler"`
ext-state (bank index + intrinsic fields + performance map), and resolves WAV audio
paths the same project-relative way `persist` does.
---
### WDL pitch/resample surface — corrected finding (feeds S16, not a committed point)
**Corrected 2026-07-26 (Daniel's duration-preserving directive).** The prior sweep dismissed
`WDL_SimplePitchShifter` as "wrong tool (duration-preserving)". Under the directive,
**duration-preserving is the requirement**, so that header is the Preserve-engine candidate,
not a mismatch — a real viability assessment replaces the dismissal.
The **full** vendored WDL pitch/resample surface is `vendor/WDL/WDL/resample.h` and
`vendor/WDL/WDL/simple_pitchshift.h` — the **only** two pitch/resample headers; there is
**no** elastique / formant-preserving anywhere in the tree. Honest findings:
- **`WDL_Resampler` (`resample.h`) — sinc/linear resampler, RT-suitable.**
`SetMode(interp, filtercnt, sinc, sinc_size≤64, sinc_interpsize)`; streaming
`ResamplePrepare`/`ResampleOut` with `Prealloc`. Its sinc mode beats the core's 2-point
linear interp for **Varispeed** base-repitch quality (less aliasing on large transpositions)
at a real CPU cost. **A resampler couples duration** → a Varispeed-quality option, **not a
Preserve engine.** Held as a Tier-2/3 Varispeed-quality toggle; not committed.
- **`WDL_SimplePitchShifter` (`simple_pitchshift.h`) — time-domain OLA, duration-preserving —
the S16 Preserve-engine candidate (fork S16-F2 route a).** Viability from the header:
- **API shape:** push/pull, block-based. `GetBuffer(size)` returns an input buffer to fill;
`BufferDone(filled)` runs the OLA shift and queues output; `GetSamples(req, buf)` pulls
from the queue. Config: `set_srate`, `set_nch`, **`set_shift(ratio)` (pitch, duration-
preserving)**, `set_tempo(scale)` (an *independent* duration knob — Preserve uses
`set_tempo(1.0)`), `SetQualityParameter(q)` (selects window/overlap ms from a fixed table).
- **Per-voice instantiability / memory:** modest. `m_psbuf` is an OLA ring of `bsize·nch`
where `bsize = window_ms · 0.001 · srate` (≈ 2205 frames at 50 ms / 44.1 kHz ≈ a few
KB/voice), plus `m_inbuf` (one input block) and a bounded `m_queue`. `m_rsbuf` allocates
only when `set_tempo ≠ 1` (unused in Preserve). One instance per voice is cheap in memory.
- **RT-safety:** allocations occur in `BufferDone` — `m_psbuf.Resize` (once, when
`bsize·nch` first sets, at a fixed quality/srate/nch — pre-warmable) and `m_queue.Add`
(grows only until the push/pull cadence reaches steady state). **Pre-warm at voice-
allocation** (run silence through once so `m_psbuf` sizes and `m_queue` settles); after
that no `process`-thread allocation. No locks. **RT-viable with the pre-warm discipline.**
- **Latency:** inherent ~half-window (initial `m_pspos = bsize/2` → ~25 ms @ 50 ms window)
plus fill-up — a **real note-onset lag**. This is the load-bearing cost. Mitigation:
pre-warm; and Varispeed (zero-latency) serves the tight-transient one-shot material, so the
lag lands on sustained/loop material where least harmful. Smaller-window quality settings
(the table goes to 310 ms) trade latency for more warble.
- **Quality:** basic — this is REAPER's "SimpleWindowed" mode. Audible warble on large
transpositions; **`set_formant_shift` is an explicit empty stub** → no formant preservation.
Usable for loop/phrase Preserve; replaceable by route (b) if not.
- **CPU / polyphony:** `PitchShiftBlock` is O(length) per block — a few mults + one OLA
crossfade branch per frame, **no FFT**. Per-voice cost is modest; **N polyphonic voices
each running one is feasible** within RT discipline. If the aggregate cost is material, a
**Preserve-mode-specific voice cap** (below the Varispeed cap) is the pressure valve —
flagged in Verify, set from measured per-voice budget at build.
- **Formant-preserving / studio-grade time-stretch (elastique-class): NOT in WDL, confirmed.**
REAPER's elastique is **licensed (zplane)**, not in the vendored tree (grep found only
unrelated libpng/giflib string matches). Formant-correct duration-preserving repitch is
**unavailable without a new third-party dependency** (JUCE / rubberband / signalsmith each a
new-dependency fork with D-A weight — not proposed). Stated, not worked around.
- **Recommendation:** the **Preserve** engine (S16-F2) is `WDL_SimplePitchShifter` (route a,
low-cost proof) or a hand-rolled pure `pitch_shift` module (route b, held quality upgrade).
The **pitch-envelope** modulation stays hand-rolled over whichever engine (a per-frame
`ratio_` multiply under Varispeed, a per-frame shift-amount add under Preserve).
`WDL_Resampler` (sinc) is a held **Varispeed-quality** upgrade only.
---
### Sequencing (S15/S16 against S7 stereo, S10 editor)
S15 and S16 are **S3-core extensions** — they touch the engine Daniel smoke-tests, like S7.
They are **channel-count-agnostic by construction**: the play-mode envelope is a per-frame
**amplitude** function, and both pitch engines carry the channel dimension internally — the
**Varispeed** path is a per-frame per-channel read-rate scalar, and the **Preserve** shifter
is **`set_nch`-aware** (one shifter instance per voice transposes all its channels together).
So S15/S16 **compose cleanly with S7's channel dimension** rather than conflicting: S7 adds a
channel axis to the read/mix; S15 adds an amplitude-shape axis; S16 adds a pitch-engine +
read-rate axis; all orthogonal. **Recommended order:** **S15 before S16** (S16 reuses S15's
per-voice param-plumbing + component-state version bumps; landing S15's `PlayMode`/param
struct first gives S16 a home to hang the pitch-engine mode + pitch-env params on). **S16 is
now meaningfully heavier than the prior "just an envelope" framing** — the Preserve engine is
a per-voice DSP object with its own RT budget, pre-warm, and possible voice-cap; treat S16's
Preserve-engine point as the phase's next real DSP spike, not a thin add-on. **S15/S16
relative to S7:** no hard dependency — spec them so the envelope/mode code never assumes a
channel count (it operates per-frame, pre-mix; the Preserve shifter is `set_nch`-driven), and
S7 can land before, after, or interleaved. **Relative to S10 (editor):** S15's mode toggle +
Trigger handles and S16's AD control **surface through** the S10/S11 waveform + guided-setup
work, so the *core* halves of S15/S16 can land independently of the editor, with the editor
surfacing following S10/S11 (the same way S12's ADSR editor follows the S3 ADSR math). Land
the **core** engine work (mode split, start point, %-length/fades, pitch-env modulation) as
soon as it is ready — it is testable in CTest without the editor — and wire the UI as the
S10/S11 surfaces mature. **Land S15/S16 core after S10's policy-reversal is settled** only if
sharing the same component-state blob would otherwise churn the version tag twice; otherwise
they are independent.
---
## ReaSampler 9000 — the UX overhaul (S10S13; DAW-tested S1S6, "the UX is awful")
**The bar is set: better than ReaSamplOMatic5000.** Daniel DAW-tested the S1S6
instrument and the verdict was that it *works* but the UX is unacceptable — "this is
supposed to be better than ReaSamplOMatic5000." The S1S6 editor was a spike-grade LICE
panel: a clickable sample list, zone rows each carrying **seven tiny ±1 nudge/delete
mini-buttons** (low-/low+/high-/high+/root-/root+/delete), text-only labels, **no keyboard
visualization, no waveform, no drag interaction of any kind, no scrolling** for long lists.
Setting a zone from C1 to C4 by clicking "+" thirty-six times is the catastrophe; the rest
(no way to *see* a sample, no loop editing by eye, unreachable rows past the panel bottom,
a fixed envelope) compound it. The overhaul is scoped as **S10S13**, sequenced so the
friction Daniel feels every test pass is removed first.
### Workflow hierarchy (REVISED 2026-07-26 — Daniel; supersedes the keymap-first S10)
The overhaul is reframed around the **actual workflow**, not a keymap. Daniel's directive,
distilled: *a giant list of "item" blocks is visually useless; optimize for working with
individual captures, not a huge list of everything.* The settled hierarchy:
1. **Primary flow = one capture, fast.** Most instances play a **single capture**. The
metric is **time-to-first-note**: open → pick a capture → see it (waveform/peaks) → play
it. The default editor face serves this, not a zone table.
2. **Fresh instance is SILENT — nothing auto-selected (policy reversal of S4).** On open
with no stored selection, the instrument plays **nothing** and shows a clear **empty
state** ("pick a capture") — it does **not** auto-play sample #1. This deliberately
reverses the S4 "first sample plays" convenience: the `selectSample` first-sample
fallback and the processor's Tier-0 fallback that resolved it are removed; an empty
stored id resolves to silence. (Recorded as a reversal, not a regression.)
3. **Capture browser, not an item list.** Scannable **cards/rows** with **peak thumbnails**
(the `Sample` peaks bank_model already carries — the same data the dock panel thumbnails
draw), name, and a **root/key badge** where present; **filterable by bank** (bank_book
named banks). A "giant list of item blocks" is the anti-pattern — the browser is designed
for scanning by eye.
4. **Graphic, descriptive controls with a guided fast path.** Once a capture is picked, a
prominent, self-explanatory single-capture setup surface (root note, play-mode basics,
level). The keyboard strip serves the **single-capture** case first (shows where the
capture sits / its root); drag matters most when zoning.
5. **Zones demoted to secondary (nice-to-have).** Multi-zone keymap editing becomes an
**opt-in "Zones" panel** (S10-Z), not the default face — "most of the time the zones
won't be used." The keyboard-strip drag machinery is still built, but in service of the
capture-first layout.
**What "better than RS5K" means, specifically (not vibes).** RS5K's genuine strengths —
match or beat each: (1) **drag a file straight onto it** loads the sample (our S13 relay);
(2) **note-start / note-end** range with a visual sense of the keyboard (our S10 keyboard
strip — RS5K's own range UI is two number fields, so a *draggable* strip beats it); (3) a
**waveform** with draggable start/end/loop markers (our S11); (4) **ADSR** sliders (our
S12); (5) velocity layers / round-robin (Tier 2 — held, not in this overhaul). RS5K's real
**weaknesses are our opening:** its **one-sample-per-instance** model forces track sprawl
(one RS5K per drum) and it has **no multi-zone view in a single instance** — ReaSampler
9000 is multi-zone in one instrument by design (S5), so the **opt-in Zones panel** showing
*all* zones at once is a capability RS5K structurally lacks. But per the reframe, the *default*
face is the single-capture fast path (browser + setup), and multi-zone is the demoted
nice-to-have. "Better than RS5K" = a fast single-capture browser where RS5K makes you drag a
file blind, direct-manipulation where RS5K uses number fields, multi-zone-when-you-want-it
where RS5K is one-shot, and bank-integrated ingest where RS5K is file-at-a-time.
**Constraints (unchanged — settled, do not re-open):** LICE/SWELL drawing only (no toolkit
change — D-A settled); **all layout/hit-test math in pure geometry modules** (mirror of
`mode_switch` / `editor_geometry` / `embed_strip`), the draw + drag-state machine in the
shell; RT discipline untouched (every edit commits **off** the audio thread via the
existing `commitMapAndReload` → off-thread `reloadFromBank` → atomic swap); the instrument
stays a **read-only bank consumer** (loop/root/ADSR edits are the instrument's *performance
map*, D-B — never written back to the bank); component-state persistence and
read-only-over-bank stay settled.
- **S10 — capture-first editor: browser + guided single-capture setup (REVISED 2026-07-26).**
The default face is the **capture browser** (scannable cards with **peak thumbnails** from
the `Sample` peaks bank_model carries, name, root/key badge; **bank filter** over bank_book
banks) feeding a **guided single-capture setup** (root note, play-mode basics, level).
Fresh instance is **silent, nothing auto-selected** — the S4 first-sample fallback is
**removed** (empty stored id → silence + a "pick a capture" empty state). New pure modules:
`capture_browser` (card/grid layout + hit-test) and `keyboard_strip` (key-span↔pixel via
the `embed_strip` idiom; a **root marker** for the single loaded capture; `pixel→note`;
drag-delta resolver; per-zone bar rect + edge-grab hit regions for the opt-in Zones panel).
Shell extends the click-only `wndProc` to a `WM_MOUSEMOVE`/`WM_LBUTTONUP` drag-state
machine with live feedback, one coherent edit on release. **Multi-zone keymap editing is an
opt-in "Zones" panel (S10-Z), not the default** — the demoted nice-to-have; it reuses the
same strip geometry + drag machine (edge = resize, body = move, key = root) and retires the
seven ±1 nudge buttons per row. **Built with the current LICE drawing; adopts the Phase L
kit (L1) when it lands** (drawn through the shared component kit rather than flat
`LICE_FillRect`/GDI once available) — **not gated on Phase L**; the drag machine's
`WM_MOUSEMOVE` tracking also lights the kit's hover states at near-zero marginal cost once
the kit is present. **Boundary shifts (from the reframe):** the "sample list" S12 was to
scroll/search **is now this browser** — the card layout, peak thumbnails, and bank filter
are S10's; S12 keeps **scroll** + **type-to-filter search** *layered over* S10's browser
(bank filter picks the bank, search narrows within it). The waveform S11 makes loop-editable
is the same waveform S10 shows read-only for the picked single capture ("see it").
- **S11 — waveform view + draggable loop points.** Selecting a zone shows its sample's
**waveform** (peaks via the existing `peaks` module over the shell's already-decoded PCM
— no new decode/WAV path) with draggable **start/end/loop-start/loop-end** markers that
**snap to zero-crossings** (the S2 zero-crossing-aware requirement). A dragged loop is a
**per-zone loop override** (additive on `PerformanceZone`, same shape as `rootOverride`;
seeded from the S2 bank intrinsic, never written back). Marker/waveform geometry pure
(`frame↔pixel`, marker grab regions, clamp start≤end, zero-crossing snap helper).
- **S12 — scale + ergonomics.** **Scroll** (wheel + scrollbar) over **S10's capture
browser** so a bank longer than the panel is fully reachable, and a **type-to-filter
search** that narrows the cards by name, **composing with S10's bank filter** (bank filter
selects the bank; search narrows within it). *(Boundary shift from the 2026-07-26 reframe:
the browser card layout, peak thumbnails, and bank filter are now **S10's**; S12 = scroll
+ search layered over that browser.)* **Direct numeric entry** for zone low/high/root (a
click-to-type field over the strip, for precision the drag can't hit — Zones-panel-scoped).
An **ADSR editor** — four draggable controls over the S3 `AdsrParams` (the math already
exists and is wired into the voice engine; today the envelope is a fixed default).
Scroll/search/slider/entry layout pure; ADSR + (implicitly) any exposed parameters become
per-instance component state (additive, version-bumped, back-compat).
- **S13 — drop-to-load (the S8 relay, in the editor).** Dropping an OS file / media item
**onto the editor window** ingests into the bank + assigns to this instance — the RS5K
"drop a file straight on it" affordance. **The instrument does not ingest:** the editor's
drop handler **relays a bank-ingest request to the extension** (S8's `option 1`), which
performs the capture/import + assign; refresh is hands-free via S9 (or a direct reload
without it). **Cross-artifact relay is the S8-flagged spike** — proven-and-shipped or
degrade to the docked-`bank_panel` drop path with a clear affordance. Never inserts a
timeline item (capture/placement separation intact).
**Sequencing (recommendation, argued below in this section's tail).** S10 first — under the
reframe it now carries the **whole felt win**: the empty-state / no-auto-select fix, the
capture browser (peak thumbnails, bank filter) that replaces the useless item list, and the
guided single-capture setup that retires the nudge buttons. This is the entire "the UX is
awful" wound, and time-to-first-note is the metric it moves. S11 (waveform + loop) and S12
(scroll/search over the browser, numeric entry, ADSR) follow — both lean on S10's browser +
drag machine, and S11's waveform is the same surface S10 shows for the picked capture. S13
depends on S8's ingest seam, so it sequences after S8. Against the queued engine work: **S10
should land before or interleaved with S7 (stereo).** S7 is a real engine capability (stereo capture in true
stereo) and touches the DSP Daniel smoke-tests — but the *reason* he'll keep smoke-testing
is the editor, and today every test pass is taxed by the nudge-button UX. Fixing what he
feels first (S10) makes every subsequent S7 test less painful; there is no hard dependency
either way (S7 is engine/bus, S10 is editor/geometry — orthogonal). Honest counter: if the
stereo *sound* is the thing blocking real use, S7 first is defensible — but "it works, the
UX is awful" points at the editor as the live wound, so **S10 leads.**
---
## REAPER / Steinberg API surface (verify all signatures)
- **VST3 SDK (a new vendored dependency — vendor it at the spike).** `FUnknown` and the
`IComponent` / `IAudioProcessor` / `IEditController` interface family; the
`SingleComponentEffect` / `EditControllerEx1` / `AudioEffect` base classes; the class
factory (`GetPluginFactory` + factory macros); `IPlugView` for the editor;
`ProcessData` / `ProcessSetup` for the hot path. **Verify** interface members, the
base-class overrides, factory-macro spellings, and the Windows module-export symbol
names (`InitDll`/`ExitDll`/`GetPluginFactory`) against the vendored SDK at the spike —
the framing doc flags several of these as experienced estimates.
- **REAPER VST-host bridge.** `hostcb` opcode `0xdeadf00d` (resolve API function by
name) and `0xdeadf00e` (host context); the by-name resolution of
`GetProjExtState`/`SetProjExtState`/`EnumProjExtState`. **Verify** against
`vendor/reaper-sdk/sdk/reaper_plugin.h` + `video_processor.h` +
`reaper_plugin_functions.h`.
- **Embedded UI (D-D, later point).** `IReaperUIEmbedInterface` and the embed
message/lifecycle contract — verify against
`vendor/reaper-sdk/sdk/reaper_plugin_fx_embed.h` before use.
- **VST3 bus arrangement (S7 channel mode).** `setBusArrangements` /
`getBusArrangement` and REAPER's mono/stereo instrument-bus expectations — verify against
the vendored Steinberg SDK + `reaper_vst3_interfaces.h`.
- **Ingest surfaces (S8).** `InsertMedia` is the placement path (untouched by ingest);
`CountSelectedMediaItems` / `GetSelectedMediaItem` + `GetSet_LoopTimeRange` are the
arrange-capture inputs (already the capture path's); `OpenMediaExplorer` +
`MediaExplorerGetLastPlayedFileInfo` are the *whole* Media-Explorer contract (thin — no
enumerate-selected, no ME-drop-handler). Drop handling is SWELL/Win32 on ReaSampler's own
panel HWNDs — REAPER exposes **no** drag-drop registration API. All verified against
`reaper_plugin_functions.h`.
- **Bank-generation seam (S9).** New forever-stable `ext_keys.h` key for the generation
counter; read over the same bridge `GetProjExtState` path S4 already uses. No new API —
confirm no torn-read hazard on the integer key.
- **WDL pitch/resample (S15/S16).** **Verified this pass:** `vendor/WDL/WDL/resample.h`
(`WDL_Resampler` — sinc/linear resampler, couples duration → **Varispeed** path) and
`vendor/WDL/WDL/simple_pitchshift.h` (`WDL_SimplePitchShifter` — time-domain OLA,
**duration-preserving** → the S16 **Preserve**-engine candidate, fork S16-F2 route a) are
the whole pitch/resample surface; **no** elastique / formant-preserving in the tree. **S16
Preserve-engine (route a) must-verify at build:** (i) **pre-warm** `WDL_SimplePitchShifter`
at voice-allocation (run silence so `m_psbuf` sizes and `m_queue` reaches steady state) →
**no `process`-thread `WDL_Queue::Add` growth**; (ii) measure **per-voice CPU + onset
latency** (window·srate) against the polyphony cap; (iii) set a **Preserve-mode-specific
voice cap** if the per-voice cost demands one. The pitch-envelope modulation is hand-rolled
over whichever engine. If the held sinc **Varispeed**-quality upgrade is taken, verify
`WDL_Resampler` streaming/prealloc against the per-voice RT budget before use.
- **LICE/SWELL editor.** Reuses the `bank_panel` LICE/SWELL drawing surface; verify the
`IPlugView`↔LICE window/bitmap bridge at the spike (window creation, sizing, event
routing) — the least-trodden edge of the phase.
- **LICE design-kit surfaces — moved to Phase L.** The shared LICE drawing-kit surface
verification (`LICE_GradRect`/`LICE_RoundRect`/AA lines/circles/beziers/polygons + the
`LICE_CachedFont`/`LICE_IFont` font engine, and the vwnd drawing-craft references) now
lives with **Phase L point L1** on `dev` — see CONTEXT.md §Phase L "LICE / WDL API
surface". Phase S surfaces (S10S13) adopt that kit when it lands; they are not gated on it.
---
## Drop-and-load — drag a capture onto a track's FX button (S17 spec)
**The gesture.** While a capture is dragged out of the `bank_panel`, a track's TCP **FX
button** becomes a drop zone. Dropping the capture there **instantiates a ReaSampler 9000
on that track with the dragged capture already loaded and selected for playback** — one
gesture from bank to playable instrument. This is the *third* integration gesture: capture
(extension), placement-into-arrange (extension), and now **placement-of-the-player**
(this wave). It is drop-and-load, not drop-to-arrange — no media item touches the timeline.
**Why it needs a new drag mode (the CF_HDROP path can't carry it).** Today's drag-out
(M11) becomes an **OS file drag** (`CF_HDROP` via `drag_out` + `drag_out_win`) the moment
the pointer leaves the panel client rect. REAPER's TCP FX button is **not** a native drop
target that instantiates a plugin-with-a-file, so this feature cannot ride the OS-drag
path: an OS drop of a WAV onto the FX area does not create "an instrument preloaded with
that WAV." It requires an **internal drag** where the extension itself tracks the pointer
over REAPER's own UI, detects the FX-button hover, and on release **drives the insert
itself**. The extension is the actor for the whole gesture.
**The two-part mechanism.**
1. **Internal-drag hover detection (extension-side, pure + shell).** The `drag_out` pure
module gains a **third `DragGesture`** beyond `Internal` (bank-to-bank) and `OsDrag`
(M11) — `InstrumentDrop`. The gesture decision is refined: leaving the panel client
rect no longer *immediately* means OS-bound. Instead:
- Pointer **inside** the panel client rect → `Internal` (unchanged bank-to-bank drag).
- Pointer **outside the panel but still over REAPER's own window/UI** →
`InstrumentDrop` (new — the shell hover-tracks the TCP FX button and highlights it).
- Pointer **left REAPER entirely** (Explorer / another app) → `OsDrag` (unchanged M11).
The pure module stays REAPER-free: it decides `InstrumentDrop` vs. `OsDrag` from
position **plus an "over-REAPER's-own-UI" predicate the shell supplies** (the shell owns
the REAPER window/hit query; the pure layer owns the set/boundary algebra). Mirror of how
M11 kept `decideGesture` pure over a rect the shell supplied. The shell then resolves the
pointer to a track + FX-button hotspot, highlights it, and on release drives the drop.
2. **FX-button drop → add-VST + load-capture (extension-side shell, then instrument
seam).** On release over an FX button the shell:
- Adds a fresh instance: `TrackFX_AddByName(track, "VST3:ReaSampler 9000", /*recFX*/
false, /*instantiate*/ <negative>)`. **Verified present** in
`reaper_plugin_functions.h`:
`int TrackFX_AddByName(MediaTrack* track, const char* fxname, bool recFX, int
instantiate)` — a **negative** `instantiate` always creates a new effect (per the
header comment); the `"VST3:"` prefix selects the format. Captures the returned FX
index (or `-1` on failure).
- **Loads the dragged capture into that instance via the load-capture seam** (below).
- Wraps the whole thing in one REAPER undo point (`Undo_BeginBlock2`/`EndBlock2`) so the
gesture is one Ctrl-Z — the same discipline the bank verbs use.
**The ReaSampler 9000 load-capture seam (the hard coupling — MUST be added; does not yet
exist).** The Phase S spec today gives the instrument a **live-state *read* seam** (it
reads bank index + mapping from `"reasampler"` ext-state via the bridge — §The two seams)
but **no entry point for an external actor to say "this fresh instance should play *this
specific* capture."** Reading the bank is not the same as being *pointed at one sample*.
This wave is the reason to add that seam, and the seam lands **inside the instrument**
(the `phase-s` artifact), not the extension.
**Mechanism (SETTLED — (B) VST3 component-state injection).** Right after
`TrackFX_AddByName` returns the new FX index, the extension writes the instance's component
state directly — the same blob the instrument's `getChunk`/`setChunk` round-trips — with
the target capture pre-selected. Deterministic, no shared-state race, no cross-process
handshake; it uses the instrument's own persistence format. The state-set path is
`TrackFX_SetNamedConfigParm` — **verified present** in `reaper_plugin_functions.h`: `bool
TrackFX_SetNamedConfigParm(MediaTrack* track, int fx, const char* parmname, const char*
value)`, and the header documents the write-parms `vst_chunk` / `vst_chunk_program` as the
base64-encoded VST-specific chunk. So the injection call is
`TrackFX_SetNamedConfigParm(track, fx, "vst_chunk", <base64 blob>)`.
**Load-bearing caveat — `vst_chunk` is the plugin's own serialized chunk.** `vst_chunk` is
ReaSampler 9000's **own** base64-encoded serialized state (its `getChunk`/`setChunk`
FXP/FXB-style blob), **not** a raw VST3 `IComponent::setState` stream that REAPER
re-marshals into the plugin. The extension therefore has to construct **exactly the
instrument's own state-blob bytes** with the capture pre-selected — REAPER does not
translate a neutral state representation on its behalf. This makes the **component-state
blob format a shared cross-artifact contract** — one that is **still being defined in Phase
S** — and a **coordination dependency between the extension and the instrument:** both must
agree on the exact byte layout that ReaSampler 9000's `setChunk` accepts before either half
is final. The load-capture seam and the component-state persistence work (§Where the toggle
lives / component-state version bumps) share this one blob format.
**Rejected alternative — (A) fresh-instance ext-state handshake.** The extension writes a
small "pending load" hint into `"reasampler"` ext-state keyed to the target track/FX (a
capture id + a target GUID); a freshly-instantiated ReaSampler 9000 reads it on init via
the bridge it already uses, claims + clears the hint, and self-selects that capture. It
would keep the artifacts loosely coupled through the one ext-state seam they already share
and avoid the extension hard-coding the instrument's state format — but it **loses on the
claim/clear race:** "which instance claims which hint" needs a stable key and a
cross-process handshake to get right, and (B) sidesteps that entirely by writing the state
directly and deterministically.
**Coexistence with the OS drag-out (disambiguation contract).** The two OS-vs-internal
modes are disambiguated **by pointer location, not a mode toggle** — the user never picks
"OS drag" vs. "instrument drop"; the extension infers it from where the pointer is when
released. The M11 boundary (left the client rect) is *refined*, not replaced: leaving the
rect now asks "over REAPER's UI → InstrumentDrop, else → OsDrag." Both M11 OS drag-out and
the internal bank-to-bank drag must remain **byte-for-byte unchanged** in their own
regions — this wave only inserts a new middle case. Multi-capture payloads are a
disambiguation input too (see open question — instrument drop is naturally single-capture;
a multi-capture drag over the FX button is either rejected or loads the first).
**Precision / invariant implications (drop-and-load).**
- **Explicit user-driven placement — consistent with capture↔placement separation.** This
is a *deliberate placement gesture*: the user chooses to put a playing instrument on a
track, exactly as inserting an item into the arrange is a deliberate act. It does **not**
auto-capture (the file already exists in the bank) and does **not** insert a media item
into the timeline. It instantiates a *reader* of the bank on a track and points it at one
already-captured sample. Capture, placement, and playback stay three distinct acts; this
is placement-of-the-player, not a capture and not a timeline insert.
- **No private sample copy.** The instantiated instrument consumes the one authoritative
bank (it resolves the WAV via the shared M4 project-relative machinery like any
ReaSampler 9000 instance); the seam hands it a *reference* (a capture identity), never a
copied file. Any path that copies bytes into the instance is a bug.
- **The internal drag stays pure-decidable and testable.** The new `InstrumentDrop`
gesture is decided in the `drag_out` pure module (REAPER-free) over a shell-supplied
predicate; the M11 `drag_out` unit tests must not regress.
**Open questions (Daniel / Phase S team to decide).**
- **Multi-capture drag over an FX button** — reject (only single-capture drags arm
`InstrumentDrop`), or load the first / a keymap of all? Tier-0 leans reject-or-first;
a multi-capture keymap load is a Tier-1 stretch.
- **FX-button hotspot vs. whole TCP.** Does the drop zone have to be the FX button
specifically, or is dropping anywhere on the target track's TCP enough (simpler hit
resolution, arguably clearer target)? Depends on what the SDK exposes (see must-verify).
**Must-verify before build (drop-and-load).**
- `TrackFX_AddByName` — **verified present** (`reaper_plugin_functions.h`): signature and
the `"VST3:"`-prefix + negative-`instantiate` semantics confirmed from the header.
- **Pointer→track / FX-button hit resolution during a drag** — **not yet confirmed.**
Candidates: `GetTrackFromPoint` / `GetThingFromPoint` (verify names + signatures against
`reaper_plugin_functions.h`); whether the FX button specifically is addressable vs. the
TCP as a whole is an open verification that also decides the "hotspot vs. whole TCP"
question.
- **Instance state injection (seam mechanism (B) — SETTLED, load-bearing prerequisite)** —
**verified present** in `reaper_plugin_functions.h`: `bool
TrackFX_SetNamedConfigParm(MediaTrack* track, int fx, const char* parmname, const char*
value)`, with the header documenting `vst_chunk` / `vst_chunk_program` as the
base64-encoded VST-specific chunk write-parms. The injection call is
`TrackFX_SetNamedConfigParm(track, fx, "vst_chunk", <base64 blob>)`. The remaining
prerequisite is **not** the API but the **shared component-state blob format**: `vst_chunk`
carries the instrument's *own* serialized chunk (its `setChunk` input), so the extension
must construct exactly ReaSampler 9000's state bytes — the cross-artifact contract still
being defined in Phase S. Blocks the drop half until the blob format is agreed.
---
### Module architecture (preserve the pure/shell split)
- **Pure (new/extended):** `envelope_overlay` (AHDSR/Trigger params + frame-length → polyline in a
rect; unit-tested); `envelope_edit` (NEW — node hit-test + pixel-delta→clamped-param inverse map
for the draggable envelope handles; mirror of `card_drag`; unit-tested at the monotonic/clamp
boundaries); `velocity_curve` (NEW, r10 — bezier `eval(velocity 0127)→amp 01` + control-point
add/move/delete clamped to the 0127×01 box, x-ordered, hit-test + pixel-delta inverse map;
mirror of `envelope_edit`; unit-tested at eval + clamp/order boundaries); `keyboard_strip`
extended with the natural/accidental predicate; the sampler core extended with the **key-track
scalar** in the repitch math (unit-tested against known note/root/keyTrack → ratio) **and the
velocity-curve eval at `Voice::start()`** replacing the linear `velocity/127` (r10; off the
per-frame path); `sample_map` (`PerformanceZone`) extended with the additive `keyTrack` field
**and the additive `velocityCurve` field** (r10 — both on the zones-payload version axis; the
velocity-curve field sequences AFTER T-KEYTRK's `keyTrack` bump), plus the **envelope-v6
`previewVelocity` field on `ComponentState`** (S-VIEW-F1) — all additive, version bumped,
back-compat defaults on read.
- **Shell (`reasampler_editor.cpp`):** re-partition the paint/hit-test into the three views
(Sample face, Browse modal overlay, Zone surface) replacing the two-view toggle; add the
preview-trigger button + velocity knob wired to an off-audio-thread preview note through the
voice engine (the velocity **persists** via the envelope-v6 `ComponentState` field, S-VIEW-F1);
draw the envelope overlay + **its draggable node handles** (routing mouse events through the pure
`envelope_edit` module and committing params via the same off-thread path the sliders use,
S-VIEW-F2) + piano-key overlay via the kit; set the larger default `ViewRect` +
`checkSizeConstraint`. All layout/hit-test math stays in the pure geometry modules.
---
## Kit architecture (the pure/shell split)
Pure (no LICE, no REAPER types, unit-tested — the mirror of `mode_switch`/`bank_grid`):
- **`theme`/palette module** — role→color mapping, direction-selectable via one constants
block (the B + REAPER-grey-neutral + three-accent-pastel + pastel-spectral values). Pure;
unit-tested that each text-on-surface pair clears its WCAG floor (the "punch" rule made
testable). *DS-2 revision (2026-07-26):* (a) the neutral ladder moves from near-black up
into REAPER's mid-grey family (`bg/base` `#2b2b2b` / `bg/panel` `#333333` / `bg/cell`
`#3a3a3a` / `line/hairline` `#4a4a4a` / `text/primary` `#dcdcdc` / `text/dim` `~#a0a0a0`+),
and (b) the accent role expands from one to three (`accent/primary` lime, `accent/secondary`
teal, `accent/tertiary` purple) with the spectral ramp a pastel sweep anchored on those
three — all still confined to the one constants block + the shell's font lifecycle. **The
WCAG test must be re-run against the GREY ladder** — it gains the newly-tight grey pairs:
`text/dim`-on-grey (AA 4.5:1) and each pastel-as-state-indicator on `bg/cell` (3:1), plus
the existing text-on-pastel-fill pairs. (No font change: a bundled-font upgrade was
declined — the cached-font set keeps the kit's current face.)
- **Component geometry/hit-test helpers** — button rect, slider track/handle geometry,
list-row rect + hover hit-test, and any new layout module L2 needs (an action-bar layout
module). No LICE, no host types; CTest-covered. Existing pure modules
(`bank_grid`/`tab_strip`/`mode_switch`) stay the source of truth for what they own.
Shell (LICE-facing, DAW-verified — thin draw layer):
- **Draw kit** — `fillSurface` (micro-gradient + inner highlight/shadow),
`drawButton`/`drawSlider`/`drawListRow`/`drawWaveform`/segmented-switch/tab draw, and a
shared `text()` over a cached `LICE_CachedFont` set (title/label/value-mono/micro). Owns
the cached-font lifecycle. Honors the interaction state model (rest/hover/active/pressed/
dragging/focus/disabled). **DS-1: WDL/vwnd reuse is assessed here at build time** — reuse
a vwnd piece where genuinely cheaper, else draw on LICE; hit-test geometry stays pure
regardless.
---
## L2 dock-panel layout contract (the M11-aware inventory)
DS-3 makes L2 a **layout design**, not a skin pass, because M11 adds a real button
inventory. L2 must place **every** affordance below without crowding the grid (the
centerpiece), grouping by *task*:
**Existing (landed / specced):**
- Bank **grid** — thumbnails, multi-select, keyboard nav, audition, focus ring.
- Design View **segmented mode switch** (`[ Arrange | Design ]`) + per-mode membership count.
- Multi-bank: **named-banks tab strip** (LICE-drawn, overflow/scroll), **active-bank
indicator**, **pool/banks full-height toggles**, create/rename/delete/activate-bank
affordances, per-selection **move / copy / remove** sample menu.
- **Prune** button (R-E) — the byte-deleting action; `warn`-colored, set apart.
**M11 adds (dev PLAN.md §M11 — merging to dev now):**
- **Action-trigger buttons** — clickable buttons firing the capture + provenance action
family directly (capture item / capture track scopes, re-capture from source,
resample-and-mute-source, batch capture, conform-on-insert, insert-at-cursor, drag-out,
null-test verify). A cluster.
- **Keybinding-help labels** — each capture/provenance action surfaces its current key
binding (e.g. "Capture Item → F5") or an "unbound"/"—" marker.
**Layout mandate:** group by task (capture / organize / reclaim / view), not by phase; a
compact action bar/toolbar for the frequent capture actions (icon+label, keybinding as a
`micro` sub-label), an overflow/menu for the rare ones, header space for the mode switch +
active-bank indicator, the bank tab strip + move/copy/remove organize cluster together,
prune set apart and `warn`-marked. Density is a design decision — 8px grid, elevation
layers over hairlines, hover on every interactive element. Then apply the L1 kit to draw
it. New layout math goes in a pure geometry module; `bank_grid`/`tab_strip`/`mode_switch`
stay the pure source of truth for their own hit-testing. **L2 sequences after M11 merges**
so it inventories the actual landed buttons.
---
## L4 dock-panel button layout (top / bottom / footer re-home)
L4 is a **second layout pass over the same `bank_panel`** that re-homes the L2 button inventory
around *frequency and intent*. It ships **no new action and changes no capture/placement
behavior** — every button fires an existing registered action; the "capture ≠ placement"
load-bearing principle is untouched (the buttons only *fire* the split acts, they never fuse
them). It is **ungated by Phase S** (the dock panel is on dev) and independent of L3.
**L4 sequences AFTER the in-flight DS-2 palette-revision branch merges to dev** — both rework
`bank_panel` heavily, and landing L4 concurrently would collide the same file. Drawn through the
L1 kit in the DS-2 grey-neutral + three-accent-pastel palette (prune stays `warn`); no palette
or font decisions are re-opened here.
**The new three-zone structure:**
- **Top toolbar = capture + placement + maintenance.** The capture cluster (capture item,
capture track, batch items, batch razor, capture RT), the placement cluster (insert,
insert-conform), and the maintenance cluster (re-capture, cancel-realtime) move from the
bottom L2 action bar to a **top** toolbar — the eye's first landing, matching the acts the
tool exists for. Icon+label buttons with the keybinding as a `micro` sub-label (the M11
keybinding-help convention), drawn through the kit.
- **Bottom toolbar = Design View tagging + switching.** The space the capture/placement buttons
vacate holds the **Design View action family** as buttons: **tag / untag selected tracks for a
mode, activate Arrange, activate Design, toggle active mode, show-both.** These are the
registered Design View actions today; L4 gives them a button home here.
- **Footer = narrow Arrange|Design toggle · Tail button · … · Prune.** The large top
`[ Arrange | Design ]` segmented toggle **shrinks to just-wide-enough-for-its-text** and moves
into the footer at the **left**, carrying its per-mode membership count as a compact adjacent
label. The **Tail** affordance is converted from a **click-zone to a proper kit button**
(rest/hover/pressed states; click still cycles the tail setting). **Prune** stays the
byte-deleting action — **set apart at the far right, `warn`-colored** — the only file-deleting
affordance, kept isolated so no benign toggle sits next to it.
**Footer affordance order (left → right):** `[Arrange|Design]` toggle · Tail button · … ·
**Prune** (rightmost, set apart, `warn`). The order reads benign/frequent at the left
(view-mode toggle, tail-length control — both "how this panel/capture behaves") → destructive/
rare at the right (Prune, isolated), so a mis-click near the left is cheap and the one
destructive control is spatially and chromatically distinct.
**Pure/shell discipline (unchanged).** All new toolbar-row and footer-strip layout math goes in
pure CTest-covered geometry modules — **extend/mirror `action_bar`** (the toolbar row layout,
now instantiated top and bottom) and **`mode_switch`** (the now-narrow, fit-to-text footer
toggle geometry + hit-test). `bank_grid` and `tab_strip` remain the pure owners of their own
surfaces' hit-testing. The L1 kit draws; the geometry stays pure. **L4 resolves its re-home
against the post-palette-revision `bank_panel`** (build-time inventory once that branch is on
dev).
---
## L5 dock-panel button refinements (overflow menu · faces+tooltips · opposite-mode tags · Toggle removal · grouping)
L5 is a **third refinement pass over the same `bank_panel` toolbars L4 built** — it re-homes
and re-labels buttons for legibility; it **ships no new action and changes no capture/placement
behavior** (every button fires an existing registered action; "capture ≠ placement" is
untouched — the buttons only *fire* the split acts). **Ungated by Phase S** (the dock panel is
on dev) and **independent of the L3 gate.** Drawn through the L1 kit in the DS-2 grey-neutral +
three-accent-pastel palette (prune stays `warn`); **no palette or font decision is re-opened.**
L5 sequences AFTER L4 (both rework the same `bank_panel` toolbars).
**1. Top-toolbar overflow menu.** The less-frequent capture variants — **Batch Items, Batch
Razor, Capture RT, Cancel RT** — leave the visible top bar for a **right-anchored "⋯ / More"
menu button** (kit-drawn button; on click a `TrackPopupMenu` popup lists all four, each entry
firing its existing command id via `Main_OnCommand`). The frequent acts stay on the bar in
cluster order Capture → Maintenance → Placement: Capture Item, Capture Track (Capture);
Re-capture (Maintenance); Insert, Insert Conform (Placement). The menu button's rect +
hit-test is **pure** (extend/mirror `action_bar` / `prune_button`); the popup + dispatch is
shell. The menu entries reuse the same `resolveBarCommandId` path the bar buttons use, so a
keybinding and a menu pick fire identically.
**2. Short faces + full-name tooltips (drop the `ReaSampler:` prefix).** Button *faces* carry
the terse `ActionBarRow.shortLabel` (already the case since L4) — L5 formalizes the face set and
adds a **hover tooltip showing the FULL action name with the `ReaSampler:` display prefix
stripped.** Note the prefix (`actionDisplayPrefix()`) is baked into the *registered gaccel
action name*, not the button face — so the tooltip derives the full name and strips the prefix
for display; the button face never carried the prefix. The keybinding is surfaced in the
hover tooltip ("`phrase — binding`" when bound, bare phrase when unbound) via
`kbd_getTextFromCmd`; the `micro` sub-row is removed from `ActionBarSlot`. **Tooltip mechanism: custom LICE-kit hover-delay tooltip** (`tooltip` pure
module) — owns its own hover timer + LICE overlay draw, stays inside the L1 kit (DS-1 "keep
drawing in the kit"). Sourced from the registered action phrase (not `kbd_getTextFromCmd`);
`ReaSampler:` prefix stripped at draw time; tooltip box width clamped to the client so it never
overhangs a narrow dock. The hover-timer threshold + overlay draw are the only DAW-bound pieces;
the "which button, what text" decision stays pure.
**3. Bottom-toolbar Item/Track × Arrange/Design tag buttons, opposite-mode-only.** The current
`Tag Design` / `Untag` pair is replaced by **four buttons — "Item: Arrange", "Item: Design",
"Track: Arrange", "Track: Design"** — in the Tagging cluster. **Both action families already
exist in the model + actions layer** (research-confirmed): the *track* family is
`doTag`/`doUntag` on the track selection (`VIEW_TAG_DESIGN` and `VIEW_TAG_ARRANGE`, where
Tag→Arrange == untag); the *item* family is `doMoveItems` on the item selection
(`VIEW_MOVE_ITEMS_DESIGN` / `VIEW_MOVE_ITEMS_ARRANGE`, driving `planItemRetag` + lane minting).
**So the four buttons are layout + enablement wiring over existing actions — NOT new feature
work; no `view_mode_model` / `view` / `actions` change is required.**
**Enablement rule (precise).** Let `active` = `view().activeModeId()` (the SAME read the footer
`[Arrange|Design]` toggle uses — one source of truth). A tag button's *target mode* is the mode
in its label (Arrange or Design). A button is **live iff target ≠ active**; otherwise it is
drawn `Disabled` (kit `InteractionState::Disabled`, `Role::TextDim`) and its click is a no-op.
Concretely:
- **Design active** → `Item: Arrange` and `Track: Arrange` are live (they send the selection to
Arrange); `Item: Design` and `Track: Design` are disabled (the selection is already there).
- **Arrange active** → the reverse: the `…: Design` buttons are live; the `…: Arrange` buttons
are disabled.
The disabled predicate is **pure** (active mode → per-button live/disabled, unit-tested); the
shell reads the active mode once per draw and applies it. Item buttons act on the current media-
item selection; Track buttons act on the current track selection — matching the existing action
bodies exactly (no selection semantics change).
**4. Toggle button removed.** `VIEW_TOGGLE_MODE` leaves the bottom toolbar — the footer's
`[Arrange|Design]` toggle (L4) already covers mode switching. **The action stays registered**
(keybinding-bound, FOREVER-STABLE id unchanged); only its *button home* is removed.
**Fate of Activate-Arrange / Activate-Design / Show-Both (FORK — RESOLVED, Daniel's call).**
- `VIEW_ACTIVATE_ARRANGE` / `VIEW_ACTIVATE_DESIGN` — **dropped from the bottom toolbar** (actions
stay registered, FOREVER-STABLE ids unchanged). The footer `[Arrange|Design]` toggle is the
single mode-switch affordance; the bottom bar is tagging + Show Both only.
- `VIEW_SHOW_BOTH` — **kept** as a set-apart button on the bottom toolbar. It is the cross-mode
"pin visible in every mode" escape hatch and is not covered by the footer toggle.
- **Landed bottom-toolbar inventory:** `[ Item: Arrange | Item: Design ] · [ Track: Arrange |
Track: Design ] ⟩⟩ [ Show Both ]` — Tagging cluster (four opposite-mode buttons) set apart
from a lone `Show Both`. No Toggle, no Activate-Arrange/Design (footer toggle owns switching).
**5. Semantic-grouping spacing.** L4's `kBarSpec` is `buttonGap=4` / `clusterGap=16` (4:1). With
the bottom bar's cluster boundary now more meaningful (four tag buttons vs. the `Show Both`
remnant) the grouping should read at a glance — **widen the inter-cluster gap: start ≈
`clusterGap=24` / `buttonGap=4` (6:1), tuned in-DAW.** One `kBarSpec` still serves both
toolbars (identical button shape top and bottom); only the gap ratio changes.
**Pure/shell discipline (unchanged).** All new geometry — the top-bar overflow menu-button rect
+ hit-test, the opposite-mode enablement predicate, the wider grouping spacing — goes in pure
CTest-covered modules (extend/mirror `action_bar` / `prune_button`; add a pure enablement
predicate). The `TrackPopupMenu` popup, the `Main_OnCommand` dispatch, and the tooltip hover-
timer + LICE overlay draw are the only DAW-bound pieces; the L1 kit draws. **L5 resolves its
menu/tooltip/action-id specifics against the landed L4 `bank_panel`** (build-time confirmation).
---
### 1. Persisted deterministic order + sparse (gap-preserving) placement
Each bank (and the pool) carries an **explicit, persisted display position per sample** — the
grid no longer derives order from insertion order. Positions are **gap-preserving**: a sample
may occupy a slot that leaves earlier slots empty (an empty first row above an occupied second
row is a valid, persisted state).
**Where the position data lives (the CLAUDE.md constraint).** `bank_model` / `Sample` are stated
**untouched by `bank_book`'s wrapping** (no `bankId` on `Sample`; a bank is a logical grouping
over the shared pool). Display position is a **per-bank display concern**, so it belongs with the
bank's membership, **not on `Sample`** — a copy of a sample into two banks can sit at different
slots. **Recommended carrier: a per-`Bank` ordered position map in `bank_book`** (sample id →
slot), leaving `bank_model` untouched. The exact carrier (ordered id list with gaps vs. explicit
id→slot map) is settled at build; the *contract* below holds either way.
**Ordering contract (deterministic, gap-preserving):**
- **Deterministic:** the grid iterates positions in ascending slot order; ties are impossible
(one sample per slot). The order is fully determined by the persisted position data, not by
insertion order or hash.
- **Insert (new capture):** a new capture takes the **next free slot after the last occupied
slot** (append). It never fills an earlier gap automatically — a gap is a user's deliberate
layout, not a hole to be plugged.
- **Delete / remove / prune:** removing a sample **leaves its slot empty** (does not re-pack) so
every other sample keeps its position. Trailing empty tail is trimmed for scroll-extent
purposes; interior gaps are preserved (confirmed at build).
- **Reorder:** the user drags a card to a target slot within its bank; the pure reorder mutator
moves that sample's position to the target slot, gap-preserving. **Drop-into-empty-slot places
there; drop-onto-occupied inserts-before and shifts the tail** (matching common file-manager
reorder) — SETTLED as the *default* drop. (The **Alt+drop-onto-occupied = REPLACE** override is
specified below under Drag disambiguation, F3.)
**JSON round-trip + migration (load-bearing).** `serialize`/`deserialize` stay lossless
including positions (`deserialize(serialize(x)) == x`). **A pre-L7 project blob has no position
data** → on load it defaults to **current insertion order, densely packed (no gaps)**, so a
project saved before L7 is visually identical on first post-L7 load. This default is the
migration; it is one-way (once re-saved, the position data is authoritative). Pure and
hard-tested — this is a persisted-model change, tested to the same bar as `bank_book`'s existing
round-trip + legacy migration.
**Undo.** A reorder is **one Ctrl-Z** — the actions/shell layer wraps the mutation in a batched
undo point (`Undo_BeginBlock2`/`EndBlock2`), matching every existing bank-verb's undo discipline.
**Position-model shape (F2 — SETTLED 2026-07-27: interchangeable substrate, NOT fixed slots).**
The carrier is the **plain gap-preserving interchangeable-slot substrate** — a per-bank id→slot
map (Daniel: *"I don't think I want fixed slots MPC style, but the substrate of interchangeable
slots is valuable"*). It is **not** M9-shaped: no slot *identities*, no numbered/addressable
slots that persist independent of their occupant, no slot actions, no MIDI-bindable slot numbers,
no capture-to-slot-N. A slot is just a display position a sample occupies; dragging cards
rearranges which sample sits where.
**M9 note.** M9 "slots" (capture-to-slot-N / insert-slot-N, MIDI-bindable, MPC-style) is
**abandoned (Daniel, 2026-07-27)** — will not be built. L7 adds **no** slot-numbered
capture/insert actions and **no** MIDI bindings. The "plain vs. M9-shaped" sub-fork is closed:
plain gap-preserving substrate, per F2 above.
---
### 2. Decorative metadata overlay (bars.beats · s.ms)
Each card overlays, on the waveform, two decorative read-outs of **capture length**:
- **bottom-LEFT:** length in **bars.beats.subdivisions** (musical).
- **bottom-RIGHT:** length in **seconds.milliseconds** (wall-clock).
**Contract:** decorative and **non-interactive** — no hit-test, no hover, no selection role.
Drawn via the L1 kit `text()` in the **micro / value-mono** type class, in `text/dim` (or a
subtle shadowed variant for legibility over the peaks), subordinate to the waveform. **Respects
the speed constraint (no animation).** Pure formatting helpers (below) are unit-tested; only the
kit draw is shell.
**Bars.beats source (F1 — SETTLED 2026-07-27: capture-time stamp).** bars.beats.subdivisions
requires a **tempo + time-signature** reference. `Sample` already carries `captureTempo` (BPM at
capture) and `lengthBeats`, but **no time-signature.** **Decision: capture-time stamp** — add
`captureTimeSigNum` / `captureTimeSigDenom` to `Sample` + its JSON round-trip, stamped on the
capture path (read the project meter at capture via `TimeMap_GetTimeSigAtTime` — confirmed
at build against the SDK). bars.beats.subdivisions renders
from the stamped tempo + meter, **stable under later project tempo/meter changes** — a bank sample
outlives the project state it was captured under, matching the existing `captureTempo` stamp
philosophy. This is a **capture-path write beyond draw work** (its own checkbox in the PLAN, now
settled). Old samples with no stamp fall back gracefully (blank musical read-out, or a documented
assumed 4/4).
- **(rejected) Live project meter at draw time:** the label would drift under the card as the
project tempo/meter changes, and would be wrong for any sample captured under a different
meter than the project's current one.
**Formatting helpers (pure, tested).** `bars.beats.subdivisions` from `lengthSeconds` +
`captureTempo` + capture-time signature; `seconds.milliseconds` from `lengthSeconds`.
Deterministic; graceful on edge cases (zero length → both read empty/`0`; missing tempo → blank
musical read-out, keep the s.ms read-out).
---
### 3. Selection styling — tertiary border replaces inversion
A selected card **drops the inverted accent-fill treatment** and instead draws the **normal
cell** (Rest or Hover surface) + an **`accent/tertiary` border** (pastel purple `#C2AAE8`); the
**waveform draws in its normal accent color** (the inverted `bg/base` wave is removed). The four
grid-card interaction states stay **visually distinct and coherent:**
- **Selected:** normal cell surface + **`accent/tertiary` (purple) 1px border**. No fill change,
no wave inversion.
- **Focus (caret):** the existing distinct inner ring — keep it separate from the selection
border so a focused *and* selected card reads both (e.g. purple outer border + a
`text/primary` inner focus ring). Settle the exact inner treatment at build so focus is
legible on top of the selection border.
- **Hover:** the kit Hover surface (unchanged) — a fill-state change, orthogonal to the purple
border, so a hovered selected card still reads as selected.
- **Drag-target slot:** the reorder drop-target highlight (a distinct accent — recommend
`accent/hot` outline on the target slot) must not be confusable with the purple selection
border; spec the exact treatment at build.
---
### Pure/shell discipline (L7)
Model: the position carrier + gap semantics + **reorder mutator + Alt-replace mutator** (the
latter reusing the existing index-only remove-from-bank semantics + pool-privilege guard) + JSON
round-trip/migration are **pure** (in `bank_book`, CTest-covered to the bar of its existing
round-trip). Layout: the sparse-aware slot↔rect math + point→slot hit-test + the
drag-disambiguation decision (**including the resolved-gesture result that drives the cursor cue,
and the Alt-over-occupied → replace resolution**) are **pure** (extend `bank_grid`; mirror
`mode_switch` / `drag_out::decideGesture`). Formatting: the bars.beats and s.ms formatters are
**pure**. Shell (DAW-bound): the reorder/replace drag wiring + drop-target highlight, the
**cursor `SetCursor` call mapping the pure resolved-gesture to a cursor resource**, the
capture-time-signature stamp (F1, settled) read on the capture path, and the kit
overlay/selection-border draw. The L1 kit draws; no palette/font decision re-opened.
---
## The L3 gate + Phase S coordination contract
**L3 (VST editor + embed-strip restyle) has landed (merged `c53683e`, 2026-07-27).** The
Phase S surfaces (`reasampler_editor.cpp` + `reasampler_embed.cpp`) arrived on dev drawing
flat `LICE_FillRect` blocks + raw GDI `DrawTextA`, off a local pre-L1 forest-green palette
— the coordination contract's "born in the kit" branch did not occur — so L3 performed a
full restyle of both draw shells through the L1 kit. **L1, L2, L3, L4, L5, L6, and L7
have all landed — see `COMPLETED.md`. Phase L is complete.** The Phase Q gate condition
"Phase S + L3 merged to dev" is now satisfied.
**Coordination contract (load-bearing — resolved):** Phase S's S10S13 built their
interaction UX with the current drawing and adopted the L1 kit via L3 (the coordination
contract's "L3 restyles them" branch). There is now one kit and one look across both
artifacts; L3 completed the VST/embed adoption and applied the settled-and-revised **B +
three-accent pastel** treatment (with C's **pastel** spectral keyboard strip as the
signature surface), routing text through the kit's cached-font `text()` (§3.1 — the kit's
current face; no font change). The VST3 class UID is unchanged — a visual refresh is not a
compat event.
---
## LICE / WDL API surface (verify all signatures)
- **LICE design-kit surfaces (L1).** `LICE_GradRect`, `LICE_RoundRect`, AA
`LICE_Line`/`LICE_FLine`/`LICE_ThickFLine`/`LICE_Circle`/`LICE_FillCircle`/
`LICE_DrawCBezier`, `LICE_FillTriangle`/`FillTrapezoid`/`FillConvexPolygon`, and the
`LICE_CachedFont`/`LICE_IFont` font engine (`SetFromHFont`, AA `DrawText`, shadow/outline/
glow FX flags). Verified *present* in `vendor/WDL/WDL/lice/lice.h` + `lice_text.h`;
**confirm exact signatures + the `LICE_CachedFont`↔`HFONT` lifecycle at build.**
- **WDL/vwnd reuse (DS-1, build-time assessment).** `virtwnd-slider.cpp` /
`vwnd_slider_drawknobstack` (slider/knob drawing reference), `virtwnd-listbox.cpp`
(candidate scroll listbox), `virtwnd-controls.h` (`WDL_STYLE_*` gradient hooks),
`virtwnd-skin.h` (image-skin helpers) — all in `vendor/WDL/WDL/wingui/`. Reuse where a
piece beats re-deriving; keep hit-test geometry pure regardless.
- **Panel drawing/hit-test (L2).** Reuses the `bank_panel` LICE surface + the existing pure
`bank_grid`/`tab_strip`/`mode_switch` hit-test modules; the new action-bar layout is a new
pure module. `WM_MOUSEMOVE`/`TrackMouseEvent` (`WM_MOUSELEAVE`) for hover on the panel's
existing timer-driven `wndProc`. Verify against the SWELL headers as `bank_panel` already
does.