docs(phase-s): settle S-VIEW velocity persistence + draggable nodes, single-pass on L3

F1 -> preview velocity persists via VST3 component state (v5->v6). F2 -> envelope nodes draggable via new pure envelope_edit module. Drop the two-pass L3 restyle framing; build on the merged L3 LnF. F3 remains open.
This commit is contained in:
2026-07-27 12:57:08 -04:00
parent 5ec84ab0eb
commit 3b9b78b82c
3 changed files with 170 additions and 55 deletions
+103 -24
View File
@@ -2277,11 +2277,32 @@ view wholesale, with two changes:
A **performance choice**, never written to `Sample` or the bank. Back-compat: an absent field on
an older blob → 100% (bit-identical playback).
- **Preview velocity** — a **utility** setting for the Sample view's preview-trigger button, not a
musical parameter of the capture. **Lean: transient editor state** (like a metronome level) —
it does not need to persist in component state, so it adds no version bump and no bank-fact risk.
*(Fork S-VIEW-F1, Daniel's call: transient vs. a small persisted editor convenience. Lean
transient — simplest, no compat surface, and a preview level is not something users expect to
survive a reload.)*
musical parameter of the capture. **SETTLED (S-VIEW-F1, Daniel 2026-07-27): it PERSISTS across
reloads.** The seam is **VST3 component state**, not the extension's `persist` ext-state module.
This distinction is load-bearing and was verified against the existing VST source, not recalled:
- **Wrong seam — the extension's `persist` module.** `persist` writes REAPER *project* ext-state
(`SetProjExtState`, namespace `"reasampler"`) and is owned by the **extension**, not the
instrument. Preview velocity is a **per-instance** instrument-editor setting; putting it in
project ext-state would (a) make it project-global rather than per-instance (two ReaSampler 9000
instances would share one preview level), (b) route an instrument-owned setting through a seam
the instrument only *reads* from (bank state), violating the read-only-over-bank contract, and
(c) leak an instrument concern into the extension's key space. Rejected.
- **Right seam — the instrument's own VST3 component state**, the same blob the processor already
round-trips via `ReaSamplerProcessor::getState`/`setState` over `IBStream`, whose format is the
**envelope-versioned `ComponentState`** in `src/vst/sample_map.h` (currently **v5**: version tag
+ channel-mode byte + last-consumed-assignment marker + selection-id + zones payload). Preview
velocity is a **top-level instance concern** — a sibling of `channelMode` and
`lastConsumedAssignGeneration`, **not** a per-`PerformanceZone` field (it is one setting per
instance, not per zone). It therefore lands as a **new field on `ComponentState`** added by
bumping the **envelope version to v6** (a new `float previewVelocity` after the assignment
marker, before the selection-id), leaving the **zones payload untouched** — exactly the
independent-version-axes composition the header already documents (envelope grows a field; the
zone-record payload version does not move). Round-trip: `serializeComponentState` appends it,
`deserializeComponentState` reads it; a v6 blob restores it directly, and **every older blob
(v5 and down) lifts to a sensible default** (e.g. 100/127 ≈ 0.79 or a chosen mid level), so
already-saved instances are unchanged and no compat surface is broken. This is the same additive
envelope-bump discipline S7 (v4→v5) already used — a well-trodden move in this codebase, not new
machinery.
### The envelope overlay — visual component (new)
@@ -2293,9 +2314,51 @@ drawn shape lines up with the waveform under it. **Pure geometry:** an `envelope
(mirror of `waveform_view` / `param_slider`) maps the AHDSR/Trigger params + the sample's
frame-length to a polyline in the waveform rect (`param↔pixel` at the shared time base),
unit-tested against known param sets; the shell traces it via kit line draws in an accent hue.
Decorative + informative — it never captures input (the sliders remain the edit surface); dragging
the envelope *nodes* directly is a deferred stretch (fork S-VIEW-F2, lean defer — sliders first,
node-drag is polish once the overlay reads).
**The overlay is directly editable — DRAGGABLE NODES (SETTLED, S-VIEW-F2, Daniel 2026-07-27).**
The envelope is not a read-only informative curve — its breakpoints are **draggable handles** that
set the envelope parameters directly. Dragging a node and the existing sliders are **two surfaces
onto one model**: the sliders stay as the precise numeric-entry surface, node-drag is the direct-
manipulation surface, and **both read and write the same `PerformanceZone` envelope fields** — a
drag updates the params, the sliders reflect them live, and a slider edit re-lays the nodes. There
is exactly one source of truth (the zone's envelope params); the two surfaces never diverge. This
is the same one-source-multiple-views instinct the whole system runs on.
- **Which nodes, and what each axis means.** The AHDSR shape (Gate) exposes handles at the segment
breakpoints — **attack-end** (top of the attack ramp), **hold-end**, **decay-end / sustain-level**
(the corner where decay settles to the sustain plateau), and **release-end**. The Trigger shape
exposes **fade-in-end**, **%-length** (the `playEnd` anchor), and **fade-out-end**. For each node,
**the horizontal axis maps to time** (the segment duration — attack/hold/decay/release seconds, or
fade/length fractions) and **the vertical axis maps to level** where the node is a level breakpoint
(the sustain node's vertical drag sets sustain 0..1; the peak nodes sit at unity). Time-only nodes
(attack-end, hold-end, release-end) drag horizontally; the sustain node drags on **both** axes
(its X sets decay time, its Y sets sustain level) — the standard ADSR-editor grammar (Ableton
Simpler, Phase Plant, Serum all use exactly this).
- **Constraints.** Nodes are **monotonic in time** — a node cannot be dragged left of its
predecessor or right of its successor (attack-end can't pass hold-end, etc.); each segment stays
≥ 0. Level drags clamp to **0..1** (sustain) / unity (peaks). Times clamp to the same per-param
min/max the sliders already enforce (so node-drag can never produce a param the slider couldn't).
The `playEnd`/%-length node additionally clamps to the sample's frame-length. **No snapping** by
default (continuous drag, matching the sliders' resolution); a fine-drag modifier (drag with a
held modifier for reduced sensitivity) is an optional polish, not required.
- **Pure module owns the geometry/hit-test math (house pattern).** A pure REAPER/LICE-free module —
name it **`envelope_edit`** (sibling to `envelope_overlay`; mirror of `card_drag` / `mode_switch`
/ `param_slider`) — owns **node hit-testing** (point → which node, with a pick radius) and the
**drag→param mapping** (a pixel delta on a given node → the resulting clamped envelope param set,
respecting the monotonic + range constraints above). It is **unit-tested**: a known node + a known
pixel delta asserts the expected param delta and the clamp/monotonic behavior at the boundaries.
`envelope_overlay` keeps the params→polyline forward map (draw); `envelope_edit` owns the
pixel→params inverse map (edit) + hit-test. The shell (`reasampler_editor.cpp`) does the LICE
handle draw (small node markers at each breakpoint, hover/drag-lit via kit states) and routes
mouse events through `envelope_edit`, then commits the resulting params to the zone through the
same off-audio-thread path the sliders use (no new RT surface — a node-drag is a param edit, same
as a slider drag). Zones and the single-capture Sample face share this one edit surface (one
storage site, S15-F2).
- **Sync with sliders (load-bearing).** Because both surfaces write the same `PerformanceZone`
envelope fields, keeping them in sync is **structural, not a listener chain**: the editor re-reads
the zone's params every paint, so a slider edit re-lays the nodes and a node-drag re-positions the
sliders with no explicit cross-wiring. The single source of truth makes divergence impossible by
construction.
### The real piano-key pattern — visual component (new)
@@ -2357,23 +2420,33 @@ face without scrolling, and cannot be resized below the constraint floor.
### Module architecture (preserve the pure/shell split)
- **Pure (new/extended):** `envelope_overlay` (AHDSR/Trigger params + frame-length → polyline in a
rect; unit-tested); `keyboard_strip` extended with the natural/accidental predicate; the sampler
core extended with the **key-track scalar** in the repitch math (unit-tested against known
rect; unit-tested); `envelope_edit` (NEW — node hit-test + pixel-delta→clamped-param inverse map
for the draggable envelope handles; mirror of `card_drag`; unit-tested at the monotonic/clamp
boundaries); `keyboard_strip` extended with the natural/accidental predicate; the sampler core
extended with the **key-track scalar** in the repitch math (unit-tested against known
note/root/keyTrack → ratio); `sample_map` (`PerformanceZone`) extended with the additive
`keyTrack` field + component-state version bump + back-compat default.
`keyTrack` field, plus the **envelope-v6 `previewVelocity` field on `ComponentState`** (S-VIEW-F1)
— both additive, component-state version bumped, back-compat defaults on read.
- **Shell (`reasampler_editor.cpp`):** re-partition the paint/hit-test into the three views
(Sample face, Browse modal overlay, Zone surface) replacing the two-view toggle; add the
preview-trigger button + velocity knob wired to an off-audio-thread preview note through the
voice engine; draw the envelope overlay + piano-key overlay via the kit; set the larger default
`ViewRect` + `checkSizeConstraint`. All layout/hit-test math stays in the pure geometry modules.
voice engine (the velocity **persists** via the envelope-v6 `ComponentState` field, S-VIEW-F1);
draw the envelope overlay + **its draggable node handles** (routing mouse events through the pure
`envelope_edit` module and committing params via the same off-thread path the sliders use,
S-VIEW-F2) + piano-key overlay via the kit; set the larger default `ViewRect` +
`checkSizeConstraint`. All layout/hit-test math stays in the pure geometry modules.
### Precision / invariant implications
- **Read-only bank consumer (unchanged).** Key-tracking, preview velocity, and every marker/mode
control are the instrument's **performance map** (D-B) — never written to `Sample` or the bank.
- **Additive, back-compat component state.** `keyTrack` is a new optional field defaulting to 100%;
an older blob (absent field) restores to 100% → **playback bit-identical** under the same engine.
No existing field changes.
- **Read-only bank consumer (unchanged).** Key-tracking, preview velocity, envelope-node edits, and
every marker/mode control are the instrument's **performance map / editor state** (D-B) — held in
the instrument's own VST3 component state, **never written to `Sample` or the bank.**
- **Additive, back-compat component state.** Two additive fields land: `keyTrack` (per-
`PerformanceZone`, default 100%) inside the zones payload, and `previewVelocity` (per-instance)
as a new top-level `ComponentState` field via an **envelope bump to v6** — the zones-payload
version axis is untouched (independent version axes). Every older blob lifts on read (absent
`keyTrack` → 100%, absent `previewVelocity` → the chosen mid default), so already-saved instances
restore **playback bit-identical** under the same engine. No existing field changes.
- **RT discipline (unchanged).** The preview-trigger fires a note through the existing voice engine
via the off-audio-thread commit path (`commitMapAndReload` idiom); no new `process`-thread work,
no allocation on the audio thread.
@@ -2384,14 +2457,13 @@ face without scrolling, and cannot be resized below the constraint floor.
### Open questions / forks (Daniel / Phase S team)
- **S-VIEW-F1 — preview velocity persistence.** Transient editor state (lean) vs. a small persisted
editor convenience. Lean transient — no compat surface, no version bump.
- **S-VIEW-F2 — envelope-overlay interactivity.** Read-only informative overlay + sliders as the
edit surface (lean, ship first) vs. draggable envelope nodes on the overlay (deferred stretch —
polish once the overlay reads).
*(S-VIEW-F1 and S-VIEW-F2 are SETTLED — Daniel 2026-07-27 — and folded into the spec above:
preview velocity **persists** via envelope-v6 `ComponentState`; the envelope overlay's nodes are
**draggable** via the pure `envelope_edit` module. They are no longer open questions.)*
- **S-VIEW-F3 — Browse modal presentation.** Full-window overlay (lean — the modal-picker feel) vs.
a large centered sheet with a dimmed Sample behind. Presentation detail; either satisfies "easy
to summon and dismiss."
to summon and dismiss." **Still Daniel's call** — the only S-VIEW fork left open.
### Must-verify before build (S-VIEW)
@@ -2404,6 +2476,13 @@ face without scrolling, and cannot be resized below the constraint floor.
- **Preview note through the voice engine off-thread** — confirm the existing `commitMapAndReload`
/ off-thread reload idiom is the right seam to fire a one-shot preview note without touching
`process` on the UI thread; no torn state on the atomic voice-engine pointer.
- **Preview-velocity persistence seam (S-VIEW-F1)** — the envelope-v6 `ComponentState` field is the
prescribed seam (verified against `src/vst/sample_map.h` + `reasampler_processor.cpp`
getState/setState this pass); confirm at build that appending a `float previewVelocity` after the
assignment marker keeps the v5→v6 lift clean and the zones-payload byte offsets unchanged.
- **Envelope node-edit inverse map (S-VIEW-F2)** — confirm the pure `envelope_edit` pixel→param
inverse map produces params the sliders' own min/max already permit (so the two surfaces can
never diverge), and that the monotonic time-node constraint holds at the segment boundaries.
- **Envelope-overlay time base** — confirm the seconds→frames resolution the overlay draws against
matches the voice engine's live-rate resolution so the drawn shape lines up with the waveform.