Retire the zone system: one capture = one parameter set, and re-seam the engine and Sample face into bands

This commit is contained in:
2026-07-30 07:15:54 -04:00
parent a689fb75eb
commit 8d4ccbf841
61 changed files with 5416 additions and 8008 deletions
+441
View File
@@ -0,0 +1,441 @@
#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/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;
// 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);
}
// 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 (Trigger with no fade-in, zero-attack 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_; }
// 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:
// True when the sustain loop applies: Gate mode with a valid, non-empty loop inside the
// sample (Trigger one-shots never loop). Single source of truth for the wrap rule shared
// by the output anchor, the Preserve feed, and the start()-time ring prime.
bool sustainLoopUsable() const {
if (sample_ == nullptr || playMode_ != PlayMode::Gate) return false;
const SampleLoop& loop = sample_->loop;
return loop.hasLoop && loop.end > loop.start && loop.start >= 0 &&
loop.end <= static_cast<std::int64_t>(sample_->frames.size());
}
// 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: fade
// shape is evaluated at the source offset (readPos - startFrame) so fades 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 {
// Anchored to the source offset so fades land on the same source frames under
// either engine's read rate. The voice also frees on readPos_ >= playEnd_ in
// advanceFrame; finished() here is the belt to that suspenders.
amp = trigEnv_.amplitudeAt(readPos_ - static_cast<double>(startFrame_));
if (trigEnv_.finished()) amplitudeDone_ = true;
}
return amp;
}
// 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);
}
// 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). A
// valid, non-zero-length loop wraps the read head back into [start, end); a
// zero-length loop is "no loop". Under Preserve the loop is over the source read
// (loop the source, shift the output).
const SampleLoop& loop = sample_->loop;
const bool loopUsable = sustainLoopUsable();
if (loopUsable) {
const double loopLen = static_cast<double>(loop.end - loop.start);
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)) {
if (declickPending_) seedDeclick();
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();
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);
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 (loopUsable) {
const std::int64_t loopLen = loop.end - loop.start;
while (feedPos_ >= loop.end) feedPos_ -= loopLen;
}
// 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);
const AudioSample feedL = feedOk ? pcm[static_cast<std::size_t>(feedPos_)] : 0.0f;
const double shift = baseRatio_ * envFactor;
shiftL_.setShiftRatio(shift);
const double shiftedL = static_cast<double>(shiftL_.process(feedL));
outL = shiftedL * gain;
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 ? pcmR[static_cast<std::size_t>(feedPos_)] : 0.0f;
shiftR_.setShiftRatio(shift);
outRlocal =
static_cast<double>(shiftR_.processLinked(feedR, shiftL_.lastSplice())) *
gain;
} 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 * gain;
}
}
++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<std::int64_t>(readPos_);
const double frac = readPos_ - static_cast<double>(i0);
std::int64_t i1 = i0 + 1;
if (loopUsable && 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 * gain;
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 * gain;
}
ratio_ = baseRatio_ * envFactor;
}
// 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ₙ*(1w) + ref*w = out*(11) + ref*1 = ref (exact boundary identity).
// Each subsequent frame the blend add is `w*(ref outCurrent)` and then w decays by
// kDeclickDecay. The output is therefore bounded by max(|ref|, |outCurrent|) in every
// frame — mid-ramp overshoot from a rising outCurrent is structurally impossible.
// [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; // 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 trigEnv_ — 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_;
TriggerEnvelope trigEnv_;
std::int64_t startFrame_ = 0; // clamped initial read frame; Trigger fade offset origin
std::int64_t playEnd_ = 0; // Trigger: source-frame end; Gate: unused
bool amplitudeDone_ = false; // set when the active amplitude envelope finished
// 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<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ₙ*(1w) + 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