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:
+44
-2
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user