#pragma once // voice.h — one sounding voice: a repitched, enveloped read over the loaded capture. // // The PER-SAMPLE render half (advanceFrame and everything it calls) is defined INLINE here // on purpose: VoiceEngine::render's inner loop lives in another TU, and with no LTO // configured an out-of-line render would put a call — and the envelope ticks behind it — // across a TU boundary on the hottest path in the program. The per-NOTE half (start / // retune / release / hardStop / presize) is cold enough to live in voice.cpp. #include #include #include #include "core/audio/peaks.h" #include "core/instrument/engine/envelopes.h" #include "core/instrument/engine/filter/filter_params.h" #include "core/instrument/engine/filter/voice_filter.h" #include "core/instrument/engine/live_params.h" #include "core/instrument/engine/loop/loop_span.h" #include "core/instrument/engine/pitch_shift.h" #include "core/instrument/engine/play_params.h" #include "core/instrument/engine/velocity_curve.h" namespace reasampler { using audio::AudioSample; using instrument::engine::PitchShifter; using instrument::engine::VelocityCurve; using instrument::engine::VelocityPoint; using instrument::engine::loop::ResolvedLoop; using instrument::engine::loop::crossfadeWeight; using instrument::engine::loop::crossfadedSource; using instrument::engine::loop::lerpSource; // 2^((note - rootNote) / 12). note == rootNote -> 1.0. Pure equal temperament; no // reference-frequency needed. inline double pitchRatio(int note, int rootNote) { return std::pow(2.0, static_cast(note - rootNote) / 12.0); } // 2^(((note - rootNote) * keyTrack) / 12) — keyTrack scales the semitone offset before the // ET conversion. keyTrack == 1.0 is bit-identical to pitchRatio(note, rootNote) // ((note-root)*1.0 is exact in IEEE-754 for an integer-valued double, feeding the same // std::pow call); 0.0 means every key plays the root pitch; 2.0 doubles the tracking rate. // At the root note the offset is 0 regardless of keyTrack. inline double keyTrackedRatio(int note, int rootNote, double keyTrack) { const double semis = static_cast(note - rootNote) * keyTrack; return std::pow(2.0, semis / 12.0); } // One octave expressed in the cutoff control's normalized domain, read out of the filter // module's OWN inverse rather than re-derived from its endpoints — the log law belongs to // filter_params, and a second copy here could drift from it. Evaluated at note-on only. inline double filterNormPerOctave() { namespace flt = instrument::engine::filter; return static_cast(flt::filterNormFromCutoffHz(2.0f * flt::kFilterCutoffMinHz) - flt::filterNormFromCutoffHz(flt::kFilterCutoffMinHz)); } // Takeover declick: a restart of a sounding voice (mono retrigger takeover/fallback or a // poly at-cap steal) hard-cuts the old tone in one frame — a step discontinuity that clicks. // When the caller opts in (start()'s declickTakeover), start() records the last rendered // output as a pre-cut reference, and the first frame after the restart seeds a compensation // equal to (reference - that frame's raw new output), summed in ungated and decaying by // kDeclickDecay/frame — so the boundary frame reproduces the old level exactly regardless of // the new envelope's first value, and the residue fades to the -80 dB floor in a few ms. // An earlier revision gated the compensation by (1 - newAmp): any restart whose new // amplitude was instantly ~1 (a zero-attack Trigger or Gate) got zero // compensation and kept the full click — the difference-seed has no such hole. Off by // default so the bare core stays byte-identical to the pre-fix engine; the processor // shell opts in. 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 the loaded capture, repitched and enveloped. // Reads the sample by fractional frame position with linear interpolation, advancing by the // pitch ratio; loops the sustain region for held notes past the loop end. class Voice { public: // Plays `sample` (a stable reference the caller must keep alive — the engine's loaded // instrument owns it), repitched from its root by `sample.keyTrack`. Play-mode / // AHDSR / pitch-engine params are read from sample.play (frames, resolved from stored // seconds at load). Preserve shifters must already be pre-sized // (presizePreserveShifters, off-thread) — start() only reset()s + warm()s them (RT-safe, // no allocation) since it runs on the audio thread inside process(). Byte-identical to // the bare engine when sample.play is default. `velocityCurve` maps note-on velocity to // amp gain, evaluated once here (off the per-frame path). `declickTakeover`: when true // and this voice is currently active (a takeover/steal restart, not a fresh start), arms // the difference-seeded declick compensation on the first frame after the restart (see // kDeclickDecay above). A fresh start never declicks. void start(int note, int velocity, const SampleData& sample, bool declickTakeover = false); // Mono legato takeover: re-pitch this active voice to `note` without touching the // amplitude envelope, read position, or shifter state — pitch moves, no re-attack. Both // engines pick the new baseRatio_ up on the next frame. No-op on an idle voice. void retune(int note); // Gate off. In Gate mode enters the AHDSR release; in Trigger mode a no-op (Trigger // ignores note-off and plays through to its play length). void release(); // Hard stop (CC 120 semantics): immediately silences this voice regardless of play mode, // no release ramp. Stops a ringing Trigger one-shot instantly (release() cannot). // RT-safe: no allocation, no lock. void hardStop() { active_ = false; } // True while 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 sounding a playable note — active and the amplitude envelope hasn't // finished. A voice ringing out a declick tail past note end is active() but not // soundingNote(); the Preserve-cap count and the mono-legato takeover predicate must // ignore a ramp-only past-end voice or a new note-on could be dropped/silently muted. bool soundingNote() const { return active_ && !amplitudeDone_; } int note() const { return note_; } // Monotonic age counter for the engine's oldest-first stealing policy. Set by the engine. std::uint64_t startOrder() const { return startOrder_; } void setStartOrder(std::uint64_t order) { startOrder_ = order; } bool releasing() const { return releasing_; } // The pitch engine this voice is running (for the engine's Preserve-voice tally). Only // meaningful while active(). PitchEngine pitchEngine() const { return pitchEngine_; } // Applies the live-parameter block to a voice that is already sounding (or, with `snap`, // to one just started). Called at BLOCK boundaries by VoiceEngine — never per frame — so // the per-sample shape is unchanged; every continuous control glides toward its new value // from here rather than jumping to it. `snap` takes the values outright — glides AND // envelopes: a fresh note has nothing to glide from, and its copy may predate the edit. // // What is NOT here is the point: velocity and its curve result, the note number and the // pitch ratio, and the decoded PCM stay latched at note-on. void applyLive(const instrument::engine::LiveValues& live, bool snap); // Pre-sizes this voice's Preserve pitch shifters (both channels) to `windowFrames`, off // the audio thread (allocates; also sizes the prime scratch buffer), so start() — which // runs inside process() — never allocates. <= 1 leaves the shifters pass-through. // Idempotent: a re-presize to the same window is a cheap no-op. void presizePreserveShifters(std::int64_t windowFrames); // Renders one frame's contribution, advancing the read head and envelope by one output // frame. Returns 0.0 (and goes idle) once the envelope finishes or the sample runs out // with no loop. Already velocity- and envelope-scaled — the engine sums voices directly. // Mono path (channel 0 only). AudioSample renderFrame() { AudioSample discard = 0.0f; return advanceFrame(/*stereo=*/false, discard); } // Writes this frame's per-channel contribution into `l`/`r` and advances the read head + // envelope by exactly one frame (the envelope ticks once per frame, shared across both // channels). A mono sample writes the same value to both (dual-mono/centered). Goes idle // on the same conditions as the mono path, writing 0 to both. void renderFrameStereo(AudioSample& l, AudioSample& r) { r = 0.0f; l = advanceFrame(/*stereo=*/true, r); } private: // This frame's amplitude in [0,1] from the active envelope. Gate: AHDSR ticks once per // output frame (envelope time is wall-clock, independent of read rate). Trigger: the AHD // is evaluated at the source offset (readPos - startFrame) so its stages anchor to source // frames regardless of pitch engine. Sets amplitudeDone_ on finish so advanceFrame frees // the voice. double tickAmplitude() { double amp; if (playMode_ == PlayMode::Gate) { amp = env_.tick(); if (env_.finished()) amplitudeDone_ = true; } else { amp = ampAhd_.amplitudeAt(sourceOffset()); if (ampAhd_.finished()) amplitudeDone_ = true; } return amp; } // Frames into the Trigger play span at the current read head — the domain both // sustain-less envelopes are evaluated over. double sourceOffset() const { return readPos_ - static_cast(startFrame_); } // Advances the filter envelope and re-solves the corner from the modulated cutoff. The // solve is UNQUANTIZED: the corner tracks the envelope continuously, so a sweep glides // rather than staircasing. State preservation across the solve is voice_filter's own // contract (voice_filter.h / filter/CLAUDE.md). Do not reintroduce a step quantizer on the // control value to save the solve — setCutoffNorm exists to make the solve cheap instead. // // Two exact skips, neither of which rounds the control: filterModAmount_ is fixed for the // note's lifetime, so a zero depth can only ever re-derive the cutoff already solved; and a // held envelope (sustain, or finished) reproduces the previous position bit-for-bit. Both // compare the value itself, so they can never suppress a move the ear would hear. // filterSolved_ == false (forced by start()/retune() via updateFilterCutoffBase) falls // through both so a moved base always re-solves. void tickFilterCutoff() { if (filterModAmount_ == 0.0 && filterSolved_) return; // The filter envelope takes the amp's shape under the active mode — AHDSR in Gate, // the source-offset AHD in Trigger. playMode_ is fixed for the note's lifetime, so the // branch is perfectly predicted. const double envOut = (playMode_ == PlayMode::Gate) ? filterEnv_.tick() : filterAhd_.amplitudeAt(sourceOffset()); double cut = static_cast(filterBaseCutoff_) + filterModAmount_ * envOut; if (cut < 0.0) cut = 0.0; if (cut > 1.0) cut = 1.0; const float cutNorm = static_cast(cut); if (filterSolved_ && cutNorm == filterSolvedCutoff_) return; filterSolvedCutoff_ = cutNorm; filterSolved_ = true; filter_.setCutoffNorm(cutNorm, filterRate_); } // The cutoff position before the envelope: the stored knob position plus this note's // velocity offset and key-tracking. Evaluated at note-on, at a legato retune (both move // the note), and when a live move changes the knob position or the key-track depth — // never per frame. double filterCutoffBaseTarget(int note) const { double base = filterCutoffNorm_ + filterVelOffset_; if (filterKeyTrack_ != 0.0 && sample_ != nullptr) { base += filterKeyTrack_ * (static_cast(note - sample_->rootNote) / 12.0) * filterNormPerOctave(); } if (base < 0.0) base = 0.0; if (base > 1.0) base = 1.0; return base; } // Takes the base outright (no glide) — a note-on or a retune is a new note position, not a // knob move, so there is nothing to glide from. void updateFilterCutoffBase(int note) { const double base = filterCutoffBaseTarget(note); rBaseCutoff_.set(base); filterBaseCutoff_ = static_cast(base); filterSolved_ = false; // forces the next frame to solve } // The full solve, from the tone-control ramps' current values, at the current base cutoff — // the same shape start() performs, and it leaves the same solved-cutoff bookkeeping behind // so an unmoved live block reproduces start()'s state exactly. State is preserved across // prepare() by contract (voice_filter.h), which is what makes a live tone move glide // rather than click. void prepareFilterFromRamps() { filterSettings_.resonanceNorm = static_cast(rResonance_.value); filterSettings_.morphNorm = static_cast(rMorph_.value); filterSettings_.driveNorm = static_cast(rDrive_.value); filterSettings_.cutoffNorm = filterBaseCutoff_; filter_.prepare(filterSettings_, filterRate_); filterSolvedCutoff_ = filterBaseCutoff_; filterSolved_ = true; } // Advances the five live filter-control glides by one frame. Q, morph and drive are // prepare()-cadence constants, so a move on any of them costs the full solve while the // glide runs (~20 ms) and nothing once it lands; the base cutoff and the mod depth feed // tickFilterCutoff's own cheap cutoff-only solve instead. void tickFilterRamps() { bool tone = false; if (rResonance_.tick()) tone = true; if (rMorph_.tick()) tone = true; if (rDrive_.tick()) tone = true; if (rBaseCutoff_.tick()) { filterBaseCutoff_ = static_cast(rBaseCutoff_.value); filterSolved_ = false; } if (rModAmount_.tick()) filterModAmount_ = rModAmount_.value; if (tone) prepareFilterFromRamps(); filterRamping_ = rResonance_.moving() || rMorph_.moving() || rDrive_.moving() || rBaseCutoff_.moving() || rModAmount_.moving(); } // 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. void seedDeclick() { // 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. (An earlier revision stored the frozen // difference (ref − x₀), which could exceed full scale if outₙ rose while that // residue was still large.) declickPending_ = false; declickWeight_ = 1.0; // one weight for both channels // ref is already clamped to ±1.0 at start(). Activate only when it's above the floor — // if ref ≈ 0 there is nothing to blend. declickActive_ = (declickRefL_ > kDeclickFloor || declickRefL_ < -kDeclickFloor || declickRefR_ > kDeclickFloor || declickRefR_ < -kDeclickFloor); } // Rings the voice's last rendered output out instead of hard-cutting it when the read head // reaches the end of its span, on the PRESERVE path only. Varispeed's final sample is real // source content at its natural end and its stop is left byte-identical; Preserve's is // recycled synthetic tail (freezeTail stops the writer a full window before the read head // arrives), whose level bears no relation to the source's own ending — cutting it at // whatever amplitude the splice machinery happens to be at is the end-of-sample click. // Reuses the takeover blend so the boundary frame reproduces the last level exactly. void seedTerminalDeclick() { if (pitchEngine_ != PitchEngine::Preserve) return; declickRefL_ = (lastOutL_ > 1.0) ? 1.0 : (lastOutL_ < -1.0) ? -1.0 : lastOutL_; declickRefR_ = (lastOutR_ > 1.0) ? 1.0 : (lastOutR_ < -1.0) ? -1.0 : lastOutR_; declickWeight_ = 1.0; declickActive_ = (declickRefL_ > kDeclickFloor || declickRefL_ < -kDeclickFloor || declickRefR_ > kDeclickFloor || declickRefR_ < -kDeclickFloor); } // Shared read/advance for both render paths: computes the interpolated per-channel // value(s) at the current read head, ticks the amplitude + pitch envelopes once, applies // the pitch engine, advances the head, and latches idle on exhaustion. `stereo` selects // whether the second channel is read (into `outR`). Returns the channel-0 value. // // INLINE BY CONSTRAINT — see the file header. AudioSample advanceFrame(bool stereo, AudioSample& outR) { if (!active_ || sample_ == nullptr) { if (stereo) outR = 0.0f; return 0.0f; } const std::vector& pcm = sample_->frames; const std::int64_t frameCount = static_cast(pcm.size()); // Read the second channel only for a genuinely stereo sample; a mono sample plays // dual-mono (channel 0 duplicated), so `pcmR` aliases channel 0 in that case. const bool haveR = stereo && sample_->channelCount() == 2; const std::vector& pcmR = haveR ? sample_->framesR : pcm; // Loop-aware sustain (Gate only — Trigger is a one-shot with no sustain loop). The // span was folded once at note-on (loop_span.h); an invalid or absent loop leaves // loop_.active false and this whole path off. Under Preserve the loop is over the // source read (loop the source, shift the output). const ResolvedLoop& loop = loop_; if (loop.active) { const double loopLen = static_cast(loop.length); while (readPos_ >= static_cast(loop.end)) { readPos_ -= loopLen; // wrap by exactly one loop length, preserving phase. } } // Trigger frees once the read head reaches playEnd; the envelope also finishes at the // same count, either latches idle. const bool triggerRanOff = playMode_ == PlayMode::Trigger && readPos_ >= static_cast(playEnd_); // Ran off the sample end with no usable loop -> voice is done, except 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 ramp). With no declick (the common case) this is // byte-identical to the plain idle-out. if (triggerRanOff || readPos_ >= static_cast(frameCount)) { // The NOTE is over the moment the read head leaves its span, whether or not a ramp // still rings: no later frame can carry envelope output. Latching here is what keeps // a ringing-out voice out of soundingNote() — the Preserve cap would otherwise // refuse a new onset, and mono legato would retune a voice already past its end // (silencing the new note) for the whole ~4 ms ramp. amplitudeDone_ = true; if (declickPending_) seedDeclick(); if (!declickActive_) seedTerminalDeclick(); 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 = declickWeight_ * declickRefL_; const double r = declickWeight_ * declickRefR_; // same weight both channels declickWeight_ *= kDeclickDecay; if (declickWeight_ < kDeclickFloor && declickWeight_ > -kDeclickFloor) { declickActive_ = false; active_ = false; } lastOutL_ = l; lastOutR_ = stereo ? r : l; if (stereo) outR = static_cast(r); return static_cast(l); } active_ = false; if (stereo) outR = 0.0f; return 0.0f; } // Envelopes tick once per output frame. Pitch envelope biases pitch under either engine. const double amp = tickAmplitude(); // Peer of the read-head exhaustion path above: a Trigger AHD whose stages end BEFORE // the play span (a zero decay, which the shape deliberately keeps expressible) cuts the // same synthetic Preserve tail at whatever level it was at. Seeded from lastOut, which // still holds the PREVIOUS frame — this one is already silent. Gate is left out of THIS // site only: its amplitude reaches zero through a release, so nothing here is cut // mid-level. The exhaustion path above deliberately does NOT exclude Gate — a held Gate // note whose source runs out with no loop is cut at its sustain level, and under // Preserve that cut lands on the same recycled synthetic tail. if (amplitudeDone_ && amp == 0.0 && !declickActive_ && playMode_ == PlayMode::Trigger) { seedTerminalDeclick(); } const double gain = amp * velocityGain_; const double pitchEnvSemis = pitchEnv_.tick(); // 2^(semis/12); when the envelope is off (semis exactly 0) this is 1.0 and skips the // pow entirely — no per-frame transcendental on the common path. const double envFactor = (pitchEnvSemis == 0.0) ? 1.0 : std::pow(2.0, pitchEnvSemis / 12.0); // Both pitch branches leave the UNENVELOPED post-pitch signal here; the filter acts on // it and the amp gain is applied afterwards, so the pipeline is pitch -> filter -> amp // and the amp envelope shapes the filtered result (drive included). double outL, outRlocal = 0.0; if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) { // Feed the shifters the source stream at unity rate (duration held) and transpose // the output by 2^((note-root + pitchEnvSemis)/12) — pitch envelope adds to the // shift amount, not the read rate. The feed runs one window ahead of readPos_ (the // rings were primed with that window at start()), under the same sustain-loop wrap // rule, reading integer source frames (nothing to interpolate). Past the last real // frame the shifter's writer is frozen — it recycles the real tail it already holds. if (loop.active) { while (feedPos_ >= loop.end) feedPos_ -= loop.length; } // feedPos_ runs one window ahead of readPos_; the last real source frame is // playEnd_-1 for Trigger or frameCount-1 for Gate. Once feedPos_ reaches that bound // the source is exhausted — feeding the held last sample instead would give the // splice correlation a DC plateau it can't align on (periodic troughs at the splice // cadence, growing toward the note end). Freezing the shifter's writer means no // padding ever enters the ring, so the splice machinery keeps recycling the frozen // all-real tail — a continuous tone through the voice's own end. The sustain-loop // path never gets here: the wrap above keeps feedPos_ < loop.end forever. const std::int64_t feedBound = (playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount) ? playEnd_ : frameCount; const bool exhausted = feedPos_ >= feedBound; if (exhausted) shiftL_.freezeTail(); // idempotent; input ignored while frozen const bool feedOk = (!exhausted && feedPos_ >= 0 && feedPos_ < frameCount); // Crossfaded on the way IN to the shifter, not on the way out: loop the source, // shift the output. const double feedXw = crossfadeWeight(loop, static_cast(feedPos_)); const AudioSample feedL = feedOk ? crossfadedSource(pcm, loop, feedPos_, feedXw) : 0.0f; const double shift = baseRatio_ * envFactor; shiftL_.setShiftRatio(shift); const double shiftedL = static_cast(shiftL_.process(feedL)); outL = shiftedL; if (stereo) { if (haveR && shiftR_.configured()) { // Genuine stereo (linked lag): channel 1's shifter FOLLOWS channel 0's // splice decisions via processLinked — one correlation search, one lag, one // splice schedule for both channels (standard stereo SOLA). An independent // per-channel search re-drew an inter-channel offset of up to +/-maxLag at // every splice: stereo image wander at the splice cadence + mono-sum // combing. Each shifter is still processed EXACTLY ONCE per output frame // (never twice — that would advance its heads twice and corrupt the state). // Gated on haveR so a MONO sample never touches shiftR_ — start() only // primes it for genuinely stereo samples, and a stale un-primed ring must // not leak a previous note. if (exhausted) shiftR_.freezeTail(); const AudioSample feedR = feedOk ? crossfadedSource(pcmR, loop, feedPos_, feedXw) : 0.0f; shiftR_.setShiftRatio(shift); outRlocal = static_cast(shiftR_.processLinked(feedR, shiftL_.lastSplice())); } else { // Mono sample in stereo mode (dual-mono): shiftL_ already produced the // shifted value from the mono feed; mirror it to R. Do NOT call // shiftL_.process again this frame. outRlocal = shiftedL; } } ++feedPos_; // Preserve advances the read head at the SOURCE rate (duration preserved). ratio_ = 1.0; } else { // VARISPEED: pitch and duration coupled. The read rate carries the repitch; the // pitch envelope multiplies the ratio for the read-rate bias (unchanged idiom when // the envelope is off -> pitchEnvSemis == 0 -> factor 1.0 -> byte-identical). // // Linear interpolation between the two bracketing SOURCE frames at the read head. // For the loop case, the second point wraps to loopStart so the seam is continuous. const std::int64_t i0 = static_cast(readPos_); const double frac = readPos_ - static_cast(i0); std::int64_t i1 = i0 + 1; if (loop.active && i1 >= loop.end) { i1 = loop.start; // seamless wrap for the interpolation partner. } const bool i0ok = (i0 >= 0 && i0 < frameCount); const bool i1ok = (i1 >= 0 && i1 < frameCount); const double srcL = (i0ok ? static_cast(pcm[i0]) : 0.0) + ((i1ok ? static_cast(pcm[i1]) : 0.0) - (i0ok ? static_cast(pcm[i0]) : 0.0)) * frac; outL = srcL; if (stereo) { const double srcR = (i0ok ? static_cast(pcmR[i0]) : 0.0) + ((i1ok ? static_cast(pcmR[i1]) : 0.0) - (i0ok ? static_cast(pcmR[i0]) : 0.0)) * frac; outRlocal = srcR; } // Loop crossfade: blend toward the same read head one loop length earlier, which // is the material the wrap is about to hand over to. Zero outside the fade region // (and always, with no fade dialled), so the un-crossfaded read stays exactly the // shape it was. const double xw = crossfadeWeight(loop, readPos_); if (xw > 0.0) { const double tap = readPos_ - static_cast(loop.length); outL += xw * (lerpSource(pcm, frameCount, tap) - outL); if (stereo) outRlocal += xw * (lerpSource(pcmR, frameCount, tap) - outRlocal); } ratio_ = baseRatio_ * envFactor; } // Skipped whole when disengaged (the default), so an un-filtered render stays // bit-identical to the pre-filter engine. if (filterOn_) { if (filterRamping_) tickFilterRamps(); // false at rest: one predicted branch tickFilterCutoff(); outL = static_cast(filter_.process(0, static_cast(outL))); // Dual-mono feeds channel 1 the value channel 0 already carried, so mirroring the // filtered result is exactly what a second identical filter would produce — one // less kernel pass per frame for the same samples. if (stereo) { outRlocal = haveR ? static_cast(filter_.process(1, static_cast(outRlocal))) : outL; } } outL *= gain; if (stereo) outRlocal *= gain; // Takeover declick (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. // [An earlier revision 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(); if (declickActive_) { const double addL = declickWeight_ * (declickRefL_ - outL); const double addR = declickWeight_ * (declickRefR_ - (stereo ? outRlocal : outL)); outL += addL; if (stereo) outRlocal += addR; declickWeight_ *= kDeclickDecay; // one shared weight — both channels decay together if (declickWeight_ < kDeclickFloor && declickWeight_ > -kDeclickFloor) { declickActive_ = false; } } if (stereo) outR = static_cast(outRlocal); // Track the value this voice actually contributed THIS frame (post-gain, incl. any // running declick) — a future takeover restart seeds its declick from exactly this. In // a mono render the R track mirrors L (dual-mono semantics, matching the stereo mirror // of a mono sample), so a later stereo takeover still has a sane R seed. lastOutL_ = outL; lastOutR_ = stereo ? outRlocal : outL; readPos_ += ratio_; // 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 unchanged. if (amplitudeDone_ && !declickActive_) { active_ = false; } return static_cast(outL); } bool active_ = false; bool releasing_ = false; int note_ = 0; double velocityGain_ = 1.0; double baseRatio_ = 1.0; // 2^((note-root)/12): the un-modulated repitch ratio double ratio_ = 1.0; // fractional source frames advanced per output frame (this frame) double readPos_ = 0.0; // fractional frame index into the sample const SampleData* sample_ = nullptr; // Gate uses env_ (AHDSR); Trigger uses ampAhd_ — only one active per voice (selected by // playMode_ at start). playEnd_ is Trigger's source-frame stop (frees when // readPos_ >= playEnd_). PlayMode playMode_ = PlayMode::Gate; AdsrEnvelope env_; AhdEnvelope ampAhd_; std::int64_t startFrame_ = 0; // clamped initial read frame; the span-offset origin std::int64_t playEnd_ = 0; // Trigger: source-frame end; Gate: unused bool amplitudeDone_ = false; // set when the active amplitude envelope finished // The sustain loop folded ONCE at note-on: the sample, the play mode and the stored span // are all fixed for the note's lifetime, so re-deriving validity per frame bought nothing. // Shared by the output anchor, the Preserve feed, and the start()-time ring prime. ResolvedLoop loop_; // The voice's OWN filter and filter envelope — per-voice, never shared, so two notes at // different envelope phases are filtered independently. filterCutoffNorm_ keeps the // unmodulated knob position the base is rebuilt from. Q, morph and drive are note-constants // solved once by start()'s prepare(), which is why every later re-solve is cutoff-only. // filterRate_ <= 0 makes prepare() bypass rather than invent a rate. instrument::engine::filter::VoiceFilter filter_; AdsrEnvelope filterEnv_; // Gate AhdEnvelope filterAhd_; // Trigger bool filterOn_ = false; double filterRate_ = 0.0; double filterCutoffNorm_ = 1.0; double filterModAmount_ = 0.0; double filterVelOffset_ = 0.0; // velAmount * velocityCurve.eval(velocity), fixed per note double filterKeyTrack_ = 0.0; instrument::engine::filter::FilterSettings filterSettings_{}; // the note's tone controls float filterBaseCutoff_ = 1.0f; // cutoff before the envelope, clamped float filterSolvedCutoff_ = 1.0f; // the position the live coefficients were solved from bool filterSolved_ = false; // false forces the next frame to solve // Live-parameter glides (live_params.h). Every one is parked at its target unless a move // is in flight, so filterRamping_ is false and the per-sample path keeps the pre-live // engine's exact shape. All five live in the filter's control domains — the envelopes // need no ramp here, because holding normalized stage position is continuous by // construction and their two genuine level steps are absorbed inside AdsrEnvelope / // PitchEnvelope themselves. bool filterRamping_ = false; instrument::engine::ValueRamp rBaseCutoff_; instrument::engine::ValueRamp rModAmount_; instrument::engine::ValueRamp rResonance_; instrument::engine::ValueRamp rMorph_; instrument::engine::ValueRamp rDrive_; // pitchEngine_ selects Varispeed (ratio bias) vs Preserve (source-rate read + shifter). // shiftL_/shiftR_ transpose the Preserve output per channel. pitchEnv_ rides either engine. // // The shifter rings are primed at start() with the first window of the actual upcoming // source (silence past the end) — output frame 0 is source frame `start`, no ring-fill // silence, and splices always land in real history. feedPos_ is the integer source frame // fed to the shifters next; it runs exactly one window ahead of readPos_ under the same // sustain-loop wrap rule. Once feedPos_ passes the last real frame (Gate: sample end; // Trigger: playEnd_), the shifters' writers freeze — no padding enters the rings and the // splice machinery recycles the frozen real tail through the note end (see advanceFrame). // primeBuf_ is the presized scratch the prime stream is assembled into. PitchEngine pitchEngine_ = PitchEngine::Varispeed; PitchEnvelope pitchEnv_; PitchShifter shiftL_; PitchShifter shiftR_; std::int64_t feedPos_ = 0; std::vector primeBuf_; // lastOut{L,R}_ track the voice's most recent rendered output. A takeover/steal start() // records them as declickRef{L,R}_ and sets declickPending_; the first frame after the // restart calls seedDeclick to arm the bounded blend: // outₙ = outₙ*(1−w) + ref*w, w = declickWeight_ (one weight, shared by both channels so // L/R can never diverge), starting at 1.0 and decaying by kDeclickDecay each frame. // 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; // clamped pre-cut reference (bounded blend target) double declickRefR_ = 0.0; double declickWeight_ = 0.0; // blend weight w; 1.0 on seed, decays by kDeclickDecay/frame double lastOutL_ = 0.0; double lastOutR_ = 0.0; std::uint64_t startOrder_ = 0; }; } // namespace reasampler