1e1d6bddbb
Per-zone play params on SampleData; hand-rolled pure pitch_shift OLA for Preserve (WDL drags windows.h); zone-payload v3 tail; RT-safe pre-warmed shifters + Preserve voice cap.
89 lines
5.0 KiB
C++
89 lines
5.0 KiB
C++
#pragma once
|
|
// pitch_shift — a PURE, per-voice, duration-preserving pitch shifter: the S16 "Preserve"
|
|
// engine's DSP core. Time-domain overlap-add (OLA) with two half-window-offset read taps
|
|
// crossfaded to hide the ring-wrap seam. Source is consumed 1:1 and output produced 1:1
|
|
// (duration held); only the PITCH changes — an octave up plays the same wall-clock length
|
|
// as the root note, unlike the Varispeed `readPos_ += ratio_` resample path.
|
|
//
|
|
// WHY A HAND-ROLLED PURE MODULE, NOT WDL (S16-F2, decided at build). The spec's lean was
|
|
// route (a) `WDL_SimplePitchShifter`. But its include chain
|
|
// (simple_pitchshift.h -> queue.h -> heapbuf.h -> wdltypes.h) does `#ifdef _WIN32 ->
|
|
// #include <windows.h>` unconditionally, which CANNOT enter the pure sampler_core module
|
|
// (CLAUDE.md load-bearing split: NO vendor/host/SDK types; sampler_core_tests links neither
|
|
// SDK and compiles outside the DAW). So the Preserve DSP lands as route (b): a house-native
|
|
// pure module alongside peaks / wav_trim, CTest-testable, RT-disciplined. Same
|
|
// PitchEngine::Preserve contract behind the seam — if WDL is ever preferred it swaps in at
|
|
// the SHELL, never in the pure core.
|
|
//
|
|
// PURE MODULE: NO VST3, NO REAPER, NO SWELL, NO vendor/ includes. Standard library only.
|
|
// Shares the `AudioSample` float alias from peaks (the one house precedent — sampler_core /
|
|
// wav_trim do the same).
|
|
//
|
|
// RT DISCIPLINE (S16 hard constraint). `configure()` sizes the ring ONCE (off the audio
|
|
// thread, at voice allocation). `warm()` pre-fills the ring with silence so steady-state
|
|
// latency is reached before the first real sample (no cold-start click). `process()` does
|
|
// NO allocation and NO locks — it reads/writes the pre-sized ring only. All state is plain
|
|
// value fields, so a voice owning one by value costs a fixed ring buffer per channel.
|
|
|
|
#include <cstddef>
|
|
#include <cstdint>
|
|
#include <vector>
|
|
|
|
#include "peaks.h" // AudioSample (float)
|
|
|
|
namespace reasampler {
|
|
|
|
// A per-channel time-domain OLA pitch shifter. One instance transposes ONE channel; a stereo
|
|
// voice owns two (or a stereo-aware wrapper) — the algorithm is per-sample and channel-count
|
|
// agnostic, matching the S7 "one read head, per-channel value" idiom of the core.
|
|
//
|
|
// The default-constructed shifter is INERT: with no configure() it passes input through
|
|
// unchanged (shift ratio 1.0, empty ring), so a Varispeed voice that never touches it is
|
|
// byte-identical to the pre-S16 engine.
|
|
class PitchShifter {
|
|
public:
|
|
// Size the delay ring for `windowFrames` (the OLA grain length) and prepare the two
|
|
// read taps a half-window apart. `windowFrames` <= 1 degrades to pass-through (no ring),
|
|
// so a degenerate configure never divides by zero or wraps a zero span. Called OFF the
|
|
// audio thread (allocates). Resets all running state. A larger window = smoother on large
|
|
// transpositions but more latency; the shell picks it from the Preserve quality setting.
|
|
void configure(std::int64_t windowFrames);
|
|
|
|
// Pre-fill the ring with silence (one full window of zero writes) so the read taps reach
|
|
// steady state before the first real sample. Removes the cold-start seam (the S16 "onset
|
|
// click absent" requirement) — call once at voice allocation after configure(). No-op when
|
|
// unconfigured (pass-through needs no warm-up).
|
|
void warm();
|
|
|
|
// The pitch shift ratio: 2^((note - root)/12) plus any per-frame pitch-envelope bias.
|
|
// 1.0 = no shift (pass-through-equivalent output). Set per frame is fine (cheap); the tap
|
|
// advance simply uses the current value. Values <= 0 are ignored (kept at the last valid
|
|
// ratio) so a bad input never runs the taps backward or stalls them.
|
|
void setShiftRatio(double ratio);
|
|
|
|
// Transform ONE input frame into ONE output frame (duration-preserving: 1 in, 1 out).
|
|
// RT-safe: reads/writes the pre-sized ring only, no allocation, no lock. When unconfigured
|
|
// (window <= 1) returns `in` unchanged (pass-through). Otherwise writes `in` at the write
|
|
// head, reads the two half-window-offset taps advancing at the shift ratio, crossfades
|
|
// them by the write-head-relative distance (equal-power), and advances both heads by one.
|
|
AudioSample process(AudioSample in);
|
|
|
|
// Reset running state to a freshly-warmed-equivalent silence (ring zeroed, heads re-seeded)
|
|
// WITHOUT reallocating — for voice reuse without a re-configure. Keeps the current window.
|
|
void reset();
|
|
|
|
// True once configure() sized a real ring (window > 1). A pass-through shifter is false.
|
|
bool configured() const { return window_ > 1; }
|
|
|
|
std::int64_t window() const { return window_; }
|
|
|
|
private:
|
|
std::vector<AudioSample> ring_; // delay line, length `window_` (channel-local)
|
|
std::int64_t window_ = 0; // OLA grain length in frames; <= 1 = pass-through
|
|
std::int64_t writePos_ = 0; // integer write head into the ring (source rate)
|
|
double readPos_ = 0.0; // fractional read head (advances at shift ratio)
|
|
double ratio_ = 1.0; // current shift ratio (>0)
|
|
};
|
|
|
|
} // namespace reasampler
|