diff --git a/CONTEXT-ARCHIVE.md b/CONTEXT-ARCHIVE.md new file mode 100644 index 0000000..fa5ed9c --- /dev/null +++ b/CONTEXT-ARCHIVE.md @@ -0,0 +1,1231 @@ +# 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 ~8–12-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 M0–M6 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 1–5 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 ~8–12-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 3–10 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 (S10–S13; DAW-tested S1–S6, "the UX is awful") + +**The bar is set: better than ReaSamplOMatic5000.** Daniel DAW-tested the S1–S6 +instrument and the verdict was that it *works* but the UX is unacceptable — "this is +supposed to be better than ReaSamplOMatic5000." The S1–S6 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 **S10–S13**, 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 (S10–S13) 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*/ )`. **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", )`. + +**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", )`. 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 0–127)→amp 0–1` + control-point + add/move/delete clamped to the 0–127×0–1 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 S10–S13 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. diff --git a/CONTEXT.md b/CONTEXT.md index c8ecde4..e1fda7d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -34,121 +34,7 @@ rejected in review. This single rule is why the tool exists. explicit tail control, channel-count preservation, and loop/zero-crossing handling all matter from day one. -## 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. +> Build detail for this phase (module architecture, data model sketch, API surface, build order) moved to CONTEXT-ARCHIVE.md. ## Precision invariants (enforce, test) @@ -282,38 +168,6 @@ 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). -## 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. - ## Show-both semantics A **per-track "pin visible across modes"** flag that **re-enables processing** @@ -323,17 +177,7 @@ 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. -## 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. +> Build detail for this phase (module architecture, API surface) moved to CONTEXT-ARCHIVE.md. ## Non-goals / guardrails @@ -454,71 +298,7 @@ and *never touch master*: **the tool drives only what it created.** Enforceable 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. -### 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. +> Build detail for this sub-phase (module architecture, new-content detection, API surface) moved to CONTEXT-ARCHIVE.md. --- @@ -652,66 +432,7 @@ arrange; the only change is *which* index the entry lands in. of the capture path and the file, and the multi-bank layer sits above the file entirely. -## 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 ~8–12-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 M0–M6 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. +> Build detail for this phase (module architecture, API surface) moved to CONTEXT-ARCHIVE.md. ## Non-goals / guardrails @@ -734,27 +455,6 @@ verify against the SDK header where they extend existing surfaces: D semantics. `bank_book` wraps; it does not modify. - **Verify API names** against the SDK header before use. -## Open questions to resolve during build - -Forks 1–5 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 ~8–12-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. - --- # Sample removal — additive spec (Phase B, point B5) @@ -830,23 +530,7 @@ model capability. index-entry destruction with no relocation, so it is where the gap first bit; the fix is shared. See product notes §Fork R-B. -## 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). +> Build detail for this phase (module architecture, API surface) moved to CONTEXT-ARCHIVE.md. ## Settled forks (Daniel, 2026-07-24) @@ -983,48 +667,7 @@ pure/shell split as `bank_model` / `view_mode_model` / `bank_book`. background sweep remains rejected (silent irreversible file-deletion violates the guardrails). -## 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. +> Build detail for this phase (module architecture, API surface) moved to CONTEXT-ARCHIVE.md. ## Non-goals / guardrails @@ -1246,29 +889,7 @@ choice**: whether the shell is bare-SDK or (hypothetically) JUCE, the pure core identical, REAPER-free, and tested the same way. The format choice is a shell choice; the core is invariant. -## 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. +> 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 @@ -1483,90 +1104,6 @@ is bit-identical under the same engine). The classic use is a percussive **pitch 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). -### 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 3–10 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. - ## 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 @@ -1667,137 +1204,6 @@ independent of S8, so it can land alongside either. > 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. -## ReaSampler 9000 — the UX overhaul (S10–S13; DAW-tested S1–S6, "the UX is awful") - -**The bar is set: better than ReaSamplOMatic5000.** Daniel DAW-tested the S1–S6 -instrument and the verdict was that it *works* but the UX is unacceptable — "this is -supposed to be better than ReaSamplOMatic5000." The S1–S6 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 **S10–S13**, 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.** - ## Product name — ReaSampler 9000 (Daniel, 2026-07-26) The MIDI-playback instrument's product name is **ReaSampler 9000**. The extension stays @@ -1828,200 +1234,6 @@ Set by Daniel on DAW-testing the S1–S6 instrument, alongside the UX-overhaul d filename, fall back to keeping the current filename (display-strings-only) and record that as the shipped choice. -## 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 (S10–S13) 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*/ )`. **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", )`. - -**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", )`. 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. - ## 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 @@ -2644,30 +1856,7 @@ starting point, tuned at build). **Routing:** flagged for **staff-engineer**; no **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. -### 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 0–127)→amp 0–1` + control-point - add/move/delete clamped to the 0–127×0–1 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. +> Build detail for this sub-phase (module architecture) moved to CONTEXT-ARCHIVE.md. ### Precision / invariant implications @@ -2880,198 +2069,7 @@ permitted motion is a level/meter readout following the audio directly (as the e 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. -## 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). +> 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) @@ -3095,111 +2093,6 @@ selected cell arms a drag; crossing a threshold begins it; leaving the client re 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.** -### 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. - ### Drag disambiguation (F3 — SETTLED 2026-07-27) In-grid reorder must coexist with the existing internal bank-move/copy drag and OS drag-out. @@ -3263,39 +2156,6 @@ precedence returns the resolved gesture (reorder / move / copy / os-drag-out / r 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. -### 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 S10–S13 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. - ## Precision / invariant implications (what Phase L does NOT change) - **The pure/shell split holds.** All layout/hit-test stays in pure CTest-covered geometry @@ -3310,25 +2170,6 @@ compat event. - **Windows-only (D5).** Font/GDI/HFONT choices assume Windows; no cross-platform font fallback concern. -## 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. - ## Non-goals / guardrails - **No external UI framework.** iPlug2 / JUCE / VSTGUI are rejected (DS-1). LICE + reused