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
+117
View File
@@ -1100,6 +1100,123 @@ v6, both with back-compat defaults on read) are the same load-bearing core.
---
### Addendum — velocity → amp transfer-curve editor (Daniel, 2026-07-27, r10)
**Framing.** Folds one more control into the S-VIEW redesign: a **visual velocity → amp
transfer-curve editor**. Today the engine maps velocity to gain *linearly* (`velocityGain_ =
velocity / 127.0`, `sampler_core.cpp:261`), applied once at note-on in `Voice::start()`. Daniel
wants that mapping to become an **editable transfer curve** — a bezier from a default flat line to
an arbitrary multi-point curve — so velocity dynamics are fully shapeable per sound.
**Directive, verbatim (Daniel, 2026-07-27):** *"By default any velocity plays at full level. Add
a visual transfer-curve editor: an editable curve from the default flat y=1 to any bezier curve
with arbitrary points, where x = velocity (0127) and y = amp scalar (01). Enables very dynamic
control over velocity → level."*
**The reference grammar.** A 2-D transfer-curve editor is the standard sampler/synth velocity-
response surface — Kontakt's velocity/mod curve editor, Serum's env/curve nodes, Ableton's
velocity device, any DAW's automation-curve editor. X is the input (velocity, 0127), Y is the
output multiplier (amp scalar, 01), and draggable control points bend the mapping. It is the
same direct-manipulation, one-source-multiple-views instinct as the r9 envelope overlay — and it
**reuses the r9 `envelope_edit` house pattern almost verbatim** (a pure geometry/clamp module +
a LICE shell that draws handles and routes the mouse).
**The four calls (r10).**
1. **State home → per-zone (`PerformanceZone`), sibling of the amp envelope + keyTrack.** Velocity
response is a *per-sound performance characteristic*, exactly like the AHDSR envelope, the pitch
engine, and the r9 key-tracking scalar — all of which already live on `PerformanceZone` (D-B,
instrument-owned, never a bank fact). A punchy drum wants a different velocity curve than a
sustained pad, so the curve must vary per zone, not per instance. This rules out `ComponentState`
(per-instance, where preview velocity correctly lives — that is a *utility* setting, one per
instrument; the transfer curve is a *musical* setting, one per sound). The curve is an additive
field on `PerformanceZone`, defaulting to the flat identity, and rides the **zones-payload
version axis** — NOT the envelope version axis (contrast preview velocity's envelope-v6 bump,
which is a per-instance top-level field). The single-capture Sample face reads/writes the same
one-zone storage site (S15-F2), so the Sample view and the Zone view share one curve store.
2. **Default → flat y=1 (all velocities full level), which is a DELIBERATE behavior change — fork
R10-F1.** Daniel's verbatim default is *"any velocity plays at full level"* — a flat curve at
y=1. That is **not** bit-identical to today's shipped linear `velocity/127` map: today a soft hit
is quieter; under a flat-y=1 default every hit plays at unity. This is the one genuine fork the
feature carries, and it is Daniel's call:
- *Option A (Daniel's stated default): flat y=1.* Honors the verbatim directive; velocity is
inert until the user draws a curve. Con: **not** back-compat — already-saved instances (and new
captures) get louder/flatter dynamics than today's build until a curve is drawn. Every existing
zone's felt response changes.
- *Option B: default = the current linear ramp (y = x/127).* Bit-identical to today's engine; the
editor's "flat y=1" is then one drawn state among many, not the default. Con: contradicts the
verbatim *"by default … full level"* — the default line would be a diagonal, not flat.
- *Recommendation: Option A (flat y=1) as the stored default, because it is what Daniel asked for
and the feature's whole point is that velocity is opt-in dynamics — but flag it loudly as a
shipped-behavior change so it is a decision, not a silent regression.* If the changed feel on
existing projects is unwanted, Option B is the safe fallback and costs only the default curve's
control-point values. **Daniel's call.** (Whichever wins, the *stored* default is a curve the
editor can draw and the core can evaluate — the two options differ only in which curve is
seeded.)
3. **Pure module → `velocity_curve` (REAPER/LICE-free, unit-tested).** Mirror of `envelope_edit` /
`card_drag`. Two responsibilities: **(a) evaluation** — `eval(velocity 0127) → amp scalar 01`
for the voice engine (a bezier through the control points, clamped to the 0127 × 01 box,
monotonic-in-x by construction so a given velocity has exactly one output); and **(b) editing** —
add / move / delete control points, each clamped into the box, x-ordered (a point cannot cross
its neighbours in x, same monotonic grammar as the envelope nodes), with a point hit-test
(point → which control point, pick radius) and a pixel-delta → clamped-point inverse map. 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 and the box/order constraints. The flat identity curve
(whichever default R10-F1 picks) is a named constructor. **No RT concern in the module**
evaluation is called at note-on, not per frame (see call 4).
4. **Voice-engine application point → `Voice::start()`, replacing the linear `velocity/127`.**
Confirmed from source: `sampler_core.cpp:261` computes `velocityGain_ = velocity / 127.0` **once
at note-on** inside `Voice::start()`; the per-frame render path (`advanceFrame`, line 408:
`gain = amp * velocityGain_`) then just multiplies the cached scalar. So the transfer curve
slots in at exactly one line: `velocityGain_ = curve.eval(velocity)` at note-on — **off the
audio-thread-hostile per-frame path**, evaluated once per voice, honoring the house RT
discipline (no new per-frame work, no allocation in `process`). The curve travels to 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 core owns
the eval; the voice reads it.
**The curve-editor UI — Sample view, near the envelope overlay.** It lives on the **Sample view**
(the r9 home face), as a compact band adjacent to the hero-waveform envelope overlay — the two are
the same grammar (a drawn 2-D curve with draggable handles) and reading them side by side is
natural (amp-over-time next to amp-over-velocity). 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 at each control point (hover/drag-lit via kit states), add-point on
click in empty space, delete on a modifier-click or drag-off. The shell (`reasampler_editor.cpp`)
does the LICE draw + mouse routing; **all geometry/hit-test/clamp math is 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, per call 2.
**What does NOT change (guardrails).** Instrument stays a **read-only bank consumer** — the curve
is *performance map* on `PerformanceZone`, never written to `Sample` or the bank (D-B). LICE/SWELL
drawing only; all math in the pure `velocity_curve` module (D-A). RT discipline untouched — eval
is at note-on, not per frame. VST3 class UID unchanged — an additive per-zone field is not a compat
event. Zones-payload version bump only; the envelope version axis (preview velocity's v6) is
independent and untouched.
**Wave-plan slot (concurrency-aware — six engineers are in worktrees now).** Because the curve
lands on `PerformanceZone`, it is **blocked by Wave 1 track T-KEYTRK** (which owns the
`PerformanceZone` schema + the zones-payload version bump right now). The velocity-curve field must
sequence as a **LATER additive payload bump AFTER T-KEYTRK merges**, so the two do not collide on a
single payload version number — T-KEYTRK's `keyTrack` field and this `velocityCurve` field are
sequential additive extensions of the same zones-payload record, not simultaneous ones. It is
**not** blocked by T-STATE (that track owns the per-instance `ComponentState` v5→v6 envelope bump
for preview velocity — a different struct on a different version axis). Concretely this is:
- **a follow-on foundation track** (pure `velocity_curve` module + the core application point at
`Voice::start` + 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 drawn through
the L1 kit, routing mouse through `velocity_curve`), gated on the foundation track and composing
with the S-VIEW-2 Sample face + S-VIEW-3 envelope-overlay work.
**Where the spec lives:** CONTEXT.md §Phase S — editor view-model redesign (S-VIEW), velocity-curve
sub-section; PLAN.md §Phase S — editor view-model redesign (S-VIEW-9/S-VIEW-10 + fork R10-F1). This
Addendum is the *why*; those are the *what/how*.
---
## Where this landed
With D1/D5/D6 locked and **D-A..D-D all settled (2026-07-26)**, the instrument is scoped