Files
reasampler/src/core/instrument/engine/voice_engine.cpp
T

298 lines
14 KiB
C++

// 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);
}
}
}
bool VoiceEngine::refreshLive() {
const instrument::engine::LiveParams* block = sample_.live;
if (block == nullptr) return false; // bare engine: the latched note-on values stand
instrument::engine::LiveValues observed;
const std::uint32_t generation = block->read(observed);
if (generation == 0 || generation == liveGeneration_) return false;
liveGeneration_ = generation;
live_ = observed;
haveLive_ = true;
return true;
}
void VoiceEngine::applyLiveToActive() {
if (!refreshLive()) return;
for (Voice& voice : voices_) {
if (voice.active()) voice.applyLive(live_, /*snap=*/false);
}
}
void VoiceEngine::startVoice(Voice& voice, int note, int velocity) {
refreshLive();
voice.start(note, velocity, sample_, /*declickTakeover=*/takeoverDeclick_);
if (haveLive_) voice.applyLive(live_, /*snap=*/true);
voice.setStartOrder(nextStartOrder_++);
}
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<std::uint8_t>(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<std::uint8_t>(note),
static_cast<std::uint8_t>(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.
startVoice(v, note, velocity);
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).
startVoice(v, fb.note, fb.velocity);
}
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();
startVoice(voices_[v], note, velocity);
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;
applyLiveToActive(); // block boundary, once — never inside the frame loop
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;
applyLiveToActive(); // block boundary, once — never inside the frame loop
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<AudioSample>& 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