docs(product): spec Phase S sampling modes (S15 Trigger/Gate) + pitch envelope (S16)

Gate: AHDSR + loops; Trigger: fade-in / %-length / fade-out, note-off-immune.
Start point joins the instrument-side overrides. Pitch env is a per-frame
ratio multiply on the existing core — no resampler rewrite. WDL swept:
sinc resampler held as optional upgrade; no elastique-class stretch in WDL.
This commit is contained in:
2026-07-26 20:09:15 -04:00
parent 991c190bb8
commit c64ab687bf
3 changed files with 396 additions and 8 deletions
+166 -1
View File
@@ -1320,6 +1320,148 @@ stereo; the current mono downmix is a Tier-0 simplification, not a permanent sha
REAPER's mono/stereo instrument-bus expectations against the vendored Steinberg SDK +
`reaper_vst3_interfaces.h`.
## Sampling modes — Trigger vs Gate + pitch envelope (S15/S16; core + editor)
**Daniel's directive (2026-07-26, verbatim):** *"Sampling mode: Trigger vs Gate. Gate has
an AHDSR envelope. Trigger has fade in, % length, and fade out. Both modes have modifiable
start point, Gate has modifiable loop points too. In addition to amp env, there will be a
pitch envelope/curve (AD?) which is off by default."* The feature set is **settled**; two
forks (S15-F1 choke, S15-F2 param granularity) are flagged with leans below.
### Play mode — Gate vs Trigger (S15)
Each played sample carries a **play mode** — a per-sample/per-zone **performance choice**
(D-B, instrument-owned, never a bank fact). Two modes, precisely:
- **Gate — classic held note (grows the current path).** Note-on enters the amp envelope;
note-off enters release; a **sustain loop** applies for held notes (S11's draggable loop
markers are Gate-mode UI). The current core envelope is **ADSR**; Gate adds a **Hold**
stage → **AHDSR**: `0→1` over attack, **hold at 1** over `holdFrames`, `1→sustain` over
decay, hold sustain until note-off, `level→0` over release. **`holdFrames == 0` is
exactly today's ADSR** — a back-compat degenerate, no behavior change for existing Gate
play. Segment math is the existing linear-ramp idiom (`AdsrEnvelope::tick`) with one new
stage inserted between Attack and Decay.
- **Trigger — one-shot drum-pad.** Note-on fires playback of a defined **% of sample
length** with a **fade-in** and **fade-out** ramp; **note-off is ignored** (the voice
plays through); **no sustain loop**. Envelope math (distinct from AHDSR): play the frame
span `[startFrame, playEnd)` where `playEnd = startFrame + round(lengthFraction·(frames
startFrame))`, `lengthFraction ∈ (0,1]`; amplitude ramps `0→1` over `fadeInFrames`
(fade-in) at the head and `1→0` over `fadeOutFrames` anchored to `playEnd` (fade-out),
unity between; fades clamp so `fadeInFrames + fadeOutFrames ≤ play length`. The voice
frees when `readPos_ ≥ playEnd` (mirror of the current run-off-end idle). **Fade curve
default: equal-power** (constant-power `sin`/`cos` — click-free on one-shots); linear is a
build-time residual. **Note-off in Trigger is a no-op** (choke is held — fork S15-F1).
**Both modes: modifiable start point.** Playback begins at `startFrame` (a frame offset into
the sample, clamped `0 ≤ startFrame < frames`), not always frame 0. This is the voice's
initial `readPos_`; the existing per-frame `readPos_ += ratio_` read and linear-interp/loop
machinery are otherwise unchanged. Gate additionally has **modifiable loop points** (already
the S2 loop intrinsic + S11 override); Trigger has none (it is a one-shot).
**Voice-stealing interaction (unchanged).** The S3 stealing policy (oldest-in-release, else
oldest-overall) is mode-agnostic — a Trigger one-shot is a normal active voice until it runs
off `playEnd`; it can be stolen like any voice. No new stealing rule.
**Confirmed from the core (`sampler_core.cpp`):** the read loop advances `readPos_` by an
arbitrary `ratio_` per frame with 2-point linear interpolation, and the amp is a per-frame
`env_.tick()` multiply — so both the AHDSR hold stage and the Trigger fade/%-length envelope
are **per-frame amplitude functions** over the existing read machinery, and the start point
is just a non-zero initial `readPos_`. No resampler or voice-lifecycle rewrite is needed.
**Parameter ownership (D-B).** The play mode + its params (Gate: AHDSR; Trigger: %-length +
fade-in + fade-out; both: start point) attach to the **capture selection / zone** and live
in the instrument's **performance map** (component state, version-bumped, back-compat: a
truncated/older blob defaults to **Gate, hold=0, start=0, no fades = exactly today**). Start
point joins `rootOverride` / loop-override as another per-`PerformanceZone` optional
override; a per-zone `PlayMode` + param struct is added additively. **Fork S15-F2 (flagged):**
per-capture-selection *and* per-zone, or per-zone only with the single-capture case as a
one-zone map? **Lean: per-zone only** — the single capture is already a one-zone map
(S10-Z's back-compat lift), so one storage site serves both; flagged because it touches
S10's single-capture setup surface shape.
**Editor (mode-aware, on the S11 waveform surface).** Gate shows draggable **start + loop
markers**; Trigger shows **start + %-length end + fade-in/out** handles — same waveform, same
pure `frame↔pixel` + marker-grab geometry module (S11), mode switches which markers draw. A
**mode toggle** per capture/zone sits in the S10 guided setup / S10-Z Zones panel. Every edit
commits **off-thread** via `commitMapAndReload`; the instrument stays a **read-only bank
consumer** (mode/params are performance map, never written to `Sample` or the bank).
### Pitch envelope — AD, off by default (S16)
A per-voice **pitch modulation curve** on top of a zone's base repitch — a short **AD**
(attack-decay) envelope that biases the voice's read-increment over time. **Off by default**
(so existing playback is bit-identical). The classic use is a percussive **pitch drop**.
- **Shape (lean, build-time residual): two-segment AD** — at note-on the pitch offset rises
to `peakSemitones` over `attackFrames`, then falls to 0 (base pitch) over `decayFrames`.
A **zero attack** gives the pure "start high, drop to base" percussive drop; a positive
peak that settles to 0 is the classic sampler pitch envelope. Documented; the alternative
(start-offset → monotone glide to base) is the degenerate `attack=0` case of this.
- **Range: semitones (±).** `peakSemitones` is signed (positive = start/peak above base,
negative = below); default depth range noted at build.
- **RT implication — confirmed clean.** The core's resampler is already an **arbitrary
per-frame ratio** linear interpolation (`readPos_ += ratio_`, verified in
`sampler_core.cpp`). The pitch envelope is therefore a **per-frame multiply of `ratio_`**
by `2^(pitchEnvSemitones(frame)/12)` — the effective read increment varies frame-by-frame
at no structural cost. **No new resampler, no WDL dependency** for the modulation path. The
envelope is the same per-frame `tick()` idiom as the amp envelope — RT-safe, no allocation
in `process`, per-voice (polyphonic notes each run their own).
- **Ownership + editor.** Per-zone instrument performance-map state (D-B), additive/version-
bumped (absent → disabled). Editor exposure folds into the S12 ADSR-editor tier: attack +
decay + a ±semitone depth control, default-off (discoverable but inert until enabled).
### WDL pitch/resample surface — verified finding (feeds S15/S16, not a committed point)
The **full** vendored WDL pitch/resample surface was swept — `vendor/WDL/WDL/resample.h`
and `vendor/WDL/WDL/simple_pitchshift.h` are the **only** two pitch/resample headers; there
is **no** elastique / formant-preserving / time-stretch anywhere in the tree. Honest
findings:
- **`WDL_Resampler` (`resample.h`) — sinc/linear resampler, RT-suitable.**
`SetMode(interp, filtercnt, sinc, sinc_size≤64, sinc_interpsize)`; streaming
`ResamplePrepare`/`ResampleOut` with `Prealloc`. Its **sinc mode beats the core's 2-point
linear interp** for base-repitch quality (less aliasing on large transpositions) at a real
CPU cost (up-to-64-tap convolution per output sample vs. one lerp). **Fit:** an *optional
quality upgrade for the base repitch path* (a per-voice linear/sinc toggle) — **not
required** for S15/S16 and **not committed**; held as a Tier-2/3 quality option (per-voice
`WDL_Resampler` instances are heavier, and S16's modulation is cleaner hand-rolled).
- **`WDL_SimplePitchShifter` (`simple_pitchshift.h`) — time-domain OLA pitch shifter, wrong
tool.** It shifts pitch *preserving duration* — the opposite of a sampler's
repitch-by-resampling (which changes pitch and duration together). Its `set_formant_shift`
is an **explicit empty stub** (no formant preservation). Not a fit for the sampler
repitch/envelope path; noted for completeness.
- **Formant-preserving / time-stretch (elastique-class): NOT available.** REAPER's elastique
is **licensed (zplane)**, not in the open WDL/reaper-sdk vendored tree (grep found only
unrelated libpng/giflib string matches). Formant-correct repitch is **unavailable to the
instrument without a new third-party dependency** — out of scope (D5-adjacent). Stated, not
worked around.
- **Recommendation:** S16's pitch-envelope ratio-modulation stays **hand-rolled** (per-frame
`ratio_` multiply over the existing linear-interp read — simplest, RT-safe, already
supported). `WDL_Resampler` (sinc) is the only WDL piece worth adopting, and only as an
**optional base-repitch quality upgrade** — held, not scheduled.
### Sequencing (S15/S16 against S7 stereo, S10 editor)
S15 and S16 are **S3-core extensions** — they touch the engine Daniel smoke-tests, like S7.
They are **channel-count-agnostic by construction**: the play-mode envelope is a per-frame
**amplitude** function and the pitch envelope is a per-frame **read-rate scalar** — both
independent of how many channels `SampleData` carries. So they **compose cleanly with S7's
channel dimension** rather than conflicting: S7 adds a channel axis to the read/mix; S15/S16
add an amplitude-shape axis and a read-rate axis; the three are orthogonal. **Recommended
order:** **S15 before S16** (S16's pitch envelope reuses S15's per-voice param-plumbing +
component-state version bumps; landing S15's `PlayMode`/param struct first gives S16 a home
to hang the pitch-env params on). **S15/S16 relative to S7:** no hard dependency — spec them
so the envelope/mode code never assumes a channel count (it operates per-frame, pre-mix), and
S7 can land before, after, or interleaved. **Relative to S10 (editor):** S15's mode toggle +
Trigger handles and S16's AD control **surface through** the S10/S11 waveform + guided-setup
work, so the *core* halves of S15/S16 can land independently of the editor, with the editor
surfacing following S10/S11 (the same way S12's ADSR editor follows the S3 ADSR math). Land
the **core** engine work (mode split, start point, %-length/fades, pitch-env modulation) as
soon as it is ready — it is testable in CTest without the editor — and wire the UI as the
S10/S11 surfaces mature. **Land S15/S16 core after S10's policy-reversal is settled** only if
sharing the same component-state blob would otherwise churn the version tag twice; otherwise
they are independent.
## Ingest through the bank — the extension owns ingest (decided "option 1", 2026-07-26; PLAN.md S8)
**Decided: loading a sample into the sampler is ONE gesture — capture/import-into-bank AND
@@ -1605,6 +1747,12 @@ Set by Daniel on DAW-testing the S1S6 instrument, alongside the UX-overhaul d
- **Bank-generation seam (S9).** New forever-stable `ext_keys.h` key for the generation
counter; read over the same bridge `GetProjExtState` path S4 already uses. No new API —
confirm no torn-read hazard on the integer key.
- **WDL pitch/resample (S15/S16).** **Verified this pass:** `vendor/WDL/WDL/resample.h`
(`WDL_Resampler` — sinc/linear, RT-suitable) and `vendor/WDL/WDL/simple_pitchshift.h`
(`WDL_SimplePitchShifter` — time-domain OLA, `set_formant_shift` empty stub) are the whole
pitch/resample surface; **no** elastique / formant-preserving / time-stretch in the tree.
S16 modulation is hand-rolled (no WDL). If the held sinc-repitch quality upgrade is taken,
verify `WDL_Resampler` streaming/prealloc against the per-voice RT budget before use.
- **LICE/SWELL editor.** Reuses the `bank_panel` LICE/SWELL drawing surface; verify the
`IPlugView`↔LICE window/bitmap bridge at the spike (window creation, sizing, event
routing) — the least-trodden edge of the phase.
@@ -1774,7 +1922,24 @@ a multi-capture drag over the FX button is either rejected or loads the first).
S7 stereo is *not* a Tier-2 feature — it is a channel-count dimension on the existing
Tier 01 engine, orthogonal to Tier 2's velocity-layers / round-robin / per-sample trim.
(S7's stereo loop read is the same loop the core already has, extended per-channel — not
the Tier-2 "sustain loops" feature.)
the Tier-2 "sustain loops" feature.) **Likewise S15/S16** (Trigger/Gate modes + pitch
envelope) are Daniel-directed engine features on the Tier 01 core, *not* Tier 2/3 — the
AHDSR hold, Trigger one-shot, start point, and AD pitch envelope are orthogonal amplitude-
shape / read-rate dimensions, not the held velocity-layers / round-robin / filter work.
- **S15/S16 params are performance choices, not bank facts.** Play mode, start point,
%-length, fades, AHDSR, and the pitch envelope are per-instance performance-map state
(component state), never written to `Sample` or the bank (D-B). The bank carries file
facts (root note, loop intrinsic, channel count); the instrument owns how they are played.
- **S15/S16 stay channel-count-agnostic (S7 interplay).** The mode/envelope logic is
per-frame amplitude and read-rate, independent of the S7 channel dimension. Any S15/S16
code that assumes a fixed channel count (mono) — rather than operating per-frame pre-mix —
is a bug that would collide with S7. Spec and build them channel-agnostic.
- **Trigger ignores note-off; choke is out of scope (S15).** In Trigger mode note-off is a
no-op and the one-shot plays to `playEnd`. Choke-on-note-off / choke-groups are held
(fork S15-F1, Tier-3-adjacent) — do not add a choke path in S15.
- **Pitch envelope is off by default (S16).** Default-disabled → offset always 0 → `ratio_`
unchanged → playback bit-identical to pre-S16. A regression that applies pitch modulation
when the envelope is off is a bug.
- **Drop-and-load must not regress the two existing drags.** S17 inserts a new middle case
(`InstrumentDrop`) between the M11 OS drag-out and the internal bank-to-bank drag; both
existing gestures stay byte-for-byte unchanged in their own regions. Drop-and-load never