docs(product): reshape S16 to Varispeed/Preserve pitch-engine modes (duration-preserving correction)

Per-zone toggle: Varispeed (classic, cheap) vs Preserve (duration-holding,
WDL_SimplePitchShifter candidate — per-voice RT viability assessed: pre-warm
settles onset latency + queue growth; quality basic; hand-rolled pure module
held as upgrade). Pitch envelope rides either engine. Fork S16-F1: default
mode is Daniel's call.
This commit is contained in:
2026-07-26 20:24:44 -04:00
parent c64ab687bf
commit bf1bf5283c
3 changed files with 478 additions and 145 deletions
+165 -57
View File
@@ -1328,6 +1328,15 @@ start point, Gate has modifiable loop points too. In addition to amp env, there
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.
**Daniel's S16 correction (2026-07-26, verbatim):** *"isn't that ratio stuff going to change
the playback rate? I want duration-preserving repitching."* Correct — the `readPos_ += ratio_`
path is **varispeed** (pitch and duration coupled). S16 is revised from "pitch envelope only"
into a **pitch-engine mode (Varispeed vs Preserve) + pitch envelope** (see §Pitch engine
modes below). Two new S16 forks are flagged: **S16-F1** (the engine default — lean Preserve)
and **S16-F2** (the Preserve implementation — lean `WDL_SimplePitchShifter` first, hand-rolled
held). The prior WDL finding that dismissed `WDL_SimplePitchShifter` is **corrected in place**
below (duration-preserving is now the requirement, so that shifter is the Preserve candidate).
### Play mode — Gate vs Trigger (S15)
Each played sample carries a **play mode** — a per-sample/per-zone **performance choice**
@@ -1386,72 +1395,154 @@ pure `frame↔pixel` + marker-grab geometry module (S11), mode switches which ma
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)
### Pitch engine modes — Varispeed vs Preserve (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**.
**Daniel's correction (2026-07-26, verbatim):** *"isn't that ratio stuff going to change the
playback rate? I want duration-preserving repitching."* Correct: the `readPos_ += ratio_`
resampling path is **Varispeed** — pitch and duration are coupled (an octave up halves the
note's duration). Daniel wants **duration-preserving** repitch. So S16 grows a per-voice/
per-zone **pitch-engine mode**, not just a pitch envelope:
- **Varispeed engine (current path).** `ratio_ = pitchRatio(note,root)`, `readPos_ += ratio_`
with 2-point linear interp — resampling that couples pitch and duration. This is the
**classic sampler / RS5K** behavior and today's shipped S3/S5 output. Cheap, zero-latency.
Musically right for **drums / one-shots** (pitch-down-lengthens-the-hit is a feature there).
- **Preserve engine (duration-preserving).** The read advances at the **source** rate
(duration held) while a **pitch shifter** transposes the output by `2^((noteroot)/12)`.
Musically right for **tempo-locked loops and phrases** — a transposed loop still lines up to
the bar. Since captured banks are project slices (loop/phrase-heavy), this is the default
lean (fork S16-F1).
Mode is **per-`PerformanceZone` performance state (D-B)** — instrument-owned, never a bank
fact — additive/version-bumped (absent/older blob → the S16-F1 default). A per-zone
**Varispeed/Preserve toggle** surfaces in the S10 guided setup / S10-Z Zones panel.
**Preserve engine implementation (fork S16-F2).** Two RT-disciplined routes behind the
`PitchEngine::Preserve` seam (identical contract either way):
- **(a) `WDL_SimplePitchShifter`** (`vendor/WDL/WDL/simple_pitchshift.h`) — a per-voice
time-domain OLA shifter. Under the duration-preserving directive this is **the right
category** (see the corrected WDL finding below). `set_shift(2^(semi/12))` for pitch,
`set_tempo(1.0)` to hold duration — pitch and duration are separately controllable. **Lean:
route (a) first** (low-cost proof), with two costs owned in the build: an inherent
**onset latency** (~half-window, ~25 ms @ the 50 ms quality-0 window; pre-warm at voice-
allocation, and it lands on sustained/loop material where least harmful) and a **queue-growth
allocation** hazard in `BufferDone` (`WDL_Queue::Add`) that is settled by a silence pre-warm
at voice-allocation so no `process`-thread allocation occurs in steady state.
- **(b) hand-rolled pure `pitch_shift` OLA/granular module** (house pattern — CTest-testable,
no REAPER/VST3/WDL type at the boundary) — **held** as the quality/latency upgrade if the
SimpleWindowed warble or onset lag proves musically unacceptable.
**`WDL_Resampler` is not a Preserve engine** — it is a *resampler* (couples duration); it
remains a held **Varispeed-quality** upgrade only. **elastique is NOT available** (licensed
zplane, not vendored — restated). JUCE / rubberband / signalsmith are **new-dependency forks
carrying D-A weight** (bare-VST3-no-framework is the locked D-A) — **not proposed**.
**S15 × S16 interaction (Preserve consumes S15's source-frame read).** S15's amplitude
semantics are defined over the voice's **source-frame** timeline; the Preserve engine wraps
that read and transposes the output, so:
- **Trigger %-length** stays a source-frame fact (`playEnd = start + round(lengthFraction·
(frames start))`); under Preserve its **wall-clock is stable under transpose** — *cleaner*
than Varispeed, where transposing a Trigger also scales its audible length.
- **Gate sustain loop** — under Preserve, **loop the source read** (the `[loopStart, loopEnd)`
source-frame region) and feed the looped stream into the shifter, which transposes the
**output**. Contract: *loop the source, shift the output*; loop points stay source-frame
facts (S11 markers unchanged). Under Varispeed the loop read itself carries the pitch.
- **Start point** is a source-frame offset in both engines (engine-independent).
### Pitch envelope — AD, off by default, engine-aware (S16)
A per-voice **pitch modulation curve** riding on top of whichever engine — a short **AD**
(attack-decay) envelope that biases pitch over time. **Off by default** (so existing playback
is bit-identical under the same engine). 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).
A **zero attack** gives the pure "start high, drop to base" percussive drop.
- **Range: semitones (±).** `peakSemitones` is signed; default depth range noted at build.
- **Applied per engine.** Under **Varispeed** the offset is a **per-frame multiply of
`ratio_`** by `2^(pitchEnvSemitones(frame)/12)` (the effective read increment varies frame-
by-frame at no structural cost — the same per-frame `tick()` idiom as the amp envelope,
RT-safe, no `process` allocation). Under **Preserve** the offset is **added to the shifter's
shift amount** — `set_shift(2^((noteroot + pitchEnvSemitones(frame))/12))` — bending pitch
without touching duration. 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)
### WDL pitch/resample surface — corrected finding (feeds 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:
**Corrected 2026-07-26 (Daniel's duration-preserving directive).** The prior sweep dismissed
`WDL_SimplePitchShifter` as "wrong tool (duration-preserving)". Under the directive,
**duration-preserving is the requirement**, so that header is the Preserve-engine candidate,
not a mismatch — a real viability assessment replaces the dismissal.
The **full** vendored WDL pitch/resample surface is `vendor/WDL/WDL/resample.h` and
`vendor/WDL/WDL/simple_pitchshift.h` — the **only** two pitch/resample headers; there is
**no** elastique / formant-preserving 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.
`ResamplePrepare`/`ResampleOut` with `Prealloc`. Its sinc mode beats the core's 2-point
linear interp for **Varispeed** base-repitch quality (less aliasing on large transpositions)
at a real CPU cost. **A resampler couples duration** → a Varispeed-quality option, **not a
Preserve engine.** Held as a Tier-2/3 Varispeed-quality toggle; not committed.
- **`WDL_SimplePitchShifter` (`simple_pitchshift.h`) — time-domain OLA, duration-preserving —
the S16 Preserve-engine candidate (fork S16-F2 route a).** Viability from the header:
- **API shape:** push/pull, block-based. `GetBuffer(size)` returns an input buffer to fill;
`BufferDone(filled)` runs the OLA shift and queues output; `GetSamples(req, buf)` pulls
from the queue. Config: `set_srate`, `set_nch`, **`set_shift(ratio)` (pitch, duration-
preserving)**, `set_tempo(scale)` (an *independent* duration knob — Preserve uses
`set_tempo(1.0)`), `SetQualityParameter(q)` (selects window/overlap ms from a fixed table).
- **Per-voice instantiability / memory:** modest. `m_psbuf` is an OLA ring of `bsize·nch`
where `bsize = window_ms · 0.001 · srate` (≈ 2205 frames at 50 ms / 44.1 kHz ≈ a few
KB/voice), plus `m_inbuf` (one input block) and a bounded `m_queue`. `m_rsbuf` allocates
only when `set_tempo ≠ 1` (unused in Preserve). One instance per voice is cheap in memory.
- **RT-safety:** allocations occur in `BufferDone` — `m_psbuf.Resize` (once, when
`bsize·nch` first sets, at a fixed quality/srate/nch — pre-warmable) and `m_queue.Add`
(grows only until the push/pull cadence reaches steady state). **Pre-warm at voice-
allocation** (run silence through once so `m_psbuf` sizes and `m_queue` settles); after
that no `process`-thread allocation. No locks. **RT-viable with the pre-warm discipline.**
- **Latency:** inherent ~half-window (initial `m_pspos = bsize/2` → ~25 ms @ 50 ms window)
plus fill-up — a **real note-onset lag**. This is the load-bearing cost. Mitigation:
pre-warm; and Varispeed (zero-latency) serves the tight-transient one-shot material, so the
lag lands on sustained/loop material where least harmful. Smaller-window quality settings
(the table goes to 310 ms) trade latency for more warble.
- **Quality:** basic — this is REAPER's "SimpleWindowed" mode. Audible warble on large
transpositions; **`set_formant_shift` is an explicit empty stub** → no formant preservation.
Usable for loop/phrase Preserve; replaceable by route (b) if not.
- **CPU / polyphony:** `PitchShiftBlock` is O(length) per block — a few mults + one OLA
crossfade branch per frame, **no FFT**. Per-voice cost is modest; **N polyphonic voices
each running one is feasible** within RT discipline. If the aggregate cost is material, a
**Preserve-mode-specific voice cap** (below the Varispeed cap) is the pressure valve —
flagged in Verify, set from measured per-voice budget at build.
- **Formant-preserving / studio-grade time-stretch (elastique-class): NOT in WDL, confirmed.**
REAPER's elastique is **licensed (zplane)**, not in the vendored tree (grep found only
unrelated libpng/giflib string matches). Formant-correct duration-preserving repitch is
**unavailable without a new third-party dependency** (JUCE / rubberband / signalsmith each a
new-dependency fork with D-A weight — not proposed). Stated, not worked around.
- **Recommendation:** the **Preserve** engine (S16-F2) is `WDL_SimplePitchShifter` (route a,
low-cost proof) or a hand-rolled pure `pitch_shift` module (route b, held quality upgrade).
The **pitch-envelope** modulation stays hand-rolled over whichever engine (a per-frame
`ratio_` multiply under Varispeed, a per-frame shift-amount add under Preserve).
`WDL_Resampler` (sinc) is a held **Varispeed-quality** upgrade only.
### 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
**amplitude** function, and both pitch engines carry the channel dimension internally — the
**Varispeed** path is a per-frame per-channel read-rate scalar, and the **Preserve** shifter
is **`set_nch`-aware** (one shifter instance per voice transposes all its channels together).
So S15/S16 **compose cleanly with S7's channel dimension** rather than conflicting: S7 adds a
channel axis to the read/mix; S15 adds an amplitude-shape axis; S16 adds a pitch-engine +
read-rate axis; all orthogonal. **Recommended order:** **S15 before S16** (S16 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-engine mode + pitch-env params on). **S16 is
now meaningfully heavier than the prior "just an envelope" framing** — the Preserve engine is
a per-voice DSP object with its own RT budget, pre-warm, and possible voice-cap; treat S16's
Preserve-engine point as the phase's next real DSP spike, not a thin add-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; the Preserve shifter is `set_nch`-driven), 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
@@ -1748,11 +1839,17 @@ Set by Daniel on DAW-testing the S1S6 instrument, alongside the UX-overhaul d
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.
(`WDL_Resampler` — sinc/linear resampler, couples duration → **Varispeed** path) and
`vendor/WDL/WDL/simple_pitchshift.h` (`WDL_SimplePitchShifter` — time-domain OLA,
**duration-preserving** → the S16 **Preserve**-engine candidate, fork S16-F2 route a) are
the whole pitch/resample surface; **no** elastique / formant-preserving in the tree. **S16
Preserve-engine (route a) must-verify at build:** (i) **pre-warm** `WDL_SimplePitchShifter`
at voice-allocation (run silence so `m_psbuf` sizes and `m_queue` reaches steady state) →
**no `process`-thread `WDL_Queue::Add` growth**; (ii) measure **per-voice CPU + onset
latency** (window·srate) against the polyphony cap; (iii) set a **Preserve-mode-specific
voice cap** if the per-voice cost demands one. The pitch-envelope modulation is hand-rolled
over whichever engine. If the held sinc **Varispeed**-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.
@@ -1937,9 +2034,20 @@ a multi-capture drag over the FX button is either rejected or loads the first).
- **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.
- **Pitch envelope is off by default (S16).** Default-disabled → offset always 0 → the
engine's un-modulated output → playback bit-identical to the same engine pre-envelope. A
regression that applies pitch modulation when the envelope is off is a bug.
- **Pitch engine is a per-zone performance choice, not a bank fact (S16).** Varispeed vs
Preserve is per-`PerformanceZone` component state (D-B), never written to `Sample` or the
bank. The engine default is fork S16-F1 (**lean Preserve** — Daniel's call), with a
prominent per-zone toggle so drum/one-shot zones opt into Varispeed cheaply.
- **Preserve engine is RT-disciplined (S16).** The `WDL_SimplePitchShifter` (or hand-rolled)
Preserve path **pre-warms at voice-allocation** and does **no allocation in `process`** — a
`WDL_Queue::Add` or `Resize` on the audio thread in steady state is a bug. Preserve's onset
latency (shifter window) is an accepted property, **not** a defect; a note-onset **click or
smear** from a cold-started (un-pre-warmed) shifter **is** a bug.
- **`WDL_Resampler` is not a Preserve engine (S16).** It is a resampler (couples duration) —
a held Varispeed-quality option only. Do not wire it as the duration-preserving path.
- **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