fix(vst): declick rev 2 — difference-seeded ungated takeover ramp (rev-1 (1-amp) gate zeroed it on Trigger/zero-attack); preview card opt-in

This commit is contained in:
2026-07-28 08:49:07 -04:00
parent e99bdcf264
commit 5d3289f0bc
4 changed files with 262 additions and 68 deletions
+6 -6
View File
@@ -52,11 +52,11 @@ struct LoadedInstrument {
PreviewCard preview; // Phase S: the isolated preview voice — never part of the pool
std::uint64_t installedAt = 0; // reload generation at which this was installed
// The takeover declick (GA fix) is opted IN here — the PRODUCT default: any restart of a
// sounding voice (mono Retrigger takeover/fallback, cross-sample legato restart, POLY
// at-cap steal) fades the cut tone over a few ms instead of clicking. The pure core
// defaults it off (regression baseline) — same layering as the kDefaultPitchEngine
// product default.
// The takeover declick (GA fix, rev 2) is opted IN here — the PRODUCT default: any
// restart of a sounding voice (mono Retrigger takeover/fallback, cross-sample legato
// restart, POLY at-cap steal, AND the preview card's replace-restart) smooths the cut
// via the difference-seeded ramp instead of clicking. The pure core defaults it off
// (regression baseline) — same layering as the kDefaultPitchEngine product default.
LoadedInstrument(Keymap km, std::size_t maxVoices,
std::uint64_t gen, std::size_t preserveVoiceCap = 0,
std::int64_t preserveWindowFrames = 0,
@@ -65,7 +65,7 @@ struct LoadedInstrument {
: keymap(std::move(km)),
engine(maxVoices, keymap, preserveVoiceCap, preserveWindowFrames,
voiceMode, monoTrigger, /*takeoverDeclick=*/true),
preview(keymap, preserveWindowFrames),
preview(keymap, preserveWindowFrames, /*takeoverDeclick=*/true),
installedAt(gen) {}
// True when nothing in this snapshot is sounding — engine voices AND the preview card.
+86 -36
View File
@@ -262,27 +262,33 @@ void Voice::presizePreserveShifters(std::int64_t windowFrames) {
void Voice::start(int note, int velocity, const SampleData& sample, int rootNote,
double keyTrack, const vst::VelocityCurve& velocityCurve,
bool unityVarispeedBypass, bool declickTakeover) {
// Takeover declick (Phase S GA fix): BEFORE any state reset, seed the compensation
// from the last rendered output IFF this start is a takeover/steal of a SOUNDING voice
// and the caller opted in. The seed is exactly the value the hard cut removes, so the
// first new frame carries the old level and the step becomes a fast fade (see
// kDeclickDecay). A fresh start (idle voice) always clears the declick state — no
// phantom ramp. lastOut{L,R}_ are deliberately NOT zeroed here: a SECOND same-block
// takeover (two steals of this voice with no frame rendered between) must re-seed from
// the same pre-cut output level — zeroing would drop the pending ramp and bring the
// click back on that edge. The next rendered frame overwrites lastOut anyway.
// Takeover declick (Phase S GA fix, rev 2): BEFORE any state reset, record the PRE-CUT
// REFERENCE — the last rendered output — and mark the compensation PENDING iff this
// start is a takeover/steal of a SOUNDING voice and the caller opted in. The ramp itself
// is seeded on the FIRST frame rendered after the restart, from the DIFFERENCE between
// this reference and the new voice's raw output that frame (seedDeclick), so the
// boundary frame reproduces the old level EXACTLY — whatever the new envelope does
// (Gate attack, zero attack, Trigger's no-fade-in instant-unity onset) and whatever
// value the new sample starts on. [Rev 1 seeded the OLD value here and gated the add by
// (1 newAmp) in the epilogue: every restart whose new amplitude was instantly ~1 got
// ZERO compensation and kept the full click — exactly the DAW-reported mono-retrig case
// on Trigger / zero-attack zones.] A fresh start (idle voice) clears the declick state —
// no phantom ramp. lastOut{L,R}_ are deliberately NOT zeroed here: a SECOND same-block
// takeover (two steals of this voice with no frame rendered between) must record the
// same pre-cut reference, not a phantom 0. The next rendered frame overwrites lastOut.
if (declickTakeover && active_) {
// Clamp the seed to ±1.0: closes the theoretical same-frame-repeat accumulation edge
// (successive starts before any frame is rendered cannot grow the seed above full scale).
declickL_ = (lastOutL_ > 1.0) ? 1.0 : (lastOutL_ < -1.0) ? -1.0 : lastOutL_;
declickR_ = (lastOutR_ > 1.0) ? 1.0 : (lastOutR_ < -1.0) ? -1.0 : lastOutR_;
declickActive_ = (declickL_ > kDeclickFloor || declickL_ < -kDeclickFloor ||
declickR_ > kDeclickFloor || declickR_ < -kDeclickFloor);
// Clamp the reference to ±1.0 full scale: a bounded seed whatever the voice was doing.
declickRefL_ = (lastOutL_ > 1.0) ? 1.0 : (lastOutL_ < -1.0) ? -1.0 : lastOutL_;
declickRefR_ = (lastOutR_ > 1.0) ? 1.0 : (lastOutR_ < -1.0) ? -1.0 : lastOutR_;
declickPending_ = true;
} else {
declickL_ = 0.0;
declickR_ = 0.0;
declickActive_ = false;
declickPending_ = false;
}
// Any in-flight ramp is superseded: pending re-derives from the reference, which already
// includes the running declick's contribution via lastOut (it tracks post-declick output).
declickActive_ = false;
declickL_ = 0.0;
declickR_ = 0.0;
active_ = true;
releasing_ = false;
@@ -417,6 +423,22 @@ double Voice::tickAmplitude() {
return amp;
}
void Voice::seedDeclick(double newOutL, double newOutR) {
// First frame after a takeover restart: the compensation is the ACTUAL discontinuity —
// (pre-cut reference the new voice's raw output this frame). Added ungated in the
// epilogue, so this frame's output is newOut + (ref newOut) == ref: zero step at the
// boundary by construction. Clamped to ±2.0 (both operands are bounded near full scale).
declickPending_ = false;
double dl = declickRefL_ - newOutL;
double dr = declickRefR_ - newOutR;
dl = (dl > 2.0) ? 2.0 : (dl < -2.0) ? -2.0 : dl;
dr = (dr > 2.0) ? 2.0 : (dr < -2.0) ? -2.0 : dr;
declickL_ = dl;
declickR_ = dr;
declickActive_ = (dl > kDeclickFloor || dl < -kDeclickFloor ||
dr > kDeclickFloor || dr < -kDeclickFloor);
}
AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
// Shared read/advance for the mono and stereo paths. The read-head geometry (loop wrap,
// bracketing indices, interpolation partner) is computed ONCE and applied identically to
@@ -453,8 +475,29 @@ AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
// trigger envelope also finishes at the same frame count; either latches the voice idle.
const bool triggerRanOff =
playMode_ == PlayMode::Trigger && readPos_ >= static_cast<double>(playEnd_);
// Ran off the sample end with no usable loop -> voice is done.
// Ran off the sample end with no usable loop -> voice is done. Peer path of the
// epilogue: an in-flight takeover declick RINGS OUT here instead of hard-cutting —
// dropping it would re-introduce a step on exactly the path the ramp exists for (a
// restart whose new play span ends within the ~4 ms ramp). The voice stays active only
// until the ramp floors; with no declick (the common case, and the entire opt-out
// baseline) this is byte-identical to the plain idle-out.
if (triggerRanOff || readPos_ >= static_cast<double>(frameCount)) {
if (declickPending_) seedDeclick(0.0, 0.0); // the new output here is silence
if (declickActive_) {
const double l = declickL_;
const double r = declickR_;
declickL_ *= kDeclickDecay;
declickR_ *= kDeclickDecay;
if (declickL_ < kDeclickFloor && declickL_ > -kDeclickFloor &&
declickR_ < kDeclickFloor && declickR_ > -kDeclickFloor) {
declickActive_ = false;
active_ = false;
}
lastOutL_ = l;
lastOutR_ = stereo ? r : l;
if (stereo) outR = static_cast<AudioSample>(r);
return static_cast<AudioSample>(l);
}
active_ = false;
if (stereo) outR = 0.0f;
return 0.0f;
@@ -528,20 +571,20 @@ AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
ratio_ = baseRatio_ * envFactor;
}
// Takeover declick (Phase S GA fix): sum the decaying compensation seeded by a
// takeover/steal start() so the restart's hard cut has no step. Engine-agnostic — applied
// after either pitch-engine branch, on the shared epilogue. Inactive (the common case)
// costs one branch.
//
// Gate by (1 - amp): the compensation fills only the HOLE the new attack leaves. When amp
// is near 0 (slow attack, typical 3 ms) the gate is ~1 — full compensation, no change in
// feel. When amp is 1 (zero-attack, instant sustain) the gate is 0 — no compensation added,
// so there is no +6 dB blip. For long attacks the gate tapers the compensation proportionally,
// removing the notch that arose when both the old tail and the new level were present in full.
// Takeover declick (Phase S GA fix, rev 2): on the FIRST frame after a takeover/steal
// restart, seed the compensation from the ACTUAL discontinuity — the pre-cut output
// minus this frame's raw new output — then add the decaying compensation UNGATED. The
// boundary frame therefore carries the old level exactly (out + (ref out) == ref) and
// every later frame moves by the new signal's own slope plus a ≤5%-of-seed decay step.
// Engine- and play-mode-agnostic: rev 1's (1 amp) gate zeroed the ramp whenever the
// new envelope opened instantly at ~1 (Trigger's no-fade-in onset, zero-attack Gate) —
// exactly where the DAW still clicked. Because the seed is a DIFFERENCE, matching old
// and new levels seed ~0 and add nothing — no +6 dB blip without any gate. Inactive
// (the common case) costs one branch.
if (declickPending_) seedDeclick(outL, stereo ? outRlocal : outL);
if (declickActive_) {
const double gate = 1.0 - amp;
outL += declickL_ * gate;
if (stereo) outRlocal += declickR_ * gate;
outL += declickL_;
if (stereo) outRlocal += declickR_;
declickL_ *= kDeclickDecay;
declickR_ *= kDeclickDecay;
if (declickL_ < kDeclickFloor && declickL_ > -kDeclickFloor &&
@@ -561,7 +604,10 @@ AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
readPos_ += ratio_;
if (amplitudeDone_) {
// A finished amplitude envelope frees the voice — unless a takeover declick still rings:
// the envelope contributes 0 from here on, so the remaining frames are the bare ramp
// fading out (bounded: the ramp floors within ~4 ms). Baseline (no declick) unchanged.
if (amplitudeDone_ && !declickActive_) {
active_ = false;
}
return static_cast<AudioSample>(outL);
@@ -859,8 +905,9 @@ std::size_t VoiceEngine::activeVoiceCount() const {
// PreviewCard (Phase S) — the isolated preview voice. See the header contract.
// ---------------------------------------------------------------------------
PreviewCard::PreviewCard(const Keymap& keymap, std::int64_t preserveWindowFrames)
: keymap_(keymap) {
PreviewCard::PreviewCard(const Keymap& keymap, std::int64_t preserveWindowFrames,
bool takeoverDeclick)
: keymap_(keymap), takeoverDeclick_(takeoverDeclick) {
// Construction is off the audio thread (the shell builds the card inside its
// LoadedInstrument) — the one allocation point for the preview voice's shifter rings,
// mirroring VoiceEngine's constructor. <= 1 leaves them pass-through.
@@ -877,8 +924,11 @@ void PreviewCard::noteOn(int note, int velocity) {
// bypass is OPTED IN here (and only here) — the preview fires at the effective root, so
// this is its zero-added-latency path (the FA1 fix, re-scoped off the MIDI engine). No
// Preserve cap and no stealing interplay: the card is structurally outside the pool.
// The takeover declick (GA fix rev 2) rides the REPLACE-restart: start() self-gates on
// the voice being active, so a fresh preview (idle voice) never ramps — only the hard
// cut of a still-ringing preview, the same physics as the mono retrig takeover.
voice_.start(note, velocity, sample, zone.rootNote, zone.keyTrack, zone.velocityCurve,
/*unityVarispeedBypass=*/true);
/*unityVarispeedBypass=*/true, /*declickTakeover=*/takeoverDeclick_);
}
void PreviewCard::noteOff(int note) {
+52 -22
View File
@@ -381,19 +381,28 @@ 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.
// Takeover declick (Phase S GA fix, rev 2 — 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, the POLY at-cap voice steal, AND the preview card's
// replace-restart. When the caller opts in (start()'s declickTakeover; the engine and the
// preview card pass it on all of those restart paths when constructed with takeoverDeclick),
// the restart smooths the ACTUAL output discontinuity: start() records the last rendered
// output as the pre-cut reference, and the FIRST frame rendered after the restart seeds a
// compensation equal to (reference that frame's raw new output). The compensation is
// summed into the output UNGATED and decays by kDeclickDecay per frame, so the boundary
// frame reproduces the old level EXACTLY — zero step whatever the new envelope's first
// value (Gate attack, zero attack, or Trigger's no-fade-in instant-unity onset) and
// whatever value the new sample starts on — and the residue fades in ~2-4 ms to the -80 dB
// floor across 44.1-96 kHz (a per-FRAME DSP micro-ramp, not a stored wall-clock quantity).
// [Rev 1 decayed the OLD output gated by (1 newAmp): any restart whose new amplitude was
// instantly ~1 — a Trigger zone with no fade-in, a zero-attack Gate — got ZERO compensation
// and kept the full click. The difference seed has no such hole and needs no gate: when old
// and new levels already match, the seed is ~0 and nothing is added, so the +6 dB sum the
// gate defended against is structurally impossible.] OFF by default so the bare core stays
// byte-identical to the pre-fix engine (the regression baseline); the processor shell opts
// in for both the engine and the preview card, 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)
@@ -428,8 +437,10 @@ public:
// 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.
// takeover/steal restart, not a fresh start), smooth the restart's output discontinuity —
// the pre-cut output is recorded here and the difference-seeded compensation is armed on
// the first frame rendered after the restart (see the takeover-declick block above
// kDeclickDecay). 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(),
@@ -538,13 +549,24 @@ 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.
// Seeds the takeover compensation on the FIRST frame after a restart: the ramp is the
// ACTUAL discontinuity — (pre-cut reference the new voice's raw output this frame) —
// applied ungated so the boundary frame reproduces the old level exactly. See the
// takeover-declick block above kDeclickDecay.
void seedDeclick(double newOutL, double newOutR);
// Takeover declick state (see kDeclickDecay above). lastOut{L,R}_ track the voice's most
// recent rendered output (post-gain, incl. any running declick). A takeover/steal start()
// records them as declickRef{L,R}_ (the pre-cut reference) and sets declickPending_; the
// first frame rendered after the restart calls seedDeclick to derive declick{L,R}_ from
// (reference that frame's raw new output), and declickActive_ then gates the per-frame
// ungated add + decay. lastOut is NOT zeroed by start() — a second same-block takeover
// (no frame rendered between) must record the same pre-cut reference, not a phantom 0.
// The whole declick state is cleared on a fresh (non-takeover) start.
bool declickPending_ = false;
bool declickActive_ = false;
double declickRefL_ = 0.0;
double declickRefR_ = 0.0;
double declickL_ = 0.0;
double declickR_ = 0.0;
double lastOutL_ = 0.0;
@@ -724,7 +746,14 @@ public:
// the shell's LoadedInstrument next to the Keymap they read). `preserveWindowFrames`
// pre-sizes the voice's Preserve shifters off-thread (0/1 = pass-through), mirroring the
// engine's constructor, so an off-root Preserve preview never allocates at note-on.
explicit PreviewCard(const Keymap& keymap, std::int64_t preserveWindowFrames = 0);
// `takeoverDeclick` (Phase S GA fix, rev 2) mirrors VoiceEngine's flag: a preview fired
// over a RINGING preview REPLACES the single voice — the same hard cut as a mono
// takeover — and with the opt-in that replace-restart runs the same difference-seeded
// declick ramp (start() self-gates on the voice being active, so a fresh preview never
// ramps). Default FALSE keeps the bare core byte-identical (regression baseline); the
// processor shell opts in.
explicit PreviewCard(const Keymap& keymap, std::int64_t preserveWindowFrames = 0,
bool takeoverDeclick = false);
// Fire the preview note (RT-safe: no allocation). A new preview replaces the ringing one
// (single voice — the card is one finger, not a pool). Out-of-zone is a defined no-play.
@@ -749,6 +778,7 @@ public:
private:
Voice voice_;
const Keymap& keymap_;
bool takeoverDeclick_ = false; // GA fix rev 2: declick the replace-restart of a ringing preview
};
} // namespace reasampler