#pragma once // voice_engine.h — the COLD half of the sampler engine: note routing, voice allocation and // stealing, the mono held-note stack, the two-tier panic, and the block render loops. The // per-voice per-sample work it drives is inline in voice.h, so render's inner loop keeps its // present inline shape across this seam. #include #include #include #include #include "core/audio/peaks.h" #include "core/instrument/engine/live_params.h" #include "core/instrument/engine/play_params.h" #include "core/instrument/engine/voice.h" namespace reasampler { using audio::AudioSample; // The polyphonic voice engine: a fixed pool of voices over ONE loaded capture, 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) — the standard // hardware-sampler policy. class VoiceEngine { public: // Builds an engine with `maxVoices` voices playing `sample` (must outlive the engine — // held by reference, never copies PCM). Every playback parameter rides on the sample; the // engine holds no parameters of its own beyond the voice-system config below. // `preserveVoiceCap` 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 cap (bounded only by maxVoices). // `preserveWindowFrames` is the OLA window every voice's Preserve shifters are pre-sized // to at construction (off the audio thread), so note-on never allocates; 0 leaves them // pass-through. The processor derives it from the host sample rate. // // `voiceMode`: 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 without a // re-attack). 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. // // `takeoverDeclick`: when true, every restart of a sounding voice (mono retrigger // takeover/fallback, poly at-cap steal) seeds the per-voice declick ramp (see // kDeclickDecay) so the hard cut doesn't click. start() self-gates on the voice being // active, so a fresh start never ramps. Default false keeps the bare core byte-identical // to the pre-fix engine; the processor shell opts in. VoiceEngine(std::size_t maxVoices, const SampleData& sample, std::size_t preserveVoiceCap = 0, std::int64_t preserveWindowFrames = 0, VoiceMode voiceMode = VoiceMode::Poly, MonoTrigger monoTrigger = MonoTrigger::Retrigger, bool takeoverDeclick = false); // MIDI note-on. Allocates a free voice, or steals one per the policy above. Returns the // index of the voice used, or kNoVoice when nothing is playable (no decoded PCM, an // out-of-range note, or a Preserve note-on past the cap) — a defined no-play, not an error. 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 (All-Notes-Off): clears the mono held stack and releases every active voice // (Gate enters AHDSR release; Trigger ignores release and plays through). 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. void allNotesOff(); // CC 120 (All-Sounds-Off): hard-stops every voice immediately, clears the mono held // stack, silences even Trigger one-shots that would ignore a release. Panic; CC 123 is // the softer "let gates release." RT-safe, callable from the audio thread. void allSoundsOff(); // Sums all active voices into the caller-provided buffer `out[0..frameCount)`, adding // to whatever is there — never allocates (the audio-thread entry point; the VST3 // process callback passes the host's own output buffer). Voices that finish mid-block // go idle. `out` must point at least `frameCount` writable samples; null/zero is a no-op. void render(AudioSample* out, std::size_t frameCount); // Stereo overload: sums per-channel into `left`/`right`, same RT discipline. A mono // sample plays dual-mono (same value both channels); a stereo sample plays its two // channels. Mono and stereo render are independent output shapes over the same voice // pool — the active channel mode 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). Delegates to the real-time overload after sizing // the buffer. Does not clear existing contents — appends. 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(); // --- Live-parameter observation (live_params.h) --- // The ONE place the seqlock is read: at block start and at each note-on, on the audio // thread, never per frame. A torn or never-published read leaves the last good snapshot // in place rather than spinning. Returns whether a NEW generation landed. bool refreshLive(); // Block-boundary refresh: pushes a newly-observed generation into every sounding voice, // which glides toward it. No-op when nothing changed (and when no block is attached). void applyLiveToActive(); // The one restart path: start the voice, hand it the live values outright (it has nothing // to glide from), and stamp its age. Shared by the poly steal and both mono restarts so // no restart site can miss the live handoff. void startVoice(Voice& voice, int note, int velocity); instrument::engine::LiveValues live_{}; std::uint32_t liveGeneration_ = 0; // last generation observed; 0 = none yet bool haveLive_ = false; // Count of active Preserve-engine voices (for the Preserve cap). Rescanned per note-on // (cheap: bounded by maxVoices) rather than maintained as a running tally. std::size_t activePreserveVoices() const; // Mono mode: last-note priority over a held-note stack. The stack holds every // currently-held, playable note in press order (top = most recent = the sounding note). // 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 at its original velocity. struct HeldNote { std::uint8_t note; std::uint8_t velocity; }; // Push to the stack and take the voice over (legato retune, else a fresh start). Returns // 0 (the mono voice) or kNoVoice for an unplayable/out-of-range note (rejected before the // stack, which stores uint8). The 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); // 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 SampleData& sample_; std::size_t preserveVoiceCap_ = 0; // 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; bool takeoverDeclick_ = false; // declick every restart/steal of a sounding voice std::array heldStack_{}; // mono held notes, press order; top = heldCount_-1 std::size_t heldCount_ = 0; }; // The editor's preview trigger is a synthetic note-on at the loaded capture's root note // through the same VoiceEngine host MIDI drives, so preview is a real voice: it counts // against the voice count, can steal/be stolen, and respects Poly/Mono + Retrigger/Legato. // There is no dedicated preview voice isolated from the MIDI pool. } // namespace reasampler