ae59e9b70d
Re-derives the splice-cadence inequality and adds a corner probe that FAILS at P=500 by design, pending a ruling. Names the baseline commit and harness edit, fixes measurement methodology, corrects the Trigger-AHD/rate coupling framing.
755 lines
44 KiB
C++
755 lines
44 KiB
C++
#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 <cmath>
|
||
#include <cstdint>
|
||
#include <vector>
|
||
|
||
#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/time_stretch.h"
|
||
#include "core/instrument/engine/velocity_curve.h"
|
||
|
||
namespace reasampler {
|
||
|
||
using audio::AudioSample;
|
||
using instrument::engine::PitchShifter;
|
||
using instrument::engine::SplineCursor;
|
||
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<double>(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<double>(note - rootNote) * keyTrack;
|
||
return std::pow(2.0, semis / 12.0);
|
||
}
|
||
|
||
// 2^(curve(velocity) * kVelocityPitchRangeSemitones / 12): the velocity->pitch transpose, which
|
||
// the voice folds into baseRatio_ once at note-on. A curve flat at 0 — the default — yields
|
||
// EXACTLY 1.0 at every velocity and skips the pow, so an undrawn curve transposes nothing.
|
||
inline double velocityPitchRatio(const VelocityCurve& curve, int velocity) {
|
||
const double semis = curve.eval(static_cast<double>(velocity)) * kVelocityPitchRangeSemitones;
|
||
return (semis == 0.0) ? 1.0 : 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<double>(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)
|
||
|
||
// How many frames the ramp emits before the weight drops under the floor. Counted the way
|
||
// advanceFrame runs it — emit, decay, test — rather than solved in closed form, so the two
|
||
// can never disagree. RATE-INDEPENDENT: the decay is per frame, not per second, so an offline
|
||
// pass at any rate pads by the same count.
|
||
inline constexpr std::int64_t declickRampFrames() {
|
||
std::int64_t n = 0;
|
||
for (double w = 1.0; w >= kDeclickFloor; w *= kDeclickDecay) ++n;
|
||
return n;
|
||
}
|
||
inline constexpr std::int64_t kDeclickFrames = declickRampFrames();
|
||
|
||
// 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.
|
||
//
|
||
// `stretchRate` is the PRESERVE playback rate — source frames consumed per output frame,
|
||
// clamped to [kStretchRateMin, kStretchRateMax]. It is a note-on latch by construction (an
|
||
// argument, not a member set separately) because the loop fold and the contour scale it
|
||
// composes with are both note-on folds. Varispeed ignores it: there, rate is a factor of the
|
||
// read increment, not a second rate. 1.0 is the shipped Preserve read, bit for bit.
|
||
void start(int note, int velocity, const SampleData& sample, bool declickTakeover = false,
|
||
double stretchRate = 1.0);
|
||
|
||
// 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:
|
||
// The read head as a fraction of the whole sample — the domain every spline EG is a pure
|
||
// function of. Zero-length sample leaves splineScale_ at 0, which parks every contour on
|
||
// its opening value.
|
||
double splinePhase() const { return readPos_ * splineScale_; }
|
||
|
||
// This frame's amplitude in [0,1] from the active envelope. Spline: the drawn contour read
|
||
// at the normalized position (one cached-segment compare per frame). 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) — see the `ratio_ = stretchRate_`
|
||
// note below for what that means for Preserve's stage-time/rate coupling. Sets
|
||
// amplitudeDone_ on finish so advanceFrame frees the voice.
|
||
double tickAmplitude() {
|
||
double amp;
|
||
// playMode_ is Trigger whenever a spline is genuinely reachable (resolvePlay forces it —
|
||
// splineActive, play_params.h); the guard is a pure-core defense against a hand-built
|
||
// SampleData pairing Gate with an amp spline, which would otherwise bypass env_
|
||
// entirely — release() then has no envelope to end, and an active sustain loop rings
|
||
// forever.
|
||
if (ampSplineCur_.active() && playMode_ == PlayMode::Trigger) {
|
||
// Early-free at a genuine permanent terminus (the spline analogue of a staged AHD's
|
||
// finished()) — onFinalSegment()/segmentEndValue()'s own doc comments own the why.
|
||
amp = ampSplineCur_.eval(splinePhase());
|
||
if (amp == 0.0 && ampSplineCur_.onFinalSegment() &&
|
||
ampSplineCur_.segmentEndValue() == 0.0) {
|
||
amplitudeDone_ = true;
|
||
}
|
||
} else 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<double>(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 = filterSplineCur_.active()
|
||
? filterSplineCur_.eval(splinePhase())
|
||
: ((playMode_ == PlayMode::Gate)
|
||
? filterEnv_.tick()
|
||
: filterAhd_.amplitudeAt(sourceOffset()));
|
||
double cut = static_cast<double>(filterBaseCutoff_) + filterModAmount_ * envOut;
|
||
if (cut < 0.0) cut = 0.0;
|
||
if (cut > 1.0) cut = 1.0;
|
||
const float cutNorm = static_cast<float>(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<double>(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<float>(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<float>(rResonance_.value);
|
||
filterSettings_.morphNorm = static_cast<float>(rMorph_.value);
|
||
filterSettings_.driveNorm = static_cast<float>(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<float>(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<AudioSample>& pcm = sample_->frames;
|
||
const std::int64_t frameCount = static_cast<std::int64_t>(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<AudioSample>& 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<double>(loop.length);
|
||
while (readPos_ >= static_cast<double>(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<double>(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<double>(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<AudioSample>(r);
|
||
return static_cast<AudioSample>(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 = pitchSplineCur_.active()
|
||
? pitchSplineDepth_ * pitchSplineCur_.eval(splinePhase())
|
||
: 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()) {
|
||
// The two rates the shifter takes (pitch_shift.h owns why they are independent):
|
||
// the source is FED at stretchRate_, and the tap is SHIFTED by
|
||
// 2^((note-root + pitchEnvSemis)/12) — the pitch envelope adds to the shift amount,
|
||
// never to 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 into the ring — no RATE-DEPENDENT interpolation
|
||
// (unlike Varispeed's readPos_ below). The shifter's own read tap still carries a
|
||
// splice's sub-sample `frac` (pitch_shift.cpp), so it interpolates on every read,
|
||
// splice or no; that constant fractional delay is not a rate coupling.
|
||
const bool stereoOut = stereo && haveR && shiftR_.configured();
|
||
// The last real source frame is playEnd_-1 for Trigger or frameCount-1 for Gate.
|
||
// Once the feed 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 keeps the cursor inside
|
||
// the loop forever.
|
||
const std::int64_t feedBound =
|
||
(playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount)
|
||
? playEnd_ : frameCount;
|
||
const double shift = baseRatio_ * envFactor;
|
||
shiftL_.setShiftRatio(shift);
|
||
if (stereoOut) shiftR_.setShiftRatio(shift);
|
||
|
||
// 0..kMaxFeedPerFrame source frames fall due this output frame. All but the LAST are
|
||
// written without producing output; the last rides the ordinary 1-in-1-out
|
||
// process(), so a rate of exactly 1.0 walks the pre-stretch code path unchanged.
|
||
// Crossfaded on the way IN to the shifter, not on the way out: loop the source,
|
||
// shift the output.
|
||
const std::int64_t due = stretch_.due(stretchRate_);
|
||
AudioSample feedL = 0.0f, feedR = 0.0f;
|
||
bool fed = false;
|
||
for (std::int64_t k = 0; k < due; ++k) {
|
||
if (fed) { // an earlier frame of this batch: write-only, no output
|
||
shiftL_.writeFrame(feedL);
|
||
if (stereoOut) shiftR_.writeFrame(feedR);
|
||
}
|
||
const std::int64_t q = stretch_.next(loop);
|
||
if (q >= feedBound) {
|
||
shiftL_.freezeTail(); // idempotent; input ignored while frozen
|
||
if (stereoOut) shiftR_.freezeTail();
|
||
feedL = feedR = 0.0f;
|
||
} else {
|
||
const double xw = crossfadeWeight(loop, static_cast<double>(q));
|
||
feedL = crossfadedSource(pcm, loop, q, xw);
|
||
if (stereoOut) feedR = crossfadedSource(pcmR, loop, q, xw);
|
||
}
|
||
fed = true;
|
||
}
|
||
const double shiftedL =
|
||
fed ? static_cast<double>(shiftL_.process(feedL))
|
||
: static_cast<double>(shiftL_.processNoInput());
|
||
outL = shiftedL;
|
||
if (stereo) {
|
||
if (stereoOut) {
|
||
// 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);
|
||
// the batch's earlier frames go through writeFrame, which produces none.
|
||
// 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.
|
||
outRlocal =
|
||
fed ? static_cast<double>(
|
||
shiftR_.processLinked(feedR, shiftL_.lastSplice()))
|
||
: static_cast<double>(
|
||
shiftR_.processNoInputLinked(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;
|
||
}
|
||
}
|
||
// Preserve advances the read head at the STRETCH rate — the one duration control.
|
||
// Everything downstream of it (the loop wrap, the Trigger span, the spline phase)
|
||
// therefore stays a source-frame fact and scales by construction.
|
||
//
|
||
// Consequence (§2.4 of instrument-control-surface.md is explicit that staged
|
||
// envelopes' stage times are wall-clock and do NOT scale with rate): Trigger's amp
|
||
// AHD and filter AHD are both evaluated at sourceOffset() = readPos_ - startFrame_
|
||
// (tickAmplitude/tickFilterCutoff above), which now advances at stretchRate_ instead
|
||
// of always 1.0 — so those two envelopes will scale with a future non-unity Rate.
|
||
// This is NEW here: Preserve's ratio_ was pinned at 1.0 before this track, so those
|
||
// stage times were exact wall-clock. It is latent (nothing publishes a non-unity
|
||
// rate yet) and owned by the track that adds the Rate control, not this one — Gate's
|
||
// AHDSR (env_.tick(), per-output-frame) and every spline contour are unaffected.
|
||
ratio_ = stretchRate_;
|
||
} 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<std::int64_t>(readPos_);
|
||
const double frac = readPos_ - static_cast<double>(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<double>(pcm[i0]) : 0.0) +
|
||
((i1ok ? static_cast<double>(pcm[i1]) : 0.0) -
|
||
(i0ok ? static_cast<double>(pcm[i0]) : 0.0)) * frac;
|
||
outL = srcL;
|
||
if (stereo) {
|
||
const double srcR = (i0ok ? static_cast<double>(pcmR[i0]) : 0.0) +
|
||
((i1ok ? static_cast<double>(pcmR[i1]) : 0.0) -
|
||
(i0ok ? static_cast<double>(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<double>(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<double>(filter_.process(0, static_cast<float>(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<double>(filter_.process(1, static_cast<float>(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<AudioSample>(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<AudioSample>(outL);
|
||
}
|
||
|
||
bool active_ = false;
|
||
bool releasing_ = false;
|
||
int note_ = 0;
|
||
double velocityGain_ = 1.0;
|
||
double baseRatio_ = 1.0; // key-tracked repitch ratio, with velocity->pitch folded in
|
||
double velPitchRatio_ = 1.0; // the velocity->pitch factor alone; retune re-applies it
|
||
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 three drawn contours, bound at note-on to the loaded capture's own point arrays (the
|
||
// SampleData outlives the voice — same contract as sample_). A Staged EG leaves its cursor
|
||
// inactive, so a purely staged instrument's per-sample path gains three predicted branches
|
||
// and nothing else. splineScale_ is 1/frameCount, the readPos -> [0,1] map every contour
|
||
// shares; pitchSplineDepth_ is the pitch envelope's peak, zero while it is disabled.
|
||
SplineCursor ampSplineCur_;
|
||
SplineCursor pitchSplineCur_;
|
||
SplineCursor filterSplineCur_;
|
||
double splineScale_ = 0.0;
|
||
double pitchSplineDepth_ = 0.0;
|
||
|
||
// 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;
|
||
// The curve's value at THIS note's velocity — a fact about the note, latched at note-on —
|
||
// and the product with the live depth, which a live depth move recomputes.
|
||
double filterVelCurve_ = 0.0;
|
||
double filterVelOffset_ = 0.0;
|
||
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. stretch_ is the integer source frame
|
||
// fed to the shifters next plus the fractional rate debt; it runs one window ahead of
|
||
// readPos_ under the same sustain-loop wrap rule and at the same rate, so the two stay one
|
||
// window apart at every stretch. Once it 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_;
|
||
instrument::engine::StretchCursor stretch_;
|
||
double stretchRate_ = 1.0; // Preserve playback rate, clamped and latched at note-on
|
||
std::vector<AudioSample> 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
|