// voice_engine.cpp — note routing, allocation/stealing, the mono held stack, panic, and the // block render loops. See voice_engine.h for the contract. // // The render loops below call Voice::renderFrame / renderFrameStereo, which are inline in // voice.h precisely so this TU boundary costs nothing on the per-sample path. #include "core/instrument/engine/voice_engine.h" namespace reasampler { VoiceEngine::VoiceEngine(std::size_t maxVoices, const SampleData& sample, std::size_t preserveVoiceCap, std::int64_t preserveWindowFrames, VoiceMode voiceMode, MonoTrigger monoTrigger, bool takeoverDeclick) // MONO always uses voices_[0] only (last-note priority, single voice); size to 1 so // the "only voices_[0] is ever driven" invariant is structurally enforced — no latent // RT-discipline risk if a future mono path touched voices_[1..]. maxVoices == 0 clamps // to 1 (documented degenerate: at least one voice so a note-on is always serviceable). : voices_(voiceMode == VoiceMode::Mono ? 1 : (maxVoices == 0 ? 1 : maxVoices)), sample_(sample), preserveVoiceCap_(preserveVoiceCap), voiceMode_(voiceMode), monoTrigger_(monoTrigger), takeoverDeclick_(takeoverDeclick) { // Pre-size every voice's Preserve shifters HERE (construction is off the audio thread), so // note-on never allocates. A 0 window leaves them pass-through (no ring). This is the one // allocation point for the shifter rings across the engine's lifetime. if (preserveWindowFrames > 1) { for (std::size_t i = 0; i < voices_.size(); ++i) { voices_[i].presizePreserveShifters(preserveWindowFrames); } } } std::size_t VoiceEngine::activePreserveVoices() const { // Count only voices that are SOUNDING A NOTE (playable span still running), not voices // that have finished their note but are still ringing out a declick tail. A ramp-only // past-end voice must not consume a cap slot — that would cause a new Preserve note-on to // be dropped during the narrow ~4 ms window the ramp lives. std::size_t n = 0; for (const Voice& v : voices_) { if (v.soundingNote() && v.pitchEngine() == PitchEngine::Preserve) ++n; } return n; } std::size_t VoiceEngine::allocateVoice() { // 1. A free (idle) voice, lowest index for determinism. for (std::size_t i = 0; i < voices_.size(); ++i) { if (!voices_[i].active()) return i; } // 2. All busy -> steal. Prefer the oldest voice already in release (a dying tail), // else the oldest voice overall. "Oldest" = smallest startOrder. std::size_t bestReleasing = kNoVoice; std::uint64_t bestReleasingOrder = 0; std::size_t bestOverall = kNoVoice; std::uint64_t bestOverallOrder = 0; for (std::size_t i = 0; i < voices_.size(); ++i) { const std::uint64_t order = voices_[i].startOrder(); if (voices_[i].releasing()) { if (bestReleasing == kNoVoice || order < bestReleasingOrder) { bestReleasing = i; bestReleasingOrder = order; } } if (bestOverall == kNoVoice || order < bestOverallOrder) { bestOverall = i; bestOverallOrder = order; } } return bestReleasing != kNoVoice ? bestReleasing : bestOverall; } void VoiceEngine::removeHeld(int note) { for (std::size_t i = 0; i < heldCount_; ++i) { if (heldStack_[i].note == static_cast(note)) { // Shift the notes above it down one slot (press order preserved). for (std::size_t j = i + 1; j < heldCount_; ++j) heldStack_[j - 1] = heldStack_[j]; --heldCount_; return; } } } std::size_t VoiceEngine::monoNoteOn(int note, int velocity) { // Reject out-of-range notes BEFORE touching the held stack: HeldNote stores the note as a // uint8, so an unguarded value (e.g. 256, or a negative) would alias mod 256 onto a real // held note and corrupt the stack. Mirrored in monoNoteOff. if (note < 0 || note > 127) return kNoVoice; // Nothing decoded: a defined no-play, and the note must not join the stack (it cannot // sound, so it must not later take the voice back on a fallback). if (!sample_.playable()) return kNoVoice; // The note joins (or moves to) the top of the held stack. Velocity is clamped into the // byte for storage only; the voice start below receives the caller's value untouched. removeHeld(note); if (heldCount_ < heldStack_.size()) { const int vclamped = velocity < 0 ? 0 : (velocity > 127 ? 127 : velocity); heldStack_[heldCount_++] = HeldNote{static_cast(note), static_cast(vclamped)}; } Voice& v = voices_[0]; // LEGATO takeover, keyed on the HELD-STACK DEPTH: after the push above, heldCount_ >= 2 // means another note was already physically held — the exact "takeover within a phrase" // predicate. (The previous guard, `active && !releasing`, broke for TRIGGER: release() is // a no-op there, so releasing_ never latches and a one-shot still ringing after the last // key-up was silently RETUNED in place instead of re-attacked. NOTE: a one-held-note // same-note re-press (heldCount_ becomes 1 after the removeHeld/re-push above — so // heldCount_ < 2) re-attacks rather than retuning, the correct fresh-phrase behavior.) // // soundingNote() (not just active()): a voice whose note has run to its play-end but is // still ringing a declick tail must NOT be retuned — that would move the pitch of a dying // ramp rather than restarting the new note, producing a silent note on the common // "hammer same key while a past-end ring-out is active" path. The tail should keep fading; // the new note-on restarts the voice normally (falls through to start() below). if (v.soundingNote() && heldCount_ >= 2 && monoTrigger_ == MonoTrigger::Legato) { v.retune(note); return 0; } // RETRIGGER takeover / first note of a phrase: (re)start the voice. The declick opt-in // rides every mono restart; start() self-gates it on the voice being ACTIVE, so a // first-note fresh start never ramps — only a hard cut of a sounding tone. v.start(note, velocity, sample_, /*declickTakeover=*/takeoverDeclick_); v.setStartOrder(nextStartOrder_++); return 0; } void VoiceEngine::monoNoteOff(int note) { // Same range guard as monoNoteOn: removeHeld compares against the uint8-cast note, so an // unguarded out-of-range off (e.g. 256 -> 0 mod 256) would evict a legitimately held note. if (note < 0 || note > 127) return; removeHeld(note); Voice& v = voices_[0]; // Releasing a note that is not the sounding one (a lower held note or an already-released // note) changes nothing audible. if (!v.active() || v.releasing() || v.note() != note) return; if (heldCount_ == 0) { v.release(); // last finger up: gate off (Trigger ignores this and plays through). return; } // FALLBACK: the most-recent still-held note takes the voice back (last-note priority). const HeldNote fb = heldStack_[heldCount_ - 1]; if (monoTrigger_ == MonoTrigger::Legato) { v.retune(fb.note); // glide back, no re-attack return; } // Retrigger fallback: re-strike the fallen-back-to note at its own original velocity. // Peer restart site of monoNoteOn's takeover — same declick opt-in (the fallback also // hard-cuts the sounding tone). v.start(fb.note, fb.velocity, sample_, /*declickTakeover=*/takeoverDeclick_); v.setStartOrder(nextStartOrder_++); } std::size_t VoiceEngine::noteOn(int note, int velocity) { if (voiceMode_ == VoiceMode::Mono) return monoNoteOn(note, velocity); if (!sample_.playable()) return kNoVoice; // nothing decoded: defined no-play. // Preserve voice cap: a Preserve voice is materially heavier than Varispeed (a per-voice // OLA shifter). When a cap is set and it is already reached, DROP a new Preserve note-on // rather than glitch (a defined no-play — no shifter is allocated). Varispeed notes are // unaffected. A voice already sounding is never cut by this cap; only NEW Preserve onsets // past the cap are refused. if (preserveVoiceCap_ > 0 && sample_.play.pitchEngine == PitchEngine::Preserve && activePreserveVoices() >= preserveVoiceCap_) { return kNoVoice; } // The voice's Preserve shifters were pre-sized at engine construction (off-thread), so // start() only reset()s + warm()s them — no allocation on this audio-thread path. // The takeover declick rides the STEAL restart too: start() self-gates on the voice being // active, so a free-voice start never ramps — only an at-cap steal, which is the same hard // cut of a sounding tone as the mono retrig takeover. const std::size_t v = allocateVoice(); voices_[v].start(note, velocity, sample_, /*declickTakeover=*/takeoverDeclick_); voices_[v].setStartOrder(nextStartOrder_++); return v; } void VoiceEngine::noteOff(int note) { if (voiceMode_ == VoiceMode::Mono) { monoNoteOff(note); return; } // Release the NEWEST active, non-releasing voice on this note (largest startOrder), // so a re-triggered note releases its newest instance first and older tails ring. std::size_t target = kNoVoice; std::uint64_t bestOrder = 0; for (std::size_t i = 0; i < voices_.size(); ++i) { if (voices_[i].active() && !voices_[i].releasing() && voices_[i].note() == note) { const std::uint64_t order = voices_[i].startOrder(); if (target == kNoVoice || order > bestOrder) { target = i; bestOrder = order; } } } if (target != kNoVoice) voices_[target].release(); } void VoiceEngine::allNotesOff() { // CC 123. Clear the mono held stack so no fallback can resurrect a phantom note (the // stuck-note scenario: a lost note-off leaves an entry that monoNoteOff's fallback // restarts and sustains forever with no key held), then gate off every active voice. // Gate voices enter their release tail; Trigger one-shots ignore release by design and // play through their bounded play length. RT-safe: no allocation, bounded by the pool size. heldCount_ = 0; for (Voice& v : voices_) { if (v.active()) v.release(); } } void VoiceEngine::allSoundsOff() { // CC 120. Hard-stop EVERY voice immediately (no release ramp — silences Trigger one-shots // that allNotesOff() cannot stop) and clear the mono held stack. RT-safe: no allocation, // bounded by the pool size. heldCount_ = 0; for (Voice& v : voices_) { v.hardStop(); } } void VoiceEngine::render(AudioSample* out, std::size_t frameCount) { // Real-time safe: no allocation, no resize — mix straight into the caller's buffer. // The VST3 process callback hands us the host's output channel buffer here, so the // audio thread never touches the heap. if (out == nullptr || frameCount == 0) return; for (Voice& voice : voices_) { if (!voice.active()) continue; for (std::size_t f = 0; f < frameCount; ++f) { if (!voice.active()) break; out[f] += voice.renderFrame(); } } } void VoiceEngine::render(AudioSample* left, AudioSample* right, std::size_t frameCount) { // Real-time safe stereo mix: no allocation, no resize. Sum each active voice's per-channel // contribution into the caller's two buffers. Mirrors the mono loop exactly (same voice // iteration, same mid-block idle short-circuit) so stereo and mono share one stealing/idle // discipline; only the per-frame call differs (renderFrameStereo vs renderFrame). if (left == nullptr || right == nullptr || frameCount == 0) return; for (Voice& voice : voices_) { if (!voice.active()) continue; for (std::size_t f = 0; f < frameCount; ++f) { if (!voice.active()) break; AudioSample l = 0.0f, r = 0.0f; voice.renderFrameStereo(l, r); left[f] += l; right[f] += r; } } } void VoiceEngine::render(std::vector& out, std::size_t frameCount) { // Off-thread / test path: grow the buffer (this allocates — never call under // process), zero-fill the appended span, then delegate to the RT mix loop so both // overloads share exactly one summation path. const std::size_t base = out.size(); out.resize(base + frameCount, 0.0f); render(out.data() + base, frameCount); } std::size_t VoiceEngine::activeVoiceCount() const { std::size_t n = 0; for (const Voice& v : voices_) { if (v.active()) ++n; } return n; } } // namespace reasampler