#pragma once // sampler_core — the HEART of the Phase S MIDI-playback instrument (D3), deliberately // free of any VST3 *and* any REAPER type so it compiles and unit-tests OUTSIDE the DAW // and outside any plugin host. It owns the pure sampler engine: polyphonic voice // allocation with bounded stealing, an ADSR amplitude envelope, a key/velocity keymap // with (note, velocity) -> zone resolution, and repitch/interpolation from a root note // with loop-point-aware sustain. // // PURE MODULE (CLAUDE.md §load-bearing split): NO VST3 types, NO REAPER types, NO SWELL, // NO vendor/ includes, no include from either SDK. Standard library only. The VST3 shell // (src/vst/reasampler_processor.cpp) marshals MIDI events + audio buffers to and from // this core; the core never sees a VST3 ProcessData or a REAPER MediaTrack. Enforced // structurally: sampler_core_tests links neither SDK (see CMakeLists §2i). // // It shares the `AudioSample` float alias from peaks — the one house precedent for a // pure module leaning on peaks for the audio-domain type (wav_trim does the same). The // S2 seam fields (root note, loop points) enter as plain int / frame-index inputs; the // core does no file I/O — it is handed decoded sample frames and produces audio frames. #include #include #include #include #include "peaks.h" // AudioSample (float) #include "pitch_shift.h" // PitchShifter (S16 Preserve engine DSP core) #include "velocity_curve.h" // VelocityCurve (S-VIEW-9 velocity->amp transfer curve; eval at start) namespace reasampler { // The instrument's per-instance output channel mode (S7, D-E). MONO keeps the pre-S7 // downmix path (one channel out); STEREO negotiates a 2-channel output bus and renders // per-channel. A PERFORMANCE choice the instrument owns (component state), never written // to the bank. Default Mono preserves current behavior. Lives in the pure core as a plain // value so the shell (bus negotiation, state) and the engine share one spelling; the core // itself never branches on it — the mode only picks which render overload the shell drives. enum class ChannelMode { Mono, Stereo }; // The instrument's per-instance VOICE MODE (Phase S voice redesign). POLY is today's // polyphonic engine (fixed pool + bounded stealing); MONO is a single voice with LAST-NOTE // priority over a held-note stack (classic mono synth: a new note takes the voice over; the // release of the top note falls back to the most-recent still-held note). A PERFORMANCE // choice the instrument owns (component state), never a bank fact. Default Poly preserves // current behavior. enum class VoiceMode { Poly, Mono }; // How a MONO takeover treats the envelopes (Phase S — Daniel: explicitly toggleable). // RETRIGGER restarts the amplitude (and pitch) envelope on every new mono note. LEGATO keeps // the envelope running when a note is taken over while another is held — pitch moves without // a re-attack (and the fallback on top-note release glides back the same way). Legato applies // only to a SAME-SAMPLE takeover: crossing into a zone playing a different sample restarts // the voice (one read head cannot glide between two PCM streams; a re-attack on a sample // change is the deterministic, documented fallback). Meaningless in Poly. Default Retrigger. enum class MonoTrigger { Retrigger, Legato }; // The user-parameterized polyphony bound (Phase S): a per-instance persisted voice count. // One spelling shared by the engine, the component-state (de)serializer, and the editor's // control so the range can never drift apart. Default 16 == the pre-Phase-S fixed pool. inline constexpr int kMinVoiceCount = 1; inline constexpr int kMaxVoiceCount = 32; inline constexpr int kDefaultVoiceCount = 16; // --------------------------------------------------------------------------- // 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 // never touches a file. // --------------------------------------------------------------------------- // A loop over [start, end) frames, half-open. A zero-length loop (start == end) // is the "no sustain loop" marker — a held note past the sample end goes silent // rather than looping a zero span. absent-loop is modeled by leaving hasLoop false. struct SampleLoop { bool hasLoop = false; std::int64_t start = 0; // first looped frame (inclusive) std::int64_t end = 0; // one-past-last looped frame (exclusive); start <= end }; // One decoded audio sample the engine can voice. DEINTERLEAVED, per-channel: `frames` is // channel 0 (always present) and `framesR` is channel 1 (present only for a STEREO sample). // A sample is stereo iff `framesR` is non-empty AND the same length as `frames`; otherwise // it is mono (the degenerate, byte-identical Tier 0-1 case — `framesR` stays empty). Both // channels share `readPos_`, `rootNote`, and `loop`, so repitch/loop are per-frame identical // across channels; only the sampled value differs. `rootNote` is the MIDI note the file was // recorded at (S2 intrinsic) — the pitch that plays back at unity ratio. struct SampleData { std::vector frames; // channel 0 PCM (mono, or L of a stereo sample) std::vector framesR; // channel 1 PCM (R); EMPTY for a mono sample int sampleRate = 0; // frames per second (for reference; ratio is // note-relative, so rate cancels for repitch). // 0 is explicitly invalid — every consumer must // receive a real rate before use. int rootNote = 60; // MIDI note recorded at (plays at unity here) SampleLoop loop; // sustain loop, if any // Initial read position (frame offset) a voice starts playback at — frame 0 by // default, so an unset start point is exactly the pre-S11 behavior. S11 makes this // an instrument-side per-zone override (the "start point" marker); S15 builds on it // (both play modes carry a modifiable start). Clamped into [0, frames) at note-on: // 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 { return (!framesR.empty() && framesR.size() == frames.size()) ? 2 : 1; } }; // --------------------------------------------------------------------------- // Keymap — the performance map (instrument-owned, D-B). A note+velocity resolves // to at most one zone; a zone names which SampleData to play and the root note to // repitch from. Tier-0 degenerate case: a single zone spanning [0,127] with the // sample's own root. Tier-1: several zones, each a key range with its own root. // // TIER-2 EXTENSION (velocity layers / round-robin) — designed for, not built: // resolution returns a zone; a zone today owns one sampleIndex. Tier 2 makes a zone // own a *list* of (velocity-range, sampleIndex) layers (and round-robin sets), and // resolve() gains the velocity dimension it already receives but currently ignores // for selection. The (note, velocity) signature and the "resolve to a zone, then a // sample within it" shape are already in place — Tier 2 fills in the second step // without changing callers or the voice engine. See the report note. // --------------------------------------------------------------------------- // A key range [lowNote, highNote] (inclusive both ends) mapping to one sample, with // the root note to repitch from (defaults to the sample's own root, overridable in // the performance map per S5). velocityLow/High reserved for Tier-2 layers; today a // zone accepts the full 1..127 velocity range (0 is note-off by MIDI convention). struct KeyZone { int lowNote = 0; int highNote = 127; int rootNote = 60; // repitch reference for this zone // S-VIEW-6 key-tracking scalar: how far keyboard pitch tracks the root. 1.0 (100%) is // standard 12-tone-ET (default; bit-identical to pre-S-VIEW-6); 0.0 = no tracking (every // key plays root pitch); 2.0 = double-rate tracking. Scales the (note-root) semitone offset // in the repitch math (keyTrackedRatio); rides BOTH engines via the voice's baseRatio_. double keyTrack = 1.0; // S-VIEW-9 velocity->amp transfer curve: maps the note-on velocity (0..127) to the voice's amp // gain, replacing the fixed linear velocity/127. A per-zone performance characteristic (mirror // of keyTrack), carried from PerformanceZone by resolvePerformance and eval'd ONCE in // Voice::start (never per frame). DEFAULT flat y=1 (R10-F1 Option A) — every velocity plays at // unity, a deliberate behavior change from the pre-r10 linear map. vst::VelocityCurve velocityCurve = vst::VelocityCurve::flat(); std::size_t sampleIndex = 0; // index into Keymap::samples }; // Result of resolving a (note, velocity). `matched == false` means the note falls in // no zone (out-of-zone) — a defined no-play result, NOT an error and NOT voice 0. struct ZoneResolution { bool matched = false; std::size_t zoneIndex = 0; // valid only when matched }; // The keymap: the decoded samples plus the zones that map keys onto them. Owns // resolution. Pure: no host types. Zones are tested first-match in order, so an // earlier zone wins an overlap (deterministic, documented). struct Keymap { std::vector samples; std::vector zones; // Resolves (note, velocity) to a zone. First zone (in order) whose [low,high] // contains `note` wins. velocity is accepted now (Tier-2 seam) but does not // affect zone choice at Tier 0-1. Returns {matched=false} when no zone contains // the note. ZoneResolution resolve(int note, int velocity) const; // Convenience: build the Tier-0 degenerate keymap — one sample mapped // chromatically across the whole keyboard from its own root note. static Keymap singleSampleChromatic(SampleData sample); }; // The chromatic pitch ratio to play `note` given a sample recorded at `rootNote`: // 2^((note - rootNote) / 12). note == rootNote -> 1.0 (unity). One octave up -> 2.0, // one octave down -> 0.5. Pure equal-temperament; no reference-frequency needed. double pitchRatio(int note, int rootNote); // The key-tracked pitch ratio (S-VIEW-6): 2^(((note - rootNote) * keyTrack) / 12). The // keyTrack scalar scales the semitone offset before the ET conversion, so it governs how // far playback pitch tracks the keyboard around the root: // keyTrack == 1.0 -> standard 12-tone-ET (BIT-IDENTICAL to pitchRatio(note, rootNote) — // (note-root)*1.0 is exact in IEEE-754, feeding the same std::pow call). // keyTrack == 0.0 -> no tracking: every key plays the root pitch (ratio 1.0 for all notes). // keyTrack == 2.0 -> double-rate tracking: each key is twice as far from the root in pitch. // At the root note the offset is 0 regardless of keyTrack, so the root always plays at unity. // Pure; both repitch engines (Varispeed read-rate, Preserve shift-amount) derive from it via // the voice's baseRatio_. double keyTrackedRatio(int note, int rootNote, double keyTrack); // --------------------------------------------------------------------------- // 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; 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. // --------------------------------------------------------------------------- class AdsrEnvelope { public: enum class Stage { Idle, Attack, Hold, Decay, Sustain, Release, Finished }; void configure(const AdsrParams& params) { params_ = params; } // Gate on: (re)start from Attack. void noteOn(); // Gate off: enter Release from the current level. void noteOff(); // Advances one frame and returns the amplitude for THIS frame (before advancing). // Once Release completes the envelope latches Finished and returns 0.0 forever // (until the next noteOn). A single, monotonic per-frame step — the caller pulls // one value per output frame. double tick(); Stage stage() const { return stage_; } bool finished() const { return stage_ == Stage::Finished; } double level() const { return level_; } private: AdsrParams params_; Stage stage_ = Stage::Idle; double level_ = 0.0; std::int64_t framesInStage_ = 0; 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 // the pitch ratio; loops the sustain region for held notes past the loop end. // --------------------------------------------------------------------------- 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`. All five AHDSR fields (A/H/D/S/R) are read directly from // sample.play.adsr — the per-zone values (in FRAMES) resolved from the stored seconds by // buildTier0Keymap / buildZonedKeymap against the live sample rate. 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). // `keyTrack` (S-VIEW-6) scales the (note-root) semitone offset feeding the repitch ratio; // 1.0 (the default) is standard 12-tone-ET, bit-identical to the pre-S-VIEW-6 baseRatio_. // `velocityCurve` (S-VIEW-9) maps the note-on velocity to the voice's amp gain, evaluated ONCE // here (off the per-frame path); defaults to flat y=1 (R10-F1) — every velocity plays at unity. // `unityVarispeedBypass` (Phase S, re-scoping FA1): when TRUE, a Preserve voice started at // UNITY shift (baseRatio_ == 1.0, pitch env off) is demoted to the Varispeed read path — at // unity the two engines are byte-identical except the OLA shifter's structural half-window // onset delay, which buys nothing when there is no shift to preserve duration against. The // PREVIEW card opts in (it fires at the root, so this is its zero-added-latency path); the // MIDI VoiceEngine does NOT (default false) — a chromatic line must not step ~25 ms faster // at the root note than one semitone away (the FA1-review timing-step finding). void start(int note, int velocity, const SampleData& sample, int rootNote, double keyTrack = 1.0, const vst::VelocityCurve& velocityCurve = vst::VelocityCurve::flat(), bool unityVarispeedBypass = false); // MONO LEGATO takeover (Phase S): re-pitch this ACTIVE voice to `note` without touching the // amplitude envelope, the read position, or the shifter state — pitch moves, no re-attack. // Both engines pick the new baseRatio_ up on the next frame (Varispeed via the read rate, // Preserve via the per-frame setShiftRatio). No-op on an idle voice. The caller guarantees // the voice is playing the SAME SampleData the (note-resolved) zone names — a cross-sample // takeover must restart the voice instead (see MonoTrigger). void retune(int note, int rootNote, double keyTrack = 1.0); // 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(); // HARD STOP — CC 120 (All Sounds Off) semantics. Immediately silences this voice regardless // of play mode: sets active_ = false with no release ramp. Stops a ringing Trigger one-shot // instantly (which release() cannot do). RT-safe: no allocation, no lock. void hardStop(); // True while this voice is producing (or about to produce) sound. bool active() const { return active_; } // The note this voice was started on (for note-off routing). Meaningless if idle. int note() const { return note_; } // Monotonic age counter — higher = started earlier relative to others. The voice // engine uses this for its stealing policy (oldest first). Set by the engine. 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(). NOTE (Phase S re-scope of FA1): the unity-shift demotion to // Varispeed is now OPT-IN via start()'s unityVarispeedBypass — only the preview card takes // it; a MIDI Preserve voice keeps its shifter at every note so a chromatic line has one // uniform onset (no ~25 ms step at the root). PitchEngine pitchEngine() const { return pitchEngine_; } // The SampleData this voice is playing (nullptr when never started). The engine's mono // legato path compares it against the new note's resolved sample — a same-sample takeover // retunes; a cross-sample one restarts. Identity only; callers never mutate through it. const SampleData* playingSample() const { return sample_; } // 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 // sample runs out with no loop. The value is already velocity- and // envelope-scaled — the engine sums voices directly. This is the MONO path (channel // 0 only) — byte-identical to the pre-S7 engine, so mono play is unchanged. AudioSample renderFrame(); // STEREO render: writes THIS frame's per-channel contribution into `l`/`r` and advances // the read head + envelope by exactly one frame (the same single advance the mono path // performs — the envelope ticks ONCE per frame, shared across both channels). For a mono // sample (channelCount()==1) both `l` and `r` receive the same value (dual-mono / centered). // Both outputs are already velocity- and envelope-scaled. Goes idle on the same conditions // as the mono path (envelope finished / sample exhausted with no loop) writing 0 to both. void renderFrameStereo(AudioSample& l, AudioSample& r); private: // Shared read/advance for both render paths: computes the interpolated per-channel // 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 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; }; // --------------------------------------------------------------------------- // The polyphonic voice engine: a fixed pool of voices, note-on allocation with // bounded voice stealing, note-off routing, and block rendering (sum of voices). // // VOICE-STEALING POLICY (deterministic, documented): when all voices are busy and a // new note-on arrives, steal in this priority order: // 1. the oldest voice already in RELEASE (finishing anyway — cheapest to cut), // 2. else the oldest voice overall (longest-held note gives way to the new one). // "Oldest" = smallest startOrder (assigned monotonically at note-on). This is the // standard hardware-sampler policy: prefer to sacrifice a dying tail, and failing // that, the note that has already had the most time. // --------------------------------------------------------------------------- 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). Every AHDSR field (A/H/D/S/R) // + play mode + pitch engine rides on each zone's SampleData::play (in FRAMES, resolved // from the stored seconds at keymap build); the engine holds no instrument-wide ADSR. // `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. // // `voiceMode` (Phase S): POLY is the pool-with-stealing engine above; MONO drives a single // voice (voices_[0]) with last-note priority over the held-note stack, per `monoTrigger` // (Retrigger restarts the envelopes on every takeover/fallback; Legato retunes a same-sample // takeover without a re-attack). Both default to today's behavior (Poly / Retrigger). The // engine's config is immutable — a mode/count change rebuilds the engine off-thread through // the processor's drain-slot reload, so ringing tails survive the swap. VoiceEngine(std::size_t maxVoices, const Keymap& keymap, std::size_t preserveVoiceCap = 0, std::int64_t preserveWindowFrames = 0, VoiceMode voiceMode = VoiceMode::Poly, MonoTrigger monoTrigger = MonoTrigger::Retrigger); // 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 // voice, or steals one per the policy above. Returns the index of the voice used, // or kNoVoice for an out-of-zone (unplayed) note. std::size_t noteOn(int note, int velocity); // MIDI note-off. Releases the most-recently-started active, non-releasing voice // playing `note` (so a re-triggered same note releases the newest first, leaving // the older tail to ring — matches hardware behavior). No-op if none match. void noteOff(int note); // CC 123 — MIDI All-Notes-Off: clears the MONO held stack and RELEASES every active voice // (Gate voices enter their AHDSR release tail; Trigger one-shots ignore release and play // through their bounded play length). This is the mono stack's ONLY reset path — a phantom // entry left by a lost note-off would otherwise be resurrected by the fallback and sustain // forever with no key held. RT-safe (no allocation, bounded by maxVoices). void allNotesOff(); // CC 120 — MIDI All-Sounds-Off: hard-stops EVERY voice immediately (active_ = false, no // release ramp), clears the MONO held stack, and silences even Trigger one-shots that would // ignore a release. Use for panic; CC 123 for the softer "let gates release" behavior. // RT-safe (no allocation, bounded by maxVoices); callable from the audio thread. void allSoundsOff(); // REAL-TIME render (S4): sums all active voices into the caller-provided buffer // `out[0..frameCount)`, ADDING to whatever is there (the caller clears or mixes — // this never touches memory it does not own and NEVER allocates). This is the // audio-thread entry point: the VST3 process callback passes the host's own output // channel buffer, so no allocation, resize, or heap traffic happens under process. // Voices that finish mid-block go idle and stop contributing. `out` must point at // at least `frameCount` writable samples; a null `out` or zero count is a no-op. void render(AudioSample* out, std::size_t frameCount); // REAL-TIME stereo render (S7): sums all active voices per-channel into the caller's two // buffers `left`/`right` (each `frameCount` writable samples), ADDING to whatever is there // (the caller clears/mixes). Same RT discipline as the mono overload — no allocation, no // resize, no lock. A mono sample plays dual-mono (same value to both channels, centered); // a stereo sample plays its two channels. A null buffer or zero count is a no-op. The mono // and stereo render paths are independent output shapes over the SAME voice pool; the active // channel mode (mono vs stereo bus) picks which one the process callback drives per block. void render(AudioSample* left, AudioSample* right, std::size_t frameCount); // TEST / off-thread convenience: appends `frameCount` summed frames to `out` // (grows it — DO NOT call on the audio thread; it allocates). Delegates to the // real-time overload after sizing the buffer, so both paths share one mix loop. // Does not clear existing contents — appends, matching the pre-S4 contract the // unit tests rely on. void render(std::vector& out, std::size_t frameCount); // Count of currently active voices (for tests / diagnostics). std::size_t activeVoiceCount() const; std::size_t maxVoices() const { return voices_.size(); } static constexpr std::size_t kNoVoice = static_cast(-1); private: // Picks a voice to (re)use for a new note-on: a free voice if any, else a stolen // 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). Rescanned per note-on // (cheap: bounded by maxVoices) rather than maintained as a running tally. std::size_t activePreserveVoices() const; // --- MONO mode (Phase S): last-note priority over a held-note stack ------------ // The stack holds every currently-held, ZONE-RESOLVING note in press order (top = most // recent = the sounding note while the voice is gated). An out-of-zone note never joins // (it cannot sound, so it must not later take the voice back on a fallback). Re-pressing // a held note moves it to the top. Fixed-capacity (128 distinct MIDI notes) — no // allocation on the audio thread. Velocity is kept per held note so a RETRIGGER fallback // re-strikes the fallen-back-to note at ITS original velocity. struct HeldNote { std::uint8_t note; std::uint8_t velocity; }; // Mono note-on: push to the stack and take the voice over (legato retune on a same-sample // takeover, else a fresh start). Returns 0 (the mono voice) or kNoVoice for out-of-zone // or out-of-range (note outside [0,127] — rejected BEFORE the stack, which stores uint8). // The S16 Preserve cap is NOT applied in mono — a single voice runs at most one shifter, // inherently within any cap; applying it would wrongly drop a Preserve->Preserve takeover. std::size_t monoNoteOn(int note, int velocity); // Mono note-off: pop from the stack; if the released note was sounding, fall back to the // most-recent still-held note (retrigger or legato per monoTrigger_), else release. void monoNoteOff(int note); // Drops `note` from the held stack (order of the remaining notes preserved). No-op if absent. void removeHeld(int note); std::vector voices_; const Keymap& keymap_; 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" VoiceMode voiceMode_ = VoiceMode::Poly; MonoTrigger monoTrigger_ = MonoTrigger::Retrigger; std::array heldStack_{}; // mono held notes, press order; top = heldCount_-1 std::size_t heldCount_ = 0; }; // --------------------------------------------------------------------------- // PREVIEW VOICE CARD (Phase S). A dedicated single voice ENTIRELY ISOLATED from the MIDI // VoiceEngine pool: the editor's preview trigger routes ONLY here, host MIDI ONLY to the // engine, and the two are summed by the shell — neither can steal from, drop, or cap the // other (the FA1 "preview dropped when voices are full" bug is structurally impossible). // // The card plays the loaded zone through its REAL params (the same Keymap resolution and // Voice machinery — you hear the actual sound), but with the FA1 unity-Varispeed bypass // OPTED IN: the preview fires at the effective root (unity shift), where the Preserve // shifter's half-window onset delay buys nothing, so the preview speaks on frame one. A // transposed preview (if ever fired off-root) keeps the genuine shifter path — the // shifters are pre-sized at construction (off-thread), so noteOn stays RT-safe. // // PLAYBACK ONLY (load-bearing principle): the card never captures, never writes the bank, // never inserts a timeline item. Pure — no VST3/REAPER types; the shell owns the mailbox // cadence and the output summation. // --------------------------------------------------------------------------- class PreviewCard { public: // `keymap` must outlive the card (same lifetime contract as VoiceEngine — both live on // the shell's LoadedInstrument next to the Keymap they read). `preserveWindowFrames` // pre-sizes the voice's Preserve shifters off-thread (0/1 = pass-through), mirroring the // engine's constructor, so an off-root Preserve preview never allocates at note-on. explicit PreviewCard(const Keymap& keymap, std::int64_t preserveWindowFrames = 0); // Fire the preview note (RT-safe: no allocation). A new preview replaces the ringing one // (single voice — the card is one finger, not a pool). Out-of-zone is a defined no-play. void noteOn(int note, int velocity); // Release the preview IF `note` is the one sounding (a stale off for a replaced note is a // no-op). Gate zones enter release; Trigger zones ignore note-off and play through. void noteOff(int note); // CC 123 peer of VoiceEngine::allNotesOff: release the ringing preview UNCONDITIONALLY, // whatever note it is on. Gate enters release; Trigger plays through (bounded). RT-safe. void releaseAll(); // CC 120 peer of VoiceEngine::allSoundsOff: HARD-STOP the preview immediately (no release // ramp, silences Trigger one-shots too). RT-safe. void hardStop(); bool active() const { return voice_.active(); } // Sum the card's contribution into the caller's buffer(s), ADDING (mirror of the engine's // RT render contract — no allocation, no lock; null/zero-count is a no-op). void render(AudioSample* out, std::size_t frameCount); void render(AudioSample* left, AudioSample* right, std::size_t frameCount); private: Voice voice_; const Keymap& keymap_; }; } // namespace reasampler