302 lines
14 KiB
C++
302 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();
|
|
// THE read of the note-on-latched commit class, and the only one: a published block outranks
|
|
// the snapshot's own copy (a live edit deliberately leaves that stale), and applyLive below
|
|
// never touches the rate — so a Rate move reaches the next note and no sounding one.
|
|
const double rate = haveLive_ ? live_.playRate : sample_.play.playRate;
|
|
voice.start(note, velocity, sample_, /*declickTakeover=*/takeoverDeclick_, rate);
|
|
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
|