Files
reasampler/CONTEXT-ARCHIVE.md
T

83 KiB
Raw Blame History

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 — two CONCRETE backends, OfflineRenderBackend (synchronous) and RealtimeRecordBackend (async begin/tick/abort); no shared interface (the former ICaptureBackend was deleted in Q-W3 — T4-26: one deriver, zero polymorphic call sites). 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 CLAUDE.md §Precision invariants).
  • 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 CLAUDE.md §Precision invariants).
  • Trigger a no-dialog render via the appropriate render action / RENDER_SETTINGS bit. Confirm the exact command id and the "render without opening dialog" flag against current REAPER — do not assume; test that it runs headless.
  • Determinism is a hard requirement: two identical requests must produce bit-identical files (enables the null test below).

Realtime record (track scope — item realtime is deferred):

  • Taps the selected track, not the master. Recipe: create a hidden temp track, route a post-fader send from each selected source track into it (CreateTrackSend, default post-fader/full-stereo — captures each track's own output before the parent/master sums it, so the tap is chain-independent by construction and needs no FX bypass), set the temp's record mode to record-output (latency-compensated, B_MAINSEND=0 so it does not sum back), arm (I_RECARM), CSurf_OnRecord, run for the range, OnStopButtonEx, then move the recorded file into the bank and delete the temp track (which drops the sends — no source track is left mutated). Verify I_RECMODE values for output-recording.
  • Master→track sends are refused by REAPER as feedback loops — this is why the tap is the selected track's post-fader output, not the master.

Sources & metadata:

  • Time selection: GetSet_LoopTimeRange. Razor edits: GetSetMediaTrackInfo_String(track, "P_RAZOREDITS", ...). Tempo: Master_GetTempo / TimeMap2_timeToBeats / GetProjectTimeSignature2. Selected items/tracks: CountSelectedMediaItems / GetSelectedTrack.

Placement:

  • InsertMedia(path, mode) at edit cursor / new track / replace selection (verify mode bits). SetEditCurPos. Wrap edits in Undo_BeginBlock2 / Undo_EndBlock2.

Persistence & paths:

  • SetProjExtState / GetProjExtState (namespace e.g. "reasampler") for the index JSON. Resolve project folder via EnumProjects / GetProjectPathEx; store the bank under a project-relative subfolder; keep only relative paths in the index.

Build order (each milestone independently testable)

  1. bank_model + JSON round-trip + unit tests. (pure — no REAPER)
  2. peaks + unit tests. (pure)
  3. Offline capture of the time-selection master mix to a wav in the project bank folder; add a Sample; log it to the console. (the render-driving spike)
  4. persist: write the index to proj ext state, reload on project open; confirm it survives Save / Save As. (bank travels with the .rpp)
  5. bank_panel: docked grid with thumbnails, audition, selection.
  6. insert: "insert selected sample at edit cursor" action via InsertMedia.
  7. Capture action family: two FX scopes — item (item/take FX only) and track (item FX + the selected track's own track FX), each chain-independent (the out-of-scope chain is neutralized to unity for the render). No master scope — to capture the master, render a track. Each scope captures over a time-or-razor range (razor is a range source, not a mode: razor-when-present, else time selection). Capture is always wet; the scope is the control. All registered as bindable actions (tail off; a tail-on variant is a later opt-in).
  8. RealtimeRecordBackend behind the same interface.
  9. Slots: "capture to slot N" / "insert slot N", MIDI-bindable (MPC-style).
  10. Provenance + "re-capture from source"; null-test verify action.
  11. Polish: batch capture (per selected item / per razor area), resample-and-mute- source, conform-on-insert, drag-out to OS.

Module architecture (preserve the pure/shell split)

Pure (no REAPER types, unit-tested — the mirror of bank_model):

  • view_mode_model — mode registry (id/name/ordinal; Arrange + Design seeded); membership index (track GUID → { mode ids } + per-track show-both flag; add / remove / retag / query); folder-tree-aware visibility derivation (given the current parent↔child tree supplied by the shell + the active mode, compute the visible set); the parking/restore planner (given active mode + snapshot record, emit the exact (track, flag, value) operation lists for park and restore — where the restore invariant is enforced); JSON round-trip of modes + membership + show-both + snapshots + active mode.

REAPER-facing:

  • view shell — reads I_FOLDERDEPTH across the track list to build the parent↔child tree and feeds it to view_mode_model; applies the planner's operations via SetMediaTrackInfo_Value (B_SHOWINTCP / B_SHOWINMIXER / B_MAINSEND / I_FXEN) and TrackFX_GetCount + per-FX TrackFX_SetOffline; snapshots prior flag values before parking; resolves GUIDs via GetTrackGUID / guidToString / stringToGuid. Never touches master visibility, never touches B_MUTE / I_SOLO.
  • persist (slice) — serialize/deserialize the view section into the "reasampler" namespace alongside the bank; on project open, rebuild the tree and reapply the active mode.
  • actions (entries) — toggle active mode; activate mode: Arrange / Design; tag/untag selected tracks → mode; show-both for selected tracks. Registered with the command_id / gaccel / hookcommand pattern; toggle + mode-jumps MIDI-bindable.
  • UI (in the ReaSampler / bank_panel window) — a segmented mode switch ([ Arrange | Design ]) in the window header, active segment lit; small per-mode membership count; the offlined-FX caveat as a tooltip. Tag/untag acts on the current REAPER track selection, not a per-track widget.

REAPER API surface (verify all signatures)

  • Visibility/routing/FX flags via GetMediaTrackInfo_Value (snapshot) / SetMediaTrackInfo_Value (apply): B_SHOWINTCP, B_SHOWINMIXER, B_MAINSEND, I_FXEN. (Note: brief cited B_SHOWINMCP; verified SDK name is B_SHOWINMIXER.)
  • Per-FX offline: TrackFX_GetCount + TrackFX_SetOffline(track, fx, offline).
  • Folder tree: read I_FOLDERDEPTH per track to derive parent↔child structure.
  • GUID keying: GetTrackGUID, guidToString, stringToGuid.
  • Persistence: SetProjExtState / GetProjExtState under "reasampler" (shared with the bank index — one blob, two logical sections).
  • Wrap flag mutations in Undo_BeginBlock2 / Undo_EndBlock2 as appropriate.

Module architecture (preserve the pure/shell split)

  • Pure (view_mode_model extension). Lane math — which lane maps to which mode, which C_LANEPLAYS value per mode, the item-lane op family alongside the existing track-flag op family — is REAPER-free and unit-tested, mirroring the D1 planner. The lane-ownership index (per (track GUID, lane): managed-which-mode vs manual) and the "which lanes may this toggle touch" query (managed only) are pure and unit-tested — the planner emits lane ops for managed lanes only and never for manual lanes. The auto-tag decision is pure too: given a set of new track GUIDs + active mode (active-mode rule) and a set of new items, each carrying the set of modes already present on its track (NewItem::trackModes) — plus the manual-lane exemption flag — autoTagNewContent produces the membership writes. The adoption guard runs inside the pure layer: single prior mode → adopt it; empty / multi-mode track, or new track → fall back to active mode.
  • Shell. Two shell responsibilities. (1) Apply the planner's item-lane ops (I_FREEMODE/I_FIXEDLANE/C_LANEPLAYS/B_FIXEDLANE_HIDDEN via the media-item info setters, UpdateTimeline() after I_FREEMODE changes) — for managed lanes only. (2) Detect new content and read live lane state — see below.
  • Persistence. The tool persists which lane maps to which mode and the managed/manual ownership index (a small addition to the "reasampler" view section); REAPER stores fixed lanes and lane-plays in the .rpp natively.

New-content detection (implementation design point)

REAPER exposes no clean "item added" / "track added" event callback. Auto-tagging therefore requires the shell to diff project state on the panel's existing timer — the bank_panel already polls and fingerprints the bank; this extends that machinery to the timeline's tracks and items.

  • Each poll, compare the live track/item GUID set against the previous poll's set. New track GUIDs are tagged to the then-active mode. New item GUIDs are passed to autoTagNewContent with the pre-existing mode-span of their track (NewItem::trackModes): single-mode track → adopt that mode; empty / multi-mode track → fall back to the then-active mode. Manual-lane items are excluded.
  • Correctness the implementation must handle: the first poll after project open must not mass-tag pre-existing content (pre-existing defaults to Arrange, per the membership rule).
  • Manual-lane exemption (design point). An item added to a manual lane is not auto-tagged — auto-tag governs normal timeline content, not lanes the user hand-manages. Distinguishing the two may need a heuristic at detection (e.g., an item whose I_FIXEDLANE is marked manual in the ownership index is exempt; content outside any managed lane on a mode-managed track is subject to the adoption rule). The precise rule is an open implementation design point; the settled boundary is that manual-lane content is off-limits to auto-tag.
  • Lane-identity fragility (design point). I_FIXEDLANE is the lane's identity and is how the ownership index keys to a lane. Whether the index survives lane reorder/renumber/deletion without going stale is an implementation design point (same class as GUID-keyed reorder-safety for tracks) — flag, don't solve here.
  • Pure/shell seam: the detection (diffing REAPER's live set each tick, reading live lane ownership and per-item trackModes) is shell; the tagging decision and the managed/manual lane query (new track GUIDs + active mode; new item GUIDs + trackModes + active mode + manual-lane exemption ⇒ membership + lane writes) are pure and unit-tested.

REAPER API surface (verify all signatures)

  • Fixed lanes — track: I_FREEMODE (=2), I_NUMFIXEDLANES, C_LANEPLAYS:N via GetMediaTrackInfo_Value/SetMediaTrackInfo_Value; item: I_FIXEDLANE, C_LANEPLAYS, B_FIXEDLANE_HIDDEN via GetMediaItemInfo_Value / SetMediaItemInfo_Value. Call UpdateTimeline() after I_FREEMODE changes.
  • Detection reuses the bank_panel timer + GUID fingerprinting already in place; item GUIDs via the item's GUID (GetSetMediaItemInfo_String "GUID"), track GUIDs via GetTrackGUID as in D1.
  • Verify every name/signature against the SDK header before use — the surface is verified present, but confirm argument order, types, and flag values.

Module architecture (preserve the pure/shell split)

Pure (no REAPER types, unit-tested — the mirror of bank_model / view_mode_model):

  • bank_book — ordered bank registry ({ bank id, display name, ordinal, BankIndex }); pool seeded with fixed id + name; create / rename / reorder / delete named banks (pool-privilege rules enforced here: reject delete/rename of pool; delete drops member index entries; display names unique — create/rename reject a name that duplicates another bank's, trimmed + case-insensitive, "Pool" protected); evacuate a bank (move every member to the pool, index-only, destination-collapse observed; pool cannot be evacuated); active-bank id (get/set, defaults to pool); move and copy a sample between banks (index-only, destination-collapse observed); query a bank's index; JSON round-trip of the whole book (pool-as-bank-zero inside the blob + named banks + per-bank indices + active id + ordinals) and legacy-bank_index→pool migration on parse (one-way; blob authoritative thereafter).

REAPER-facing:

  • persist (slice) — serialize/deserialize the book under the banks key in "reasampler" (pool-as-bank-zero inside the blob; no separate bank_index key going forward); migrate a legacy bank_index key into the pool on first load (one-way; blob authoritative thereafter, legacy key retired); reload-on-open and Save-As survival ride the existing M4 machinery. The session exposes the book the way it exposes the bank today; the active bank's BankIndex is what the capture layer adds to.
  • bank_panel (extension) — the vertical split: pool grid on top, named-banks tab-page region below; two full-height toggles; the active-bank indicator; the create / rename / delete / activate affordances. The named-banks tab strip is LICE-drawn to match the M5 grid and the Design View segmented switch (not a SWELL-native tab control), with an overflow/scroll affordance so it scales past the ~812-tab point. Sample move ships both ways: a "move to bank" menu on the current selection (the bindable front-end for the B3 move action) and drag-between-regions (the direct-manipulation accelerator); copy is the deliberate secondary act, offered on the menu. Drag carries clear drop-target highlighting on the destination region/tab, and a mis-drop is recoverable by design (move is index-only and reversible — the user moves the sample back). Reuses the existing LICE grid render loop per bank region.
  • actions (entries) — create bank / rename bank / delete bank (confirm on non-empty delete); evacuate bank → pool; activate bank (direct + cycle); move selected samples → bank; copy selected samples → bank; pool/banks full-height toggles. Registered with the command_id / gaccel / hookcommand pattern; bank-activate + move/copy + evacuate MIDI-bindable to suit the capture-heavy workflow.

REAPER API surface (verify all signatures)

No new REAPER API is invented at spec stage — the multi-bank layer is pure model + persistence + panel UI over machinery M0M6 already established. Shells will need to verify against the SDK header where they extend existing surfaces:

  • Persistence: SetProjExtState / GetProjExtState under "reasampler", new key banks (shared blob machinery from M4 — no new API, new key only).
  • Panel UI: the docked-window + LICE-grid surface from M5 (bank_panel), extended to two grid regions + a LICE-drawn tab strip (with overflow/scroll) + toggles. The tab strip, the toggle affordances, and the drag hit-testing are custom-drawn on the M5 LICE surface (not SWELL-native tabs); the "move to bank" menu uses a SWELL popup-menu surface. Verify LICE drawing and any SWELL menu/drag hit-test usage against the M5 reference / SWELL headers, and confirm the drag hit-test does not collide with the M5 grid's multi-select drag. No new REAPER audio API involved.
  • Actions: the command_id / gaccel / hookcommand contract from main.cpp (unchanged), new command-id strings under the sampler family prefix.

Open questions to resolve during build

Forks 15 are all settled (see product notes → Settled forks and Fork 5 — settled, and the settled-decision prose above). One panel-polish detail remains open.

  • Fork 5 — tab rendering + move affordance (B4). Settled (2026-07-23). (5a) The named-banks region is LICE-drawn to match the M5 grid and the Design View segmented switch — not a SWELL-native tab control — with an overflow/scroll affordance in scope from the start so the strip scales past the ~812-tab breakdown. (5b) Move ships as both a "move to bank" menu (the precise, MIDI-bindable front-end for the B3 move action) and drag-between-regions (the direct-manipulation accelerator); copy stays the deliberate secondary act via the menu. Drag mis-drop is mitigated by drop-target highlighting and is recoverable by design (move is index-only and reversible). Folded into the bank_panel prose and the API surface below. Verify LICE tab drawing and any SWELL menu/drag hit-test surface against the M5 reference / SWELL headers before use (confirm no collision with the M5 grid's multi-select drag). Analysis in product notes → Fork 5.
  • Active-bank indicator placement (B4 polish) — per-region headers vs. a single header readout vs. lit-tab treatment. The "visually unmistakable" requirement is settled (fork 4); only the placement is open. Panel-polish detail.

Module architecture (preserve the pure/shell split)

  • bank_book / BankIndex (pure) — expose remove of a Sample from a bank's index (the existing BankIndex::remove primitive, surfaced through the book); pool contents removable, pool-container privileges unchanged.
  • actions (entry) — "remove selected sample(s) from bank" (and, under fork R-A, a scope parameter); registered with the command_id/gaccel/hookcommand contract; MIDI-bindable to suit the capture-heavy workflow.
  • bank_panel (affordance) — remove on the current selection (menu entry / key), reusing the M5 selection model exactly as move/copy do; confirm-on-last-reference at this layer.

REAPER API surface

No new REAPER API. Pure model + a new action command-id string under the sampler family prefix + a panel affordance on the existing M5 LICE surface. Verify the command-id/gaccel/hookcommand usage against main.cpp (unchanged contract).


Module architecture (preserve the pure/shell split)

Pure (no REAPER types, unit-tested — the mirror of reconcile):

  • Prune-reconcile core — given { files present in the bank folder }, { files referenced by the book }, and { files the book owns } (the owned-file manifest, R-D), compute the orphan set (owned ∩ present) referenced. REAPER-free, filesystem-free, unit-tested hard (the prune null test lives here). The referenced-set is unioned across all banks by asking the bank_book.

REAPER-facing / filesystem-facing (thin):

  • persist / session — supplies the referenced-set (union across the book) and the owned-file manifest (R-D, written from capture onward in Phase B); resolves the current project bank folder via the M4 project-relative machinery.
  • A prune shell — enumerates the bank folder (filesystem I/O), feeds the pure core, presents the dry-run manifest, and on confirmation deletes the orphan set (via OS trash where portably available — fork R-C — else unlink). Filesystem I/O only; the decision stays in the pure core.
  • actions (entry) — "Prune bank folder" (dry-run-first, confirm-to-delete), registered with the command_id/gaccel/hookcommand contract; plus a bank_panel button (R-E) that fires the same action.

REAPER / platform API surface (verify all signatures)

No new REAPER audio API. New surfaces to verify before use:

  • Filesystem enumeration + delete — directory listing and file removal for the project bank folder. Verify the portable approach against SWELL / the existing file-handling in persist / capture (which already resolve and write files); prefer reusing whatever path/file machinery M4 established.
  • Move-to-trash (fork R-C, settled trash-preferred) — verify a portable move-to-trash exists (SWELL, or per-platform: Win IFileOperation/ SHFileOperation, macOS NSFileManager trashItemAtURL:, Linux XDG trash spec). This is a must-verify per platform before use, not an assumed capability; where it is unavailable, fall back to unlink behind the dry-run/confirm guardrail. (R3 verified: Windows routes to Recycle Bin via SHFileOperationW + FOF_ALLOWUNDO, verified against SDK 10.0.26100. macOS / Linux: no portable SWELL trash surface found — fall back to unlink behind the dry-run/confirm guardrail, as specified.)
  • Owned-file manifest persistence (fork R-D, settled) — a new tracked set in the "reasampler" ext-state (a sibling key or folded into the banks blob — build-time residual); shared M4 blob machinery, new data only. Written from capture onward in Phase B (the seam lands early), consumed by prune in Phase R.
  • Actions — the command_id/gaccel/hookcommand contract from main.cpp (unchanged), a new command-id string under the sampler family prefix.

Module architecture (preserve the pure/shell split — in the new artifact)

Pure (no REAPER types, no VST3 types, unit-tested — the mirror of bank_model):

  • Sampler core — voice allocation/polyphony, amplitude envelope (ADSR), key→sample and velocity→sample mapping (the keymap), repitch/interpolation from root note, and keymap resolution. REAPER-free and VST3-free, unit-tested in CTest against known signals (mirror of how peaks asserts an envelope). This is D3's pure core and the heart of the phase.

Shell (VST3-facing / REAPER-facing, thin):

  • VST3 wrapperSingleComponentEffect 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 BufferDonem_psbuf.Resize (once, when bsize·nch first sets, at a fixed quality/srate/nch — pre-warmable) and m_queue.Add (grows only until the push/pull cadence reaches steady state). Pre-warm at voice- allocation (run silence through once so m_psbuf sizes and m_queue settles); after that no process-thread allocation. No locks. RT-viable with the pre-warm discipline.
    • Latency: inherent ~half-window (initial m_pspos = bsize/2 → ~25 ms @ 50 ms window) plus fill-up — a real note-onset lag. This is the load-bearing cost. Mitigation: pre-warm; and Varispeed (zero-latency) serves the tight-transient one-shot material, so the lag lands on sustained/loop material where least harmful. Smaller-window quality settings (the table goes to 310 ms) trade latency for more warble.
    • Quality: basic — this is REAPER's "SimpleWindowed" mode. Audible warble on large transpositions; set_formant_shift is an explicit empty stub → no formant preservation. Usable for loop/phrase Preserve; replaceable by route (b) if not.
    • CPU / polyphony: PitchShiftBlock is O(length) per block — a few mults + one OLA crossfade branch per frame, no FFT. Per-voice cost is modest; N polyphonic voices each running one is feasible within RT discipline. If the aggregate cost is material, a Preserve-mode-specific voice cap (below the Varispeed cap) is the pressure valve — flagged in Verify, set from measured per-voice budget at build.
  • Formant-preserving / studio-grade time-stretch (elastique-class): NOT in WDL, confirmed. REAPER's elastique is licensed (zplane), not in the vendored tree (grep found only unrelated libpng/giflib string matches). Formant-correct duration-preserving repitch is unavailable without a new third-party dependency (JUCE / rubberband / signalsmith each a new-dependency fork with D-A weight — not proposed). Stated, not worked around.
  • Recommendation: the Preserve engine (S16-F2) is WDL_SimplePitchShifter (route a, low-cost proof) or a hand-rolled pure pitch_shift module (route b, held quality upgrade). The pitch-envelope modulation stays hand-rolled over whichever engine (a per-frame ratio_ multiply under Varispeed, a per-frame shift-amount add under Preserve). WDL_Resampler (sinc) is a held Varispeed-quality upgrade only.

Sequencing (S15/S16 against S7 stereo, S10 editor)

S15 and S16 are S3-core extensions — they touch the engine Daniel smoke-tests, like S7. They are channel-count-agnostic by construction: the play-mode envelope is a per-frame amplitude function, and both pitch engines carry the channel dimension internally — the Varispeed path is a per-frame per-channel read-rate scalar, and the Preserve shifter is set_nch-aware (one shifter instance per voice transposes all its channels together). So S15/S16 compose cleanly with S7's channel dimension rather than conflicting: S7 adds a channel axis to the read/mix; S15 adds an amplitude-shape axis; S16 adds a pitch-engine + read-rate axis; all orthogonal. Recommended order: S15 before S16 (S16 reuses S15's per-voice param-plumbing + component-state version bumps; landing S15's PlayMode/param struct first gives S16 a home to hang the pitch-engine mode + pitch-env params on). S16 is now meaningfully heavier than the prior "just an envelope" framing — the Preserve engine is a per-voice DSP object with its own RT budget, pre-warm, and possible voice-cap; treat S16's Preserve-engine point as the phase's next real DSP spike, not a thin add-on. S15/S16 relative to S7: no hard dependency — spec them so the envelope/mode code never assumes a channel count (it operates per-frame, pre-mix; the Preserve shifter is set_nch-driven), and S7 can land before, after, or interleaved. Relative to S10 (editor): S15's mode toggle + Trigger handles and S16's AD control surface through the S10/S11 waveform + guided-setup work, so the core halves of S15/S16 can land independently of the editor, with the editor surfacing following S10/S11 (the same way S12's ADSR editor follows the S3 ADSR math). Land the core engine work (mode split, start point, %-length/fades, pitch-env modulation) as soon as it is ready — it is testable in CTest without the editor — and wire the UI as the S10/S11 surfaces mature. Land S15/S16 core after S10's policy-reversal is settled only if sharing the same component-state blob would otherwise churn the version tag twice; otherwise they are independent.


ReaSampler 9000 — the UX overhaul (S10S13; DAW-tested S1S6, "the UX is awful")

The bar is set: better than ReaSamplOMatic5000. Daniel DAW-tested the S1S6 instrument and the verdict was that it works but the UX is unacceptable — "this is supposed to be better than ReaSamplOMatic5000." The S1S6 editor was a spike-grade LICE panel: a clickable sample list, zone rows each carrying seven tiny ±1 nudge/delete mini-buttons (low-/low+/high-/high+/root-/root+/delete), text-only labels, no keyboard visualization, no waveform, no drag interaction of any kind, no scrolling for long lists. Setting a zone from C1 to C4 by clicking "+" thirty-six times is the catastrophe; the rest (no way to see a sample, no loop editing by eye, unreachable rows past the panel bottom, a fixed envelope) compound it. The overhaul is scoped as S10S13, sequenced so the friction Daniel feels every test pass is removed first.

Workflow hierarchy (REVISED 2026-07-26 — Daniel; supersedes the keymap-first S10)

The overhaul is reframed around the actual workflow, not a keymap. Daniel's directive, distilled: a giant list of "item" blocks is visually useless; optimize for working with individual captures, not a huge list of everything. The settled hierarchy:

  1. Primary flow = one capture, fast. Most instances play a single capture. The metric is time-to-first-note: open → pick a capture → see it (waveform/peaks) → play it. The default editor face serves this, not a zone table.
  2. Fresh instance is SILENT — nothing auto-selected (policy reversal of S4). On open with no stored selection, the instrument plays nothing and shows a clear empty state ("pick a capture") — it does not auto-play sample #1. This deliberately reverses the S4 "first sample plays" convenience: the selectSample first-sample fallback and the processor's Tier-0 fallback that resolved it are removed; an empty stored id resolves to silence. (Recorded as a reversal, not a regression.)
  3. Capture browser, not an item list. Scannable cards/rows with peak thumbnails (the Sample peaks bank_model already carries — the same data the dock panel thumbnails draw), name, and a root/key badge where present; filterable by bank (bank_book named banks). A "giant list of item blocks" is the anti-pattern — the browser is designed for scanning by eye.
  4. Graphic, descriptive controls with a guided fast path. Once a capture is picked, a prominent, self-explanatory single-capture setup surface (root note, play-mode basics, level). The keyboard strip serves the single-capture case first (shows where the capture sits / its root); drag matters most when zoning.
  5. Zones demoted to secondary (nice-to-have). Multi-zone keymap editing becomes an opt-in "Zones" panel (S10-Z), not the default face — "most of the time the zones won't be used." The keyboard-strip drag machinery is still built, but in service of the capture-first layout.

What "better than RS5K" means, specifically (not vibes). RS5K's genuine strengths — match or beat each: (1) drag a file straight onto it loads the sample (our S13 relay); (2) note-start / note-end range with a visual sense of the keyboard (our S10 keyboard strip — RS5K's own range UI is two number fields, so a draggable strip beats it); (3) a waveform with draggable start/end/loop markers (our S11); (4) ADSR sliders (our S12); (5) velocity layers / round-robin (Tier 2 — held, not in this overhaul). RS5K's real weaknesses are our opening: its one-sample-per-instance model forces track sprawl (one RS5K per drum) and it has no multi-zone view in a single instance — ReaSampler 9000 is multi-zone in one instrument by design (S5), so the opt-in Zones panel showing all zones at once is a capability RS5K structurally lacks. But per the reframe, the default face is the single-capture fast path (browser + setup), and multi-zone is the demoted nice-to-have. "Better than RS5K" = a fast single-capture browser where RS5K makes you drag a file blind, direct-manipulation where RS5K uses number fields, multi-zone-when-you-want-it where RS5K is one-shot, and bank-integrated ingest where RS5K is file-at-a-time.

Constraints (unchanged — settled, do not re-open): LICE/SWELL drawing only (no toolkit change — D-A settled); all layout/hit-test math in pure geometry modules (mirror of mode_switch / editor_geometry / embed_strip), the draw + drag-state machine in the shell; RT discipline untouched (every edit commits off the audio thread via the existing commitAndReload → 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 §"LICE / WDL API surface" in this file. Phase S surfaces (S10S13) adopt that kit when it lands; they are not gated on it.

Drop-and-load — drag a capture onto a track's FX button (S17 spec)

The gesture. While a capture is dragged out of the bank_panel, a track's TCP FX button becomes a drop zone. Dropping the capture there instantiates a ReaSampler 9000 on that track with the dragged capture already loaded and selected for playback — one gesture from bank to playable instrument. This is the third integration gesture: capture (extension), placement-into-arrange (extension), and now placement-of-the-player (this wave). It is drop-and-load, not drop-to-arrange — no media item touches the timeline.

Why it needs a new drag mode (the CF_HDROP path can't carry it). Today's drag-out (M11) becomes an OS file drag (CF_HDROP via drag_out + drag_out_win) the moment the pointer leaves the panel client rect. REAPER's TCP FX button is not a native drop target that instantiates a plugin-with-a-file, so this feature cannot ride the OS-drag path: an OS drop of a WAV onto the FX area does not create "an instrument preloaded with that WAV." It requires an internal drag where the extension itself tracks the pointer over REAPER's own UI, detects the FX-button hover, and on release drives the insert itself. The extension is the actor for the whole gesture.

The two-part mechanism.

  1. Internal-drag hover detection (extension-side, pure + shell). The drag_out pure module gains a third DragGesture beyond Internal (bank-to-bank) and OsDrag (M11) — InstrumentDrop. The gesture decision is refined: leaving the panel client rect no longer immediately means OS-bound. Instead:

    • Pointer inside the panel client rect → Internal (unchanged bank-to-bank drag).
    • Pointer outside the panel but still over REAPER's own window/UIInstrumentDrop (new — the shell hover-tracks the TCP FX button and highlights it).
    • Pointer left REAPER entirely (Explorer / another app) → OsDrag (unchanged M11).

    The pure module stays REAPER-free: it decides InstrumentDrop vs. OsDrag from position plus an "over-REAPER's-own-UI" predicate the shell supplies (the shell owns the REAPER window/hit query; the pure layer owns the set/boundary algebra). Mirror of how M11 kept decideGesture pure over a rect the shell supplied. The shell then resolves the pointer to a track + FX-button hotspot, highlights it, and on release drives the drop.

  2. FX-button drop → add-VST + load-capture (extension-side shell, then instrument seam). On release over an FX button the shell:

    • Adds a fresh instance: TrackFX_AddByName(track, "VST3:ReaSampler 9000", /*recFX*/ false, /*instantiate*/ <negative>). Verified present in reaper_plugin_functions.h: int TrackFX_AddByName(MediaTrack* track, const char* fxname, bool recFX, int instantiate) — a negative instantiate always creates a new effect (per the header comment); the "VST3:" prefix selects the format. Captures the returned FX index (or -1 on failure).
    • Loads the dragged capture into that instance via the load-capture seam (below).
    • Wraps the whole thing in one REAPER undo point (Undo_BeginBlock2/EndBlock2) so the gesture is one Ctrl-Z — the same discipline the bank verbs use.

The ReaSampler 9000 load-capture seam (the hard coupling — MUST be added; does not yet exist). The Phase S spec today gives the instrument a live-state read seam (it reads bank index + mapping from "reasampler" ext-state via the bridge — CONTEXT.md §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_SetNamedConfigParmverified present in reaper_plugin_functions.h: bool TrackFX_SetNamedConfigParm(MediaTrack* track, int fx, const char* parmname, const char* value), and the header documents the write-parms vst_chunk / vst_chunk_program as the base64-encoded VST-specific chunk. So the injection call is TrackFX_SetNamedConfigParm(track, fx, "vst_chunk", <base64 blob>).

Load-bearing caveat — vst_chunk is the plugin's own serialized chunk. vst_chunk is ReaSampler 9000's own base64-encoded serialized state (its getChunk/setChunk FXP/FXB-style blob), not a raw VST3 IComponent::setState stream that REAPER re-marshals into the plugin. The extension therefore has to construct exactly the instrument's own state-blob bytes with the capture pre-selected — REAPER does not translate a neutral state representation on its behalf. This makes the component-state blob format a shared cross-artifact contract — one that is still being defined in Phase S — and a coordination dependency between the extension and the instrument: both must agree on the exact byte layout that ReaSampler 9000's setChunk accepts before either half is final. The load-capture seam and the component-state persistence work (CONTEXT.md §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_AddByNameverified 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 dragnot yet confirmed. Candidates: GetTrackFromPoint / GetThingFromPoint (verify names + signatures against reaper_plugin_functions.h); whether the FX button specifically is addressable vs. the TCP as a whole is an open verification that also decides the "hotspot vs. whole TCP" question.
  • Instance state injection (seam mechanism (B) — SETTLED, load-bearing prerequisite)verified present in reaper_plugin_functions.h: bool TrackFX_SetNamedConfigParm(MediaTrack* track, int fx, const char* parmname, const char* value), with the header documenting vst_chunk / vst_chunk_program as the base64-encoded VST-specific chunk write-parms. The injection call is TrackFX_SetNamedConfigParm(track, fx, "vst_chunk", <base64 blob>). The remaining prerequisite is not the API but the shared component-state blob format: vst_chunk carries the instrument's own serialized chunk (its setChunk input), so the extension must construct exactly ReaSampler 9000's state bytes — the cross-artifact contract still being defined in Phase S. Blocks the drop half until the blob format is agreed.

Module architecture (preserve the pure/shell split)

  • Pure (new/extended): envelope_overlay (AHDSR/Trigger params + frame-length → polyline in a rect; unit-tested); envelope_edit (NEW — node hit-test + pixel-delta→clamped-param inverse map for the draggable envelope handles; mirror of card_drag; unit-tested at the monotonic/clamp boundaries); velocity_curve (NEW, r10 — bezier eval(velocity 0127)→amp 01 + control-point add/move/delete clamped to the 0127×01 box, x-ordered, hit-test + pixel-delta inverse map; mirror of envelope_edit; unit-tested at eval + clamp/order boundaries); keyboard_strip extended with the natural/accidental predicate; the sampler core extended with the key-track scalar in the repitch math (unit-tested against known note/root/keyTrack → ratio) and the velocity-curve eval at Voice::start() replacing the linear velocity/127 (r10; off the per-frame path); sample_map (PerformanceZone) extended with the additive keyTrack field and the additive velocityCurve field (r10 — both on the zones-payload version axis; the velocity-curve field sequences AFTER T-KEYTRK's keyTrack bump), plus the envelope-v6 previewVelocity field on ComponentState (S-VIEW-F1) — all additive, version bumped, back-compat defaults on read.
  • Shell (reasampler_editor.cpp): re-partition the paint/hit-test into the three views (Sample face, Browse modal overlay, Zone surface) replacing the two-view toggle; add the preview-trigger button + velocity knob wired to an off-audio-thread preview note through the voice engine (the velocity persists via the envelope-v6 ComponentState field, S-VIEW-F1); draw the envelope overlay + its draggable node handles (routing mouse events through the pure envelope_edit module and committing params via the same off-thread path the sliders use, S-VIEW-F2) + piano-key overlay via the kit; set the larger default ViewRect + checkSizeConstraint. All layout/hit-test math stays in the pure geometry modules.

Kit architecture (the pure/shell split)

Pure (no LICE, no REAPER types, unit-tested — the mirror of mode_switch/bank_grid):

  • theme/palette module — role→color mapping, direction-selectable via one constants block (the B + REAPER-grey-neutral + three-accent-pastel + pastel-spectral values). Pure; unit-tested that each text-on-surface pair clears its WCAG floor (the "punch" rule made testable). DS-2 revision (2026-07-26): (a) the neutral ladder moves from near-black up into REAPER's mid-grey family (bg/base #2b2b2b / bg/panel #333333 / bg/cell #3a3a3a / line/hairline #4a4a4a / text/primary #dcdcdc / text/dim ~#a0a0a0+), and (b) the accent role expands from one to three (accent/primary lime, accent/secondary teal, accent/tertiary purple) with the spectral ramp a pastel sweep anchored on those three — all still confined to the one constants block + the shell's font lifecycle. The WCAG test must be re-run against the GREY ladder — it gains the newly-tight grey pairs: text/dim-on-grey (AA 4.5:1) and each pastel-as-state-indicator on bg/cell (3:1), plus the existing text-on-pastel-fill pairs. (No font change: a bundled-font upgrade was declined — the cached-font set keeps the kit's current face.)
  • Component geometry/hit-test helpers — button rect, slider track/handle geometry, list-row rect + hover hit-test, and any new layout module L2 needs (an action-bar layout module). No LICE, no host types; CTest-covered. Existing pure modules (bank_grid/tab_strip/mode_switch) stay the source of truth for what they own.

Shell (LICE-facing, DAW-verified — thin draw layer):

  • Draw kitfillSurface (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 (COMPLETED.md §M11):

  • 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 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 activeItem: 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_DESIGNdropped 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_BOTHkept as a set-apart button on the bottom toolbar. It is the cross-mode "pin visible in every mode" escape hatch and is not covered by the footer toggle.
  • Landed bottom-toolbar inventory: [ Item: Arrange | Item: Design ] · [ Track: Arrange | Track: Design ] ⟩⟩ [ Show Both ] — Tagging cluster (four opposite-mode buttons) set apart from a lone Show Both. No Toggle, no Activate-Arrange/Design (footer toggle owns switching).

5. Semantic-grouping spacing. L4's kBarSpec is buttonGap=4 / clusterGap=16 (4:1). With the bottom bar's cluster boundary now more meaningful (four tag buttons vs. the Show Both remnant) the grouping should read at a glance — widen the inter-cluster gap: start ≈ clusterGap=24 / buttonGap=4 (6:1), tuned in-DAW. One kBarSpec still serves both toolbars (identical button shape top and bottom); only the gap ratio changes.

Pure/shell discipline (unchanged). All new geometry — the top-bar overflow menu-button rect

  • hit-test, the opposite-mode enablement predicate, the wider grouping spacing — goes in pure CTest-covered modules (extend/mirror action_bar / prune_button; add a pure enablement predicate). The TrackPopupMenu popup, the Main_OnCommand dispatch, and the tooltip hover- timer + LICE overlay draw are the only DAW-bound pieces; the L1 kit draws. L5 resolves its menu/tooltip/action-id specifics against the landed L4 bank_panel (build-time confirmation).

1. Persisted deterministic order + sparse (gap-preserving) placement

Each bank (and the pool) carries an explicit, persisted display position per sample — the grid no longer derives order from insertion order. Positions are gap-preserving: a sample may occupy a slot that leaves earlier slots empty (an empty first row above an occupied second row is a valid, persisted state).

Where the position data lives (the CLAUDE.md constraint). bank_model / Sample are stated untouched by bank_book's wrapping (no bankId on Sample; a bank is a logical grouping over the shared pool). Display position is a per-bank display concern, so it belongs with the bank's membership, not on Sample — a copy of a sample into two banks can sit at different slots. Recommended carrier: a per-Bank ordered position map in bank_book (sample id → slot), leaving bank_model untouched. The exact carrier (ordered id list with gaps vs. explicit id→slot map) is settled at build; the contract below holds either way.

Ordering contract (deterministic, gap-preserving):

  • Deterministic: the grid iterates positions in ascending slot order; ties are impossible (one sample per slot). The order is fully determined by the persisted position data, not by insertion order or hash.
  • Insert (new capture): a new capture takes the next free slot after the last occupied slot (append). It never fills an earlier gap automatically — a gap is a user's deliberate layout, not a hole to be plugged.
  • Delete / remove / prune: removing a sample leaves its slot empty (does not re-pack) so every other sample keeps its position. Trailing empty tail is trimmed for scroll-extent purposes; interior gaps are preserved (confirmed at build).
  • Reorder: the user drags a card to a target slot within its bank; the pure reorder mutator moves that sample's position to the target slot, gap-preserving. Drop-into-empty-slot places there; drop-onto-occupied inserts-before and shifts the tail (matching common file-manager reorder) — SETTLED as the default drop. (The Alt+drop-onto-occupied = REPLACE override is specified below under Drag disambiguation, F3.)

JSON round-trip + migration (load-bearing). serialize/deserialize stay lossless including positions (deserialize(serialize(x)) == x). A pre-L7 project blob has no position data → on load it defaults to current insertion order, densely packed (no gaps), so a project saved before L7 is visually identical on first post-L7 load. This default is the migration; it is one-way (once re-saved, the position data is authoritative). Pure and hard-tested — this is a persisted-model change, tested to the same bar as bank_book's existing round-trip + legacy migration.

Undo. A reorder is one Ctrl-Z — the actions/shell layer wraps the mutation in a batched undo point (Undo_BeginBlock2/EndBlock2), matching every existing bank-verb's undo discipline.

Position-model shape (F2 — SETTLED 2026-07-27: interchangeable substrate, NOT fixed slots). The carrier is the plain gap-preserving interchangeable-slot substrate — a per-bank id→slot map (Daniel: "I don't think I want fixed slots MPC style, but the substrate of interchangeable slots is valuable"). It is not M9-shaped: no slot identities, no numbered/addressable slots that persist independent of their occupant, no slot actions, no MIDI-bindable slot numbers, no capture-to-slot-N. A slot is just a display position a sample occupies; dragging cards rearranges which sample sits where.

M9 note. M9 "slots" (capture-to-slot-N / insert-slot-N, MIDI-bindable, MPC-style) is abandoned (Daniel, 2026-07-27) — will not be built. L7 adds no slot-numbered capture/insert actions and no MIDI bindings. The "plain vs. M9-shaped" sub-fork is closed: plain gap-preserving substrate, per F2 above.


2. Decorative metadata overlay (bars.beats · s.ms)

Each card overlays, on the waveform, two decorative read-outs of capture length:

  • bottom-LEFT: length in bars.beats.subdivisions (musical).
  • bottom-RIGHT: length in seconds.milliseconds (wall-clock).

Contract: decorative and non-interactive — no hit-test, no hover, no selection role. Drawn via the L1 kit text() in the micro / value-mono type class, in text/dim (or a subtle shadowed variant for legibility over the peaks), subordinate to the waveform. Respects the speed constraint (no animation). Pure formatting helpers (below) are unit-tested; only the kit draw is shell.

Bars.beats source (F1 — SETTLED 2026-07-27: capture-time stamp). bars.beats.subdivisions requires a tempo + time-signature reference. Sample already carries captureTempo (BPM at capture) and lengthBeats, but no time-signature. Decision: capture-time stamp — add captureTimeSigNum / captureTimeSigDenom to Sample + its JSON round-trip, stamped on the capture path (read the project meter at capture via TimeMap_GetTimeSigAtTime — confirmed at build against the SDK). bars.beats.subdivisions renders from the stamped tempo + meter, stable under later project tempo/meter changes — a bank sample outlives the project state it was captured under, matching the existing captureTempo stamp philosophy. This is a capture-path write beyond draw work (its own checkbox in the PLAN, now settled). Old samples with no stamp fall back gracefully (blank musical read-out, or a documented assumed 4/4).

  • (rejected) Live project meter at draw time: the label would drift under the card as the project tempo/meter changes, and would be wrong for any sample captured under a different meter than the project's current one.

Formatting helpers (pure, tested). bars.beats.subdivisions from lengthSeconds + captureTempo + capture-time signature; seconds.milliseconds from lengthSeconds. Deterministic; graceful on edge cases (zero length → both read empty/0; missing tempo → blank musical read-out, keep the s.ms read-out).


3. Selection styling — tertiary border replaces inversion

A selected card drops the inverted accent-fill treatment and instead draws the normal cell (Rest or Hover surface) + an accent/tertiary border (pastel purple #C2AAE8); the waveform draws in its normal accent color (the inverted bg/base wave is removed). The four grid-card interaction states stay visually distinct and coherent:

  • Selected: normal cell surface + accent/tertiary (purple) 1px border. No fill change, no wave inversion.
  • Focus (caret): the existing distinct inner ring — keep it separate from the selection border so a focused and selected card reads both (e.g. purple outer border + a text/primary inner focus ring). Settle the exact inner treatment at build so focus is legible on top of the selection border.
  • Hover: the kit Hover surface (unchanged) — a fill-state change, orthogonal to the purple border, so a hovered selected card still reads as selected.
  • Drag-target slot: the reorder drop-target highlight (a distinct accent — recommend accent/hot outline on the target slot) must not be confusable with the purple selection border; spec the exact treatment at build.

Pure/shell discipline (L7)

Model: the position carrier + gap semantics + reorder mutator + Alt-replace mutator (the latter reusing the existing index-only remove-from-bank semantics + pool-privilege guard) + JSON round-trip/migration are pure (in bank_book, CTest-covered to the bar of its existing round-trip). Layout: the sparse-aware slot↔rect math + point→slot hit-test + the drag-disambiguation decision (including the resolved-gesture result that drives the cursor cue, and the Alt-over-occupied → replace resolution) are pure (extend bank_grid; mirror mode_switch / drag_out::decideGesture). Formatting: the bars.beats and s.ms formatters are pure. Shell (DAW-bound): the reorder/replace drag wiring + drop-target highlight, the cursor SetCursor call mapping the pure resolved-gesture to a cursor resource, the capture-time-signature stamp (F1, settled) read on the capture path, and the kit overlay/selection-border draw. The L1 kit draws; no palette/font decision re-opened.


The L3 gate + Phase S coordination contract

L3 (VST editor + embed-strip restyle) has landed (merged c53683e, 2026-07-27). The Phase S surfaces (reasampler_editor.cpp + reasampler_embed.cpp) arrived on dev drawing flat LICE_FillRect blocks + raw GDI DrawTextA, off a local pre-L1 forest-green palette — the coordination contract's "born in the kit" branch did not occur — so L3 performed a full restyle of both draw shells through the L1 kit. L1, L2, L3, L4, L5, L6, and L7 have all landed — see COMPLETED.md. Phase L is complete. The Phase Q gate condition "Phase S + L3 merged to dev" is now satisfied.

Coordination contract (load-bearing — resolved): Phase S's S10S13 built their interaction UX with the current drawing and adopted the L1 kit via L3 (the coordination contract's "L3 restyles them" branch). There is now one kit and one look across both artifacts; L3 completed the VST/embed adoption and applied the settled-and-revised B + three-accent pastel treatment (with C's pastel spectral keyboard strip as the signature surface), routing text through the kit's cached-font text() (§3.1 — the kit's current face; no font change). The VST3 class UID is unchanged — a visual refresh is not a compat event.


LICE / WDL API surface (verify all signatures)

  • LICE design-kit surfaces (L1). LICE_GradRect, LICE_RoundRect, AA LICE_Line/LICE_FLine/LICE_ThickFLine/LICE_Circle/LICE_FillCircle/ LICE_DrawCBezier, LICE_FillTriangle/FillTrapezoid/FillConvexPolygon, and the LICE_CachedFont/LICE_IFont font engine (SetFromHFont, AA DrawText, shadow/outline/ glow FX flags). Verified present in vendor/WDL/WDL/lice/lice.h + lice_text.h; confirm exact signatures + the LICE_CachedFontHFONT 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.