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:
+165
-57
@@ -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^((note−root)/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^((note−root + 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 3–10 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 S1–S6 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
|
||||
|
||||
Reference in New Issue
Block a user