S15/S16: Gate(AHDSR)/Trigger play modes + Varispeed/Preserve pitch engines + AD pitch envelope
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.
This commit is contained in:
+232
-24
@@ -21,7 +21,8 @@
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "peaks.h" // AudioSample (float)
|
||||
#include "peaks.h" // AudioSample (float)
|
||||
#include "pitch_shift.h" // PitchShifter (S16 Preserve engine DSP core)
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
@@ -33,6 +34,92 @@ namespace reasampler {
|
||||
// itself never branches on it — the mode only picks which render overload the shell drives.
|
||||
enum class ChannelMode { Mono, Stereo };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// S15/S16 per-zone play PARAMETERS (plain data). Defined up here (before SampleData) because
|
||||
// SampleData carries a ZonePlayParams by value — a voice reads it at start(). The matching
|
||||
// per-frame EVALUATOR classes (AHDSR AdsrEnvelope, TriggerEnvelope, PitchEnvelope) live lower
|
||||
// with the rest of the engine machinery; only the value structs need to precede SampleData.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// AHDSR amplitude envelope parameters (S15 grows the S3 ADSR with a HOLD stage between Attack
|
||||
// and Decay). holdFrames == 0 is EXACTLY the pre-S15 ADSR (back-compat). See AdsrEnvelope below.
|
||||
struct AdsrParams {
|
||||
std::int64_t attackFrames = 0;
|
||||
std::int64_t holdFrames = 0; // S15: hold at 1.0 between Attack and Decay; 0 = pre-S15 ADSR
|
||||
std::int64_t decayFrames = 0;
|
||||
double sustainLevel = 1.0; // 0..1
|
||||
std::int64_t releaseFrames = 0;
|
||||
};
|
||||
|
||||
// S15 play mode. GATE = classic held note (AHDSR + sustain loop + note-off release, today's
|
||||
// behavior grown by the hold stage). TRIGGER = one-shot: note-off-immune, no sustain loop,
|
||||
// plays a % of the sample length shaped by fade-in/out. Both honor the start point. Per-zone
|
||||
// (D-B); DEFAULT Gate so an instrument with no S15 params plays exactly as before.
|
||||
enum class PlayMode { Gate, Trigger };
|
||||
|
||||
// Trigger amplitude envelope parameters (S15). Playback covers the source-frame span
|
||||
// [startFrame, playEnd), playEnd = startFrame + round(lengthFraction*(frames - startFrame)),
|
||||
// lengthFraction in (0,1]. Amplitude ramps 0->1 over fadeInFrames at the head and 1->0 over
|
||||
// fadeOutFrames anchored to playEnd; unity between. Fades clamp so fadeIn + fadeOut <= play
|
||||
// length. The voice frees when the head reaches playEnd. Note-off is a no-op in Trigger.
|
||||
struct TriggerParams {
|
||||
double lengthFraction = 1.0; // (0,1] of the post-start span to play
|
||||
std::int64_t fadeInFrames = 0; // 0->1 ramp at the head
|
||||
std::int64_t fadeOutFrames = 0; // 1->0 ramp anchored to playEnd
|
||||
};
|
||||
|
||||
// The fade curve for Trigger's ramps. EQUAL_POWER (constant-power sin/cos) is the default
|
||||
// (click-free on one-shots, per spec); LINEAR is the build-time residual. An enum (not a bool)
|
||||
// so a third curve can join without a signature change.
|
||||
enum class FadeCurve { EqualPower, Linear };
|
||||
|
||||
// The DEFAULT fade curve (S15 spec: equal-power). One constant to flip if linear is wanted.
|
||||
inline constexpr FadeCurve kDefaultFadeCurve = FadeCurve::EqualPower;
|
||||
|
||||
// The per-zone pitch engine. VARISPEED = today's path (readPos_ += ratio_): pitch and duration
|
||||
// coupled (an octave up plays half as long). PRESERVE = duration-preserving: the read advances
|
||||
// at the SOURCE rate while a PitchShifter transposes the output (an octave up keeps its length).
|
||||
enum class PitchEngine { Varispeed, Preserve };
|
||||
|
||||
// The PRODUCT DEFAULT pitch engine (S16-F1 — Daniel's "I want duration-preserving repitching"
|
||||
// directive). ONE constant to flip if Varispeed should be the default instead. This is the
|
||||
// default a NEW or absent-in-the-blob zone gets — APPLIED AT THE STATE BOUNDARY (sample_map's
|
||||
// deserialize / editor zone-creation), NOT the pure-core struct default. The pure-core
|
||||
// ZonePlayParams.pitchEngine member defaults to VARISPEED so that "no params == the pre-S16
|
||||
// engine" holds for the core's own regression tests (an octave up still halves duration in the
|
||||
// bare engine); the Preserve product default is layered on above at (de)serialization.
|
||||
inline constexpr PitchEngine kDefaultPitchEngine = PitchEngine::Preserve;
|
||||
|
||||
// The OLA window (frames) the Preserve PitchShifter uses, derived from a window in milliseconds
|
||||
// at the voice's sample rate. ~50 ms is the WDL quality-0 window the spec cites; larger =
|
||||
// smoother on big transpositions, more onset latency. One knob, resolved at voice allocation.
|
||||
inline constexpr double kPreserveWindowMs = 50.0;
|
||||
|
||||
// A per-voice AD pitch-modulation envelope (S16), OFF by default (enabled=false -> offset always
|
||||
// 0 -> playback bit-identical to the un-modulated engine). 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. peakSemitones is signed (+/-).
|
||||
struct PitchEnvParams {
|
||||
bool enabled = false;
|
||||
std::int64_t attackFrames = 0;
|
||||
std::int64_t decayFrames = 0;
|
||||
double peakSemitones = 0.0; // signed depth at the peak
|
||||
};
|
||||
|
||||
// The bundle of S15/S16 per-zone play parameters a voice reads at start(). Lives on SampleData
|
||||
// (each zone owns one SampleData in the zoned keymap). DEFAULTS are EXACTLY the pre-S15/S16
|
||||
// engine: Gate mode, AHDSR with hold 0 (= the S3 ADSR), VARISPEED pitch engine, pitch envelope
|
||||
// disabled — so a bare-core voice with default play is byte-identical to the pre-S15 build (the
|
||||
// core regression tests rely on this). The PRODUCT default of Preserve (S16-F1) is applied one
|
||||
// layer up at (de)serialization for new/absent zones — see kDefaultPitchEngine.
|
||||
struct ZonePlayParams {
|
||||
PlayMode playMode = PlayMode::Gate;
|
||||
AdsrParams adsr; // Gate: the AHDSR envelope
|
||||
TriggerParams trigger; // Trigger: %-length + fades
|
||||
PitchEngine pitchEngine = PitchEngine::Varispeed;
|
||||
PitchEnvParams pitchEnv; // AD pitch modulation, off by default
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sample data the core plays. Plain, decoded PCM + the S2 bank intrinsics that
|
||||
// govern playback. The shell decodes the on-disk WAV and fills this; the core
|
||||
@@ -69,6 +156,11 @@ struct SampleData {
|
||||
// a start >= the sample length is a no-op (voice starts at 0), never out of bounds.
|
||||
std::int64_t startFrame = 0;
|
||||
|
||||
// S15/S16 per-zone play parameters (play mode, AHDSR/Trigger envelope, pitch engine, pitch
|
||||
// envelope). Defaults reproduce the pre-S15 engine EXCEPT the pitch engine default is
|
||||
// Preserve (S16-F1). A voice reads this at start(). Struct defined above SampleData.
|
||||
ZonePlayParams play;
|
||||
|
||||
// 2 iff a matching-length second channel exists; else 1. A framesR of a different
|
||||
// length than frames is treated as absent (mono) — a malformed pair never half-plays.
|
||||
int channelCount() const {
|
||||
@@ -134,30 +226,26 @@ struct Keymap {
|
||||
double pitchRatio(int note, int rootNote);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ADSR amplitude envelope. Sample-based (times in frames), linear segments. A gate:
|
||||
// noteOn() enters Attack; noteOff() enters Release from wherever it is. The classic
|
||||
// four-stage shape, asserted against a known signal in the tests (mirror of peaks).
|
||||
// AHDSR amplitude envelope (S15 grows the S3 ADSR with a HOLD stage). Sample-based
|
||||
// (times in frames), linear segments. A gate: noteOn() enters Attack; noteOff() enters
|
||||
// Release from wherever it is. Asserted against a known signal in the tests (mirror of peaks).
|
||||
//
|
||||
// Segment math (all linear ramps):
|
||||
// Attack: 0 -> 1 over attackFrames
|
||||
// Hold: hold 1 over holdFrames (S15: NEW stage between A and D)
|
||||
// Decay: 1 -> sustainLevel over decayFrames
|
||||
// Sustain: hold sustainLevel until noteOff
|
||||
// Release: currentLevel -> 0 over releaseFrames
|
||||
// A zero-length attack jumps straight to 1 on the first frame; zero decay jumps to
|
||||
// sustain; a noteOff during attack/decay (release-before-sustain) releases from the
|
||||
// current partial level, not from sustainLevel.
|
||||
// A zero-length attack jumps straight to 1 on the first frame; HOLDFRAMES == 0 skips Hold
|
||||
// entirely, which is EXACTLY the pre-S15 ADSR (back-compat — existing Gate play is unchanged);
|
||||
// zero decay jumps to sustain; a noteOff during attack/hold/decay (release-before-sustain)
|
||||
// releases from the current partial level, not from sustainLevel. AdsrParams is defined above
|
||||
// (with the other per-zone value structs); this section holds only the per-frame evaluator.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct AdsrParams {
|
||||
std::int64_t attackFrames = 0;
|
||||
std::int64_t decayFrames = 0;
|
||||
double sustainLevel = 1.0; // 0..1
|
||||
std::int64_t releaseFrames = 0;
|
||||
};
|
||||
|
||||
class AdsrEnvelope {
|
||||
public:
|
||||
enum class Stage { Idle, Attack, Decay, Sustain, Release, Finished };
|
||||
enum class Stage { Idle, Attack, Hold, Decay, Sustain, Release, Finished };
|
||||
|
||||
void configure(const AdsrParams& params) { params_ = params; }
|
||||
|
||||
@@ -184,6 +272,64 @@ private:
|
||||
double releaseFrom_ = 0.0; // level at the moment noteOff() was called
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// S15 Trigger amplitude envelope (per-frame evaluator). The PlayMode / TriggerParams /
|
||||
// FadeCurve value structs are defined above with the other per-zone params.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Trigger amplitude envelope: a stateless-shape amplitude function over the play span, evaluated
|
||||
// at a SOURCE-frame offset into the span. Anchoring the fades to SOURCE frames (not output
|
||||
// frames) is what makes S15 compose with S16: under Preserve the read advances at source rate so
|
||||
// output and source frames coincide, but under Varispeed a transposed voice consumes source
|
||||
// faster — driving the fades off the read position keeps the fade-in/out anchored to the SAME
|
||||
// source frames regardless of engine (the play-length end is a source-frame fact, S15×S16). The
|
||||
// voice reports the read offset; this maps it to amplitude. Distinct from AHDSR — time-boxed by
|
||||
// the play length and note-off-immune. Reports finished() once the offset reaches the play length.
|
||||
class TriggerEnvelope {
|
||||
public:
|
||||
// Configure from the play span + fades. `playLengthFrames` is (playEnd - startFrame): the
|
||||
// SOURCE-frame length of the play span. Fades are clamped so fadeIn + fadeOut <= playLength
|
||||
// (fadeOut anchored to the end). A zero/negative play length finishes immediately.
|
||||
void configure(std::int64_t playLengthFrames, std::int64_t fadeInFrames,
|
||||
std::int64_t fadeOutFrames, FadeCurve curve = kDefaultFadeCurve);
|
||||
|
||||
// Amplitude in [0,1] at `sourceOffset` = (readPos - startFrame) source frames into the play
|
||||
// span. Latches finished() once the offset reaches the play length (>= playLength). Pure over
|
||||
// the offset (no internal advance) so it composes with either pitch engine's read rate.
|
||||
double amplitudeAt(double sourceOffset);
|
||||
|
||||
bool finished() const { return finished_; }
|
||||
|
||||
private:
|
||||
std::int64_t playLength_ = 0;
|
||||
std::int64_t fadeIn_ = 0;
|
||||
std::int64_t fadeOut_ = 0;
|
||||
FadeCurve curve_ = kDefaultFadeCurve;
|
||||
bool finished_ = false;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// S16 pitch envelope (per-frame evaluator). The PitchEngine / PitchEnvParams value structs
|
||||
// and the kDefaultPitchEngine / kPreserveWindowMs constants are defined above.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Per-frame AD pitch-envelope evaluator. tick() returns the CURRENT pitch offset in semitones
|
||||
// (0 when disabled or past attack+decay), advancing one frame. The voice converts the semitone
|
||||
// offset to a ratio multiply (Varispeed) or a shift-amount add (Preserve). Pure, unit-tested
|
||||
// for offset at t=0, peak at t=attack, and 0 at t=attack+decay.
|
||||
class PitchEnvelope {
|
||||
public:
|
||||
void configure(const PitchEnvParams& params) { params_ = params; pos_ = 0; }
|
||||
void noteOn() { pos_ = 0; }
|
||||
|
||||
// Advance one frame, return this frame's pitch offset in semitones.
|
||||
double tick();
|
||||
|
||||
private:
|
||||
PitchEnvParams params_;
|
||||
std::int64_t pos_ = 0;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// A single voice: one active note playing one repitched, enveloped sample. Reads
|
||||
// the sample by fractional frame position with linear interpolation, advancing by
|
||||
@@ -193,13 +339,21 @@ private:
|
||||
class Voice {
|
||||
public:
|
||||
// Starts this voice on `note` at `velocity`, playing `sample` (a stable reference
|
||||
// the caller must keep alive for the voice's lifetime — the Keymap owns it),
|
||||
// repitched from `rootNote`, with `adsr` as the amplitude envelope.
|
||||
// the caller must keep alive for the voice's lifetime — the Keymap owns it), repitched
|
||||
// from `rootNote`. `gateAdsr` is the effective Gate AHDSR (the engine supplies the
|
||||
// instrument-wide attack/decay/sustain/release timing; the per-zone HOLD stage comes from
|
||||
// sample.play.adsr.holdFrames, folded in here). The S15 play MODE + Trigger params and the
|
||||
// S16 pitch ENGINE + pitch envelope are read from `sample.play`. The Preserve shifters MUST
|
||||
// already be pre-sized (presizePreserveShifters, off-thread) — start() only reset()s + warm()s
|
||||
// them (RT-safe, no allocation) since it runs on the audio thread inside process(). The warm
|
||||
// silence pass settles the OLA taps before the first output frame (no cold-start click).
|
||||
// Byte-identical to the pre-S15 engine when sample.play is default (Gate + Varispeed + no
|
||||
// pitch env).
|
||||
void start(int note, int velocity, const SampleData& sample, int rootNote,
|
||||
const AdsrParams& adsr);
|
||||
const AdsrParams& gateAdsr);
|
||||
|
||||
// Gate off — begins the amplitude release. The voice keeps rendering (and looping,
|
||||
// if it would) until the release finishes, then goes idle.
|
||||
// Gate off — begins the amplitude release. In GATE mode this enters the AHDSR release; in
|
||||
// TRIGGER mode it is a NO-OP (Trigger ignores note-off and plays through to its play length).
|
||||
void release();
|
||||
|
||||
// True while this voice is producing (or about to produce) sound.
|
||||
@@ -211,6 +365,17 @@ public:
|
||||
std::uint64_t startOrder() const { return startOrder_; }
|
||||
void setStartOrder(std::uint64_t order) { startOrder_ = order; }
|
||||
bool releasing() const { return releasing_; }
|
||||
// The S16 pitch engine this voice is running (for the engine's Preserve-voice tally). Only
|
||||
// meaningful while active().
|
||||
PitchEngine pitchEngine() const { return pitchEngine_; }
|
||||
|
||||
// Pre-SIZE this voice's Preserve pitch shifters (both channels) to `windowFrames`, OFF the
|
||||
// audio thread (this allocates). The engine calls it once at construction so start() — which
|
||||
// runs on the audio thread inside process() — never allocates: start() only reset()s + warm()s
|
||||
// the already-sized rings. `windowFrames` <= 1 leaves the shifters as pass-through (Varispeed
|
||||
// instruments pay no ring cost). Idempotent: a re-presize to the same window is a cheap no-op
|
||||
// in the underlying vector.
|
||||
void presizePreserveShifters(std::int64_t windowFrames);
|
||||
|
||||
// Renders one frame's contribution, advancing the read head and envelope by one
|
||||
// output frame. Returns 0.0 (and goes idle) once the envelope finishes or the
|
||||
@@ -229,19 +394,46 @@ public:
|
||||
|
||||
private:
|
||||
// Shared read/advance for both render paths: computes the interpolated per-channel
|
||||
// value(s) at the current read head, ticks the envelope once, advances the head, and
|
||||
// value(s) at the current read head, ticks the amplitude + pitch envelopes once, applies
|
||||
// the pitch engine (Varispeed read-rate bias OR Preserve shift), advances the head, and
|
||||
// latches idle on exhaustion. `stereo` selects whether the second channel is read (and
|
||||
// returned in `outR`); when false `outR` is left untouched. Returns the channel-0 value.
|
||||
AudioSample advanceFrame(bool stereo, AudioSample& outR);
|
||||
|
||||
// This frame's amplitude in [0,1] from the active envelope. GATE: the AHDSR ticks once per
|
||||
// output frame (independent of the read rate — envelope time is wall-clock). TRIGGER: the
|
||||
// fade shape is evaluated at the SOURCE offset (readPos - startFrame) so the fades anchor to
|
||||
// source frames and compose with either pitch engine. Sets amplitudeDone_ when the envelope
|
||||
// finishes (Gate: release complete; Trigger: play length reached) so advanceFrame frees the voice.
|
||||
double tickAmplitude();
|
||||
|
||||
bool active_ = false;
|
||||
bool releasing_ = false;
|
||||
int note_ = 0;
|
||||
double velocityGain_ = 1.0;
|
||||
double ratio_ = 1.0; // fractional frames advanced per output frame
|
||||
double baseRatio_ = 1.0; // 2^((note-root)/12): the un-modulated repitch ratio
|
||||
double ratio_ = 1.0; // fractional SOURCE frames advanced per output frame (this frame)
|
||||
double readPos_ = 0.0; // fractional frame index into the sample
|
||||
const SampleData* sample_ = nullptr;
|
||||
|
||||
// S15 play mode + amplitude envelopes. Gate uses env_ (AHDSR); Trigger uses trigEnv_. Only
|
||||
// one is active per voice (selected by playMode_ at start). playEnd_ is Trigger's source-frame
|
||||
// stop (the voice frees when readPos_ >= playEnd_, mirroring the run-off-end idle).
|
||||
PlayMode playMode_ = PlayMode::Gate;
|
||||
AdsrEnvelope env_;
|
||||
TriggerEnvelope trigEnv_;
|
||||
std::int64_t startFrame_ = 0; // clamped initial read frame; Trigger fade offset origin
|
||||
std::int64_t playEnd_ = 0; // Trigger: source-frame end; Gate: unused
|
||||
bool amplitudeDone_ = false; // set when the active amplitude envelope finished
|
||||
|
||||
// S16 pitch engine + pitch envelope. pitchEngine_ selects Varispeed (ratio bias) vs Preserve
|
||||
// (source-rate read + shifter). shiftL_/shiftR_ transpose the Preserve output per channel
|
||||
// (one read head, per-channel shift — S7 compose). pitchEnv_ rides EITHER engine.
|
||||
PitchEngine pitchEngine_ = PitchEngine::Varispeed;
|
||||
PitchEnvelope pitchEnv_;
|
||||
PitchShifter shiftL_;
|
||||
PitchShifter shiftR_;
|
||||
|
||||
std::uint64_t startOrder_ = 0;
|
||||
};
|
||||
|
||||
@@ -262,8 +454,19 @@ class VoiceEngine {
|
||||
public:
|
||||
// Builds an engine with `maxVoices` voices (the polyphony bound) playing from
|
||||
// `keymap`. The keymap must outlive the engine (the engine holds a reference — it
|
||||
// reads zones and sample data through it, never copies PCM).
|
||||
VoiceEngine(std::size_t maxVoices, const Keymap& keymap, const AdsrParams& adsr);
|
||||
// reads zones and sample data through it, never copies PCM). `adsr` is the instrument-wide
|
||||
// Gate AHDSR timing (attack/decay/sustain/release); each zone's HOLD stage + play mode +
|
||||
// pitch engine ride on its SampleData::play. `preserveVoiceCap` (S16) bounds how many
|
||||
// Preserve-engine voices may sound at once (the shifter is materially heavier than
|
||||
// Varispeed) — a Preserve note-on beyond the cap is dropped rather than glitching; 0 means
|
||||
// "no separate Preserve cap" (bounded only by maxVoices). `preserveWindowFrames` is the OLA
|
||||
// window (in OUTPUT frames) every voice's Preserve pitch shifters are PRE-SIZED to at
|
||||
// construction (OFF the audio thread), so note-on (which runs in process()) never allocates;
|
||||
// 0 leaves them pass-through (a Varispeed-only instrument pays no ring cost). The processor
|
||||
// derives it from the host sample rate (kPreserveWindowMs). Defaulted so existing callers
|
||||
// (and the pure-core tests) are unaffected.
|
||||
VoiceEngine(std::size_t maxVoices, const Keymap& keymap, const AdsrParams& adsr,
|
||||
std::size_t preserveVoiceCap = 0, std::int64_t preserveWindowFrames = 0);
|
||||
|
||||
// MIDI note-on. Resolves the note+velocity to a zone; if none matches (out of
|
||||
// zone) it is a defined no-op (no voice consumed). Otherwise allocates a free
|
||||
@@ -313,9 +516,14 @@ private:
|
||||
// one per the documented policy. Always returns a valid index (maxVoices >= 1).
|
||||
std::size_t allocateVoice();
|
||||
|
||||
// Count of active Preserve-engine voices (for the S16 Preserve cap). A cheap running tally
|
||||
// kept in sync at note-on/steal/free rather than rescanned per note.
|
||||
std::size_t activePreserveVoices() const;
|
||||
|
||||
std::vector<Voice> voices_;
|
||||
const Keymap& keymap_;
|
||||
AdsrParams adsr_;
|
||||
std::size_t preserveVoiceCap_ = 0; // S16: max simultaneous Preserve voices (0 = no separate cap)
|
||||
std::uint64_t nextStartOrder_ = 1; // monotonic; 0 reserved for "never started"
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user