Merge pS-ga2-clicks: bounded-blend takeover declick (mono retrig + preview + poly steal), soundingNote() gating — click-free voice restarts

This commit is contained in:
2026-07-28 09:10:23 -04:00
4 changed files with 346 additions and 75 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.
+110 -41
View File
@@ -272,27 +272,33 @@ bool Voice::sustainLoopUsable() const {
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;
@@ -450,6 +456,30 @@ double Voice::tickAmplitude() {
return amp;
}
void Voice::seedDeclick(double newOutL, double newOutR) {
// First frame after a takeover restart: ARM the bounded blend. The weight starts at 1.0
// so this frame's output is `out*(1-1) + ref*1 == ref` — exact boundary identity whatever
// the new envelope's first value. Each subsequent frame adds `w*(ref outCurrent)` then
// decays w, so output is provably bounded by max(|ref|, |outCurrent|) — mid-ramp overshoot
// is impossible even if outCurrent rises while the weight is still significant.
// [Rev 1 stored the frozen difference (ref x₀); if outₙ rose while that residue was
// still large the sum could exceed full scale. The ±2.0 clamp there was the only guard
// and it silently broke the boundary identity when |x₀| > 1. The bounded blend removes
// both the overshoot hole and the need for a clamp on the stored value.]
// newOutL/R are used only to decide whether an active ramp exists (the seed is purely
// the weight 1.0; ref was clamped to ±1 at start()). The ±2 clamp on the difference is
// gone: the blend formula keeps every output within max(|ref|,|outₙ|) by construction.
(void)newOutL; (void)newOutR; // consumed only for the floor guard below
declickPending_ = false;
declickL_ = 1.0;
declickR_ = 1.0;
// The reference is already clamped to ±1.0 at start() (lines in start(): the ±1 clamp
// on lastOutL_/R_ before storing into declickRefL_/R_). No secondary clamp needed here.
// Activate only when the ref itself is above the floor — if ref ≈ 0 there is nothing to blend.
declickActive_ = (declickRefL_ > kDeclickFloor || declickRefL_ < -kDeclickFloor ||
declickRefR_ > kDeclickFloor || declickRefR_ < -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
@@ -485,8 +515,30 @@ 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_) {
// Bounded blend at silence: outCurrent == 0, so the blend is w*(ref 0) == w*ref.
// The weight decays by kDeclickDecay each frame, floor-checked on the weight itself.
const double l = declickL_ * declickRefL_;
const double r = declickL_ * declickRefR_; // same weight for both channels
declickL_ *= kDeclickDecay;
declickR_ *= kDeclickDecay;
if (declickL_ < kDeclickFloor && declickL_ > -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;
@@ -585,24 +637,24 @@ 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, bounded-blend revision): on the FIRST frame
// after a takeover/steal restart, seed the blend weight at 1.0 so this frame's output is
// outₙ*(1w) + ref*w = out*(11) + ref*1 = ref (exact boundary identity).
// Each subsequent frame the blend add is `w*(ref outCurrent)` and then w decays by
// kDeclickDecay. The output is therefore bounded by max(|ref|, |outCurrent|) in every
// frame — mid-ramp overshoot from a rising outCurrent is structurally impossible.
// [Rev 1 added the frozen difference (ref x₀) ungated; if outₙ rose while the residue
// was still large the sum could exceed ±1 by up to ~+3.8 dB on an extreme retrig.]
// Inactive (the common case) costs one branch; the blend itself costs one extra subtract.
if (declickPending_) seedDeclick(outL, stereo ? outRlocal : outL);
if (declickActive_) {
const double gate = 1.0 - amp;
outL += declickL_ * gate;
if (stereo) outRlocal += declickR_ * gate;
const double addL = declickL_ * (declickRefL_ - outL);
const double addR = declickL_ * (declickRefR_ - (stereo ? outRlocal : outL));
outL += addL;
if (stereo) outRlocal += addR;
declickL_ *= kDeclickDecay;
declickR_ *= kDeclickDecay;
if (declickL_ < kDeclickFloor && declickL_ > -kDeclickFloor &&
declickR_ < kDeclickFloor && declickR_ > -kDeclickFloor) {
declickR_ *= kDeclickDecay; // kept in sync (mirrors L — both channels share one weight)
if (declickL_ < kDeclickFloor && declickL_ > -kDeclickFloor) {
declickActive_ = false;
}
}
@@ -618,7 +670,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);
@@ -666,9 +721,13 @@ VoiceEngine::VoiceEngine(std::size_t maxVoices, const Keymap& keymap,
}
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 (kNoVoice return at :797-800) during the narrow ~4 ms window the ramp lives.
std::size_t n = 0;
for (const Voice& v : voices_) {
if (v.active() && v.pitchEngine() == PitchEngine::Preserve) ++n;
if (v.soundingNote() && v.pitchEngine() == PitchEngine::Preserve) ++n;
}
return n;
}
@@ -740,7 +799,13 @@ std::size_t VoiceEngine::monoNoteOn(int note, int velocity) {
// 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, which is
// the correct fresh-phrase behavior for that edge case.) Same-sample requirement unchanged.
if (v.active() && heldCount_ >= 2 && monoTrigger_ == MonoTrigger::Legato &&
//
// 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 (monoNoteOn falls through to start() below).
if (v.soundingNote() && heldCount_ >= 2 && monoTrigger_ == MonoTrigger::Legato &&
v.playingSample() == &sample) {
v.retune(note, zone.rootNote, zone.keyTrack);
return 0;
@@ -916,8 +981,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.
@@ -934,8 +1000,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) {
+67 -24
View File
@@ -383,19 +383,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)
@@ -430,8 +439,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(),
@@ -455,8 +466,16 @@ public:
// instantly (which release() cannot do). RT-safe: no allocation, no lock.
void hardStop();
// True while this voice is producing (or about to produce) sound.
// True while this voice is producing (or about to produce) sound (including any
// declick ring-out tail past the note's playable span).
bool active() const { return active_; }
// True while this voice is sounding a PLAYABLE NOTE — active AND the amplitude
// envelope has not yet finished. A voice whose note has run to its end but is still
// ringing out a declick tail is active() but NOT soundingNote(). Use this to
// distinguish "note is alive" (active) from "note occupies a voice slot" (soundingNote)
// for the Preserve-cap count and the mono-Legato takeover predicate — both must ignore
// a ramp-only past-end voice or a new note-on can be dropped / silently muted.
bool soundingNote() const { return active_ && !amplitudeDone_; }
// The note this voice was started on (for note-off routing). Meaningless if idle.
int note() const { return note_; }
// Monotonic age counter — higher = started earlier relative to others. The voice
@@ -558,14 +577,30 @@ private:
std::int64_t feedPos_ = 0;
std::vector<AudioSample> primeBuf_;
// 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 clamped pre-cut reference) and sets declickPending_;
// the first frame rendered after the restart calls seedDeclick to arm the BOUNDED BLEND:
// outₙ = outₙ*(1w) + ref*w where w = declickL_/R_ starts at 1.0 and decays by
// kDeclickDecay each frame. This is algebraically `outₙ + w*(ref outₙ)`, so the
// boundary frame (w=1) is exactly `ref` and every subsequent output is bounded by
// max(|ref|, |outₙ|) — mid-ramp overshoot is impossible regardless of outₙ rising.
// [Rev 1 stored the frozen difference (ref x₀); when outₙ rose while that residue
// was still large the sum could exceed full scale by up to ~+3.8 dB.]
// 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 declickL_ = 0.0;
double declickRefL_ = 0.0; // clamped pre-cut reference (bounded blend target)
double declickRefR_ = 0.0;
double declickL_ = 0.0; // blend weight w; 1.0 on seed, decays by kDeclickDecay/frame
double declickR_ = 0.0;
double lastOutL_ = 0.0;
double lastOutR_ = 0.0;
@@ -744,7 +779,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.
@@ -769,6 +811,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