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:
+166
-1
@@ -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 S1–S6 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 0–1 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 0–1 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
|
||||
|
||||
Reference in New Issue
Block a user