docs(plan): archive Phase S (S3-S18) to COMPLETED; PLAN keeps only the deferred S13 relay stub

This commit is contained in:
2026-07-27 11:57:27 -04:00
parent c53683e7da
commit 0ffa7a4e77
2 changed files with 736 additions and 1130 deletions
+703
View File
@@ -1481,3 +1481,706 @@ placement behavior; the "capture ≠ placement" principle is untouched.
meter stamp; SWELL stock cursors chosen (no custom cursor load/synthesis required); meter stamp; SWELL stock cursors chosen (no custom cursor load/synthesis required);
gap navigation = skip gaps (arrow keys skip empty slots); same-slot reorder = no-op. gap navigation = skip gaps (arrow keys skip empty slots); same-slot reorder = no-op.
- New CTest targets `card_drag_tests`, `card_meta_tests`. - New CTest targets `card_drag_tests`, `card_meta_tests`.
---
# Phase S — MIDI-playback instrument (native VST3 sampler; a second build artifact)
> **Landed on dev (merged 2026-07-27); DAW verification pending Daniel's smoke test.**
> S1S18 are all on dev. The cross-artifact ingest relay (one S13 bullet) was explicitly
> DEGRADED and remains deferred in `PLAN.md`. Authoritative spec: **CONTEXT.md
> §MIDI-playback instrument — additive phase spec (Phase S)**. Product framing:
> `docs/product/midi-playback.md`.
---
## 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.
- [x] 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.
- [x] 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`.
- [x] 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).
---
## 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. **S12 now also
> carries the S15/S16 control surfaces** (per-zone Gate/Trigger mode toggle, AHDSR hold
> control, Trigger %-length/fade controls, Varispeed/Preserve engine toggle, and the AD pitch
> envelope depth/shape controls) — deferred here from S15 and S16 per spec.
- [x] 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. **Landed:**
pure `browser_scroll` module (`browser_scroll_tests`) — scrollContentHeight / max / clamp,
visibleCardRange window, scrolledCardCellRect, scrollThumbRect + thumbDragToOffset inverse,
searchBoxRect, nameMatchesQuery + filterNameIndices. Editor shell wires wheel (`WM_MOUSEWHEEL`),
thumb-drag (`DragKind::kScrollThumb`), and the search box (`WM_CHAR` -> `onSearchChar`,
composed into `rebuildVisible`). Scroll/search are transient (never persisted).
- [x] Direct numeric entry for zone low/high/root: a click-to-edit field over the strip
(LICE text-entry idiom) so a precise note can be typed, not only dragged. Commits via
`commitAndReload` like every other edit. **Landed:** pure `note_entry` module
(`note_entry_tests`) — `parseNoteEntry` accepts a decimal integer OR a note name (C4==60),
clamps to [0,127], rejects garbage. Editor shell hosts three focusable fields (low/high/root)
on the Zones legend, committing on Enter through `commitAndReload`.
- [x] ADSR/AHDSR editor + S15/S16 control surfaces: draggable sliders over the S3 `AdsrParams`
(attack/**hold**/decay/sustain/release — hold is the S15 addition) plus the deferred S15/S16
controls — per-zone Gate|Trigger mode toggle, Trigger %-length/fade-in/fade-out, Varispeed|
Preserve engine toggle, and the AD pitch-envelope enable/attack/decay/±semitone depth. All
edit the SELECTED zone's `ZonePlayParams` (instrument-owned, D-B; never the bank), round-trip
through the existing v3 component-state blob (no new persistence — the S15/S16 payload already
landed in the core pass), and commit off-thread via `commitAndReload`. **Landed:** pure
`param_slider` module (`param_slider_tests`) — control-panel stack layout, toggle-segment
split + hit-test, slider value<->pixel round-trip + clamping, point->control routing. The
shell owns the control-id -> engine-param binding + the value DOMAIN mapping (frames/fraction/
semitones); the module stays engine-free.
**Notes/decisions:**
- **Wall-clock envelope times stored as rate-free SECONDS (zones payload v5)**, resolved to
frames at keymap build against the live project rate — no hardcoded sample rates anywhere in
`src/`. Daniel's standing ruling; enforced throughout the voice engine and verified at S12.
---
## S13 — drop-to-load (partial landing; relay deferred)
**Goal:** Make "load a sample into the sampler" **one gesture from the editor** via an
OS file drop onto the editor window. The cross-artifact relay was a spike — the
instrument's REAPER bridge (`reaper_bridge`) is deliberately READ-ONLY; the relay is
DEGRADED and deferred. The two landed items are the drop-accept surface and the UX degrade
path. The deferred relay item remains in `PLAN.md`. CONTEXT.md §Phase S (drop-to-load).
**Spike verdict (ps-w12, 2026-07-27): DEGRADED.** The relay would require (a) a new
instrument WRITE seam into ext-state and (b) an extension-side timer poller + claim/clear
nonce — the same cross-process handshake race the S17 spec rejected. The shipped ingest
gesture stays drop-onto-docked-panel (S8). The relay is a future wave when the design is
ready.
- [x] 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.
**Landed:** the editor child window calls `DragAcceptFiles(TRUE)` on attach and handles
`WM_DROPFILES` (`reasampler_editor.cpp`). Windows-only (D5).
- [ ] Cross-artifact ingest relay (the S8-flagged spike): **DEFERRED** — relay mechanism
proved load-bearing to redesign. Remains in `PLAN.md` §S13.
- [x] 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. **Landed:** the editor
ACCEPTS the drop and flashes a transient banner ("drop files onto the ReaSampler bank panel
to add them") that decays over a few sync ticks, plus a persistent affordance line in the
empty state ("drop a file onto the ReaSampler bank panel"). No file is ingested; NO timeline
item is ever inserted (the hard invariant — the editor only displays guidance).
---
## 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.**
**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; existing
Gate/ADSR behavior is unchanged when hold=0 (regression).
**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.
- [x] 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); pure, unit-tested against a known
signal.
- [x] 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`.
- [x] 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`. **Choke on note-off is
NOT in scope** (fork S15-F1, held).
- [x] 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). Additive/
version-bumped component state; back-compat — a truncated/older blob defaults to **Gate**,
hold=0, start=0, no fades = exactly today's behavior.
- [x] 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; commits off-thread via
`commitMapAndReload`. The instrument stays a **read-only bank consumer**. **Editor control
surface deferred to S12 tier (spec-sanctioned).**
---
## 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. **Varispeed** — resampling that couples pitch and duration (classic sampler /
RS5K default). **Preserve** — duration-preserving repitch, where a transposed note keeps
its original length. A **per-zone/per-capture pitch-engine mode**. On top of either engine
rides a per-voice **AD pitch envelope**, **off by default** — a short attack-decay pitch
modulation. Per-instance performance-map state (D-B). CONTEXT.md §Phase S (Pitch engine
modes + pitch envelope).
**Verify (in DAW):** Varispeed — a note an octave up plays half as long as the root note;
Preserve — a note an octave up plays at the same duration as the root note; pitch envelope
off (default) under either engine: no pitch modulation applied (regression); pitch envelope
on: an AD envelope makes a note start offset in pitch and glide to the zone's base pitch
over attack+decay; CPU stays within budget at polyphony cap.
**Depends on:** S3, S5, S15. Independent of S7.
- [x] Core: **pitch-engine mode on the voice/zone** — `PitchEngine { Varispeed, Preserve }`.
**Varispeed** = `readPos_ += ratio_` (today's path, pitch and duration coupled). **Preserve**
= duration-preserving: the read advances at the **source** rate while a pitch shifter
transposes the output. Mode is per-`PerformanceZone` performance state (D-B), additive/
version-bumped; absent/older blob → engine default. Pure where possible: Varispeed math and
duration-invariance contract unit-tested.
- [x] Core: **Preserve engine implementation** — hand-rolled pure OLA `pitch_shift` module
(house pattern — CTest-testable, no WDL/REAPER/VST3 type at the boundary). Pre-allocated,
no locks, no `process` allocation; pre-warmed at voice allocation. `pitch_shift_tests`
CTest target. WDL_SimplePitchShifter excluded by include-chain (windows.h); held as a
quality/latency swap alternative (`PitchEngine::Preserve` contract identical behind the seam).
- [x] Core: a per-voice **AD pitch envelope**, engine-aware — `PitchEnvParams { enabled=false,
int64 attackFrames, int64 decayFrames, double peakSemitones }`. Off by default (`enabled=false`
→ offset always 0). Under **Varispeed** the offset multiplies `ratio_`; under **Preserve**
the offset is **added to the shifter's shift amount**. Pure, unit-tested.
- [x] Parameter ownership + editor: pitch-engine mode + pitch envelope are per-zone
instrument performance-map state (D-B), additive/version-bumped. Editor exposure deferred
to S12 tier (spec-sanctioned). The instrument stays a **read-only bank consumer**.
**Notes/decisions:**
- **S16-F1 (default engine):** default is **Preserve** (Daniel's directive: "I want
duration-preserving repitching"). Per-zone toggle prominent and cheap to flip for
drum/one-shot zones that want Varispeed character.
- **S16-F2 (Preserve engine):** hand-rolled pure OLA `pitch_shift` module chosen over
`WDL_SimplePitchShifter` (excluded by include-chain). Both share the same
`PitchEngine::Preserve` contract; `WDL_SimplePitchShifter` is held as the quality/latency
swap (the HELD item in `PLAN.md`).
---
## 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. While a capture
is dragged from the `bank_panel`, a track's TCP **FX button** lights as a drop zone,
and dropping there instantiates a **ReaSampler 9000** on that track with the dragged
capture **already loaded and selected** for playback. A third `DragGesture` —
`InstrumentDrop` — added to the `drag_out` pure module. Mechanism: `TrackFX_AddByName` +
VST3 component-state injection via `TrackFX_SetNamedConfigParm(..., "vst_chunk", blob)`.
The shared `instrument_drop` pure module constructs the blob via the instrument's own
`sample_map::serializeComponentState`/`deserializeComponentState` — one serializer called
from both artifacts, so the blob format cannot drift. CONTEXT.md §Phase S (drop-and-load).
**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; OS drag-out to Explorer still works
unchanged; internal bank-to-bank drag still works unchanged; no media item ever inserted
into the arrange. DAW-verify: whether REAPER's `vst_chunk` write-parm expects the plugin's
raw IComponent-state bytes or wraps them in a REAPER container header.
**Depends on:** M11 (`drag_out` gesture machinery), Phase S S4, the load-capture seam
(VST3 component-state injection — the shared blob contract).
- [x] Extend the `drag_out` pure module with the third gesture: `DragGesture::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. `DragState` gains two defaulted fields (`singleCapture`, `overReaperUi`); M11
callers filling only `{dragging, hasArmedSamples}` get byte-identical M11 behavior — the
existing tests are the non-regression proof. **OPEN QUESTION RESOLVED (multi-capture over
FX button): REJECT** — only `singleCapture` arms InstrumentDrop; multi-payload over
REAPER UI falls through to `OsDrag`.
- [x] Shell (extension): hover-track the pointer over REAPER's UI during the drag; resolve
the hovered track + its FX button via `GetThingFromPoint` (verified present; its info string
reports `"fx_chain"`/`"fx_N"` for the FX region); highlight it as a drop target; on release
drive the drop. FX HOTSPOT resolved — drop target is the FX region (info prefix `"fx_"`),
not home-grown geometry. `instrument_drop_win::resolveFxDropTarget` implements this.
**Landed in `src/instrument_drop_win.cpp`.**
- [x] Shell (extension): on drop, `TrackFX_AddByName(track, "VST3:" + app_version::vstPluginName(),
false, negative)` to always add a fresh instance; capture the returned FX index; invoke the
load-capture seam. Batched into one REAPER undo point (`Undo_BeginBlock2`/`EndBlock2`).
NEVER inserts a timeline item. **Landed: `instrument_drop_win::performInstrumentDrop`.**
- [x] **ReaSampler 9000 load-capture seam:** the instrument's existing `setState`/`getState`
already round-trip the full `ComponentState` via
`sample_map::serializeComponentState`/`deserializeComponentState` (S10). The extension
REUSES that exact serializer through the new pure `instrument_drop` module
(`buildInstrumentDropChunk` → `serializeComponentState` → base64). The shared-writer
requirement is met STRUCTURALLY — the blob format cannot drift. Pure round-trip test decodes
back through the instrument's own reader and asserts the capture is selected.
**`instrument_drop` lives at `src/instrument_drop.{h,cpp}`** (extension-side pure module);
CTest target `instrument_drop_tests`.
- [x] Tests: gesture disambiguation (inside-panel / over-REAPER-UI / left-REAPER) across
single- and multi-capture payloads; M11 OS drag-out and internal bank-to-bank drag both
unchanged. `test_drag_out.cpp` adds the InstrumentDrop cases with M11 cases retained as
the non-regression guard; `test_instrument_drop.cpp` is the blob round-trip + base64 codec
coverage. FX hit resolution is REAPER-API-bound (`GetThingFromPoint`) — DAW-verified in the
shell, not pure-tested (noted honestly).
**Notes/decisions:**
- `resetDragState()` consolidation in `bank_panel` (prior drift cleaned up this wave).
- Copy-only / no-auto-insert / no-source-deletion invariants all hold: NEVER inserts a
timeline item; the bank file is not deleted; prune remains the sole file-deleter.
---
## 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) 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. **One channel per binary; all identity derives from the ONE
`REASAMPLER_CHANNEL_IS_BETA` bit via `app_version`, no scattered `#ifdef`s.** Mirrors V4's
philosophy exactly. Two forever-stable VST3 class UIDs committed (the existing stable UID +
a new beta UID). CONTEXT.md §Phase S (VST3 channel identity — the UID-pair invariant).
**Verify (in DAW):** stable VST3 (`reasampler_9000.vst3`) and beta VST3
(`reasampler_9000_beta.vst3`) install side-by-side in one REAPER as distinct plugins;
a beta instance reads only the beta extension's banks; a stable instance reads only stable's;
save/reopen rebinds by the correct UID; nothing plays differently (identity/pairing wave only).
**Depends on:** V4 (`app_version` channel-identity single-source), S1 (VST3 factory identity).
- [x] **Beta VST3 class UID (the permanent commitment).** Second FOREVER-STABLE class UID
(`REASAMPLER_PROC_UID_BETA_1..4` + `kReaSamplerProcessorUIDBeta`) alongside the existing
stable UID in `reasampler_vst.h`. The channel bit selects which UID the factory registers
(`DEF_CLASS2`) — compile-time, one class per binary. Both UIDs frozen forever.
- [x] **Channel-derived binary + display identity (no scattered `#ifdef`s).** Binary name:
CMake VST3 target `OUTPUT_NAME` forks by channel — `reasampler_9000` (stable) /
`reasampler_9000_beta` (beta) — via `REASAMPLER_VST_OUTPUT_NAME`. Display name: factory
`DEF_CLASS2` plug-in display string sourced from `app_version::vstPluginName()` — "ReaSampler
9000" / "ReaSampler 9000 beta". Editor title band + S6 embed-strip label channel-aware from
the same accessor.
- [x] **Factory vendor/version strings channel-aware** where V4 does the equivalent. Version
display carries the `-beta` render (`appVersion()` yields `"0.9.01-beta"` on beta).
- [x] **Pairing-surface invariant recorded** (no new code — a documented guarantee): channel
isolation is structural — all wire keys live under the channel-derived `kProjExtNamespace()`,
so a future wire key that forgets to isolate is impossible by construction. The invariant
is recorded in CONTEXT.md.
- [x] **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
project saved with a beta instance reopens rebinding to the beta VST and restores its state.
+33 -1130
View File
File diff suppressed because it is too large Load Diff