fix(voice): declick every takeover of a sounding voice — mono retrig/fallback and poly at-cap steal; same-block double-steal keeps its seed; over-cap repro proves the engine steals exactly one voice per note-on

This commit is contained in:
2026-07-28 06:37:36 -04:00
parent 104a25f390
commit 056ccd003e
4 changed files with 341 additions and 10 deletions
+44 -2
View File
@@ -381,6 +381,22 @@ private:
std::int64_t pos_ = 0;
};
// Takeover declick (Phase S GA fix — audible click when a sounding voice is restarted). A
// takeover restart HARD-CUTS the sounding tone: the read head and envelope restart in one
// frame, a step discontinuity that clicks. This is the same physics on EVERY restart-of-a-
// sounding-voice path — the MONO Retrigger takeover/fallback, the mono cross-sample legato
// restart, AND the POLY at-cap voice steal. When the caller opts in (start()'s
// declickTakeover; the engine passes it on all of those restart paths when constructed with
// takeoverDeclick), start() seeds a compensation from the voice's last rendered output;
// each frame it is summed into the output and decays by kDeclickDecay, so the step becomes a
// fast fade-out of the old tone under the new note's attack. The decay is a per-FRAME DSP
// micro-ramp (~2-4 ms to the -80 dB floor across 44.1-96 kHz), not a stored wall-clock
// quantity — no rate resolution needed. OFF by default so the bare core stays byte-identical
// to the pre-fix engine (the regression baseline); the processor shell opts in, mirroring the
// kDefaultPitchEngine layering.
inline constexpr double kDeclickDecay = 0.95; // per-frame decay of the compensation
inline constexpr double kDeclickFloor = 1e-4; // below this the ramp is done (~ -80 dB)
// ---------------------------------------------------------------------------
// A single voice: one active note playing one repitched, enveloped sample. Reads
// the sample by fractional frame position with linear interpolation, advancing by
@@ -411,10 +427,14 @@ public:
// PREVIEW card opts in (it fires at the root, so this is its zero-added-latency path); the
// MIDI VoiceEngine does NOT (default false) — a chromatic line must not step ~25 ms faster
// at the root note than one semitone away (the FA1-review timing-step finding).
// `declickTakeover` (Phase S GA fix): when TRUE and this voice is currently ACTIVE (a
// takeover/steal restart, not a fresh start), seed the takeover declick from the last
// rendered output — see kDeclickDecay above. A fresh start never declicks.
void start(int note, int velocity, const SampleData& sample, int rootNote,
double keyTrack = 1.0,
const vst::VelocityCurve& velocityCurve = vst::VelocityCurve::flat(),
bool unityVarispeedBypass = false);
bool unityVarispeedBypass = false,
bool declickTakeover = false);
// MONO LEGATO takeover (Phase S): re-pitch this ACTIVE voice to `note` without touching the
// amplitude envelope, the read position, or the shifter state — pitch moves, no re-attack.
@@ -518,6 +538,18 @@ private:
PitchShifter shiftL_;
PitchShifter shiftR_;
// Takeover declick state (see kDeclickDecay above). lastOut{L,R}_ track the voice's
// most recent rendered output (post-gain, incl. any running declick) so a takeover/steal
// restart can seed declick{L,R}_ with the exact value the hard cut removed. lastOut is
// NOT zeroed by start() — a second same-block takeover (no frame rendered between) must
// re-seed from the same pre-cut level, not from a phantom 0. declickActive_ gates the
// per-frame add + decay; the declick trio is cleared on a fresh (non-takeover) start.
bool declickActive_ = false;
double declickL_ = 0.0;
double declickR_ = 0.0;
double lastOutL_ = 0.0;
double lastOutR_ = 0.0;
std::uint64_t startOrder_ = 0;
};
@@ -557,10 +589,19 @@ public:
// takeover without a re-attack). Both default to today's behavior (Poly / Retrigger). 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` (Phase S GA fix): when TRUE, every RESTART of a SOUNDING voice —
// the MONO Retrigger takeover, the retrigger fallback on note-off, the cross-sample
// legato restart, and the POLY at-cap voice STEAL — seeds the per-voice declick ramp
// (see kDeclickDecay) so the hard cut of the old tone does not click. start() self-gates
// on the voice being active, so a fresh start (free voice) never ramps. Default FALSE
// keeps the bare core byte-identical to the pre-fix engine (regression baseline); the
// processor shell opts in — the same layering as the kDefaultPitchEngine product default.
VoiceEngine(std::size_t maxVoices, const Keymap& keymap,
std::size_t preserveVoiceCap = 0, std::int64_t preserveWindowFrames = 0,
VoiceMode voiceMode = VoiceMode::Poly,
MonoTrigger monoTrigger = MonoTrigger::Retrigger);
MonoTrigger monoTrigger = MonoTrigger::Retrigger,
bool takeoverDeclick = false);
// MIDI note-on. Resolves the note+velocity to a zone; if none matches (out of
// zone) it is a defined no-op (no voice consumed). Otherwise allocates a free
@@ -654,6 +695,7 @@ private:
std::uint64_t nextStartOrder_ = 1; // monotonic; 0 reserved for "never started"
VoiceMode voiceMode_ = VoiceMode::Poly;
MonoTrigger monoTrigger_ = MonoTrigger::Retrigger;
bool takeoverDeclick_ = false; // GA fix: declick every restart/steal of a sounding voice
std::array<HeldNote, 128> heldStack_{}; // mono held notes, press order; top = heldCount_-1
std::size_t heldCount_ = 0;
};