fix(voice): Trigger legato keys on held-stack depth, CC123 allNotesOff clears mono stack, voice-param edits rebuild from decoded PCM (no re-decode), mono sizes 1 shifter ring
This commit is contained in:
@@ -13,6 +13,7 @@
|
|||||||
#include "pluginterfaces/vst/ivstaudioprocessor.h"
|
#include "pluginterfaces/vst/ivstaudioprocessor.h"
|
||||||
#include "pluginterfaces/vst/ivsteditcontroller.h" // RestartFlags::kIoChanged (S7 re-negotiate)
|
#include "pluginterfaces/vst/ivsteditcontroller.h" // RestartFlags::kIoChanged (S7 re-negotiate)
|
||||||
#include "pluginterfaces/vst/ivstevents.h"
|
#include "pluginterfaces/vst/ivstevents.h"
|
||||||
|
#include "pluginterfaces/vst/ivstmidicontrollers.h" // kCtrlAllNotesOff / kCtrlAllSoundsOff (panic)
|
||||||
#include "pluginterfaces/vst/vstspeaker.h"
|
#include "pluginterfaces/vst/vstspeaker.h"
|
||||||
|
|
||||||
#include "public.sdk/source/vst/vstbus.h" // Vst::AudioBus::setArrangement (S7 output arr)
|
#include "public.sdk/source/vst/vstbus.h" // Vst::AudioBus::setArrangement (S7 output arr)
|
||||||
@@ -314,10 +315,12 @@ void ReaSamplerProcessor::setVoiceCount(int count) {
|
|||||||
if (voiceCount_ == count) return; // no-op: don't churn a rebuild
|
if (voiceCount_ == count) return; // no-op: don't churn a rebuild
|
||||||
voiceCount_ = count;
|
voiceCount_ = count;
|
||||||
}
|
}
|
||||||
// Rebuild the engine OFF-thread through the drain-slot swap (the FA1 machinery): the
|
// LIGHT rebuild OFF-thread through the drain-slot swap: the engine is reconstructed from
|
||||||
// displaced instrument keeps rendering its ringing tails, so a polyphony change never
|
// the already-decoded keymap (no bridge re-read, no WAV re-decode — a polyphony change
|
||||||
// cuts a sounding note. Same contract for the mode/trigger setters below.
|
// touches no audio data) and the displaced instrument keeps rendering its ringing tails,
|
||||||
reloadFromBank();
|
// so a voice-param change never cuts a sounding note NOR stalls the UI re-decoding every
|
||||||
|
// zone from disk. Same contract for the mode/trigger setters below.
|
||||||
|
rebuildVoiceEngine();
|
||||||
}
|
}
|
||||||
|
|
||||||
VoiceMode ReaSamplerProcessor::voiceMode() {
|
VoiceMode ReaSamplerProcessor::voiceMode() {
|
||||||
@@ -331,7 +334,7 @@ void ReaSamplerProcessor::setVoiceMode(VoiceMode mode) {
|
|||||||
if (voiceMode_ == mode) return;
|
if (voiceMode_ == mode) return;
|
||||||
voiceMode_ = mode;
|
voiceMode_ = mode;
|
||||||
}
|
}
|
||||||
reloadFromBank();
|
rebuildVoiceEngine();
|
||||||
}
|
}
|
||||||
|
|
||||||
MonoTrigger ReaSamplerProcessor::monoTrigger() {
|
MonoTrigger ReaSamplerProcessor::monoTrigger() {
|
||||||
@@ -345,7 +348,7 @@ void ReaSamplerProcessor::setMonoTrigger(MonoTrigger trigger) {
|
|||||||
if (monoTrigger_ == trigger) return;
|
if (monoTrigger_ == trigger) return;
|
||||||
monoTrigger_ = trigger;
|
monoTrigger_ = trigger;
|
||||||
}
|
}
|
||||||
reloadFromBank();
|
rebuildVoiceEngine();
|
||||||
}
|
}
|
||||||
|
|
||||||
void ReaSamplerProcessor::previewNoteOn(int note) {
|
void ReaSamplerProcessor::previewNoteOn(int note) {
|
||||||
@@ -525,12 +528,20 @@ std::string ReaSamplerProcessor::reloadFromBank() {
|
|||||||
// `built` is heap-owned; release() hands ownership to the atomic; the drain-evicted
|
// `built` is heap-owned; release() hands ownership to the atomic; the drain-evicted
|
||||||
// pointer is re-owned by the graveyard.
|
// pointer is re-owned by the graveyard.
|
||||||
//
|
//
|
||||||
// Bounded reclaim: free graveyard entries whose installedAt < seen, where seen is
|
publishBuiltLocked(std::move(built));
|
||||||
// the minimum installedAt process() published over the pointers it holds. Both
|
return resolvedId;
|
||||||
// slots are monotone in installedAt, so seen is monotone and any future process()
|
}
|
||||||
// load yields installedAt >= seen — an entry below seen is provably unreachable
|
|
||||||
// (see the header proof). Remaining entries drain at setActive(false) / terminate()
|
void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr<LoadedInstrument> built) {
|
||||||
// when process is guaranteed stopped.
|
// REQUIRES reloadMutex_ held (single-writer over both slots + the graveyard). Shared by
|
||||||
|
// reloadFromBank and rebuildVoiceEngine — the one safety-critical swap dance.
|
||||||
|
//
|
||||||
|
// Bounded reclaim: free graveyard entries whose installedAt < seen, where seen is
|
||||||
|
// the minimum installedAt process() published over the pointers it holds. Both
|
||||||
|
// slots are monotone in installedAt, so seen is monotone and any future process()
|
||||||
|
// load yields installedAt >= seen — an entry below seen is provably unreachable
|
||||||
|
// (see the header proof). Remaining entries drain at setActive(false) / terminate()
|
||||||
|
// when process is guaranteed stopped.
|
||||||
const std::uint64_t seen = processGeneration_.load(std::memory_order_acquire);
|
const std::uint64_t seen = processGeneration_.load(std::memory_order_acquire);
|
||||||
graveyard_.erase(
|
graveyard_.erase(
|
||||||
std::remove_if(graveyard_.begin(), graveyard_.end(),
|
std::remove_if(graveyard_.begin(), graveyard_.end(),
|
||||||
@@ -541,7 +552,40 @@ std::string ReaSamplerProcessor::reloadFromBank() {
|
|||||||
LoadedInstrument* prev = live_.exchange(built.release());
|
LoadedInstrument* prev = live_.exchange(built.release());
|
||||||
LoadedInstrument* evicted = draining_.exchange(prev);
|
LoadedInstrument* evicted = draining_.exchange(prev);
|
||||||
if (evicted) graveyard_.push_back(std::unique_ptr<LoadedInstrument>(evicted));
|
if (evicted) graveyard_.push_back(std::unique_ptr<LoadedInstrument>(evicted));
|
||||||
return resolvedId;
|
}
|
||||||
|
|
||||||
|
void ReaSamplerProcessor::rebuildVoiceEngine() {
|
||||||
|
// OFF THE AUDIO THREAD (the editor's voice-deck click handlers). See the header contract:
|
||||||
|
// a voice-param change touches NO audio data, so this rebuilds the engine + preview card
|
||||||
|
// around a COPY of the live instrument's already-decoded keymap — no bridge, no disk —
|
||||||
|
// and publishes through the same drain-slot swap, so ringing tails survive.
|
||||||
|
std::lock_guard<std::mutex> lock(reloadMutex_);
|
||||||
|
LoadedInstrument* cur = live_.load(std::memory_order_acquire);
|
||||||
|
if (!cur) return; // nothing loaded: the new params bake into the next real reload.
|
||||||
|
|
||||||
|
int builtVoiceCount = kDefaultVoiceCount;
|
||||||
|
VoiceMode builtVoiceMode = VoiceMode::Poly;
|
||||||
|
MonoTrigger builtMonoTrigger = MonoTrigger::Retrigger;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> vp(voiceParamsMutex_);
|
||||||
|
builtVoiceCount = voiceCount_;
|
||||||
|
builtVoiceMode = voiceMode_;
|
||||||
|
builtMonoTrigger = monoTrigger_;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1;
|
||||||
|
// Same Preserve-window derivation as reloadFromBank (kPreserveWindowMs at the host rate).
|
||||||
|
std::int64_t preserveWindow = static_cast<std::int64_t>(
|
||||||
|
kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5);
|
||||||
|
if (preserveWindow < 2) preserveWindow = 2;
|
||||||
|
|
||||||
|
// Deep-copy the decoded PCM + zones. Safe to read concurrently with process(): the keymap
|
||||||
|
// is immutable after construction, and under reloadMutex_ nobody can free `cur`.
|
||||||
|
Keymap km = cur->keymap;
|
||||||
|
auto built = std::make_unique<LoadedInstrument>(
|
||||||
|
std::move(km), static_cast<std::size_t>(builtVoiceCount), gen,
|
||||||
|
kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger);
|
||||||
|
publishBuiltLocked(std::move(built));
|
||||||
}
|
}
|
||||||
|
|
||||||
void ReaSamplerProcessor::retireIdleDrain() {
|
void ReaSamplerProcessor::retireIdleDrain() {
|
||||||
@@ -721,6 +765,26 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
|
|||||||
} else if (e.type == Event::kNoteOffEvent) {
|
} else if (e.type == Event::kNoteOffEvent) {
|
||||||
if (inst) inst->engine.noteOff(e.noteOff.pitch);
|
if (inst) inst->engine.noteOff(e.noteOff.pitch);
|
||||||
if (drain) drain->engine.noteOff(e.noteOff.pitch);
|
if (drain) drain->engine.noteOff(e.noteOff.pitch);
|
||||||
|
} else if (e.type == Event::kLegacyMIDICCOutEvent) {
|
||||||
|
// PANIC (Phase S voice-review Major #2): CC 123 (All Notes Off) / CC 120 (All
|
||||||
|
// Sound Off) reset the engine — clear the mono held stack and release every
|
||||||
|
// voice, live AND drain, engine AND preview card — so a phantom held-stack
|
||||||
|
// entry left by a lost note-off can never be resurrected by the mono fallback
|
||||||
|
// and sustain forever. REAPER delivers raw input MIDI CC to a VST3 instrument
|
||||||
|
// as kLegacyMIDICCOut events on the INPUT event list (a REAPER-ism — the type
|
||||||
|
// is nominally an output event; DAW-verify, see handoff). allNotesOff /
|
||||||
|
// releaseAll are RT-safe (no allocation, bounded scans).
|
||||||
|
const auto cc = static_cast<int>(e.midiCCOut.controlNumber);
|
||||||
|
if (cc == kCtrlAllNotesOff || cc == kCtrlAllSoundsOff) {
|
||||||
|
if (inst) {
|
||||||
|
inst->engine.allNotesOff();
|
||||||
|
inst->preview.releaseAll();
|
||||||
|
}
|
||||||
|
if (drain) {
|
||||||
|
drain->engine.allNotesOff();
|
||||||
|
drain->preview.releaseAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -200,8 +200,9 @@ public:
|
|||||||
// --- Phase S voice-system parameters (per-instance, persisted in component state v7) ---
|
// --- Phase S voice-system parameters (per-instance, persisted in component state v7) ---
|
||||||
// Read/written on the UI thread (the editor's voice deck) and by getState/setState; guarded
|
// Read/written on the UI thread (the editor's voice deck) and by getState/setState; guarded
|
||||||
// by voiceParamsMutex_. NOT read on the audio thread — each setter rebuilds the VoiceEngine
|
// by voiceParamsMutex_. NOT read on the audio thread — each setter rebuilds the VoiceEngine
|
||||||
// OFF-thread through reloadFromBank's drain-slot swap, so changing polyphony / mode / the
|
// OFF-thread via rebuildVoiceEngine (a LIGHT rebuild around the already-decoded keymap; no
|
||||||
// retrigger toggle never cuts a ringing tail (the same path FA1 added for curve edits).
|
// bridge read, no WAV re-decode) published through the same tail-preserving drain-slot swap,
|
||||||
|
// so changing polyphony / mode / the retrigger toggle never cuts a ringing tail.
|
||||||
int voiceCount();
|
int voiceCount();
|
||||||
void setVoiceCount(int count); // clamped to kMinVoiceCount..kMaxVoiceCount
|
void setVoiceCount(int count); // clamped to kMinVoiceCount..kMaxVoiceCount
|
||||||
VoiceMode voiceMode();
|
VoiceMode voiceMode();
|
||||||
@@ -240,6 +241,24 @@ private:
|
|||||||
// proof (see below) covers the free.
|
// proof (see below) covers the free.
|
||||||
void retireIdleDrain();
|
void retireIdleDrain();
|
||||||
|
|
||||||
|
// Phase S voice-param LIGHT rebuild (voice-review Major #3): rebuild the engine + preview
|
||||||
|
// card around a COPY of the LIVE instrument's already-decoded Keymap — no bridge read, no
|
||||||
|
// filesystem, no WAV re-decode — and publish through the same tail-preserving drain-slot
|
||||||
|
// swap as a full reload. A polyphony/mode/trigger change touches no audio data, so the
|
||||||
|
// full reloadFromBank (which re-decodes every zone WAV from disk on the UI thread) was
|
||||||
|
// pure waste — a visible UI stall on a many-zone instrument. Copying the keymap is safe:
|
||||||
|
// it is immutable after construction and, under reloadMutex_, the live instrument can
|
||||||
|
// neither be swapped nor freed while we read it. When nothing is loaded this is a no-op —
|
||||||
|
// the new params bake into the next real reload. Off the audio thread only.
|
||||||
|
void rebuildVoiceEngine();
|
||||||
|
|
||||||
|
// Publish `built` (null = install silence) into live_: prune the graveyard by the last
|
||||||
|
// process()-published generation, swap `built` into live_, displace the previous live into
|
||||||
|
// the drain slot, and park the drain-evicted instrument in the graveyard. REQUIRES
|
||||||
|
// reloadMutex_ held — factored out so reloadFromBank and rebuildVoiceEngine share the ONE
|
||||||
|
// safety-critical swap dance (see the handoff proof below).
|
||||||
|
void publishBuiltLocked(std::unique_ptr<LoadedInstrument> built);
|
||||||
|
|
||||||
ReaperBridge bridge_;
|
ReaperBridge bridge_;
|
||||||
|
|
||||||
// --- The audio-thread handoff (S4 real-time discipline, FA1 drain slot) --
|
// --- The audio-thread handoff (S4 real-time discipline, FA1 drain slot) --
|
||||||
|
|||||||
@@ -538,7 +538,15 @@ VoiceEngine::VoiceEngine(std::size_t maxVoices, const Keymap& keymap,
|
|||||||
// note-on never allocates. A 0 window leaves them pass-through (no ring). This is the one
|
// 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.
|
// allocation point for the shifter rings across the engine's lifetime.
|
||||||
if (preserveWindowFrames > 1) {
|
if (preserveWindowFrames > 1) {
|
||||||
for (Voice& v : voices_) v.presizePreserveShifters(preserveWindowFrames);
|
// MONO drives voices_[0] only, and the mode is immutable per engine (a change
|
||||||
|
// rebuilds via the processor's drain-slot reload), so sizing the other voices'
|
||||||
|
// shifter rings would waste up to (maxVoices-1) windows on voices that can
|
||||||
|
// never start. Poly sizes the whole pool as before.
|
||||||
|
const std::size_t ringCount =
|
||||||
|
voiceMode_ == VoiceMode::Mono ? 1 : voices_.size();
|
||||||
|
for (std::size_t i = 0; i < ringCount; ++i) {
|
||||||
|
voices_[i].presizePreserveShifters(preserveWindowFrames);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -589,6 +597,10 @@ void VoiceEngine::removeHeld(int note) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
std::size_t VoiceEngine::monoNoteOn(int note, int velocity) {
|
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;
|
||||||
const ZoneResolution res = keymap_.resolve(note, velocity);
|
const ZoneResolution res = keymap_.resolve(note, velocity);
|
||||||
if (!res.matched) return kNoVoice; // out-of-zone: defined no-play, never joins the stack.
|
if (!res.matched) return kNoVoice; // out-of-zone: defined no-play, never joins the stack.
|
||||||
const KeyZone& zone = keymap_.zones[res.zoneIndex];
|
const KeyZone& zone = keymap_.zones[res.zoneIndex];
|
||||||
@@ -605,10 +617,14 @@ std::size_t VoiceEngine::monoNoteOn(int note, int velocity) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Voice& v = voices_[0];
|
Voice& v = voices_[0];
|
||||||
// LEGATO takeover: another note is sounding (active + not releasing — a releasing voice's
|
// LEGATO takeover, keyed on the HELD-STACK DEPTH: after the push above, heldCount_ >= 2
|
||||||
// note has left the stack, so a fresh phrase after release always re-attacks) AND the new
|
// means another note was already physically held — the exact "takeover within a phrase"
|
||||||
// note resolves to the SAME sample. Retune in place: pitch moves, no re-attack.
|
// predicate. (The previous guard, `active && !releasing`, broke for TRIGGER zones:
|
||||||
if (v.active() && !v.releasing() && monoTrigger_ == MonoTrigger::Legato &&
|
// Voice::release() is a no-op in Trigger, so releasing_ never latches, and a one-shot
|
||||||
|
// still ringing after the last key-up was silently RETUNED in place instead of
|
||||||
|
// re-attacked. Equivalent in every Gate case — a gated, non-releasing voice always has
|
||||||
|
// its note on the stack; a released note has left it.) Same-sample requirement unchanged.
|
||||||
|
if (v.active() && heldCount_ >= 2 && monoTrigger_ == MonoTrigger::Legato &&
|
||||||
v.playingSample() == &sample) {
|
v.playingSample() == &sample) {
|
||||||
v.retune(note, zone.rootNote, zone.keyTrack);
|
v.retune(note, zone.rootNote, zone.keyTrack);
|
||||||
return 0;
|
return 0;
|
||||||
@@ -620,6 +636,9 @@ std::size_t VoiceEngine::monoNoteOn(int note, int velocity) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void VoiceEngine::monoNoteOff(int note) {
|
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);
|
removeHeld(note);
|
||||||
Voice& v = voices_[0];
|
Voice& v = voices_[0];
|
||||||
// Releasing a note that is not the sounding one (a lower held note, an already-released
|
// Releasing a note that is not the sounding one (a lower held note, an already-released
|
||||||
@@ -697,6 +716,18 @@ void VoiceEngine::noteOff(int note) {
|
|||||||
if (target != kNoVoice) voices_[target].release();
|
if (target != kNoVoice) voices_[target].release();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void VoiceEngine::allNotesOff() {
|
||||||
|
// PANIC / CC 123. Clear the mono held stack FIRST 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), 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::render(AudioSample* out, std::size_t frameCount) {
|
void VoiceEngine::render(AudioSample* out, std::size_t frameCount) {
|
||||||
// Real-time safe: no allocation, no resize — mix straight into the caller's buffer.
|
// 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
|
// The VST3 process callback hands us the host's output channel buffer here, so the
|
||||||
@@ -778,6 +809,12 @@ void PreviewCard::noteOff(int note) {
|
|||||||
if (voice_.active() && voice_.note() == note) voice_.release();
|
if (voice_.active() && voice_.note() == note) voice_.release();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void PreviewCard::releaseAll() {
|
||||||
|
// Panic peer of VoiceEngine::allNotesOff: unconditional release, whatever note rings.
|
||||||
|
// Gate enters release; Trigger plays through (bounded, cannot be stuck). RT-safe.
|
||||||
|
if (voice_.active()) voice_.release();
|
||||||
|
}
|
||||||
|
|
||||||
void PreviewCard::render(AudioSample* out, std::size_t frameCount) {
|
void PreviewCard::render(AudioSample* out, std::size_t frameCount) {
|
||||||
if (out == nullptr || frameCount == 0 || !voice_.active()) return;
|
if (out == nullptr || frameCount == 0 || !voice_.active()) return;
|
||||||
for (std::size_t f = 0; f < frameCount; ++f) {
|
for (std::size_t f = 0; f < frameCount; ++f) {
|
||||||
|
|||||||
+13
-1
@@ -568,6 +568,14 @@ public:
|
|||||||
// the older tail to ring — matches hardware behavior). No-op if none match.
|
// the older tail to ring — matches hardware behavior). No-op if none match.
|
||||||
void noteOff(int note);
|
void noteOff(int note);
|
||||||
|
|
||||||
|
// PANIC / MIDI All-Notes-Off (CC 123). Clears the MONO held stack and releases EVERY
|
||||||
|
// active voice: Gate voices enter their release tail; Trigger one-shots ignore release
|
||||||
|
// by design and play through their bounded play length (they can never be stuck). This
|
||||||
|
// is the mono stack's ONLY reset path — a phantom entry left by a lost note-off would
|
||||||
|
// otherwise be resurrected by the next fallback and sustain forever with no key held.
|
||||||
|
// RT-safe (no allocation, bounded by maxVoices); callable from the audio thread.
|
||||||
|
void allNotesOff();
|
||||||
|
|
||||||
// REAL-TIME render (S4): sums all active voices into the caller-provided buffer
|
// 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 —
|
// `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
|
// this never touches memory it does not own and NEVER allocates). This is the
|
||||||
@@ -619,7 +627,8 @@ private:
|
|||||||
struct HeldNote { std::uint8_t note; std::uint8_t 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
|
// 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.
|
// 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,
|
// 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.
|
// inherently within any cap; applying it would wrongly drop a Preserve->Preserve takeover.
|
||||||
std::size_t monoNoteOn(int note, int velocity);
|
std::size_t monoNoteOn(int note, int velocity);
|
||||||
@@ -671,6 +680,9 @@ public:
|
|||||||
// Release the preview IF `note` is the one sounding (a stale off for a replaced note is a
|
// 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.
|
// no-op). Gate zones enter release; Trigger zones ignore note-off and play through.
|
||||||
void noteOff(int note);
|
void noteOff(int note);
|
||||||
|
// PANIC 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();
|
||||||
|
|
||||||
bool active() const { return voice_.active(); }
|
bool active() const { return voice_.active(); }
|
||||||
|
|
||||||
|
|||||||
@@ -1699,6 +1699,121 @@ static void testMonoIgnoresPreserveCap() {
|
|||||||
CHECK(eng.activeVoiceCount() == 1);
|
CHECK(eng.activeVoiceCount() == 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A ramp sample (output value == read position) so a re-attack (read restarts at 0) is
|
||||||
|
// directly distinguishable from a legato retune (read continues) on the rendered value.
|
||||||
|
static SampleData rampSample(std::size_t frames, int rootNote) {
|
||||||
|
SampleData s;
|
||||||
|
s.frames.resize(frames);
|
||||||
|
for (std::size_t i = 0; i < frames; ++i) s.frames[i] = static_cast<float>(i);
|
||||||
|
s.rootNote = rootNote;
|
||||||
|
s.play.adsr = flatAdsr();
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// MAJOR-1 regression: MONO+LEGATO with a TRIGGER zone RE-ATTACKS after the last key is up.
|
||||||
|
// Trigger ignores note-off (Voice::release() is a no-op, so releasing_ never latches), so a
|
||||||
|
// legato guard keyed on `active && !releasing` saw a ringing one-shot as "still held" and
|
||||||
|
// silently RETUNED it in place. The correct predicate is the HELD-STACK depth: with no other
|
||||||
|
// key down, the next note is a fresh phrase and must restart the read head.
|
||||||
|
static void testMonoLegatoTriggerReattacksAfterKeyUp() {
|
||||||
|
SampleData s = rampSample(200000, 60);
|
||||||
|
s.play.playMode = PlayMode::Trigger; // default TriggerParams: full length, no fades
|
||||||
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||||
|
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
|
||||||
|
eng.noteOn(60, 127); // unity: read advances 1/frame
|
||||||
|
std::vector<AudioSample> out;
|
||||||
|
eng.render(out, 10);
|
||||||
|
eng.noteOff(60); // Trigger ignores the gate: keeps ringing...
|
||||||
|
CHECK(approx(probeFrame(eng), 10.0, 1e-4)); // ...read head still advancing past 10
|
||||||
|
eng.noteOn(62, 127); // NO key held -> fresh phrase: RE-ATTACK
|
||||||
|
CHECK(approx(probeFrame(eng), 0.0, 1e-4)); // read RESTARTED at 0 (a retune would read ~11)
|
||||||
|
// And it is genuinely playing from the top at the new pitch (ratio 2^(2/12) ~ 1.1225),
|
||||||
|
// not merely silent: the next frame reads at the advanced position.
|
||||||
|
CHECK(approx(probeFrame(eng), std::pow(2.0, 2.0 / 12.0), 1e-3));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Companion boundary: with another key STILL physically held, a same-sample Trigger takeover
|
||||||
|
// under Legato still RETUNES (read continues) — the held-stack predicate matches the old
|
||||||
|
// behavior everywhere except the ringing-but-unheld case above.
|
||||||
|
static void testMonoLegatoTriggerHeldKeyStillRetunes() {
|
||||||
|
SampleData s = rampSample(200000, 60);
|
||||||
|
s.play.playMode = PlayMode::Trigger;
|
||||||
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||||
|
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
|
||||||
|
eng.noteOn(60, 127);
|
||||||
|
std::vector<AudioSample> out;
|
||||||
|
eng.render(out, 10);
|
||||||
|
eng.noteOn(62, 127); // 60 still held -> legato takeover
|
||||||
|
CHECK(approx(probeFrame(eng), 10.0, 1e-4)); // read CONTINUES at 10 — no re-attack
|
||||||
|
}
|
||||||
|
|
||||||
|
// MAJOR-2: allNotesOff releases every gated poly voice (flat release -> instant silence).
|
||||||
|
static void testAllNotesOffReleasesPolyVoices() {
|
||||||
|
SampleData s = dcLevelSample(200000, 1.0f, 60);
|
||||||
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||||
|
VoiceEngine eng(4, km);
|
||||||
|
eng.noteOn(60, 127);
|
||||||
|
eng.noteOn(62, 127);
|
||||||
|
eng.noteOn(64, 127);
|
||||||
|
CHECK(eng.activeVoiceCount() == 3);
|
||||||
|
eng.allNotesOff();
|
||||||
|
CHECK(approx(probeFrame(eng), 0.0, 1e-9)); // all gated off (release 0 = instant)
|
||||||
|
CHECK(eng.activeVoiceCount() == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// MAJOR-2, the STUCK-NOTE path: allNotesOff clears the mono held stack, so a phantom entry
|
||||||
|
// (simulating a LOST note-off) can never be resurrected by the fallback afterwards.
|
||||||
|
static void testAllNotesOffClearsMonoHeldStack() {
|
||||||
|
Keymap km = twoLevelKeymap();
|
||||||
|
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
|
||||||
|
eng.noteOn(50, 127); // 50's note-off will never arrive (phantom)
|
||||||
|
eng.noteOn(70, 127); // 70 sounds, phantom 50 buried on the stack
|
||||||
|
eng.allNotesOff(); // PANIC
|
||||||
|
CHECK(approx(probeFrame(eng), 0.0, 1e-9));
|
||||||
|
CHECK(eng.activeVoiceCount() == 0);
|
||||||
|
// The stack is empty: a fresh press + release gates off cleanly, with NO fallback
|
||||||
|
// restart of the phantom (pre-fix, noteOff(70) here re-struck 50 -> 0.25 forever).
|
||||||
|
eng.noteOn(70, 127);
|
||||||
|
CHECK(approx(probeFrame(eng), 0.75, 1e-6));
|
||||||
|
eng.noteOff(70);
|
||||||
|
CHECK(approx(probeFrame(eng), 0.0, 1e-9));
|
||||||
|
CHECK(eng.activeVoiceCount() == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// MAJOR-2 companion: the preview card's unconditional releaseAll (the panic peer).
|
||||||
|
static void testPreviewCardReleaseAll() {
|
||||||
|
Keymap km = twoLevelKeymap();
|
||||||
|
PreviewCard card(km);
|
||||||
|
card.noteOn(70, 127);
|
||||||
|
CHECK(card.active());
|
||||||
|
card.releaseAll(); // no note argument: quiets whatever rings
|
||||||
|
std::vector<AudioSample> buf(1, 0.0f);
|
||||||
|
card.render(buf.data(), buf.size());
|
||||||
|
CHECK(approx(buf[0], 0.0, 1e-9));
|
||||||
|
CHECK(!card.active());
|
||||||
|
}
|
||||||
|
|
||||||
|
// GREEN: out-of-range notes are rejected at BOTH mono entry points. The held stack stores
|
||||||
|
// uint8, so an unguarded off for note 256 (== 0 mod 256) would alias-evict held note 0 —
|
||||||
|
// losing its fallback. Note-ons out of [0,127] are a defined no-play.
|
||||||
|
static void testMonoOutOfRangeNotesRejected() {
|
||||||
|
SampleData s = dcLevelSample(200000, 1.0f, 60);
|
||||||
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||||
|
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
|
||||||
|
CHECK(eng.noteOn(128, 127) == VoiceEngine::kNoVoice);
|
||||||
|
CHECK(eng.noteOn(-1, 127) == VoiceEngine::kNoVoice);
|
||||||
|
CHECK(eng.activeVoiceCount() == 0);
|
||||||
|
eng.noteOn(0, 127); // hold the aliasing target (note 0)
|
||||||
|
eng.noteOn(62, 127); // 62 takes the voice; 0 held beneath
|
||||||
|
eng.noteOff(256); // MUST NOT alias-evict held note 0
|
||||||
|
eng.noteOff(-256); // likewise for the negative wrap
|
||||||
|
CHECK(approx(probeFrame(eng), 1.0, 1e-6)); // 62 undisturbed
|
||||||
|
eng.noteOff(62); // falls back to STILL-HELD note 0
|
||||||
|
CHECK(approx(probeFrame(eng), 1.0, 1e-6)); // (alias-evicted pre-fix -> silence here)
|
||||||
|
eng.noteOff(0);
|
||||||
|
CHECK(approx(probeFrame(eng), 0.0, 1e-9));
|
||||||
|
}
|
||||||
|
|
||||||
// The user-parameterized polyphony bound: an N-voice engine holds exactly N simultaneous
|
// The user-parameterized polyphony bound: an N-voice engine holds exactly N simultaneous
|
||||||
// notes and steals (never grows) on the N+1th; 0 clamps to the documented 1-voice degenerate.
|
// notes and steals (never grows) on the N+1th; 0 clamps to the documented 1-voice degenerate.
|
||||||
static void testVoiceCountBoundsPolyphony() {
|
static void testVoiceCountBoundsPolyphony() {
|
||||||
@@ -1850,6 +1965,12 @@ int main() {
|
|||||||
testMonoLegatoCrossSampleRestarts();
|
testMonoLegatoCrossSampleRestarts();
|
||||||
testMonoLegatoAfterReleaseReattacks();
|
testMonoLegatoAfterReleaseReattacks();
|
||||||
testMonoIgnoresPreserveCap();
|
testMonoIgnoresPreserveCap();
|
||||||
|
testMonoLegatoTriggerReattacksAfterKeyUp();
|
||||||
|
testMonoLegatoTriggerHeldKeyStillRetunes();
|
||||||
|
testAllNotesOffReleasesPolyVoices();
|
||||||
|
testAllNotesOffClearsMonoHeldStack();
|
||||||
|
testPreviewCardReleaseAll();
|
||||||
|
testMonoOutOfRangeNotesRejected();
|
||||||
testVoiceCountBoundsPolyphony();
|
testVoiceCountBoundsPolyphony();
|
||||||
testPreviewCardIsolatedFromPool();
|
testPreviewCardIsolatedFromPool();
|
||||||
testPreviewCardReplaceStaleOffAndOutOfZone();
|
testPreviewCardReplaceStaleOffAndOutOfZone();
|
||||||
|
|||||||
Reference in New Issue
Block a user