Merge pS-ga2-clicks: bounded-blend takeover declick (mono retrig + preview + poly steal), soundingNote() gating — click-free voice restarts
This commit is contained in:
@@ -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
@@ -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ₙ*(1−w) + ref*w = out*(1−1) + 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
@@ -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ₙ*(1−w) + 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
|
||||
|
||||
Reference in New Issue
Block a user