Files
reasampler/PLAN.md
T

1676 lines
129 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# PLAN.md — ReaSampler milestone roadmap
Living milestone roadmap for ReaSampler. Derived from CONTEXT.md's 11-step build
order; CONTEXT.md remains the authoritative spec — this file is the tickable
checklist, not a re-statement of the spec. When a point lands, doc-keeper removes
it here and appends it to `COMPLETED.md`.
**Conventions**
- One checkbox `- [ ]` = one discrete, independently-landable point.
- Each milestone opens with a **Goal** (one line) and a **Verify** criterion
(the acceptance gate; precision invariants pulled in where one applies).
- Verify-in-DAW points require a manual REAPER run; pure points are gated by CTest.
- "See CONTEXT.md §…" points at the authoritative detail — do not duplicate it here.
---
## Milestone 9 — slots (MPC-style)
> **Explicitly deferred (Daniel, 2026-07-26) — not silently dropped. Can be picked
> up later if wanted.** An extra, not scheduled work.
**Goal:** "Capture to slot N" / "insert slot N", MIDI-bindable. CONTEXT.md
Build order 9.
**Verify (in DAW):** Slot capture and slot insert fire from MIDI bindings; slot
state persists via the index.
- [ ] Slot model + slot↔sample assignment.
- [ ] "Capture to slot N" / "insert slot N" actions, MIDI-bindable.
## Open questions to resolve during build
Carried from CONTEXT.md §Open questions — keep visible until each is closed by a
landed milestone.
- **`parseInt` narrowing hardening:** `src/bank_model.cpp` `parseInt` casts
`int64_t → int` via `static_cast` without a range check; integers that fit
in int64 but exceed `INT_MAX` are implementation-defined. Hardening candidate
— add bounds check before the cast when integer-field validation is in scope.
- **Capture send/routing isolation (TODO):** The FX-scope capture neutralizes out-of-scope FX, gain, and pan — but NOT aux **sends**. So a downstream coloring send (e.g. a folder → reverb-track send) still routes and blends the reverb into an item/track capture, past the intended isolation point. A true item-level capture should be taken at the isolated graph point — the target scope's output before out-of-scope track FX/gain/pan **and** before out-of-scope aux/parallel sends. The hard part: distinguish **source routing that must be preserved** (e.g. a MIDI send T1→T2 where T2's synth is where a MIDI item's audio is actually produced — the "item level" for that MIDI item is T2's synth output) from **coloring sends that must be excluded** (folder→reverb). Repro: folder F1; T1 (MIDI) sends MIDI to T2 (synth); T1+T2 → F1; F1 sends to reverb T3; capturing the MIDI item on T1 currently includes the reverb, should be isolated to T2's synth output pre-F1 with the MIDI send preserved and the reverb send excluded. Likely approach: snapshot + mute out-of-scope tracks' aux sends during the render while preserving the main/source signal path — needs a rule for which sends are load-bearing.
---
# Phase D2 — Two-canvas (item-level mode projection; additive to D1)
> **Design View sub-phase.** Extends D1's track-level mode projection to **item
> level** via REAPER 7 fixed lanes: on a track present in both stances, each mode
> owns a fixed lane — the active mode's lane shows and plays, the inactive mode's is
> hidden and silenced — so a Design take and an Arrange take can share the same
> track and time position without colliding on the view. Nothing in D1 changes.
> Runtime floor rises to **REAPER 7** for this sub-phase (no version-gate branch;
> below v7 it is simply unavailable). Authoritative spec: **CONTEXT.md §Two-canvas
> sub-phase (Phase D2 / Phase E)** and the surrounding §Design View — additive phase
> spec. Product framing: `docs/product/design-view.md` §Two-canvas direction. When a
> point lands, doc-keeper moves it to `COMPLETED.md`.
>
> **D2-W1 (pure lane extension), D2-W2 (shell: lane application + new-content
> detection), D2-W3-A (lane minting + item→lane assignment + persist round-trip),
> and D2-W3-B (item-level mode actions + W3-A polish) have all landed** — see
> `COMPLETED.md`. **Phase D2 is functionally complete.**
>
> **Deferred:** panel UI indicator for per-track lane/mode state (a per-track
> lane-split marker). The mode switch already shows the active mode; no natural
> cheap home for a per-track indicator was found in the bank panel. Explicitly
> deferred — not silently dropped. Can be picked up later if wanted.
---
# Phase S — MIDI-playback instrument (native VST3 sampler; a second build artifact)
> **New pillar, own lettered namespace, and — uniquely — a second build artifact.**
> Every prior phase ships inside the one `reaper_reasampler` extension binary; Phase
> S does not. A REAPER extension *cannot* be a MIDI-triggered instrument (it is not a
> node in any track's signal chain), so the instrument is a **separate native VST3
> plugin** the user instantiates on an instrument track, reading ReaSampler's banks
> and playing them MIDI-triggered. Namespaced **`S` (Sampler)** rather than "D"
> (Daniel's call — "D" collides with Design View). Authoritative spec: **CONTEXT.md
> §MIDI-playback instrument — additive phase spec (Phase S)**. Product framing +
> the settled decision record (D1/D5/D6 locked, D-A..D-D settled 2026-07-26):
> `docs/product/midi-playback.md`. When a point lands, doc-keeper moves it to
> `COMPLETED.md`.
>
> **Locked (see `docs/product/midi-playback.md` §4):** D1 native VST3 (not JSFX);
> D5 Windows-only / VST3-only / REAPER-only; D6 two products, tightly integrated via
> the VST-host bridge (live `"reasampler"` ext-state, project-aware). **Settled forks
> (2026-07-26):** D-A bare Steinberg VST3 SDK + LICE editor (no JUCE); D-B split seam
> with root-note + loop-points added to `Sample` *now*; D-C Tier 01 committed (Tier 2
> held, Tier 3 optional-forever); D-D embedded TCP/MCP UI **scheduled** as a later
> in-phase point (after the main editor exists).
>
> **Visual design moved to Phase L (2026-07-26).** The look-and-feel work originally
> drafted here as **S0-DS** (shared LICE drawing kit) and **S14** (dock-panel refresh) has
> been lifted out of Phase S into its own **Phase L** (Look-and-feel), taken up by a
> parallel team on `dev` so Phase S feature work proceeds ungated. See **PLAN.md §Phase L
> + CONTEXT.md §Phase L on `dev`** and `docs/product/visual-design-language.md` (on `dev`).
> S10S13 build their interaction UX with the **current** drawing and **adopt the Phase L
> kit when it lands — they are not gated on Phase L.**
>
> **Numbering note:** S0-DS and S14 are removed (moved to Phase L); S7S13 keep their
> numbers.
>
> **Second build artifact (load-bearing, flagged up front):** Phase S produces a
> *separate* VST3 binary alongside `reaper_reasampler`. The Steinberg VST3 SDK is a
> **new vendored dependency** (vendor at the spike — an implementation-time
> prerequisite, not done here), and CMake grows a second target with Windows VST3
> module-export/bundle wiring. Both are established by S1 so nothing downstream leans
> on an unbuilt target.
## S1 — opening spike: VST3 skeleton + `IPlugView`↔LICE bridge (proof + second target)
**Goal:** Stand up the second build artifact and prove the two least-trodden
unknowns before the engine build leans on them: (1) a silent-but-loading VST3
`SingleComponentEffect` skeleton that REAPER hosts, and (2) an `IPlugView` that hosts
a LICE-drawn surface. Converts §1a's experienced-estimates (Windows module-export
names, factory-macro spellings, exact bridge marshalling) into verified fact.
CONTEXT.md §Phase S (build shape, module architecture, API surface).
**Prerequisite (implementation-time):** vendor the Steinberg VST3 SDK (a new
submodule/dependency alongside `reaper-sdk` / `WDL`); confirm whether VSTGUI is
bundled (moot for D-A but resolves the noted fallback rung).
**Verify (in DAW):** the VST3 skeleton loads in REAPER on an instrument track,
enumerates via `GetPluginFactory`, sets up an event-in + audio-out bus, and runs an
empty `process` without error; an `IPlugView` opens and draws a LICE surface with a
working hit-test; the VST-host bridge resolves `GetProjExtState` by name and reads a
known `"reasampler"` value. Nothing plays yet — this is the loading/drawing/bridge
proof.
- [x] CMake second target: a separate VST3 module artifact built alongside
`reaper_reasampler` (Windows VST3 export/bundle wiring; `GetPluginFactory` +
`InitDll`/`ExitDll`**verify exact export names against the vendored SDK**).
- [x] `SingleComponentEffect` skeleton: factory + class registration, `initialize`
declaring an event-input bus + an audio-output bus (no audio input),
`setupProcessing`, `setActive`, empty `process`. Loads silently in REAPER.
- [x] `IPlugView`↔LICE bridge spike: open a plugin editor window hosting a LICE-drawn
surface (window creation/sizing, host→draw/hit-test event routing), reusing the
`bank_panel` LICE/SWELL competence. **The decision's one real unknown — prove it
here.** (VSTGUI is the noted fallback only if this proves gnarlier than the panel
work suggests.)
- [x] Bridge read spike: resolve `GetProjExtState`/`EnumProjExtState` by name via
`IReaperHostApplication::getReaperApi` (host project context via `getReaperParent(3)`)
from `reaper_vst3_interfaces.h`, and read a known `"reasampler"` ext-state value.
(The `0xdeadf00d`/`0xdeadf00e` opcodes are the VST2 path — verified not applicable here.)
## S2 — `Sample` intrinsic fields (root note + loop points; in the *extension*)
**Goal:** Add the two bank-intrinsic seam fields to `Sample`**root note** (MIDI
note the sample was recorded at; distinct from the existing optional *musical key*)
and **loop points** (sustain-loop start/end, sample-accurate, zero-crossing-aware) —
as an additive field extension with JSON round-trip, populated at/after capture. This
touches the **extension** codebase, is independently shippable, and lands early to
close the backfill cliff before the instrument consumes the fields. CONTEXT.md
§Phase S (seam fields, D-B). **Same additive shape as `provenance` (M1).**
**Verify:** CTest green. Round-trip lossless across the new fields; pre-existing
samples (no root note / loop points) deserialize with clean defaults (no loss, no
migration break); capture populates root note where derivable and loop points where
set; relative-paths-only unaffected; `BankIndex` behavior unchanged (purely
additive).
**Depends on:** nothing in Phase S (extension-only; can land before or in parallel
with S1).
- [x] Add `rootNote` (optional MIDI note) + `loopStart`/`loopEnd` (optional
sample-accurate loop points) to `Sample`; JSON serialize/deserialize with clean
defaults for samples lacking them (additive, backward-compatible — mirror of how
`provenance` was added).
- [x] Populate the fields on capture where derivable (root note) / settable (loop
points); leave them cleanly empty otherwise. No existing `Sample` field changes.
- [x] Tests: full round-trip lossless including the new fields; a legacy `Sample`
JSON (no new fields) parses with defaults and re-serializes without loss; additive
invariant (no change to existing fields, dedup, tier, or `BankIndex` behavior).
## S3 — pure sampler core (voice engine / envelope / keymap / repitch)
**Goal:** The REAPER-free **and** VST3-free sampler core — voice allocation/polyphony,
amplitude envelope (ADSR), key→sample and velocity→sample mapping (the keymap),
repitch/interpolation from root note, keymap resolution — unit-tested in CTest against
known signals. **The heart of the phase (D3); the mirror of
`bank_model`/`peaks`/`view_mode_model`/`bank_book`; test it hard.** The core is
invariant under the build-shape choice — no VST3 or REAPER type at its boundary.
CONTEXT.md §Phase S (pure core, module architecture).
**Verify:** CTest green. Voice allocation is correct under polyphony (note-on/off,
voice stealing where bounded); ADSR shape asserted against a known signal (mirror of
`peaks`); repitch from root note produces the expected pitch ratio; keymap resolution
maps a (note, velocity) to the correct sample/zone; the core takes and returns only
plain data (no VST3/REAPER types) — enforced by the test target linking neither SDK.
**Depends on:** S2 (consumes `rootNote` / loop points as core inputs).
- [x] Voice engine: polyphonic voice allocation (note-on/off, bounded voice stealing),
per-voice state, mono-and-basic-polyphony sufficient for Tier 0.
- [x] Amplitude envelope (ADSR) math — asserted against a known signal.
- [x] Repitch/interpolation from root note (chromatic pitch ratio across the
keyboard); loop-point-aware sustain for held notes.
- [x] Keymap model + resolution: key ranges/zones (Tier-1 shape) and the
(note, velocity) → sample/zone query; Tier-0 chromatic-from-single-root as the
degenerate case.
- [x] Tests: voice allocation under polyphony + stealing; ADSR envelope shape;
repitch pitch-ratio correctness; keymap resolution (single-root chromatic + zoned);
core boundary is plain-data-only (no VST3/REAPER types).
## S4 — Tier 0: "the bank plays" (single sample, chromatic)
**Goal:** The honest MVP — one bank sample mapped chromatically across the keyboard
from its root note, basic polyphony, a simple amp envelope, velocity→volume. Wire the
S3 core into the S1 VST3 shell over the live-state seam (bridge-read bank + audio via
the M4 project-relative path machinery). Editor deferrable behind a parameters-only
default view. CONTEXT.md §Phase S (Tier 0, seams). **Delivers the core promise.**
**Verify (in DAW):** on an instrument track, the VST3 plays a chosen bank sample
MIDI-triggered, repitched chromatically from its root note, with basic polyphony,
an amp envelope, and velocity→volume; it reads the live `"reasampler"` bank via the
bridge and resolves the WAV audio the same project-relative way `persist` does;
following the active project works; it never captures and never inserts into the
arrange (read-only over the bank).
**Depends on:** S1, S2, S3.
- [x] VST3 `process` marshalling: read MIDI note-on/off/velocity off the event bus,
drive the S3 core, write per-voice audio to the output bus. (Block-granular event
timing at Tier 0; sample-accurate offset scheduling is a later tier.)
- [x] Live-state seam: read the bank index + selected sample's root note from
`"reasampler"` ext-state via the bridge; resolve the WAV audio path the M4
project-relative way (shared convention with `persist`, not re-implemented — the
parent-of-.rpp derivation is extracted to `capture_paths::projectDirOfRpp`, which both
`persist` and the bridge call). Bank JSON parsed via the shared `bank_book` path (the
spike string-scan reader retired); ext-state key names shared via pure `ext_keys.h`.
- [x] Sample selection UI (minimal, in the `IPlugView` LICE editor): a clickable list
of the bank's samples; the pick is the instance's own VST3 component state
(setState/getState), never written back to the bank.
- [x] Tier-0 playback: chromatic-from-root, basic polyphony (16 voices), amp envelope,
velocity→volume — plays in REAPER's routing/record/render path like any VSTi. Sample
load / decode / keymap build happen off the audio thread and hand to `process` via a
lock-free atomic pointer swap (graveyard-reclaim); `process` never allocates.
## S5 — Tier 1: "a keymap" (zoned multisamples, per-sample root notes)
**Goal:** Multiple bank samples zoned across the keyboard (key ranges), each with its
own root note — a captured *kit* (one-shots) or a *multisampled instrument* (same
instrument sampled at several pitches) plays correctly. One sample per key-region.
CONTEXT.md §Phase S (Tier 1). **Where the root-note + key-range seam fields earn
their place.**
**Verify (in DAW):** a keymap of several bank samples plays correctly zoned across
the keyboard, each repitched from its own root note within its range; a captured kit
and a multisampled instrument both play as expected; the keymap is authored in the
instrument (performance map) while root notes come from the bank intrinsics (S2);
editing the keymap does not touch the bank.
**Depends on:** S4.
- [x] Keymap editor in the `IPlugView` LICE editor: assign bank samples to key ranges
(low/high note per sample), each with its own root note (from S2 intrinsics,
overridable in the performance map).
- [x] Tier-1 playback: zoned resolution — a note picks its zone's sample and repitches
from that sample's root note; one sample per key-region.
- [x] Performance-map persistence: the keymap (zones, per-sample assignment) is the
instrument's own state — held in the instrument as VST3 component state (setState/getState)
per D-B's data-ownership split; the live `"reasampler"` seam is read-only (bank +
intrinsics in, nothing written back), never written back as a bank intrinsic.
## S6 — embedded TCP/MCP UI (D-D — scheduled in-phase, after the editor)
**Goal:** Render a compact keymap/level strip **inline in the track/mixer control
panel** via `reaper_plugin_fx_embed.h` (`IReaperUIEmbedInterface`) — the same
Cockos surface REAPER's own embedded FX use — so the instrument draws inline, not only
in its own window. Composes with the S1/S5 LICE editor path (same LICE-class drawing).
**Scheduled, not deferred (D-D settled 2026-07-26):** a real later point, sequenced
last because it is polish over a Tier-0 need — but on the roadmap. CONTEXT.md §Phase S
(embedded UI, D-D).
**Verify (in DAW):** the instrument draws a compact inline strip in the TCP/MCP (not
only its own editor window); the inline surface reflects and (where offered) edits the
keymap/levels; the embed lifecycle is clean (open/close/resize); the same LICE drawing
as the main editor is reused.
**Depends on:** S5 (composes over the existing LICE editor). **Must-verify before
build:** the `IReaperUIEmbedInterface` contract + embed message/lifecycle against
`vendor/reaper-sdk/sdk/reaper_plugin_fx_embed.h`.
- [x] Implement `IReaperUIEmbedInterface` on the VST3; draw a compact keymap/level
strip inline in the TCP/MCP using the same LICE surface as the editor.
- [x] Embed lifecycle (open/close/resize/hit-test inline) handled cleanly; reflects
the live keymap/levels.
## S7 — stereo channel mode (mono | stereo; core channel dimension + bus negotiation)
**Goal:** Give the instrument a per-instance **channel-mode toggle — 1 (mono) or 2
(stereo)** — that "works with the REAPER audio bus automatically." Mono keeps today's
downmix path; stereo grows the S3 core a **channel dimension** (2-channel sample data,
per-voice stereo render, stereo interp/loop) and negotiates the VST3 output bus so
mono/stereo just works in REAPER's routing. **This is an S3-core extension, not a shell
hack** — it touches the engine Daniel smoke-tests, so it sequences first after the
editor/embed work. CONTEXT.md §Phase S (channel mode, D-E). **Decided direction
(2026-07-26); leans below are build-time residuals, not open forks.**
**Verify (in DAW):** an instance set to stereo plays a stereo capture in true stereo,
its VST3 output bus negotiated to 2 channels via `setBusArrangements` so REAPER routes it
without manual channel wiring; an instance set to mono plays the existing downmix path; a
mono source in stereo mode plays dual-mono (centered); a stereo source in mono mode
downmixes (existing policy); the mode is per-instance state that survives project
save/reopen (component state, like the selected sample); the pure core's stereo render is
asserted against a known two-channel signal (mirror of `peaks`), and mono behavior is
unchanged (regression).
**Depends on:** S3 (extends the core), S4 (extends the process/bus shell). Independent of
S8/S9.
- [x] Core channel dimension (pure, S3 extension): `SampleData` carries 1- or 2-channel
decoded PCM (`frames` + optional length-matched `framesR`; `channelCount()`);
`Voice::renderFrameStereo` + a `VoiceEngine::render(left,right,n)` overload produce a
per-channel frame sharing one read head + one envelope tick; stereo linear interpolation +
loop read per channel. Mono stays the degenerate case (`renderFrame` reads channel 0 only,
byte-identical). Tests: stereo render asserted against a known 2-channel signal; dual-mono;
per-channel repitch + additive mix; mono render unchanged (regression) — sampler_core_tests.
- [x] Channel-mode toggle as per-instance state: `ChannelMode {Mono,Stereo}` in the
instrument's own component state (v4 = v3 + a channel-mode byte; setState/getState);
default mono. Cross-mode policy in `decodeChannels`: **mono source + stereo mode →
dual-mono**; **stereo source + mono mode → downmix** (existing decode-side policy). The
toggle lives in the instrument, never written to the bank (D-B). v1/v2/v3 blobs lift to v4
with mono default; round-trip + lift tests — sample_map_tests.
- [x] Shell: `decodeRelative` fills 1- or 2-channel `DecodedZonePcm` per the active mode
(source channel count from the WAV layout); the process path renders the host's negotiated
output channel count (stereo into ch0/ch1, mono into ch0) — RT discipline unchanged.
- [x] VST3 bus negotiation: `setBusArrangements` accepts only the mode's arrangement
(kMono/kStereo), else rejects (kResultFalse) but keeps a valid mode arrangement so
`getBusArrangement` (base default) reports it; a runtime mode change repoints the output bus
+ calls `restartComponent(kIoChanged)` so REAPER re-negotiates. **Verified** against the
vendored Steinberg SDK (`ivstaudioprocessor.h` contract, `vstsinglecomponenteffect.cpp`
base impl, `ivsteditcontroller.h` kIoChanged); see handoff notes.
## S8 — ingest through the bank (one gesture: capture/import into bank + assign to instance)
**Goal:** Loading a sample into the sampler is **one gesture** — capture/import-into-bank
**and** auto-assign to the active sampler instance. **The extension owns ingest** (it has
arrange access, media-explorer access, and drop-target surface on its own panels); the
instrument stays a **read-only bank consumer**. This lives in the *extension* codebase
(actions + bank_panel + capture/insert), routing through the existing capture add-path and
the live `"reasampler"` seam the instrument already reads. CONTEXT.md §Phase S
(ingest-through-bank contract). **Decided direction "option 1" (2026-07-26).**
**Verify (in DAW):** a one-click "capture selected item / time-selection into the bank and
assign to the active instance" action captures via the existing capture path (never
auto-inserting into the arrange — load-bearing principle intact) and the target instance
plays the new sample on its next reload; a Media Explorer file imports into the bank and
assigns the same way; a file dropped onto a ReaSampler panel surface ingests into the bank
and assigns; the instrument never captures or imports (read-only over the bank throughout).
**Depends on:** S4 (an instance to assign to), M7 capture add-path, B2 (active-bank add
target). Best paired with S9 so assignment refreshes hands-free; functional without it
(assign triggers a reload on the target instance directly).
- [ ] "Capture selected item / time-selection into bank + assign to active instance"
action (`command_id`/`gaccel`/`hookcommand`, MIDI-bindable): reuse the existing capture
request path (`CountSelectedMediaItems`/`GetSelectedMediaItem` + `GetSet_LoopTimeRange`
as the capture inputs), add the resulting `Sample` to the active bank, then assign its
id to the target instance. **Never inserts a timeline item** (capture/placement stay
separate — the assignment is a bank-index + instance-selection act, not a placement).
- [ ] Media Explorer import → bank → assign: read the Media Explorer's current selection
via `MediaExplorerGetLastPlayedFileInfo` (path + selection range), import the file into
the bank (existing import/capture add-path), assign to the target instance. **Honest SDK
limit (verified against the vendored headers):** the Media-Explorer surface is thin —
`OpenMediaExplorer` (open/select) + `MediaExplorerGetLastPlayedFileInfo` (read the *one*
last-played/selected file + its range) are the whole contract; there is **no**
enumerate-selected-files and **no** register-a-drop-handler-on-the-Media-Explorer API.
So ME import is *single-file, pull-on-action* (an action the user fires while a file is
selected in the ME), not a push/drop from inside the Media Explorer. **Spike:** confirm
`MediaExplorerGetLastPlayedFileInfo` returns a usable path+range for a merely-*selected*
(not-yet-played) file, or whether a play is required first.
- [ ] Drag-and-drop onto ReaSampler surfaces: accept an OS file drop onto the docked
`bank_panel` (and its bank/tab regions) → ingest into the bank → assign. **Honest SDK
limit (verified):** REAPER exposes **no** drag-drop registration API; drop handling is on
ReaSampler's *own* HWNDs via SWELL/Win32 (`WM_DROPFILES` / an `IDropTarget` on the panel
HWND), the same surface the panel already owns. **Assess-and-flag (spike, do not promise
here):** a drop *onto the VST3 editor window* — whether the `IPlugView` HWND can accept an
OS file drop and relay it to the extension as a bank-ingest request (the instrument does
**not** ingest; it forwards a request to the extension over an agreed seam). Reported
honestly as a spike because it crosses the two-artifact boundary and the relay mechanism
is unproven; if it proves gnarly, drop-onto-panel is the shipped path and drop-onto-editor
is deferred.
- [ ] "Assign to instance" seam: how the ingest action names the target instance and hands
it the new sample id. Lean (build-time residual, not a fork): the active/last-focused
instance is the target, discovered via the host context the bridge already resolves; the
assignment is the same instance-owned selection state S4 already persists, so a reload
picks it up. If the change-detection seam (S9) exists, assignment refreshes hands-free;
without it, the ingest action pokes the target instance's reload directly.
## S9 — bank-generation change-detection (recapture / ingest refreshes instances hands-free)
**Goal:** Because instances reference sample **ids**, a **recapture** (M10) landing under
the same id — or an **ingest** (S8) touching the active bank — should refresh playing
instances **hands-free**, without the user re-opening each editor. Add a **bank-generation
counter** to `"reasampler"` ext-state that the extension bumps on any bank-content
mutation, and that the instrument polls off the audio thread on a safe cadence, calling its
existing `reloadFromBank()` when the generation changes. CONTEXT.md §Phase S
(bank-generation seam). **Closes the missing change-detection trigger the recapture
auto-update story needs.**
**Verify (in DAW):** a recapture that regenerates a sample already assigned to a live
instance refreshes that instance's playback within a bounded cadence, no editor re-open; an
ingest (S8) that updates the active bank likewise refreshes assigned instances; the poll
runs off the audio thread (never in `process`) and triggers the existing off-thread reload
path; instances not referencing a changed sample do not audibly glitch (reload is atomic —
the S4 graveyard-reclaim handoff); a project with no generation stamp (pre-S9) defaults
cleanly (treated as generation 0; first bump refreshes).
**Depends on:** S4 (the off-thread `reloadFromBank` + atomic handoff this drives). Writer
side is extension-only and independent of S8; consumed by S8 and M10 recapture. Best landed
alongside S8.
- [ ] Writer (extension): a monotonic **bank-generation counter** stamped into
`"reasampler"` ext-state (new `ext_keys.h` constant — forever-stable spelling), bumped
on every bank-content mutation that changes what an instance would play (capture add,
recapture-in-place, sample-remove, move/copy affecting the active bank). Additive to the
persist blob; defaults to 0 for projects saved before the stamp exists.
- [ ] Reader (instrument): poll the generation over the bridge on a safe **off-audio-thread
cadence** (a UI/timer tick, not `process`), compare to the last-seen value, and call the
existing `reloadFromBank()` on change — reusing S4's atomic pointer-swap handoff so a
refresh mid-play does not glitch. No new audio-thread work; no allocation in `process`.
- [ ] Cadence + coalescing: pick a poll interval that is responsive but cheap (build-time
residual — a low-frequency UI timer, coalescing multiple bumps between polls into one
reload). **Must-verify before build:** that a bridge ext-state read on the instrument's
UI/timer thread is safe against a concurrent extension write (the read already tolerates a
stale value by design — it reloads on the *next* poll; confirm no torn-read hazard for the
single integer generation key).
> **S0-DS moved to Phase L (2026-07-26).** The shared LICE drawing kit that stood here is
> now **Phase L point L1** on `dev` — see PLAN.md §Phase L + CONTEXT.md §Phase L and
> `docs/product/visual-design-language.md` (all on `dev`). S10S13 below build with the
> current drawing and **adopt the L1 kit when it lands — not gated on Phase L.**
## S10 — capture-first editor: browser + guided single-capture setup ("ReaSampler 9000" UX overhaul, part 1)
**Goal (REVISED 2026-07-26 — workflow-first reframe, Daniel):** Rebuild the editor's
default face around the **primary flow = one capture, fast**, not a keymap. A giant list of
"item" blocks is visually useless; most instances play a *single capture*, and zones are a
nice-to-have. So the default view is a **capture browser** (scannable cards with peak
thumbnails, name, root/key badge; **bank filter**) feeding a **guided single-capture setup**
(root note, play-mode basics, level) — and the keyboard strip serves the *single-capture*
case first (shows where the capture sits / its root). **Time-to-first-note is the metric.**
Multi-zone keymap editing is **demoted to an opt-in "Zones" panel** (S10-Z below), not the
default. The keyboard-strip drag machinery is still built here, but in service of the
capture-first layout. All layout/hit-test math is **pure geometry** (new `keyboard_strip` +
a `capture_browser` layout module — mirrors of `mode_switch`/`editor_geometry`); the LICE
draw + drag-state machine is the editor shell. RT discipline untouched (edits commit
off-thread via `commitMapAndReload`); the instrument stays a **read-only bank consumer**.
CONTEXT.md §Phase S (ReaSampler 9000 UX — capture-first editor).
**Policy reversal — fresh instance is SILENT, nothing auto-selected (was S4).** The S4
"first sample plays" fallback is **removed**: on open with no stored selection, the
instrument plays **nothing** and the editor shows a clear **empty state** ("pick a capture")
— it does not auto-play sample #1. Retires the `selectSample` first-sample fallback
(`sample_map.cpp` "No stored id → fall back to the FIRST sample") and the processor's
Tier-0 fallback that resolved it; an empty stored id now resolves to silence. A capture is
loaded when the user picks one (or via S13 drop-to-load / S8 ingest). This is a deliberate
reversal of the S4 convenience default, not a regression.
**Verify (in DAW):** a fresh instance plays **nothing** and shows the "pick a capture"
empty state (no auto-play of sample #1); the capture browser draws **peak thumbnails**
(the `Sample` peaks bank_model already carries — same data the dock panel thumbnails use),
name, and a root/key badge where present, and is **filterable by bank** (bank_book named
banks); picking a capture loads it, shows it (waveform/peaks + its root on the keyboard
strip), and it plays repitched from its root; time-to-first-note is a pick-then-play, not a
list-scroll; the keyboard strip shows the single capture's root and is draggable to set it;
the pure geometry modules are CTest-green (browser card/grid layout + hit-test; strip
edge-grab/body-move/key→note) with no host types at their boundary; the ±1 nudge-button row
is gone.
**Depends on:** S4 (the selection state + reload path this reverses the fallback on), S1
(the LICE `IPlugView` drag/event routing — extends the click-only `wndProc` to
`WM_MOUSEMOVE`/`WM_LBUTTONUP`), S5 (the `PerformanceMap`/zone model the opt-in Zones panel
edits — but the default face does not require a keymap). **Adopts the Phase L kit when
available — not gated on Phase L.** S10 builds its browser cards + keyboard strip with the
current LICE drawing; when Phase L's L1 kit lands on `dev`, this surface adopts it (the one
source of drawing). The drag machine's `WM_MOUSEMOVE` tracking also lights the kit's
**hover** states at near-zero marginal cost once the kit is present.
- [ ] No-auto-select + empty state (the policy reversal): remove the `selectSample`
first-sample fallback (`sample_map.cpp`) and the processor's Tier-0 fallback that
consumed it — an empty stored selection resolves to **silence**, not sample #1. The
editor draws a clear **empty state** ("pick a capture" affordance) when nothing is
selected. Pure change is testable (empty id → `nullopt`); the empty-state draw is shell.
- [ ] Capture browser (pure layout + shell draw): grow `SampleChoice` to carry the
**peak thumbnail data** (from the `Sample` peaks bank_model already stores — the same
peaks the dock panel draws), the **root/key badge** (S2 `rootNote` intrinsic / the
optional musical key), and its bank. A new pure `capture_browser` module lays out
scannable **cards/rows** (card rect grid, thumbnail rect, hit-test a point → card) — no
host types at the boundary, unit-tested. The shell draws each card's peak thumbnail +
name + badge in LICE (house palette) and routes a click to select.
- [ ] Bank filter (pure + shell): a filter/tab strip over the browser that narrows the
drawn cards to a chosen bank_book bank (or "all"). Filter-tab layout + hit-test pure
(mirror of `mode_switch`); the active-filter state is transient UI state; the shell draws
the tabs and applies the filter to the card list. (Type-to-filter search folds in from
S12 — see S12's boundary note; a name-substring filter over the same card list.)
- [ ] Guided single-capture setup (the fast path): once a capture is picked, a prominent,
self-explanatory setup surface — **root note** (settable on the keyboard strip / typed),
**play-mode basics**, **level** — sized for the single-capture case, not a zone table.
Graphic and descriptive; the point is to get from pick → set → play with no hunting.
- [ ] Pure `keyboard_strip` geometry module (serves the single-capture case first): map a
MIDI key span across a strip width (128 keys → pixels, reusing the S6 `embed_strip`
key-span idiom); a **root marker** for the loaded capture; `pixel→note` and a `keyAtPoint`
for click-to-set-root; a drag-delta resolver `(grabbedField, startNote, dxPixels) →
newNote`; **per-zone bar rect** + **edge-grab hit regions** (resize handles vs. body
move-handle) for the opt-in Zones panel. No VST3/REAPER/LICE types at the boundary;
unit-tested (root marker, edge grabs, body-move delta, key mapping, clamps low≤high,
boundary rounding). Mirror of `mode_switch`/`editor_geometry`.
- [ ] Editor shell drag-state machine: `WM_LBUTTONDOWN` grabs a card / a key / a zone
edge-or-body, `WM_MOUSEMOVE` updates the in-flight edit against the pure resolver,
`WM_LBUTTONUP` commits via the existing `commitMapAndReload` (off-thread reload; RT path
untouched). Live visual feedback while dragging; a single undo-coherent edit on release.
### S10-Z — Zones panel (opt-in multi-zone keymap editing; demoted from the default face)
The multi-zone keymap editor is now an **opt-in view/panel** ("Zones" toggle), not the
default. It reuses the same `keyboard_strip` geometry and drag-state machine: each zone a
bar over the keys it covers; **drag an edge** → low/high note; **drag the bar body** → move
the zone (span preserved); **click a key** → set/relocate the zone's root. This is the
capability RS5K structurally lacks (multi-zone in one instrument), kept as a *nice-to-have*
per Daniel's hierarchy — "most of the time the zones won't be used." Add/select/delete a
zone; overlapping zones render legibly and resolve first-match. The seven ±1 nudge/delete
mini-buttons are retired everywhere; delete is one affordance (a small × on the bar or a
keystroke). The `zoneHitTest`/±1 nudge path in `editor_geometry` is retired (a numeric
fallback for accessibility is a build-time residual, not a fork).
**Verify (in DAW):** the Zones panel is reachable via an explicit toggle (default view is
the capture browser + single-capture setup, not this); a zone's range is set by **dragging
edges** (not nudge clicks); body-drag moves the span; click-a-key sets the root (audible on
the next held note); zone add/select/delete work; the ±1 nudge row is gone.
- [ ] "Zones" panel toggle (opt-in): the default editor face is the capture browser +
single-capture setup; a toggle reveals the multi-zone keymap editor. Toggle state is
transient UI state (or per-instance component state if it should persist — build-time
residual).
- [ ] Zone edit via the shared strip: draw the keyboard strip + zone bars in LICE, drive
the shared drag-state machine (edge = resize, body = move, key = root), commit via
`commitMapAndReload`. Zone add/select/delete as single affordances; ±1 nudge row gone.
## S11 — waveform view with draggable loop points (UX overhaul, part 2)
**Goal:** Give each sample/zone a **waveform display** with **draggable start/end/loop
markers** — the S2 loop-point intrinsics and the S5 performance map already carry the data;
today there is no way to *see* a sample or *set* its loop by eye. Selecting a zone (or a
bank sample) shows its waveform (peaks via the existing `peaks` module, fed the decoded
PCM the shell already loads); drag the **loop-start / loop-end** markers to set the sustain
loop, snapping to zero-crossings (the S2 spec's zero-crossing-aware requirement). Loop
points are a **performance-map override on the zone** where set, seeded from the bank
intrinsic (D-B split: the bank carries the file-fact default; the instrument's drag is the
performance choice). All marker/waveform layout + hit-test is pure geometry; peaks compute
reuses `peaks`; the draw + drag is the shell. CONTEXT.md §Phase S (ReaSampler 9000 UX —
waveform view).
**Verify (in DAW):** selecting a zone shows its sample's waveform; dragging the loop-start
and loop-end markers sets the sustain loop and a held note audibly loops that region;
markers snap to the nearest zero-crossing; a sample with no loop shows the "no loop" state
and a held note past the end goes silent (existing core behavior); the waveform peaks match
the audio (mirror of the `peaks` envelope assertion); the marker geometry module is
CTest-green (px↔frame mapping, marker grab regions, clamp start≤end).
**Depends on:** S2 (loop-point intrinsics), S3 (loop-aware sustain the markers drive), S5
(the zone the loop attaches to), S10 (shares the editor's drag-state machine + shell). The
zero-crossing snap is a small pure helper over the decoded PCM.
> **Boundary note (S10 reframe, 2026-07-26):** the waveform view is now **central to the
> single-capture fast path**, not just per-zone. Selecting a capture in S10's browser shows
> its waveform (this is "see it" in pick → see it → play it); the loop-marker drag here
> extends that same waveform surface. S11's waveform draw is the same one S10's picked-
> capture view uses — build it once, S10 shows it read-only for the single capture, S11 adds
> the draggable loop markers. No renumber; S11 stays the loop-editing point.
- [ ] Pure waveform/marker geometry: `frame↔pixel` mapping across the waveform rect, marker
x-position from a frame index, marker grab regions (start/end/loop-start/loop-end),
drag-delta `(grabbedMarker, dxPixels) → newFrame` with clamps (start≤end, in-bounds). A
**zero-crossing snap** helper: nearest sign-change frame to a target (pure, over the
decoded mono PCM). No host types; unit-tested.
- [ ] Waveform draw: compute peaks with the existing `peaks` module from the shell's already-
decoded PCM (no new decode path, no new WAV reader); draw the envelope in LICE in the
house style; draw the loop markers over it. Reuses the S10 drag-state machine.
- [ ] Loop-point edit → performance-map override: a dragged loop writes a per-zone loop
override (seeded from the S2 bank intrinsic, D-B), committed off-thread via
`commitMapAndReload`; the bank intrinsic is never written back (instrument is a read-only
bank consumer). Extends `PerformanceZone` with an optional loop override (additive, same
shape as `rootOverride`) + its component-state (de)serialize (version bump, back-compat
with S5's v2 map blob — a truncated/older blob defaults the override absent).
## S12 — editor scale + ergonomics (UX overhaul, part 3; scrollable/searchable list, direct entry)
**Goal:** Make the editor usable **at bank scale** and close the remaining RS5K-parity
gaps: the sample list **scrolls** (today a long bank's rows run off the panel with no way
to reach them) and has a **type-to-filter search**; add **direct numeric entry** for a
zone's low/high/root (a click-to-type field over the strip, for precision the drag can't
hit) and an **ADSR control** for the amp envelope (S3 already has the ADSR math; today it
is fixed — expose attack/decay/sustain/release as draggable sliders, per-instance state).
This is the "sensible list handling + direct manipulation of the parameters that exist"
tier. All slider/scroll/search-box layout + hit-test is pure geometry; the shell draws +
routes; ADSR/scroll/filter state is instrument-owned (component state / transient UI
state). CONTEXT.md §Phase S (ReaSampler 9000 UX — scale + ergonomics).
**Verify (in DAW):** a bank with more samples than fit **scrolls** (wheel + drag) and every
sample is reachable; typing filters the list to matching names; a zone's low/high/root can
be **typed** (not only dragged) via a click-to-edit field; the amp envelope's ADSR is
**adjustable** (four draggable controls) and the change is audible + persists across project
save/reopen (component state); the scroll/search/slider geometry is CTest-green.
**Depends on:** S10 (the editor shell + drag-state machine + the **capture browser** the
scroll/search now apply to), S3 (the `AdsrParams` the ADSR sliders drive — already wired
into the voice engine; today they are fixed defaults), S5 (the map the numeric fields edit).
> **Boundary note (S10 reframe, 2026-07-26):** the "sample list" S12 originally scrolled and
> searched **is now S10's capture browser** (cards with peak thumbnails, bank filter). What
> pulled INTO S10: the browser layout itself, the peak thumbnails, and the **bank filter**
> (a bank_book tab, distinct from name search). What stays in S12 and applies **to S10's
> browser**: (a) **scroll** for a bank longer than the panel, and (b) **type-to-filter
> search** (a name-substring narrow over the same cards, composing with S10's bank filter —
> bank filter picks the bank, search narrows within it). The scroll/search geometry is pure,
> layered over the `capture_browser` module S10 builds. Net: S12 = scroll + search over the
> S10 browser + numeric entry + ADSR; the browser *card* work is S10's.
- [ ] Scrollable, searchable capture browser: a scroll offset (wheel + scrollbar drag) so a
bank longer than the panel is fully reachable; a **type-to-filter search** that narrows
the drawn cards to matching display names, **composing with S10's bank filter** (bank
filter selects the bank; search narrows within it). Scroll/search layout + hit-test is
pure geometry (visible-card window, scrollbar thumb rect, search-box rect), layered over
S10's `capture_browser` module; filter/scroll state is transient UI state.
- [ ] Direct numeric entry for zone low/high/root: a click-to-edit field over the strip
(LICE text-entry idiom or a SWELL edit control on the child HWND) so a precise note can be
typed, not only dragged. Commits via `commitMapAndReload` like every other edit.
- [ ] ADSR editor: four draggable controls (attack/decay/sustain/release) over the S3
`AdsrParams`; per-instance component state (additive to the map/selection blob, version-
bumped, back-compat defaults to the current fixed envelope). Slider layout/hit-test pure;
the audible envelope change goes through the same off-thread reload.
## S13 — drop-to-load: the S8 ingest story, folded into the editor UX (UX overhaul, part 4)
**Goal:** Make "load a sample into the sampler" **one gesture from the editor**: dropping
an OS file (or REAPER media item) **onto the editor window** ingests it into the bank and
assigns it to this instance — the RS5K "drag a file straight onto it" affordance, which is
the single biggest first-impression win RS5K has and we currently lack. This is the
**editor-window end of S8's `option 1` ingest** — the instrument does **not** ingest itself
(it stays a read-only bank consumer); the editor's drop handler **relays a bank-ingest
request to the extension** over the agreed cross-artifact seam, and the extension performs
the capture/import-into-bank + assign. S8 already flags drop-onto-editor as an unproven
cross-artifact **spike** — this point is where that spike is either proven and shipped or
falls back to the docked-panel drop path. CONTEXT.md §Phase S (ReaSampler 9000 UX — drop to
load / S8 relay). **Cross-artifact relay is a spike, not a promise — sequence after S8.**
**Verify (in DAW):** an OS file dropped onto the ReaSampler 9000 editor window ingests into
the active bank and this instance plays it (via the S8 capture/import add-path + S9 refresh,
or a direct reload if S9 absent) — **never** inserting a timeline item (capture/placement
separation intact); the instrument itself performs no capture/import/write (the editor only
*relays* the request to the extension); if the cross-artifact relay proves unworkable, the
drop-onto-docked-`bank_panel` path (S8) is the shipped ingest and this degrades cleanly with
a clear affordance pointing there.
**Depends on:** S8 (owns the extension-side capture/import + assign, and the relay seam), S1
(the editor HWND that accepts the drop), S9 (hands-free refresh after assign; functional
without it via a direct reload). **Spike — do not promise the drop-onto-editor path until
the relay is proven.**
- [ ] Editor-window drop target: accept `WM_DROPFILES`/`IDropTarget` on the editor child
HWND (the same SWELL/Win32 surface `bank_panel` owns), extracting the dropped file
path(s). Windows-only (D5). This is the *acceptance* half; the ingest is the extension's.
- [ ] Cross-artifact ingest relay (the S8-flagged spike): the editor hands the dropped
path + this instance's identity to the extension as a **bank-ingest request** over the
agreed seam (the instrument never writes the bank). **Prove the relay mechanism before
promising it**; if gnarly, fall back to the S8 docked-panel drop path and mark
drop-onto-editor deferred.
- [ ] UX degrade path: when the relay is unavailable/unproven, the editor shows a clear
"drop files on the ReaSampler panel to add" affordance rather than silently swallowing the
drop — the shipped ingest gesture stays discoverable either way.
## S15 — sampling modes: Trigger vs Gate (per-sample play-mode; core + editor)
**Goal:** Give each played sample a **play mode****Gate** (classic held note) or
**Trigger** (one-shot) — a per-sample/per-zone performance choice (D-B, instrument-owned).
**Gate** is today's behavior grown from ADSR to **AHDSR** (adds a Hold stage): note-on →
attack/hold/decay/sustain, note-off → release, sustain **loop points apply** (S11's
draggable loop UI is Gate-mode UI). **Trigger** is a one-shot drum-pad: note-on fires
playback of a defined **% of sample length** with a **fade-in** and **fade-out** ramp,
**ignores note-off**, and uses **no sustain loop**. **Both** modes carry a **modifiable
start point** (playback begins at an offset into the sample, not always frame 0). This is
an **S3-core extension** (the engine Daniel smoke-tests) plus editor surfacing — the mode +
its parameters are instrument performance-map state, never a bank fact. CONTEXT.md §Phase S
(Sampling modes — Trigger vs Gate). **Daniel's feature set is settled; the leans below are
build-time residuals, not open forks — except the flagged forks S15-F1/F2.**
**Verify (in DAW):** a sample in **Gate** mode plays held with the AHDSR envelope (hold
stage audible between attack and decay), releases on note-off, and loops its sustain region
if loop points are set; a sample in **Trigger** mode fires a fixed % of its length on
note-on with audible fade-in/out, **plays through to completion regardless of note-off**,
and never sustain-loops; the **start point** offsets playback in both modes (a note starts
partway into the sample); the mode + parameters are per-instance component state that
survive save/reopen; the pure core's Trigger envelope (fade-in → hold → fade-out over
%-length frames) and the AHDSR hold stage are asserted against known signals (mirror of
`peaks`); existing Gate/ADSR behavior is unchanged when hold=0 (regression). **Spec
channel-count-agnostic** — the mode/envelope logic is per-frame amplitude and read-position,
independent of the S7 channel dimension (§sequencing).
**Depends on:** S3 (extends the envelope + voice read-position machinery), S5 (the
`PerformanceZone` the mode + params attach to), S11 (Gate loop-point UI; Trigger's waveform
shows start + %-length + fades on the **same** waveform surface). Independent of S7 —
orthogonal dimensions (§sequencing note in CONTEXT.md).
- [ ] Core: `PlayMode { Gate, Trigger }` on the voice + the envelope split. **Gate** grows
`AdsrParams``AhdsrParams` (add `holdFrames` between attack and decay; hold=0 is the
exact current ADSR — back-compat). **Trigger** is a distinct envelope: play `[start,
start + lengthFraction·(framesstart))` with a **fade-in** ramp (0→1 over `fadeInFrames`)
and a **fade-out** ramp (1→0 over `fadeOutFrames` ending at the play-length end),
**ignoring note-off** (release is a no-op in Trigger). Fade curve default **equal-power**
(constant-power `sin`/`cos`, click-free on one-shots) with the shape noted; linear is a
build-time residual. Pure, unit-tested against a known signal.
- [ ] Core: **modifiable start point** — the voice's initial `readPos_` is `startFrame`
(frame offset), applied in both modes; the existing per-frame `readPos_ += ratio_` read
and loop/interp machinery is otherwise unchanged. Clamp `0 ≤ startFrame < frames`.
- [ ] Core: **% length → frames + fade mapping** for Trigger. `lengthFraction ∈ (0,1]`
resolves to `playEnd = start + round(lengthFraction·(frames start))`; `fadeInFrames` /
`fadeOutFrames` clamp so their sum ≤ play length (fade-out anchored to `playEnd`). Note-off
in Trigger does nothing; the voice frees when `readPos_ ≥ playEnd` (mirror of the current
run-off-end idle). **Choke on note-off is NOT in scope** (fork S15-F1, held below).
- [ ] Parameter ownership (per-sample/per-zone, instrument-owned): the play mode + its
params (Gate: AHDSR; Trigger: %-length, fade-in, fade-out; both: start point) attach to
the **capture selection / zone**, stored in the **performance map** (D-B). **Lean
(build-time residual):** start point joins `rootOverride`/loop-override as another
per-`PerformanceZone` optional override, and a per-zone `PlayMode` + its param struct is
added additively (version-bumped component state, back-compat — a truncated/older blob
defaults to **Gate**, hold=0, start=0, no fades = exactly today's behavior). **Fork
S15-F2 (flagged):** whether these live per-capture-selection (S10's single-capture flow)
**and** per-zone, or per-zone only with the single-capture case as a one-zone map. Lean:
per-zone only — the single capture is already a one-zone map (S10-Z back-compat lift),
so one storage site serves both. Flagged because it touches S10's single-capture setup
surface shape.
- [ ] Editor (S11 waveform surface, mode-aware): **Gate** shows draggable **start + loop
markers** (S11's loop UI); **Trigger** shows **start + %-length end + fade-in/out**
handles on the same waveform. A **mode toggle** per capture/zone in the guided setup
(S10) / Zones panel (S10-Z). Marker/handle geometry is pure (extends the S11
`frame↔pixel` + marker-grab module); commits off-thread via `commitMapAndReload`. The
instrument stays a **read-only bank consumer** (mode/params are performance map, never
written to the bank).
> **S15 × S16 pitch-engine interaction (informs the S16 Preserve engine).** S15's amplitude
> semantics are defined over the voice's **source-frame** timeline, which the S16 pitch-engine
> mode (Varispeed vs Preserve) changes underneath them. Contracts to hold:
> - **Trigger %-length** — under **Preserve**, %-length is measured in **source frames**
> (`playEnd = start + round(lengthFraction·(frames start))`, unchanged) but wall-clock is
> now **stable under transpose** (a transposed Trigger keeps its %-length duration). This is
> *cleaner* than Varispeed, where transposing a Trigger also scales its audible length. So
> S15's %-length spec is unchanged; Preserve just makes it pitch-independent.
> - **Gate sustain loop** — under **Preserve**, loop the **source read** (the `[loopStart,
> loopEnd)` source-frame region S15/S2 already defines) and feed the looped source stream
> into the shifter; the shifter transposes the **output**. Contract: *loop the source, shift
> the output* — the loop points stay source-frame facts (S11's markers are unchanged), and
> the Preserve engine sits after the loop read. Under Varispeed the loop read itself carries
> the pitch (today's behavior).
> - **Start point** — unaffected by engine: it is a source-frame offset (initial read
> position) in both, independent of how pitch is applied.
> These are S16-owned build details (the Preserve engine consumes S15's source-frame read);
> S15 lands its amplitude/read machinery source-frame-defined and channel-agnostic (S7), and
> S16's engine wraps it — no change to S15's committed points.
## S16 — pitch engine modes (Varispeed vs Preserve) + pitch envelope (per-voice)
**Goal:** Give the sampler **two pitch behaviors** and a pitch envelope that rides whichever
is chosen. Repitch today is **Varispeed** — resampling that couples pitch and duration (an
octave up halves the note's duration; the classic sampler / RS5K default). Daniel's directive
(2026-07-26, verbatim: *"isn't that ratio stuff going to change the playback rate? I want
duration-preserving repitching"*) adds **Preserve** — duration-preserving repitch, where a
transposed note keeps its original length. Both are musically legitimate: **drums / one-shots
often want varispeed character** (the pitch-down-lengthens-the-hit sound), **tempo-locked
loops and phrases want Preserve** (a repitched loop still lines up to the bar). So the shape
is a **per-zone/per-capture pitch-engine mode** — a **Varispeed** engine (current, cheap,
`readPos_ += ratio_` resampling) vs a **Preserve** engine (duration-preserving pitch shift).
On top of either engine rides a per-voice **AD pitch envelope**, **off by default** — a short
attack-decay pitch modulation (the classic percussive **pitch drop**): under Varispeed it
biases `ratio_`; under Preserve it biases the shifter's shift amount. Per-instance
performance-map state (D-B). CONTEXT.md §Phase S (Pitch engine modes + pitch envelope).
**Feature settled per the directive; the engine default is a flagged Daniel fork (S16-F1),
the Preserve-engine implementation choice is a flagged fork (S16-F2).**
**Verify (in DAW):**
- **Varispeed engine** (per-zone): a note an octave up plays **half as long** as the root
note (pitch and duration coupled) — the current behavior, now explicitly the Varispeed mode.
- **Preserve engine** (per-zone): a note an octave up plays at the **same duration** as the
root note (pitch shifted, length held) — a Gate held note sustains as long as held; a
Trigger one-shot at %-length keeps its %-length wall-clock regardless of transpose.
- **Pitch envelope off** (default) under **either** engine: playback is identical to the
engine's un-modulated output (regression — no pitch modulation applied).
- **Pitch envelope on**: an AD envelope makes a note **start offset in pitch and glide to the
zone's base pitch** over attack+decay (percussive drop when the offset is positive-then-
settle); range settable in **semitones (±)**; per-voice (polyphonic notes each run their own).
- **Preserve CPU / voice-cap:** with a chord of Preserve-mode voices, CPU stays within budget
and no audio dropout at the polyphony cap; if Preserve is materially heavier, a **Preserve-
mode-specific voice cap** kicks in (below the Varispeed cap) rather than glitching.
- **Latency honesty:** a Preserve note has a small onset latency (the shifter's window); the
spec accepts this as a Preserve-mode property, and a **note-onset click/smear is absent**
(the shifter is pre-warmed at voice-allocation, not cold-started in `process`).
- **RT-safety:** no allocation in `process` for **either** engine — the Varispeed path is the
same per-frame tick idiom as the amp envelope; the Preserve shifter's buffers are
pre-sized/pre-warmed at voice allocation and reused (no queue growth in steady state).
- Pure-core assertions: the pitch-envelope curve against known values (offset at t=0, base at
t=attack+decay); the Varispeed ratio math; the Preserve engine's duration invariance (a
transposed render is the same frame-length as the un-transposed render).
**Depends on:** S3 (the voice read-increment + envelope tick idiom; the Varispeed path *is*
the current read loop), S5 (the `PerformanceZone` the mode + envelope attach to), S15 (the
per-zone param plumbing + component-state version bump the mode/envelope hang on; and the
Gate-loop / Trigger-%-length semantics the Preserve engine must honor — see S15 interaction
below). Independent of S7 (both engines operate per-frame, channel-count agnostic).
- [ ] Core: **pitch-engine mode on the voice/zone**`PitchEngine { Varispeed, Preserve }`.
**Varispeed** = today's path (`readPos_ += ratio_`, `ratio_ = pitchRatio(note,root)`), pitch
and duration coupled. **Preserve** = duration-preserving: the read advances at the **source**
rate (duration held) while a pitch shifter transposes the output by
`2^((noteroot)/12)`. Mode is per-`PerformanceZone` performance state (D-B), additive/
version-bumped; **default is S16-F1 (flagged Daniel fork — lean Preserve, argued below).**
Absent/older blob → the fork's default. Pure where possible: the Varispeed math and the
duration-invariance contract are unit-tested; the Preserve DSP core is unit-tested for
duration invariance and transpose correctness against a known signal.
- [ ] Core: **Preserve engine implementation (fork S16-F2, flagged).** Two viable routes, both
RT-disciplined (pre-allocated, no locks, no `process` allocation):
- **(a) `WDL_SimplePitchShifter`** (`vendor/WDL/WDL/simple_pitchshift.h`) — a per-voice
time-domain OLA shifter. **Now the right category** (duration-preserving is the
requirement, not the wrong tool it was under the varispeed-only framing). Viability from
the header (assessed this pass): push/pull block API (`GetBuffer`/`BufferDone`/
`GetSamples`), `set_shift(2^(semi/12))` for pitch with `set_tempo(1.0)` for held duration
— pitch and duration are **separately controllable**, exactly Preserve. Per-instance memory
is modest (an OLA ring `bsize = window_ms·srate` ≈ a few KB/voice at the ~50 ms quality-0
window, plus a bounded output queue). CPU is cheap (O(length), a few mults + one OLA
crossfade per frame — REAPER's "SimpleWindowed" mode, known-basic but usable), so N
polyphonic voices each running one is **feasible within RT discipline**. **Costs, stated:**
(i) **latency** — inherent ~half-window onset delay (~25 ms at the 50 ms window) plus
fill-up, so Preserve notes have a real onset lag; mitigated by pre-warming the shifter at
voice-allocation, and it lands mostly on sustained/loop material (Varispeed serves the
tight-transient one-shots); (ii) **queue allocation**`BufferDone` grows `m_queue` via
`WDL_Queue::Add`, an RT hazard *only* until steady state; pre-warm with silence at voice-
allocation so the buffers settle and stop growing; (iii) **quality** — basic OLA, audible
warble on large transpositions and no formant preservation (`set_formant_shift` is an
empty stub), acceptable for the loop/phrase use case.
- **(b) hand-rolled OLA/granular pitch core** as a **pure module** (`pitch_shift`, mirroring
the house pattern — CTest-testable, no REAPER/VST3/WDL type at the boundary). More work,
but full control over latency/window/crossfade, RT-shape owned by us, and it sits natively
alongside `peaks`/`wav_trim`. **Lean: start with (a)** to prove the Preserve mode end-to-
end at low cost, and hold (b) as the quality/latency upgrade if the SimpleWindowed warble
or onset lag proves musically unacceptable — the mode's *contract* is identical either way,
so the engine swap is behind the `PitchEngine::Preserve` seam. **`WDL_Resampler` does not
apply here** — it is a *resampler* (couples duration), a Varispeed-quality option, not a
Preserve engine. **elastique is NOT available** (licensed zplane, not in the vendored tree
— restated, not worked around); JUCE / rubberband / signalsmith are **new-dependency forks
carrying full D-A weight** (bare-VST3-no-framework is locked D-A) — not proposed.
- [ ] Core: a per-voice **AD pitch envelope**, engine-aware — `PitchEnvParams { enabled=false,
int64 attackFrames, int64 decayFrames, double peakSemitones }`. **Shape (lean, build-time
residual):** two-segment AD — at note-on the pitch offset rises to `peakSemitones` over
`attackFrames`, then falls to 0 over `decayFrames` (**zero attack** = the pure "start high,
drop to base" percussive drop). Off by default (`enabled=false` → offset always 0). **Applied
per engine:** under **Varispeed** the offset multiplies `ratio_` by
`2^(pitchEnvSemitones(frame)/12)` (the read-rate bias, as before); under **Preserve** the
offset is **added to the shifter's shift amount** — `set_shift(2^((noteroot +
pitchEnvSemitones(frame))/12))` — so the pitch bends without touching duration. Pure, unit-
tested (t=0 offset, t=attack peak, t=attack+decay → 0; and the semitone→shift/ratio mapping
for both engines).
- [ ] Parameter ownership + editor: pitch-engine mode + pitch envelope are per-zone
instrument performance-map state (D-B), additive/version-bumped (absent → engine default per
S16-F1, envelope disabled). Editor exposure: a **per-zone Varispeed/Preserve toggle** in the
S10 guided setup / S10-Z Zones panel (a two-state control next to the S15 mode toggle), plus
a small AD + ±semitone depth control for the envelope (folds into the S12 ADSR-editor tier).
Default-off envelope so the control is discoverable but inert until enabled. The instrument
stays a **read-only bank consumer** (mode/envelope are performance map, never written to the
bank).
> **S16-F1 (FLAGGED — Daniel fork): the default pitch engine.** **Lean: Preserve default.**
> Argued honestly both ways:
> - **For Preserve default** (the lean): Daniel asked for duration-preserving *unprompted*,
> which reads as the behavior he expects; and the capture workflow is **loop/phrase-heavy**
> (banks are captured slices of a project — tempo-locked material that benefits from
> duration preservation when transposed). For that material, Varispeed's tempo-drift on
> transpose is the surprising/wrong-feeling result.
> - **For Varispeed default** (the honest counter): **Varispeed is the classic-sampler
> expectation** (RS5K, hardware samplers, the whole tradition default to it); it is **cheaper
> and zero-latency** (no shifter, no onset lag); and it is bit-identical to the current
> shipped S3/S5 behavior, so a Preserve default is a *behavior change* for any existing feel.
> Percussive one-shot material specifically *wants* the varispeed character.
> - **Recommendation:** default **Preserve** because Daniel asked for it and the material skews
> loops, but make the per-zone toggle **prominent and cheap to flip** so drum/one-shot zones
> trivially opt into Varispeed. **Daniel's call.**
> **S16-F2 (FLAGGED): the Preserve engine implementation.** `WDL_SimplePitchShifter` (route a,
> low-cost proof) vs a hand-rolled pure `pitch_shift` OLA/granular module (route b, more work,
> full control, house-native + CTest-testable). **Lean: (a) first, (b) as the held quality/
> latency upgrade** — same `PitchEngine::Preserve` contract behind the seam either way. Not
> load-bearing for the *feature* decision (S16-F1); a build-time route choice flagged because
> it sets whether a new vendored-WDL usage or a new pure module enters the tree.
> **WDL pitch capabilities — verified finding (feeds S16 build, not a committed point).**
> The full WDL pitch/resample surface was swept (`vendor/WDL/WDL/resample.h`,
> `simple_pitchshift.h` — the only two pitch/resample headers; no elastique, no
> formant-preserving/time-stretch anywhere in the vendored tree). Findings, honest:
> - **`WDL_Resampler`** (`resample.h`) — a real **sinc/linear resampler** (`SetMode(interp,
> filtercnt, sinc, sinc_size, sinc_interpsize)`; sinc up to 64-tap). It is **RT-suitable**
> (streaming `ResamplePrepare`/`ResampleOut`, prealloc-able, no per-block alloc if
> pre-sized) and its **sinc mode beats the core's current 2-point linear interp** for
> repitch quality (fewer aliasing artifacts on large transpositions) at a real CPU cost
> (64-tap conv per output sample vs. one lerp). **Fit:** an *optional quality upgrade for
> the **Varispeed** base repitch path* — a per-voice quality toggle (linear = cheap default,
> sinc = quality) — **not** required for S16 and **not** committed here. A resampler couples
> duration, so it is **not** a Preserve engine. Held as a Tier-2/3 Varispeed-quality option.
> - **`WDL_SimplePitchShifter`** (`simple_pitchshift.h`) — a **time-domain overlap-add,
> duration-preserving pitch shifter** (push/pull block API; `set_shift` for pitch and
> `set_tempo` as an independent duration knob; quality parameter selecting window/overlap
> sizes). **Under Daniel's duration-preserving directive this is the right category** — the
> candidate Preserve engine (fork S16-F2, route a). **Viability (from the header):** per-voice
> instantiable at modest memory (an OLA ring ≈ window·srate, a few KB/voice at the ~50 ms
> quality-0 window, plus a bounded output queue); CPU is cheap (O(length), a few mults + one
> OLA crossfade per frame — no FFT); N polyphonic voices each running one is **feasible in
> RT discipline** with **two caveats:** (i) **inherent latency** ~half-window (~25 ms @ 50 ms
> window) + fill-up → a real note-onset lag (pre-warm at voice-allocation; it lands on
> sustained/loop material where it is least harmful); (ii) `BufferDone` grows `m_queue` via
> `WDL_Queue::Add` — an allocation hazard **only until steady state**, pre-warmed away with a
> silence pass at voice-allocation. **Quality is basic** (SimpleWindowed warble on large
> transpositions) and **`set_formant_shift` is an empty stub** (no formant preservation) —
> acceptable for the loop/phrase Preserve use, replaceable by the hand-rolled route (b) if not.
> - **Formant-preserving / high-quality time-stretch (elastique-class): NOT in WDL, confirmed.**
> REAPER's elastique is **licensed (zplane), not part of the open WDL/reaper-sdk vendored
> tree** — grep of `vendor/WDL` for elastique/formant/time-stretch found only unrelated
> libpng/giflib string matches. Formant-correct / studio-grade duration-preserving repitch is
> **unavailable to the instrument** without a new third-party dependency (JUCE / rubberband /
> signalsmith would each be a **new-dependency fork carrying D-A weight** — bare-VST3-no-
> framework is the locked D-A choice — and are **not proposed**). **Stated, not worked around.**
> - **Recommendation:** the **Preserve** engine (S16-F2) is either `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) remains a held **Varispeed-quality** upgrade only.
> **S14 moved to Phase L (2026-07-26).** The dock-panel refresh that stood here is now
> **Phase L point L2** on `dev` — and, per Daniel's DS-3 call, expanded from a light re-skin
> into a **thorough dock-panel layout redesign** that lays out the full M11-aware button
> inventory before applying the kit. See PLAN.md §Phase L + CONTEXT.md §Phase L and
> `docs/product/visual-design-language.md` (all on `dev`). The design-system forks DS-1/
> DS-2/DS-3 are all **SETTLED (2026-07-26)** and recorded in the Phase L docs on `dev`.
## Phase S — product name (ReaSampler 9000)
The MIDI-playback instrument's product name is **ReaSampler 9000** (Daniel, 2026-07-26,
on DAW-testing the S1S6 instrument). The extension remains **ReaSampler**; the instrument
is **ReaSampler 9000**. Framing + propagation surfaces:
`docs/product/midi-playback.md` §Product name.
- [ ] Propagate the display name **ReaSampler 9000** across user-visible surfaces: the VST3
class **display name** string (in the factory registration), the `IPlugView` editor title
band (currently "ReaSampler Instrument"), the S6 embed-strip label, and the Phase S docs.
**Do NOT change the VST3 class UID** — instances in already-saved projects key off it; a
UID change orphans every existing instance.
- [ ] **Rename the binary filename too (S-NAME-1 SETTLED, Daniel 2026-07-26):** rename the
built VST3 module (CMake `OUTPUT_NAME` / target artifact — e.g. `reasampler_9000.vst3`)
alongside the display strings, so the on-disk name matches the product name. Record the
full rename surface: **CMake output name** (the second VST3 target's artifact name), the
**factory vendor/name strings**, the **`IPlugView` editor title**, and the **S6 embed
label**. Do NOT touch the **VST3 class UID** (unchanged — the compat anchor).
- [ ] **Compat verification (must-DAW-verify before shipping the rename):** the working
assumption is that REAPER **rebinds a saved instance by its VST3 class UID, not by the
module filename**, so a filename rename with an unchanged UID keeps saved projects working
(existing instances still resolve). **This is not yet confirmed from source** — a web
check surfaced a JUCE/VST3-replace-VST2 case suggesting REAPER's binding is more nuanced
than "UID only" (it can involve an FXID match), so treat UID-rebind as **to-verify, not
asserted fact**. **DAW-verify:** save a project with a ReaSampler 9000 instance under the
old filename, rename the module, reopen — confirm the instance rebinds and restores its
state. If REAPER does key partly on filename, fall back to keeping the current filename
(display-strings-only) and record that as the shipped choice.
## S17 — drop-and-load: drag a capture onto a track's FX button → instantiate ReaSampler 9000 with the capture loaded
**Goal:** Turn a bank capture into a playable instrument in one gesture. Today a drag
out of the `bank_panel` becomes an OS file drag once it leaves the panel (M11 —
`drag_out` + `drag_out_win`, CF_HDROP). This wave adds a **second, internal drag mode**:
while a capture is dragged, a track's TCP **FX button** lights as a drop zone, and
dropping there instantiates a **ReaSampler 9000** (the Phase S VST3 sampler) on that
track with the dragged capture **already loaded and selected** for playback. The
extension drives the whole gesture itself — REAPER's FX button is not a native
plugin-with-file drop target, so this cannot ride the CF_HDROP path. CONTEXT.md §Phase S
(drop-and-load — internal-drag hover mode + the FX-button drop → add-VST + load-capture
seam). Product framing: `docs/product/midi-playback.md` (drop-and-load — the third
integration gesture).
**Consistent with the load-bearing principle (make the reasoning explicit):** this is an
**explicit, user-driven placement gesture** — the user is deliberately choosing to place
a playing instrument on a track, exactly as inserting an item into the arrange is a
deliberate placement 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 distinct acts; drop-and-load is a placement-of-the-player gesture, not
a capture and not a timeline insert.
**Two-part mechanism:**
- **(a) Internal-drag hover mode.** A drag armed with a *single* capture that stays
*inside* REAPER's own UI (does not cross to Explorer / another app) is tracked by the
extension: it detects the pointer hovering a track's TCP FX button, highlights it as a
drop target, and on release drives the insert. This is a *third* `DragGesture` beyond
the existing `Internal` (bank-to-bank) and `OsDrag` (M11) — call it `InstrumentDrop`.
- **(b) FX-button drop → add-VST + load-capture.** On drop, the extension adds a
ReaSampler 9000 instance to the target track via `TrackFX_AddByName` (verified present
in `reaper_plugin_functions.h`; signature
`int TrackFX_AddByName(MediaTrack*, const char* fxname, bool recFX, int instantiate)`
use `"VST3:ReaSampler 9000"` and a negative `instantiate` to always create a new
instance), then pushes the dragged capture's identity into that instance so it plays
that sample — via the **load-capture seam** (below).
**The ReaSampler 9000 load-capture seam (the hard Phase S coupling — MUST be added):**
The current Phase S spec gives the instrument a *live-state read* seam (it reads the bank
index + mapping from `"reasampler"` ext-state via the bridge) but **no entry point for an
external actor to say "instantiate playing *this specific* capture."** This wave is the
reason to add that seam. It is a Phase S dependency, not extension-side, and must land in
the instrument before drop-and-load's drop half can work end-to-end. **Mechanism (SETTLED
— (B) VST3 component-state injection):** after `TrackFX_AddByName`, the extension writes the
new instance's component state directly via `TrackFX_SetNamedConfigParm(track, fx,
"vst_chunk", <blob>)`**verified present** in `reaper_plugin_functions.h` (`bool
TrackFX_SetNamedConfigParm(MediaTrack*, int fx, const char* parmname, const char* value)`;
the header documents the `vst_chunk`/`vst_chunk_program` parms as the base64-encoded
VST-specific chunk write-path). Deterministic, no shared-state race, no cross-process
handshake. **Load-bearing caveat:** `vst_chunk` is the plugin's **own** base64-encoded
serialized chunk (its `getChunk`/`setChunk` FXP/FXB-style blob), **not** a raw VST3
`IComponent::setState` stream REAPER re-marshals — so the extension must construct exactly
ReaSampler 9000's own state-blob bytes with the target capture pre-selected. That makes the
**component-state blob format a shared cross-artifact contract** — still being defined in
Phase S — and a coordination dependency between the extension and the instrument: both must
agree on the exact byte layout the instrument's `setChunk` accepts. Rejected alternative —
**(A) fresh-instance ext-state handshake** (extension writes a "pending load" hint into
`"reasampler"` ext-state, the fresh instance claims + clears it on init): loosely coupled
through the existing bridge, but loses on the claim/clear race — "which instance claims
which hint" needs a stable key and a cross-process handshake to get right. See CONTEXT.md
§Phase S (drop-and-load) for the full seam decision.
**Coexistence with the M11 OS drag-out (disambiguation, load-bearing):** the two drag
modes are disambiguated by **where the pointer goes**, not by a mode toggle. Inside the
panel client rect → `Internal` (unchanged). Left the panel but still over REAPER's own
window/UI → `InstrumentDrop` (new — hover-tracks the FX button). Left REAPER entirely
(Explorer / another app) → `OsDrag` (unchanged M11). The M11 `decideGesture` boundary
(pointer left the client rect) is **refined**, not replaced: leaving the client rect no
longer immediately means OS-bound; it means "resolve which of InstrumentDrop / OsDrag by
whether the pointer is over REAPER's UI." Single-capture vs. multi-capture also
disambiguates — see open questions.
**Verify (in DAW):** dragging a single capture from the dock over a track's FX button
highlights it; dropping instantiates ReaSampler 9000 on that track with the dragged
capture loaded, selected, and MIDI-playable immediately (no manual pick step); the OS
drag-out to Explorer / another DAW still works unchanged; the internal bank-to-bank drag
still works unchanged; no media item is ever inserted into the arrange; the instrument
holds no private copy (it reads the one authoritative bank).
**Depends on:** M11 (`drag_out` gesture machinery — the mode it extends); **Phase S S4**
(a loadable, playing ReaSampler 9000 instance must exist) **AND the new load-capture seam
added inside ReaSampler 9000 via (B) component-state injection — the shared component-state
blob contract must be defined so the extension can construct it and the instrument's
`setChunk` accept it**. Composes with — but is distinct from — **S8** (ingest
through the bank: capture/import/drop *into* the bank) and **S13** (drop-to-load *inside*
the editor). S17 is the third integration gesture: drop *onto a track's FX button* to
instantiate a player. Gated on the rest of Phase S; the instrument seam is a Phase S
artifact, not extension-only.
- [ ] Extend the `drag_out` pure module with the third gesture: `decideGesture` (or a
successor) returns `InstrumentDrop` when a drag armed with a single capture is over
REAPER's UI outside the panel client rect, `OsDrag` only when it has left REAPER
entirely, `Internal`/`OsDrag`/`None` otherwise unchanged. Pure over (drag state +
pointer + panel rect + an "over-own-UI" predicate the shell supplies). Unit-tested —
the existing `drag_out` invariants (M11) must not regress.
- [ ] Shell (extension): hover-track the pointer over REAPER's UI during the drag,
resolve the hovered track + its FX button (verify the TCP/FX-button hit surface against
the SDK — see must-verify), highlight it as a drop target, and on release drive the
drop. Extends the `bank_panel` drag hook alongside the M11 `drag_out_win` path.
- [ ] Shell (extension): on drop, `TrackFX_AddByName(track, "VST3:ReaSampler 9000",
false, /*instantiate*/ negative)` to always add a fresh instance; capture the returned
FX index; then invoke the load-capture seam to point the new instance at the dragged
capture. Batched into one REAPER undo point (`Undo_BeginBlock2`/`EndBlock2`) so the
whole gesture is one Ctrl-Z (mirrors the bank-verb undo discipline).
- [ ] **ReaSampler 9000 (Phase S artifact):** add the **load-capture seam** — the entry
point that lets the just-added instance be told which capture to play via **(B)
component-state injection**: the instrument's `getChunk`/`setChunk` must round-trip a
component-state blob whose byte layout is the **shared cross-artifact contract** (defined
in Phase S) that the extension constructs with the target capture pre-selected and writes
via `TrackFX_SetNamedConfigParm(..., "vst_chunk", ...)`. This is the cross-artifact half;
it lands in the instrument, not the extension. **Coordination dependency:** the blob
format must be agreed between extension and instrument before either half is final.
- [ ] Tests: gesture disambiguation (inside-panel / over-REAPER-UI / left-REAPER) across
single- and multi-capture payloads; FX-button hit resolution (pure geometry where it
can be factored out); M11 OS drag-out and internal bank-to-bank drag both unchanged.
## S18 — VST3 channel isolation: a beta ReaSampler 9000 that pairs with the beta extension only
**Goal:** Extend Phase V's beta/stable channel split (V4 — the *extension* is fully
isolated per channel) to the **ReaSampler 9000 VST3 instrument**, so a beta-built VST is a
distinct plugin that pairs only with the beta extension, and a stable VST pairs only with
stable — installable side-by-side in one REAPER with no collision. **What already works
(established from the tree, not re-derived): data pairing is done.** `ext_keys.h`'s
namespace is channel-derived (`kProjExtNamespace()` → `app_version::extStateNamespace()`),
so a beta-compiled VST already *reads* `"reasampler_beta"`; every wire key (`banks`,
`assign_request`, S9's future generation key, any future key) is a plain constant *under*
that namespace, so channel data-isolation is **structural, not per-key** — new keys inherit
it automatically. **What is missing is the VST's *plugin identity*:** its class UID, binary
filename, and display strings are single-valued today (same for both channels → a
UID/filename collision if both are installed). This wave closes that. Mirrors V4's
philosophy: **one channel per binary; all identity derives from the ONE
`REASAMPLER_CHANNEL_IS_BETA` bit via `app_version`, no scattered `#ifdef`s.** CONTEXT.md
§Phase S (VST3 channel identity — the UID-pair invariant + the pairing surface). Product
framing: `docs/product/midi-playback.md` §Addendum — VST channel isolation.
**Consistent with V4 (make the invariant explicit):** V4 already committed the extension to
*two* forever-stable command-id families and *two* forever-stable ext-state namespaces. This
wave commits the *instrument* to the parallel permanent cost: **two forever-stable VST3 class
UIDs** — the existing stable UID (S-NAME-1, locked) and a second beta UID (minted once,
locked forever the same way). Both are frozen forever; the channel bit selects which is
*compiled into this binary*. **One class per binary, not both in one binary** — this mirrors
V4's fully-isolated-binary philosophy (a channel build carries only its own identity), keeps
the factory registration a single `DEF_CLASS2`, and means a beta-installed VST cannot present
the stable identity.
**Verify (in DAW):** the stable VST3 (`reasampler_9000.vst3`, existing UID) and a beta VST3
(`reasampler_9000_beta.vst3`, the new UID) install side-by-side in one REAPER and both appear
in the FX browser as distinct plugins ("ReaSampler 9000" and "ReaSampler 9000 beta"); a beta
instance reads only the beta extension's banks (its browser shows the beta banks, never
stable's) and a stable instance only stable's; a project saved with a **beta** instance
reopens rebinding to the beta VST (not the stable one) and restores its state; a project
saved with a **stable** instance opened where only the beta extension has banks shows a clean
empty "pick a capture" state (S10 policy), **not** an error; the S-NAME-1 save-rename-reopen
compat test extends to the beta UID (a beta instance rebinds by its own UID). **Nothing plays
differently** — this is an identity/pairing wave, no engine change.
**Depends on:** V4 (the `app_version` channel-identity single-source this extends), S1 (the
VST3 factory + `reasampler_vst.h` identity constants + the CMake second target it edits).
Consumes the S4 live-state seam only to *demonstrate* pairing; adds no new seam. **Disjoint
from the in-flight waves:** touches `vst_entry.cpp`, `reasampler_vst.h`, and the CMake VST3
block — S9 (ext_keys additions) and S15/S16 (processor/editor) are elsewhere. **ext_keys
coordination note:** S9's new generation key is a plain constant under the channel-derived
namespace, so it inherits this isolation with no S18 change; no coordination edit is needed —
only the shared awareness that all wire keys must live under `kProjExtNamespace()` (already
the rule). Dispatchable in parallel with, or immediately after, the in-flight waves.
- [ ] **Beta VST3 class UID (the permanent commitment).** Mint a second FOREVER-STABLE
class UID in `reasampler_vst.h` alongside the existing `kReaSamplerProcessorUID`
(`REASAMPLER_PROC_UID_1..4`) — a distinct forever-frozen constant (e.g.
`REASAMPLER_PROC_UID_BETA_1..4` + `kReaSamplerProcessorUIDBeta`). The channel bit selects
which UID the factory registers (`DEF_CLASS2`'s `INLINE_UID` + the runtime `FUID`) —
compile-time, one class per binary. **Both UIDs are frozen forever once shipped** — the
same lock the stable UID carries (a saved project records the UID and rebinds by it; a
changed UID orphans every saved beta instance). Record the invariant in the header's
UID-lock comment. **Fork S18-F1 (flagged — Daniel's call):** whether the beta UID is
minted *now* (locked from this wave) or deferred to *first beta release* of the VST
(leaving a compile-time placeholder that must be replaced-once-and-locked before any beta
VST ships). Lean: **mint now** — mirrors how the stable UID was minted at the S1 spike
(locked long before ship), removes a "remember to mint before shipping" landmine, and the
cost of an unused-until-beta constant is zero. Flagged because it is a forever commitment.
- [ ] **Channel-derived binary + display identity (no scattered `#ifdef`s — the V4
invariant).** Route all VST identity through `app_version` accessors, mirroring how the
extension's `OUTPUT_NAME` already derives from `REASAMPLER_OUTPUT_NAME`. (a) **Binary
name:** the CMake VST3 target's `OUTPUT_NAME` forks by channel — `reasampler_9000`
(stable) / `reasampler_9000_beta` (beta) — driven by the same `REASAMPLER_CHANNEL` config
the extension target reads (add a `REASAMPLER_VST_OUTPUT_NAME` beside
`REASAMPLER_OUTPUT_NAME`, or reuse the one channel bit; **check what the extension does and
mirror it exactly**). (b) **Display name:** the factory `DEF_CLASS2` plug-in display string
is "ReaSampler 9000" / "ReaSampler 9000 beta" — sourced from an `app_version` VST-name
accessor (a new channel-derived accessor beside `binaryName()`/`dockTitle()`, e.g.
`vstPluginName()`), not a literal in `reasampler_vst.h`/`vst_entry.cpp`. (c) **Editor title
band + S6 embed-strip label** are channel-aware from the same accessor. All fan out from the
ONE channel bit — no per-file `#ifdef`.
- [ ] **Factory vendor/version strings channel-aware where V4 does the equivalent.** The
`BEGIN_FACTORY` vendor/url/email and the `PClassInfo2` version string
(`REASAMPLER_VST_VERSION`, currently a fixed `"0.1.0.0"`) align with the channel where the
extension's V4 equivalents do — the version display carries the `-beta` render
(`appVersion()` already yields `"0.9.01-beta"` on beta); the vendor strings stay shared
unless V4 qualified them (**check V4's treatment and match it** — V4 kept the lane-name
prefix shared, so shared-where-V4-shares is the default). No new forever-stable string
beyond the UID and the id-family V4 already owns.
- [ ] **Pairing-surface invariant recorded (no new code — a documented guarantee).** Write
the complete pairing surface as an invariant in CONTEXT.md: a channel's VST talks to that
channel's extension **only**, because (1) plugin identity (UID + filename + display) is
channel-forked here, and (2) **all** wire keys — `banks`, `assign_request`, S9's
generation key, and any future key — live under the channel-derived `kProjExtNamespace()`,
so the beta VST's bridge reads only `"reasampler_beta"`. The invariant to write: **channel
isolation is structural — no per-key opt-in — so a future wire key that forgets to isolate
is impossible by construction** (it keys under the namespace accessor, not a raw literal).
This is the guard that S9/S8/S17's cross-artifact keys inherit isolation for free.
- [ ] **DAW-verify contract (the acceptance gate, no unit test — identity is a shell fact).**
Both channels installed side-by-side: each browser sees only its channel's banks; a
stable-project + beta-VST opens clean-empty (not error); the S-NAME-1 rename/rebind compat
test extended to the beta UID (save a beta instance, confirm it rebinds by the beta UID on
reopen). The pure `app_version` accessors (binary/display name per channel) are CTest-green
in `app_version_tests` (extend the existing V4 channel-string assertions to the new VST
accessors); the UID selection + factory wiring are DAW-verified (no host-free test path).
## Phase S — held and optional-forever (noted, not specified)
- **Tier 2 — "expressive" (HELD).** Velocity layers, round-robin (anti-machine-gun),
full ADSR, per-sample tuning/gain trim, sustain loops. The next depth increment once
Tier 01 proves the instrument belongs — **its points are not drawn up here.**
- **Tier 3 — "instrument polish" (optional-forever).** Filters, filter/pitch
envelopes, LFOs, per-voice pan, choke groups, a modest FX slot. A direction to leave
room for, never a commitment. **Note:** S16 lands the *pitch* envelope + the Varispeed/
Preserve pitch-engine mode early (Daniel's directive) — the Tier-3 "filter/pitch envelopes"
line now means the *filter* envelope + LFOs remainder.
- **Sinc Varispeed-quality upgrade (HELD — WDL_Resampler).** `WDL_Resampler`'s sinc mode
beats the core's 2-point linear interp for **Varispeed** base-repitch quality (see the S16
WDL finding). An optional per-voice quality toggle (linear default / sinc), RT-suitable but
heavier. Held as a Tier-2/3 quality option — not needed for S15/S16, not scheduled. (A
resampler couples duration, so it is a Varispeed-quality option only, **not** a Preserve
engine.)
- **Hand-rolled Preserve pitch core (HELD — fork S16-F2 route b).** A pure `pitch_shift`
OLA/granular module (house pattern, CTest-testable) as the quality/latency upgrade for the
S16 Preserve engine if `WDL_SimplePitchShifter`'s SimpleWindowed warble or onset lag proves
musically unacceptable. Same `PitchEngine::Preserve` contract behind the seam. Held — the
build starts with route a (WDL) to prove Preserve at low cost.
- **Trigger choke-on-note-off (HELD — fork S15-F1).** A future option for Trigger mode to
*cut* (choke) on note-off or on a same-group re-trigger (hi-hat open/closed). Deliberately
out of S15 scope (Trigger ignores note-off entirely there); a Tier-3 choke-group direction.
## Phase S — must-verify-before-build (carried from CONTEXT.md §Phase S)
- **Steinberg VST3 SDK surface** — interface members, base-class overrides,
factory-macro spellings, Windows module-export symbol names
(`InitDll`/`ExitDll`/`GetPluginFactory`), and whether VSTGUI is bundled. Several are
§1a experienced-estimates until S1 confirms them against the vendored SDK.
- **VST-host bridge** — ~~opcodes `0xdeadf00d`/`0xdeadf00e`~~ **verified (S1):** VST3
path is `IReaperHostApplication::getReaperApi` (resolve-by-name) and
`getReaperParent(3)` (host context) via `reaper_vst3_interfaces.h`; the
`0xdeadf00d`/`0xdeadf00e` opcodes are VST2-only and do not apply.
- **`IReaperUIEmbedInterface`** — ~~embed contract + message/lifecycle, against
`reaper_plugin_fx_embed.h` (needed only at S6).~~ **verified (S6):** interface
exposed via `queryInterface` on the controller; inline strip drawn into REAPER's
embed bitmap (LICE idiom, no HWND); `WM_GETMINMAXINFO` size hints confirmed;
embed open/close/resize lifecycle clean.
- **VST3 bus arrangement (S7)** — `setBusArrangements` / `getBusArrangement` and REAPER's
mono/stereo instrument-bus expectations, against the vendored Steinberg SDK +
`reaper_vst3_interfaces.h`. The channel-mode toggle depends on the output bus
re-negotiating cleanly.
- **Media Explorer surface (S8)** — confirmed thin against the vendored headers:
`OpenMediaExplorer` (open/select) + `MediaExplorerGetLastPlayedFileInfo` (read the one
last-played/selected file + range) are the whole contract; **no** enumerate-selected and
**no** ME-drop-handler API. Spike: does `MediaExplorerGetLastPlayedFileInfo` return a
usable path+range for a merely-selected (not-yet-played) file?
- **Drop targets (S8)** — REAPER exposes **no** drag-drop registration API (verified); drop
handling is on ReaSampler's own panel HWNDs via SWELL/Win32 (`WM_DROPFILES` / `IDropTarget`).
Drop-onto-VST3-editor relayed as a bank-ingest request is an **unproven cross-artifact
spike**, not a promise.
- **Bank-generation ext-state read (S9)** — confirm no torn-read hazard on the single
integer generation key for a bridge read on the instrument's UI/timer thread concurrent
with an extension write.
- **WDL pitch/resample surface (S15/S16)** — **verified this pass:** `resample.h`
(`WDL_Resampler`, sinc/linear, RT-suitable — a *resampler*, couples duration → Varispeed
path) and `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 vendored WDL tree.
**S16 Preserve-engine (route a) must-verify at build:** pre-warm `WDL_SimplePitchShifter`
at voice-allocation so `m_queue` reaches steady state (no `process`-thread `WDL_Queue::Add`
growth); measure the per-voice CPU + onset latency (window·srate) against the polyphony cap;
set the **Preserve-mode-specific voice cap** if the per-voice cost demands one. The pitch-
envelope modulation stays hand-rolled over whichever engine. If the held sinc Varispeed-
quality upgrade is ever taken, verify `WDL_Resampler` streaming/prealloc against per-voice
RT budget before use.
- **Drop-and-load (S17) — three surfaces.** (1) `TrackFX_AddByName` — **verified present**
in `reaper_plugin_functions.h` (signature confirmed; the `"VST3:"` name prefix and the
negative-`instantiate`-always-adds semantics are documented in the header comment). (2)
**TCP / FX-button hit resolution** — how the extension resolves the pointer-under-cursor
to a track and its FX-button hotspot during a drag: **not yet confirmed against the
SDK/SWELL** — `GetTrackFromPoint` / `GetThingFromPoint` are candidates to verify against
`reaper_plugin_functions.h`; whether the FX button specifically is addressable (vs. the
TCP as a whole) is an open verification. (3) The **ReaSampler 9000 load-capture seam** —
a *new* interface added inside the Phase S instrument; its mechanism is a Phase S design
choice (see CONTEXT.md §Phase S open question), not a pre-existing SDK surface.
---
# Phase L — Look-and-feel (system-wide visual design language)
> **New pillar, own lettered namespace, taken up by a parallel team.** Phase L is the
> whole-system look-and-feel effort: a shared LICE drawing kit and the surfaces that
> adopt it, so ReaSampler and ReaSampler 9000 shed the flat "temple os" drawing for a
> modern, sleek 2026 dark synth look. It answers Daniel's post-DAW-test verdict on the
> instrument ("this looks like temple os… the VST is dogshit… scope it for the whole
> system… does Cockos have a toolkit?"). Namespaced **`L` (Look-and-feel)** so it is
> orthogonal to and ungated by the M/D/B/R/V/S pillars — a parallel team owns it while
> Phase S feature work proceeds independently. Authoritative spec: **CONTEXT.md §Phase L
> — visual design language (design-system spec)**. Product framing, the settled decision
> record (DS-1/DS-2/DS-3 all SETTLED 2026-07-26), palette, and the three visual
> directions: `docs/product/visual-design-language.md`. When a point lands, doc-keeper
> moves it to `COMPLETED.md`.
>
> **L1 (shared LICE drawing kit — the foundation), L2 (dock-panel layout redesign), L4
> (dock-panel button layout enhancement), L5 (dock-panel button refinements), L6 (toolbar
> polish), and L7 (capture ordering, card metadata, and selection styling) have landed** —
> `theme`/palette module, `component_geometry` geometry/hit-test helpers, `draw_kit` shell, GDI
> `DrawText` retirement in `bank_panel` (L1); `action_bar` pure task-grouped layout module, full
> M11-aware button inventory placed by task cluster, `bank_panel` redesigned through the L1 kit
> (L2); three-zone layout (top capture/placement/maintenance toolbar, bottom Design-View toolbar,
> footer toggle + Tail button + Prune), `footer_bar` pure module, `ActionCluster::Tagging`/
> `Switching` in `action_bar` (L4); top-bar overflow menu (`overflow_menu` pure module),
> custom LICE-kit hover-delay tooltips (`tooltip` pure module), opposite-mode Item/Track tag
> buttons + Show Both, Toggle + Activate-Arrange/Design buttons removed, grouping spacing
> widened (`mode_enable` pure module) (L5); single-row button faces, keybinding in tooltip,
> Cancel RT moved to overflow, top-bar cluster order tidied (L6); per-bank `SlotMap`
> (id→slot) in `bank_book`, sparse-grid rendering, `card_drag` + `card_meta` pure modules,
> `captureTimeSigNum`/`captureTimeSigDenom` on `Sample`, tertiary-border selection (L7). See
> `COMPLETED.md`. **L3 remains** — L3 gated on Phase S landing on dev.
>
> **This section is self-contained for a team without Phase S context.** Where a point
> touches a Phase S surface (the VST editor, the embed strip, the keyboard strip), the
> gate is stated explicitly so the team does not chase files that are not on dev yet.
>
> **Settled decisions (Daniel, 2026-07-26 — see `docs/product/visual-design-language.md`
> §6):**
> - **DS-1 — toolkit: LICE + WDL free game, no external frameworks.** Draw the modern
> look with LICE directly; reuse any useful WDL/vwnd piece (skin/image helpers, draw
> idioms, a control like the scroll listbox) where it beats re-deriving — "don't
> reinvent the wheel." Reject iPlug2 / JUCE / VSTGUI (external frameworks re-opening the
> settled bare-SDK+LICE build shape). Keep hit-test geometry in pure CTest-covered
> modules — do not import vwnd's retained-mode object model wholesale.
> - **DS-2 — visual direction: Direction B ("Neon Console") + Direction C's spectral
> keyboard strip. SETTLED 2026-07-26, REVISED 2026-07-26 (Daniel) — palette-only.**
> *Neutral surfaces (revised):* the neutral ladder moved **from near-black up into REAPER's
> mid-grey theme family** so the dock reads as part of REAPER, not a black slab — `bg/base`
> ≈ `#2b2b2b`, `bg/panel` ≈ `#333333`, `bg/cell` ≈ `#3a3a3a`, `line/hairline` ≈ `#4a4a4a`,
> `text/primary` ≈ `#dcdcdc`, `text/dim` ≈ `~#a0a0a0`+ (elevation-ladder discipline
> unchanged). *Accent layer (revised):* now a **three-accent pastel system** —
> `accent/primary` pastel lime green (live/active/selected), `accent/secondary` pastel teal
> + `accent/tertiary` pastel purple (categorical distinctions) — replacing the original
> single electric cyan. The spectral keyboard strip is a **pastel** sweep anchored on the
> three accents. *Tight WCAG pairs to re-verify against the grey ladder:* `text/dim`-on-grey
> (mid-grey-on-mid-grey, AA 4.5:1) and the three pastels-as-indicators on `bg/cell` (shrunk
> from ~15:1 to ~6:17:1). **A stylish/bundled-font upgrade was considered and DECLINED
> (Daniel):** no font bundling/redistribution — the kit keeps its current cached-font face,
> no new typeface. The kit palette stays abstract (roles, one constants block — three accent
> roles + grey neutrals), so the direction is a single-file change; final hex is locked
> against the theme WCAG tests within the pastel intent. Detail:
> `docs/product/visual-design-language.md` §2.1/§4/§6 + CONTEXT.md §Phase L.
> - **DS-3 — dock-panel scope: a thorough layout redesign, not a light re-skin.** L2 lays
> out the full button inventory (including M11's action-button additions) intuitively,
> uncluttered, and useful — then applies the kit. Sequenced after M11 merges.
## L3 — VST editor + embed-strip restyle (GATED on Phase S landing on dev)
**Goal:** Bring the ReaSampler 9000 VST editor (`IPlugView` LICE surface) and the S6 embed
strip up to the settled-and-revised **B + three-accent pastel** look via the L1 kit: kit
cached-font text (§3.1 — the kit's current face, no font change), kit component draws, the
Neon-Console palette with **REAPER-grey neutrals** (`#2b2b2b`/`#333333`/`#3a3a3a`) and the
**three pastel accents** (primary lime / secondary teal / tertiary purple), the **pastel
spectral keyboard strip** as the signature surface (DS-2
revised), and hover/pressed/drag states throughout. CONTEXT.md §Phase L (VST restyle + the
Phase S coordination contract). Product framing: `docs/product/visual-design-language.md` §5.3.
**Verify (in DAW):** the VST editor + embed strip render in the settled B + three-accent
pastel language through the L1 kit — kit AA cached-font text, gradient/rounded kit
components, the pastel spectral keyboard strip, working hover/pressed/drag; the VST3 class
UID is unchanged (a visual refresh is not a compat event).
**Depends on:** L1 (the kit) **AND — GATE — Phase S landing on dev.** The VST editor, embed
strip, and keyboard strip live in Phase S, which is **not on dev yet** (it exists on the
phase-s worktree). **L3 cannot be built on dev until Phase S's drawing shells
(`src/vst/reasampler_editor.cpp` + `reasampler_embed.cpp`) merge to dev. The Phase L team
must NOT chase these files on dev — they are not there.** Until then L3 is a planned,
blocked point; L1 and L2 are the live Phase L work.
> **Coordination contract with Phase S (load-bearing).** Phase S's editor/embed UX
> (S10S13, S15S18) build their interaction UX with the **current** drawing and **adopt the
> L1 kit when it is available — they are NOT gated on Phase L.** Whichever lands first (the
> L1 kit or the Phase S UX), the kit is the **one source of drawing**: if the Phase S
> surfaces reach dev before L1, they draw in the current language and L3 restyles them; if
> L1 lands first, they are born in the kit. **Reality check (product-designer, 2026-07-27,
> read-only against the phase-s worktree): the Phase S surfaces did NOT adopt the kit —
> `reasampler_editor.cpp` and `reasampler_embed.cpp` still draw flat `LICE_FillRect` blocks
> + raw GDI `DrawTextA`, off a local pre-L1 forest-green palette (`kColBackground` etc.),
> not `theme`/`draw_kit`.** So L3 lands as a **full restyle**, not a born-in-kit no-op — all
> three plan points below are live and confirmed still-needed. Either way there is one kit
> and one look, and L3 is the point that completes the VST/embed adoption and applies the
> settled B+spectral treatment.
>
> **Landed-scope note (product-designer, 2026-07-27).** All Phase S drawing is centralized
> in **exactly two shells** — `reasampler_editor.cpp` (the `IPlugView` editor) and
> `reasampler_embed.cpp` (the S6 embed strip). Every other `src/vst/` UI file
> (`editor_geometry`, `keyboard_strip`, `waveform_view`, `capture_browser`, `param_slider`,
> `browser_scroll`, `embed_strip`) is a **pure geometry/hit-test module — zero LICE, zero
> draw** (the house discipline held on the branch). So L3 touches two draw shells, but their
> rendered component set is broader than the original plan named: the editor draws a
> **capture-first browser** (search + tabs + thumbnails, S10), **channel toggles** (S7),
> **waveform + start/loop markers** (S11), the **keyboard strip + zone bars** (S10/S10-Z),
> and **ADSR / pitch sliders + mode toggles** (S12/S15/S16). Every one of these is on the
> restyle surface — enumerated in the points below.
- [ ] Route the VST editor's + embed strip's text through the kit's cached-font `text()`
(the kit's current face — §3.1, no font change); retire their raw GDI `DrawTextA` path
(present in both shells today). (Gated — Phase S surfaces must be on dev.)
- [ ] Retire the shells' **local pre-L1 palette** — the `kColBackground`/`kColCardBg`/
`kColThumb`/… forest-green-on-charcoal constants block in `reasampler_editor.cpp` (and the
mirrored constants in `reasampler_embed.cpp`) — and draw every surface through the L1
`theme` roles instead. **This is the coordination-contract reconcile: today there are two
palettes (the landed `theme` kit + this local constants block); L3 collapses them to the
one kit.** (Gated.)
- [ ] Restyle the editor + embed components through the kit (browser search/tabs/thumbnails,
channel toggles, ADSR + pitch sliders, mode toggles, zone bars, list rows, waveform,
segmented controls) in the B (Neon Console) palette — **REAPER-grey neutrals** (`bg/base`
`#2b2b2b` / `bg/panel` `#333333` / `bg/cell` `#3a3a3a`) with the **three pastel accents**
(primary lime = live layer; secondary teal / tertiary purple = categorical); add
hover/pressed/drag states. (Gated.)
- [ ] Apply **Direction C's pastel spectral treatment to the keyboard strip + zone bars**
(hue-mapped zones as a pastel sweep anchored on the three accents, active zone lifting to
`accent/primary` + a static glow — never a pulse, per the speed constraint) as the
signature surface; also carry the waveform + loop/start markers onto the kit's
`drawWaveform` + `warn`/accent marker roles; VST3 class UID unchanged. (Gated.)
## Phase L — sequencing
```
L1 (shared kit) ──► L2 (dock-panel layout redesign) [LANDED]
├─────────► L3 (VST editor + embed-strip restyle; GATED on Phase S landing on dev)
└─────────► L4 (dock-panel button layout enhancement) [LANDED]
└────► L5 (dock-panel button refinements; ungated, after L4) [LANDED]
└────► L6 (toolbar polish; ungated, after L5) [LANDED]
└────► L7 (capture ordering + card metadata + selection styling; ungated, after L6) [LANDED]
```
L1, L2, L4, L5, L6, and L7 have all landed. L3 waits for Phase S to reach dev. Phase S
feature work runs in parallel, ungated by Phase L.
## Phase L — must-verify-before-build
- **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`
(design-language doc §1.1); **confirm exact signatures + the `LICE_CachedFont`↔`HFONT`
lifecycle at build.**
- **WDL/vwnd reuse assessment (DS-1)** — at build time, evaluate whether a vwnd piece beats
re-deriving it: `virtwnd-slider.cpp` / `vwnd_slider_drawknobstack` as the slider/knob
drawing reference, `virtwnd-listbox.cpp` as a candidate scroll listbox, `virtwnd-controls.h`
for `WDL_STYLE_*` gradient hooks, `virtwnd-skin.h` for image-skin helpers. Reuse where
useful; keep hit-test geometry pure regardless.
- **M11 button inventory (L2)** — resolved at L2 build: inventory taken against dev after
M11 merged; all buttons placed by task cluster in the landed `action_bar` module.
- **L4 re-home surface (L4)** — resolved at L4 build: three-zone layout confirmed against the
post-palette-revision `bank_panel`; `action_bar` extended with `ActionCluster::Tagging` +
`Switching`; new pure `footer_bar` module covers footer layout/hit-test.
- **L5 refinement surface (L5)** — resolved at L5 build: `TrackPopupMenu` overflow menu
confirmed; tooltip mechanism resolved as **custom LICE-kit hover-delay tooltip** (`tooltip`
pure module; sourced from the registered action phrase, prefix stripped at draw time); item-move
and track-tag action ids confirmed; active-mode read confirmed via the same
`view().activeModeId()` the footer toggle uses.
- **L7 model + tempo surface (L7)** — **RESOLVED at build (L7 landed).** Forks F1/F2/F3 all
confirmed: `TimeMap_GetTimeSigAtTime` confirmed at build for the meter stamp; SWELL stock
cursors chosen for drop-result cues (Reorder→IDC_SIZEALL, Move→IDC_HAND, Copy→IDC_UPARROW,
Replace→IDC_SIZEWE); existing pool-privilege guard reused as-is for Alt-replace. Gap navigation
in the grid = skip gaps (arrow keys skip empty slots). See `COMPLETED.md` §L7.
- **L3 restyle surface (L3)** — **PRE-CONFIRMED read-only against the phase-s worktree
(product-designer, 2026-07-27); re-confirm against dev once Phase S merges.** (1) Both draw
shells (`reasampler_editor.cpp`, `reasampler_embed.cpp`) still draw flat — `LICE_FillRect` +
raw GDI `DrawTextA`, no `theme`/`draw_kit`/`LICE_CachedFont` include — so all three L3 points
are live (no born-in-kit no-op). (2) There is a **second palette** to reconcile: a local
`kCol*` forest-green-on-charcoal constants block in the editor shell, divergent from the
landed `theme` roles. (3) Draw is centralized in those two shells only; every other
`src/vst/` UI file is pure geometry (kit adoption is a shell-side change, no geometry
rework). (4) The phase-s branch carries an **older L1 kit** (its `action_bar` predates dev's
L4L7 `theme` palette revisions) — after Phase S merges, L3 draws against dev's current
`theme`/`draw_kit`, so re-confirm the palette roles L3 consumes are dev's, not the branch's
stale copy. **The single open question is now RESOLVED (Daniel, 2026-07-27):** the S18 beta
channel title band gets **no distinct visual accent** — L3 restyles the title band in the
standard B pastel palette, and the beta-vs-stable distinction stays **purely textual** (the
channel-derived plugin name, as today), no channel-specific accent color. With that closed,
the settled DS-1/2/3 spec forces the rest.
---
# Phase Q — Quality (structural reorganization; zero-runtime-cost)
> **New pillar, own lettered namespace, and — uniquely — the LAST structural pillar.** Phase
> Q is a **pure structural refactor**: it reorganizes `src/` into a healthier shape (more
> encapsulation, granular namespaces, `core/`/`shell/`/`app/` subdirectories) against a stated
> quality bar — *"mtytel Vital is my code reference for quality"* — to bring the codebase
> "into the realm of something I can stand to look at." It ships **no feature and changes no
> behavior**: the test suite passing unchanged is the proof of correctness. Namespaced **`Q`
> (Quality)** — M/D/B/R/V/S/L are all taken; `Q` names the *end* (the quality bar), the reorg
> being the *means*. Authoritative spec: **CONTEXT.md §Phase Q — structural reorganization
> (reorg spec)**. Product framing, the Vital-grounded target shape, the grep-verified SOLID
> audit that is the evidence base, and the settled/recommended fork record (Q-1..Q-6):
> `docs/product/code-organization.md`. When a point lands, doc-keeper moves it to
> `COMPLETED.md`.
>
> **THE GATE (load-bearing — state first; reconciled to reality 2026-07-27).** Phase Q is
> **gated on the tree being otherwise quiescent.** Daniel's plain readiness target: **"when
> Phase S and L3 are finished."** As of 2026-07-27 the outstanding work is precisely: **(1)
> Phase S** merged to dev (the large second-artifact branch, currently on the phase-s worktree —
> the dominant gate item); **(2) Phase L L3** merged to dev (the VST restyle, itself gated on
> Phase S landing on dev). **L1/L2/L4/L5/L6/L7 have already landed** (see `COMPLETED.md`) — the
> earlier "L2 + L3" wording was stale and is corrected here to **L3 only**. **D2** is functionally
> complete (D2-W1..W3-B landed; the lone open item, a per-track lane-split panel indicator, is
> *explicitly deferred*, not a blocking residual). **M9** (slots) is *explicitly deferred*
> (Daniel, 2026-07-26), not scheduled work. D2 and M9 are named in the gate only so that
> *reactivating* either re-arms the quiescence condition; neither blocks the gate today. *Why the
> gate:* Phase Q touches **nearly every file in `src/`** (relocate into subdirectories,
> re-namespace every header, split the four largest TUs, plus the §2b renames). Every large
> in-flight branch (Phase S on its worktree, and L3 once it lands) is diffed against the *current
> flat layout*; landing a rename-and-relocate-everything reorg mid-flight forces every open branch
> through the worst conflict class (every hunk moved, every qualified reference changed) — a
> combinatorial re-resolution, not a linear one. Phase Q is *last* precisely because it reshapes
> the ground every other pillar stands on. Landing it early taxes every subsequent phase; landing
> it last taxes nothing. **Do not begin any Q point until the gate is satisfied.**
>
> **M9 disposition (Daniel-decision note).** M9 is recorded "deferred indefinitely," which is not
> the same as "abandoned." Immaterial to the gate (both clear it). It matters only if M9 is ever
> reactivated: *before* Phase Q it lands cheaply on the flat layout; *after*, it is authored
> against the reorganized tree. Surfaced, not silently resolved — no action unless Daniel schedules
> M9. (Full note: `docs/product/code-organization.md` §4.)
>
> **Settled (Q-1, this-doc):** the phase is **`Q` (Quality)**; point-id family `Q1..Qn`, wave
> prefixes `Q-W1..Q-W6`. **Recommended, Daniel's to call (Q-2..Q-9, see
> `docs/product/code-organization.md` §6):** Q-2 JSON extraction in scope + first (rec: yes);
> Q-3 directory shape `core/`/`shell/`/`app/` top-split with subsystem dirs beneath (rec: this
> over pure-Vital subsystem-first — it makes the pure/shell invariant *structural*); Q-4
> sub-namespace to match sub-directory (rec: both); Q-5 split god-modules to the audit's named
> seams, no finer (rec: yes); Q-6 OCP registration-table as the final wave (rec: in, last);
> **Q-7 naming rides the relocation waves, no dedicated naming wave (rec: yes — forced once
> Q-3/Q-4 settle); Q-8 class/module renames — fix the two that actively mislead (`BankIndex`→
> `BankModel`; the JSON `Parser`→`json::Reader`/`Writer`), leave the merely-quirky (rec);
> Q-9 align the `capture_realtime`/`realtime_record` shell↔core word order during W3 (rec: yes).**
>
> **HARD CONSTRAINT — performance (see CONTEXT.md §Phase Q, `docs/product/code-organization.md`
> §3).** The reorg must cost **zero runtime.** On the three hot paths — `peaks` envelope
> compute, audition/preview, the realtime-capture tick — **no added virtual dispatch, no
> header→TU indirection, no changed call/inline or branch shape.** `computeEnvelope` stays a
> free function on `const std::vector<float>&`; audition split stays a direct call-through;
> the realtime idle tick stays a single pointer test; `FxBypassGuard` stays stack RAII. This is
> an acceptance criterion on every point: *a split that would add a hot-path indirection is out
> of scope — rework it or drop it.*
>
> **NAMING dimension (added 2026-07-27; grep-verified audit in `docs/product/code-organization.md`
> §2b).** Beyond giving symbols a directory + namespace *home* (Q-3/Q-4), Phase Q also gives
> poorly/inconsistently-named symbols a consistent *name*, against the same Vital bar. The audit
> found: four `class Parser` copies collapsing to one `json::Parser` (Q-W1); shared pure-UI rect
> types (`FooterRect`/`ButtonRect`) that the codebase already hand-checks for collision
> (`footer_bar.h`'s "NAME NOTE") — resolved by the Q-4 sub-namespaces for free; the
> `bank_model`/`BankIndex` file↔class word-mismatch (Q-8); and the `capture_realtime`/
> `realtime_record` shell↔core word-order inversion (Q-9). **Renames ride the wave that already
> relocates/splits the file — no dedicated naming wave (Q-7);** the geometry-mirror
> `compute*`/`hitTest*` verb vocabulary and the `_tests` suffix are already consistent and are
> preserved verbatim. Naming changes are zero-behavior-change like the rest of Phase Q, and the
> FOREVER-STABLE contract strings (command ids, action names, ext-state namespace/keys, VST3 UID)
> are **not** C++ symbols and are never renamed.
>
> **Every point is independently landable and CTest-green at EVERY step.** The CMake
> per-module static-lib + per-module test-executable seams already draw the module boundaries;
> a file move + namespace change is mechanically verifiable — `ctest --test-dir build` is green
> or it isn't. **Green-CTest-at-every-point is an acceptance criterion.** Big-bang is rejected;
> the reorg is risk-ordered waves (W1 safe opener → W2W5 god-module splits → W6 OCP finish).
## Q-W1 — safe opener: extract `core/json` + impose the directory/namespace layout on clean modules
**Goal:** The zero-god-module-risk opener. Two moves: (1) extract a pure **`core/json`** module
(parser + serializer) and **delete the four hand-rolled `Parser`s** in `bank_model` /
`bank_book` / `view_mode_model` / `owned_manifest` (the single largest DRY+SRP violation, and
entirely off the hot paths); (2) impose the settled `core/`/`shell/`/`app/` directory layout +
sub-namespaces (`reasampler::model`/`view`/`capture`/`audio`/`ui`/`reclaim`/`version`/`json`) on
the **30 clean pure libs + the clean shells that need no splitting** — pure relocation, no logic
change. Proves the wave discipline (relocate + encapsulate, CTest-green) before any god-module
surgery. CONTEXT.md §Phase Q (json extraction; directory + namespace map).
**Verify:** CTest green at every commit. The four duplicate `Parser`s are gone, replaced by one
`core/json` consumed by all four models; round-trip serialization is byte-identical to before
(no format change — a *structural* dedupe, not a behavior change). Every relocated clean module
compiles and its test executable passes unmoved. `Sample` (model) vs `AudioSample` (audio) vs
unified `Parser` (json) do not collide once sub-namespaced. No REAPER type crosses into any
`core/` file; the CMake pure/shell enforcement still holds.
**Depends on:** the GATE (tree quiescent). Nothing else in Phase Q.
- [ ] Extract `core/json` (pure parser + serializer: parseString/parseInt/parseKey/skipValue +
escape, plus emit helpers); unify under `reasampler::json`; guard the `Parser` name against
cross-lib collision. Off all hot paths — safe to abstract freely.
- [ ] Rewire `bank_model`, `bank_book`, `view_mode_model`, `owned_manifest` onto `core/json`;
**delete the four duplicate `Parser`s.** Round-trip output byte-identical (dedupe, not
reformat).
- [ ] Relocate the 30 clean pure libs into `core/{model,view,capture,audio,ui,reclaim,version,
json}/` and the clean shells into `shell/{capture,panel,view,persist,actions}/`; move
`main.cpp` to `app/`. Update `CMakeLists.txt` `src/` paths only (no target-graph change).
- [ ] Apply sub-namespaces matching the directories on every relocated *clean* module (the
god-modules re-namespace their own new TUs as they split, W2W5). Resolve `Sample`/
`AudioSample`/`Parser` homes. **This alone resolves the naming *collisions*** (§2b.2): the
shared pure-UI rect types (`FooterRect`/`ButtonRect`/`Selection`/`CellRect`) get one `ui::`
owner — retire the hand-collision "NAME NOTE" in `footer_bar.h`.
- [ ] **Naming riders (Q-8, if settled):** rename the survivor JSON parser to `json::Parser`
(or `json::Reader`/`json::Writer`); if Daniel takes the `BankIndex`→`BankModel` rename, land
it here (mechanical class rename, verified by `bank_model_tests`). No rename on a file this
wave isn't already relocating (Q-7).
- [ ] Confirm CTest green + no hot-path change: `peaks`/audition/realtime-tick untouched by this
wave (pure relocation of clean modules; `peaks` stays a free function).
## Q-W2 — split `bank_panel.cpp` (the biggest god-module, 2424 LOC)
**Goal:** Split the largest god-module (8+ responsibilities) along the audit's named seams:
`panel_render` / `panel_thumbnails` / `panel_audition` / `panel_input` / `panel_bank_ops` /
`panel_window`. Split the fat `bank_panel.h` alongside (Interface Segregation). **Preserve the
audition hot path as a direct call-through, never virtual.** `panel_bank_ops` becomes the single
home for the bank-CRUD verbs that W4 will dedupe `actions.cpp` against. CONTEXT.md §Phase Q
(bank_panel split seams; hot-path audition guardrail). See `docs/product/code-organization.md`
§2.1, §5.
**Verify:** CTest green at every commit. Each seam is its own TU under `shell/panel/`; the panel
draws, thumbnails, auditions, handles input, does bank ops, and manages its window exactly as
before (no behavior change — verify in DAW that the panel is visually and interactively
unchanged). Audition/preview call path stays a **direct call-through** (no virtual dispatch, no
added header→TU indirection on the preview path). The ~20-function public API is now segmented
across the split headers.
**Depends on:** Q-W1 (directory/namespace layout established). Independently landable.
- [ ] Split rendering (`draw*`/`paint*`) → `panel_render`; thumbnail compute+cache →
`panel_thumbnails`.
- [ ] Split the audio audition/preview engine → `panel_audition` — **direct call-through, not
virtual; preview idle path unchanged.**
- [ ] Split input handling (mouse/key/wheel) + new-content detection → `panel_input`; window
lifecycle + OS drag-out/drop-target → `panel_window`.
- [ ] Extract bank-CRUD verbs → `panel_bank_ops` (the future single owner; W4 dedupes
`actions.cpp` against it). Split `bank_panel.h` into per-seam headers (I).
- [ ] Verify in DAW: panel unchanged; CTest green; no hot-path indirection added.
## Q-W3 — split `main.cpp` (hoist orchestration; leave main = pointers + entry + dispatch)
**Goal:** Reduce `main.cpp` (1762 LOC) to its actual job — API pointers + `ReaperPluginEntry` +
dispatch (~the owns-pointers ~120 lines) — by hoisting: `capture_orchestrator` (`RunCapture` /
`captureAndIndexOne` / `renderOffline` / batch/recapture/realtime `Run*`), `scope_resolve`
(`resolveRange`/`resolveRazorRange`/`collectSelectedTracks` + provenance assembly inputs), and
`realtime_lifecycle` (the realtime-capture state machine + globals + selection guards).
**`FxBypassGuard` moves out but stays a stack RAII object (precision-critical); the realtime idle
tick stays a single pointer test.** CONTEXT.md §Phase Q (main split seams; FxBypassGuard +
realtime-tick guardrails). See `docs/product/code-organization.md` §2.1, §3.
**Verify:** CTest green at every commit. Capture (offline + realtime + batch + recapture) behaves
identically in DAW; the null test still nulls, bit-identical repeats still match (the precision
invariants `FxBypassGuard` protects are unchanged); capture ≠ placement holds (no hoisted `Run*`
path gains an `InsertMedia` call). The realtime idle fast-path is still a single pointer test.
`main.cpp` is now pointers + entry + dispatch only.
**Depends on:** Q-W1. Independent of Q-W2.
- [ ] Hoist capture orchestration → `capture_orchestrator` (`shell/capture/`); keep
`FxBypassGuard` a **stack RAII** object as it moves (precision-invariant-critical).
- [ ] Hoist scope/source resolution + provenance assembly inputs → `scope_resolve`.
- [ ] Hoist the realtime-capture lifecycle state machine + globals + the two RAII selection
guards → `realtime_lifecycle`; **idle tick stays a single pointer test.**
- [ ] Leave `main.cpp` = API-pointer ownership + `ReaperPluginEntry` + dispatch; move to `app/`.
- [ ] **Naming rider (Q-9, if settled):** align the `capture_realtime` (shell) / `realtime_record`
(pure) word-order inversion to the house shell↔core convention (rec: stem `capture_realtime`,
shell suffixed) — a free rider since W3 already hoists the realtime lifecycle. No rename on a
file this wave isn't already touching (Q-7).
- [ ] Verify in DAW: null test nulls, bit-identical repeats match, capture≠placement holds;
CTest green; no realtime-tick branch-shape change.
## Q-W4 — split `actions.cpp` + dedupe bank verbs against `panel_bank_ops`
**Goal:** Split the two unrelated command-id families in one TU (981 LOC) into
`design_view_actions` / `bank_actions` / `prune_action`, and **dedupe** `actions.cpp`'s own
`promptText`/`mintBankId` and bank verbs against the `panel_bank_ops` single-owner established in
Q-W2. `prune_action` keeps the `doBankPruneFolder` deletion authority contract intact (routes to
`persist`'s `prune_fs` after W5). CONTEXT.md §Phase Q (actions split seams; bank-verb dedupe).
See `docs/product/code-organization.md` §2.1, §2.4.
**Verify:** CTest green at every commit. Every action fires identically in DAW (Design View
family; multi-bank create/rename/reorder/delete/evacuate/activate/move/copy/remove; prune). The
bank-CRUD verbs have **one** implementation home (no `bank_panel`/`actions` duplication). Each
bank verb still wraps its mutation in one batched undo point; the prune action still writes no
ext state and opens no undo point. Command-id strings are **unchanged** (FOREVER-STABLE
contract — a reorg must not touch a shipped command id).
**Depends on:** Q-W2 (`panel_bank_ops` is the dedupe target). Independent of Q-W3.
- [ ] Split → `design_view_actions` (toggle/activate/tag/untag/showBoth/moveItems),
`bank_actions` (bank CRUD family), `prune_action` (`doBankPruneFolder` — the single
file-deletion action).
- [ ] Dedupe `actions.cpp`'s `promptText`/`mintBankId` + bank verbs against `panel_bank_ops`
(one owner); do **not** change any command-id string.
- [ ] Verify in DAW: all action families fire unchanged; one bank op = one Ctrl-Z; prune still
no-undo/no-ext-state; CTest green.
## Q-W5 — split `persist.cpp` (isolate the single file-deletion authority into `prune_fs`)
**Goal:** Split `persist.cpp` (766 LOC, 5 responsibilities) into `session` (lifecycle+poll,
`BeginLoadProjectState` reload hook), `ext_state_io` (the ext-state ↔ JSON serialization bridge +
GUID minting + folder relocation), and **`prune_fs`** (prune scanning + `deleteOrphanFile` via
`SHFileOperationW`). The split **concentrates** the byte-deleting authority into one obvious
module — it must never spread it. CONTEXT.md §Phase Q (persist split seams; deletion-authority
isolation). See `docs/product/code-organization.md` §2.1, §7.
**Verify:** CTest green at every commit. Session save/load/undo-reload, ext-state round-trip,
folder relocation, and prune deletion all behave identically in DAW. **File deletion lives in
exactly one module (`prune_fs`)** — the single-file-deletion-authority invariant is *improved*
(concentrated), never diluted. Relative-paths-only persistence is unchanged.
**Depends on:** Q-W1. Best after Q-W4 (so `prune_action` routes cleanly to `prune_fs`), but
independently landable.
- [ ] Split → `session` (lifecycle/poll + `projectconfig` reload hook), `ext_state_io`
(serialization bridge + GUID minting + folder relocation).
- [ ] Isolate prune scanning + `deleteOrphanFile` (`SHFileOperationW`) → **`prune_fs`** — the
one file-deletion module; nothing else may delete bytes.
- [ ] Verify in DAW: save/load/undo-reload/relocation/prune unchanged; deletion authority is one
module; relative-paths-only holds; CTest green.
## Q-W6 — OCP registration-table + residual fat-header (I) splits
**Goal:** Close the last SOLID wart: replace the ~350-line hand-written **non-table** action
registration blocks (now isolated in `app/main.cpp` after Q-W3) with a **registration table**, so
adding an action edits one place, not four parallel ones (OCP). Split any remaining fat headers
(`capture.h`/`persist.h`) not already resolved by their TU splits (I). CONTEXT.md §Phase Q (OCP
registration-table). See `docs/product/code-organization.md` §2.3, §6 (Q-6).
**Verify:** CTest green at every commit. Every action still registers, appears in the Actions
list, and fires via `hookcommand` exactly as before; command-id + display strings unchanged
(FOREVER-STABLE, per-channel); unload still mirror-unregisters everything. Adding a hypothetical
new action now touches the table only (demonstrated in review, not shipped). Remaining fat
headers are segmented.
**Depends on:** Q-W3 (registration code must be isolated first). Sequenced last; the most
droppable point if the phase needs narrowing (Q-6).
- [ ] Convert the hand-written `Register("command_id"/"gaccel"/"hookcommand")` blocks to a
data-driven registration table; unload mirror-unregisters from the same table.
- [ ] Split residual fat headers (`capture.h`/`persist.h` and any other) alongside their TUs (I).
- [ ] Verify: all actions register/fire/unregister unchanged; command-id strings untouched; CTest
green.
## Phase Q — sequencing
```
GATE: Phase S + Phase L L3 merged to dev (D2 complete, M9 deferred) — tree quiescent
("when Phase S and L3 are finished" — L1/L2/L4L7 already landed)
Q-W1 (safe opener: core/json extract + directory/namespace layout on clean modules)
├─► Q-W2 (split bank_panel) ──► Q-W4 (split actions + dedupe bank verbs vs panel_bank_ops)
├─► Q-W3 (split main.cpp; hoist orchestration) ──► Q-W6 (OCP registration-table + I splits)
└─► Q-W5 (split persist; isolate prune_fs) [best after Q-W4]
```
W1 is the safe, high-leverage opener (all later waves assume the layout it establishes). The four
god-module splits (W2W5) are risk-ordered and mostly parallel-safe; W4 depends on W2's
`panel_bank_ops`, W6 depends on W3's isolated registration code. Big-bang is rejected — every wave
is independently landable and CTest-green.
## Phase Q — must-verify-before-build
- **Hot-path call/inline shape** — before landing each split, confirm no virtual dispatch and no
header→TU indirection was added on `peaks` envelope compute, audition/preview, or the realtime
tick. `computeEnvelope` stays a free function on `const std::vector<float>&`;
audition stays a direct call-through; the idle tick stays a single pointer test. (CONTEXT.md
§Phase Q, `docs/product/code-organization.md` §3.)
- **Command-id + display strings are FOREVER-STABLE** — a reorg must not change a shipped
`command_id` string, action display name, ext-state namespace, or VST3 class UID. Re-namespacing
C++ symbols is orthogonal to these on-the-wire/on-disk contract strings; keep them byte-identical
(per-channel, per the Phase V V4 contract). (CONTEXT.md §Phase Q.)
- **Name-collision sweep on sub-namespacing** — `Sample` (model) vs `AudioSample` (audio) vs the
unified `Parser` (json), plus the shared pure-UI rect types `FooterRect`/`ButtonRect` (already
hand-collision-checked in `footer_bar.h`), and any other cross-lib name that collides once
flattened into granular namespaces. Resolve by each symbol's new subsystem home before landing
W1. (naming audit §2b.2; `docs/product/code-organization.md` §6 Q-4/Q-8.)
- **Naming stays zero-behavior-change and off the wire** — the §2b renames touch C++ symbols only;
no `command_id` string, action display name, ext-state namespace/key, `reasampler:` lane prefix,
or VST3 class UID is renamed (naming audit §2b.5). A rename that would alter a shipped contract
literal is out of scope, full stop.
- **The GATE** — do not begin any Q point until **Phase S + Phase L L3** are merged to dev and the
tree is quiescent (D2 complete, M9 deferred; a reactivation of either re-arms the gate).
Re-confirm quiescence against dev before W1.