docs(phase-s): fold velocity->amp transfer-curve editor into S-VIEW (r10)

Per-PerformanceZone state home, new pure velocity_curve module, application at
Voice::start(), Sample-view editor UI. New PLAN points S-VIEW-9 (foundation,
blocked by Wave 1 T-KEYTRK) + S-VIEW-10 (Wave 2 shell). Fork R10-F1 (flat-y=1
default is a non-back-compat behavior change) left open for Daniel.
This commit is contained in:
2026-07-27 13:35:01 -04:00
parent 3b9b78b82c
commit 5a859cc9bf
3 changed files with 279 additions and 18 deletions
+133 -16
View File
@@ -2143,12 +2143,14 @@ not a raw literal). What S18 adds is only the missing *plugin identity* layer.
> **Additive sub-phase of Phase S — an *editor* redesign, not an engine change.** Re-partitions
> the ReaSampler 9000 editor from today's two-view toggle (Browser + Zones) into a **three-view
> model where the loaded sample is the home**, adds two new performance parameters (key-tracking,
> preview velocity) and two visual components (envelope overlay, real piano-key pattern), and
> model where the loaded sample is the home**, adds three new performance parameters (key-tracking,
> preview velocity, and the r10 velocity→amp transfer curve) and three visual components (envelope
> overlay, real piano-key pattern, and the r10 velocity-curve editor), and
> frames two engineering prerequisites (drop-to-FX bug, default window size). The S3 voice
> engine, keymap resolution, and read-only-over-bank contract are **unchanged**; the
> component-state format extends additively for key-tracking. Product framing: `docs/product/
> midi-playback.md` §Addendum r9. Same standing discipline: **LICE/SWELL drawing only,
> component-state format extends additively (key-tracking + the r10 velocity curve on the
> zones-payload axis, preview velocity on the envelope axis). Product framing: `docs/product/
> midi-playback.md` §Addendum r9 + r10. Same standing discipline: **LICE/SWELL drawing only,
> all layout/hit-test in pure geometry modules, RT-safe, VST3 class UID unchanged, verify every
> API name/signature against the vendored headers before use.**
@@ -2366,6 +2368,93 @@ Covered under View 3 above. Pure: `keyboard_strip` gains the natural/accidental
draws the bright/dark overlay over the existing pastel spectral fill. Shared by the Zone strip and
the Sample root affordance.
### The velocity → amp transfer-curve editor — visual component (new; Daniel, 2026-07-27, r10)
A **visual transfer-curve editor** mapping MIDI velocity to an amp scalar: **X = velocity (0127),
Y = amp scalar (01)**, an editable bezier from a flat default to an arbitrary multi-point curve.
It gives fully shapeable velocity dynamics per sound. Today the engine maps velocity to gain
*linearly*`velocityGain_ = velocity / 127.0`, computed once at note-on in `Voice::start()`
(`src/vst/sampler_core.cpp:261`); this replaces that fixed line with an editable curve evaluated at
the same point.
**State home — per-`PerformanceZone` (instrument-owned, D-B).** Velocity response is a per-sound
performance characteristic — a sibling of the AHDSR amp envelope, the pitch engine, and the r9
`keyTrack` scalar, all of which already live on `PerformanceZone`. A drum and a pad want different
velocity curves, so the curve varies **per zone**, not per instance. This is deliberately **not**
`ComponentState` (per-instance): that is where **preview velocity** correctly lives, because
preview velocity is a *utility* setting (one per instrument, like a metronome level), whereas the
transfer curve is a *musical* setting (one per sound). The curve is an **additive field on
`PerformanceZone`** riding the **zones-payload version axis** (contrast preview velocity's
envelope-v6 bump — a top-level per-instance field on the *envelope* axis; the two version axes are
independent, as the header documents). The single-capture Sample face reads/writes the same
one-zone storage site (S15-F2), so Sample and Zone views share one curve store per zone.
**Default — flat y=1, a deliberate behavior change (fork R10-F1, Daniel's call).** Daniel's
verbatim default is *"any velocity plays at full level"* — a flat curve at y=1. This is **not**
bit-identical to today's shipped linear `velocity/127` map: today soft hits are quieter; under a
flat-y=1 default every hit plays at unity, so every existing zone's felt dynamics change. This is
the one genuine fork the feature carries:
- **Option A (Daniel's stated default): flat y=1.** Honors the directive; velocity is inert until a
curve is drawn. *Con:* NOT back-compat — already-saved instances and new captures get flatter
dynamics than today until a curve is drawn.
- **Option B: default = today's linear ramp (y = x/127).** Bit-identical to the shipped engine; the
editor's flat y=1 is one drawn state, not the default. *Con:* contradicts the verbatim *"by
default … full level"* (the default line is a diagonal, not flat).
- **Lean: Option A (flat y=1)** — it is what Daniel asked for and the feature's point is opt-in
velocity dynamics — but flagged loudly as a **shipped-behavior change**, not a silent regression.
Option B is the safe fallback and costs only the default curve's seeded control points. Whichever
wins, the *stored* default is a curve the editor draws and the core evaluates; the options differ
only in which curve is seeded.
**Pure module — `velocity_curve` (REAPER/LICE-free, unit-tested).** Mirror of `envelope_edit` /
`card_drag`. Two responsibilities:
- **Evaluation:** `eval(velocity 0127) → amp scalar 01` — a bezier through the control points,
clamped to the 0127 × 01 box, **monotonic in x** by construction (each velocity has exactly one
output). Called at note-on, never per frame.
- **Editing:** add / move / delete control points, each clamped into the box and **x-ordered** (a
point cannot cross its neighbours in x — the same monotonic grammar as the envelope nodes); a
point hit-test (point → which control point, pick radius) and a pixel-delta → clamped-point
inverse map (mirror of `envelope_edit`'s inverse map). The flat identity curve (per R10-F1) is a
named constructor.
Both halves are **unit-tested** at the boundaries: a known curve + known velocity asserts the eval
output; a known drag asserts the clamped point set + the box/order constraints; add/delete assert
the point count and ordering invariants.
**Voice-engine application point — `Voice::start()`, off the per-frame path.** Confirmed from
source: `Voice::start(int note, int velocity, …)` (`sampler_core.cpp:252`) computes
`velocityGain_ = velocity / 127.0` **once at note-on** (line 261); the per-frame render then just
multiplies the cached scalar (`advanceFrame`, line 408: `gain = amp * velocityGain_`). The transfer
curve slots in at exactly that line — `velocityGain_ = curve.eval(velocity)` at note-on — **off the
audio-thread-hostile per-frame path** (evaluated once per voice, no new `process`-thread work, no
allocation). The curve reaches the voice the same way the AHDSR/keyTrack params do: on the zone's
`SampleData::play` bundle (resolved from the stored `PerformanceZone` at keymap build), read by the
voice at `start()`. The pure `velocity_curve` core owns the eval; the voice reads it.
**The curve-editor UI — Sample view, adjacent to the envelope overlay.** Lives on the **Sample
view** (the r9 home face), a compact band next to the hero-waveform envelope overlay — the two are
the same grammar (a drawn 2-D curve with draggable handles), and amp-over-time beside
amp-over-velocity reads naturally. Draws through the **L1 kit** like every S-VIEW surface: a
bordered box (X = velocity 0127, Y = amp 01), the bezier traced in an accent hue, small draggable
node markers per control point (hover/drag-lit via kit states), add-point on empty-space click,
delete on modifier-click / drag-off. The shell (`reasampler_editor.cpp`) does the LICE draw + mouse
routing; **all geometry/hit-test/clamp math lives in the pure `velocity_curve` module**. On the
Zone view the same editor appears in the per-zone param panel (one curve per zone). Additive and
bit-identical for existing projects only under R10-F1 Option B; under Option A (the lean) it is
additive-but-behavior-changing.
**Wave-plan slot (concurrency-aware).** The curve lands on `PerformanceZone`, so it is **blocked by
Wave 1 track T-KEYTRK** (which owns the `PerformanceZone` schema + zones-payload version bump). The
`velocityCurve` field must sequence as a **LATER additive payload bump AFTER T-KEYTRK merges** — the
two are sequential additive extensions of the same zones-payload record, not simultaneous ones, so
they never collide on one payload version. It is **not** blocked by T-STATE (that owns the
per-instance `ComponentState` v5→v6 envelope bump for preview velocity — a different struct on the
independent envelope version axis). Concretely: a **follow-on foundation track** (pure
`velocity_curve` + the `Voice::start` application point + the additive `PerformanceZone` field +
payload bump), gated on T-KEYTRK; plus a **Wave 2 shell-integration item** (the Sample-view +
Zone-panel curve-editor UI through the L1 kit), gated on the foundation track and composing with the
S-VIEW-2 Sample face + S-VIEW-3 envelope overlay.
### Engineering prerequisite 1 — drop-to-FX bug (routed to implementation, NOT a design call)
**Symptom (Daniel):** dropping a capture onto a track's FX chain does not instantiate + init
@@ -2422,11 +2511,17 @@ face without scrolling, and cannot be resized below the constraint floor.
- **Pure (new/extended):** `envelope_overlay` (AHDSR/Trigger params + frame-length → polyline in a
rect; unit-tested); `envelope_edit` (NEW — node hit-test + pixel-delta→clamped-param inverse map
for the draggable envelope handles; mirror of `card_drag`; unit-tested at the monotonic/clamp
boundaries); `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, plus the **envelope-v6 `previewVelocity` field on `ComponentState`** (S-VIEW-F1)
— both additive, component-state version bumped, back-compat defaults on read.
boundaries); `velocity_curve` (NEW, r10 — bezier `eval(velocity 0127)→amp 01` + control-point
add/move/delete clamped to the 0127×01 box, x-ordered, hit-test + pixel-delta inverse map;
mirror of `envelope_edit`; unit-tested at eval + clamp/order boundaries); `keyboard_strip`
extended with the natural/accidental predicate; the sampler core extended with the **key-track
scalar** in the repitch math (unit-tested against known note/root/keyTrack → ratio) **and the
velocity-curve eval at `Voice::start()`** replacing the linear `velocity/127` (r10; off the
per-frame path); `sample_map` (`PerformanceZone`) extended with the additive `keyTrack` field
**and the additive `velocityCurve` field** (r10 — both on the zones-payload version axis; the
velocity-curve field sequences AFTER T-KEYTRK's `keyTrack` bump), plus the **envelope-v6
`previewVelocity` field on `ComponentState`** (S-VIEW-F1) — all additive, version bumped,
back-compat defaults on read.
- **Shell (`reasampler_editor.cpp`):** re-partition the paint/hit-test into the three views
(Sample face, Browse modal overlay, Zone surface) replacing the two-view toggle; add the
preview-trigger button + velocity knob wired to an off-audio-thread preview note through the
@@ -2441,12 +2536,18 @@ face without scrolling, and cannot be resized below the constraint floor.
- **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.
- **Additive, back-compat component state.** Three additive fields land: `keyTrack` (per-
`PerformanceZone`, default 100%) and — sequenced after it on the same zones-payload axis —
`velocityCurve` (per-`PerformanceZone`, r10, default the R10-F1 curve) inside the zones payload,
and `previewVelocity` (per-instance) as a new top-level `ComponentState` field via an **envelope
bump to v6** on the independent envelope axis. Every older blob lifts on read (absent `keyTrack` →
100%, absent `velocityCurve` → the R10-F1 default curve, absent `previewVelocity` → the chosen mid
default), so already-saved instances restore cleanly. **Playback back-compat carries a caveat for
the velocity curve:** under R10-F1 Option A (flat y=1 default) an already-saved zone with no
stored curve now plays every velocity at unity — **not** bit-identical to the linear `velocity/127`
it played before; under Option B (linear default) it is bit-identical. This is the one non-back-
compat surface in S-VIEW and is the substance of fork R10-F1. `keyTrack` and `previewVelocity`
remain fully bit-identical on lift. 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.
@@ -2463,7 +2564,13 @@ preview velocity **persists** via envelope-v6 `ComponentState`; the envelope ove
- **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." **Still Daniel's call** — the only S-VIEW fork left open.
to summon and dismiss." **Still Daniel's call.**
- **R10-F1 — velocity-curve default (Daniel, 2026-07-27).** **Option A: flat y=1** (Daniel's
verbatim default — every velocity plays at full level; NOT back-compat with today's linear
`velocity/127`, so existing zones' dynamics change) vs. **Option B: linear y = x/127** (bit-
identical to the shipped engine; contradicts the verbatim "full level" default). **Lean A**,
flagged as a deliberate shipped-behavior change, not a silent regression. This is the only
non-back-compat surface the velocity-curve feature introduces. **Daniel's call.**
### Must-verify before build (S-VIEW)
@@ -2485,6 +2592,16 @@ preview velocity **persists** via envelope-v6 `ComponentState`; the envelope ove
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.
- **Velocity-curve application point (r10)** — confirm `Voice::start()` (`sampler_core.cpp:252`) is
the sole velocity→gain site and that replacing `velocityGain_ = velocity / 127.0` (line 261) with
`velocityGain_ = curve.eval(velocity)` keeps the eval at note-on only, off the per-frame render
path (line 408 `gain = amp * velocityGain_` unchanged). No new `process`-thread work or allocation.
- **Velocity-curve payload sequencing (r10)** — confirm the additive `velocityCurve` field on
`PerformanceZone` lands as a zones-payload bump AFTER T-KEYTRK's `keyTrack` bump (not simultaneous),
so the two sequential extensions of the same record never collide on one payload version number.
- **Velocity-curve eval monotonicity (r10)** — confirm the pure `velocity_curve` bezier is monotonic
in x over the 0127×01 box (one output per velocity) and that control-point edits stay clamped +
x-ordered at the boundaries.
---