Cut core/instrument/engine comment bloat ~33% (comments only, zero code change)

This commit is contained in:
2026-07-29 20:48:32 -04:00
parent 1f24c4b095
commit 3599d97836
9 changed files with 655 additions and 1082 deletions
+1 -1
View File
@@ -11,7 +11,7 @@
namespace reasampler::instrument::engine {
using util::clamp01; // the ONE unit-interval clamp (Q-W1, T4-24)
using util::clamp01;
double masterGainMaxLinear() { return std::pow(10.0, kMasterGainMaxDb / 20.0); }
+15 -35
View File
@@ -1,19 +1,9 @@
// master_gain.h — PURE dB<->linear<->knob-taper math for the FB1 post-mixer master gain.
// NO VST3, NO REAPER, NO SWELL/LICE types. The mirror of trigger_seam: one tiny module owns
// the ONE formula both sides of a seam share — here the editor's Gain knob (normalized 0..1)
// and the processor's stored/applied linear gain — so the drawn needle, the persisted value,
// and the audio-thread multiply can never drift.
//
// THE CONTROL (Daniel, FB1). A post-mixer master gain, range -inf .. +24 dB, dB-scaled taper
// with -inf at the BOTTOM of the knob: normalized 0 maps to TRUE ZERO linear gain (silence,
// not a tiny epsilon), and the remaining travel maps linearly in dB from kMasterGainMinDb
// (the finite taper floor) up to kMasterGainMaxDb. Unity (0 dB) sits at norm
// kMasterGainMinDb/(kMasterGainMinDb - kMasterGainMaxDb) ~= 0.714 — most of the throw is
// usable trim, the last stretch is boost. The PERSISTED value is the LINEAR gain (a plain
// finite double, 0 = silence — no -inf on the wire); the taper is a UI-side view of it.
//
// RT DISCIPLINE: the processor applies the linear gain as one multiply over the summed
// output — these functions run on the UI/state threads only.
// master_gain.h — dB<->linear<->knob-taper math for the post-mixer master gain.
// One shared formula so the drawn needle, the persisted value, and the audio-thread
// multiply can't drift. Norm 0 = true zero gain (not an epsilon); persisted value is
// linear gain, the dB taper is a UI-side view of it. Unity (0 dB) sits at ~0.714 norm.
// RT: the processor applies the linear gain as one multiply over the summed output;
// these functions themselves run on UI/state threads only.
#pragma once
@@ -21,38 +11,28 @@
namespace reasampler::instrument::engine {
// The dB taper endpoints. norm 0 is -inf (true zero); norm just above 0 starts at the
// finite floor kMasterGainMinDb and sweeps linearly in dB to kMasterGainMaxDb at norm 1.
// norm 0 is -inf (true zero); norm just above 0 starts at the finite floor kMasterGainMinDb
// and sweeps linearly in dB to kMasterGainMaxDb at norm 1.
inline constexpr double kMasterGainMinDb = -60.0;
inline constexpr double kMasterGainMaxDb = 24.0;
// The largest linear gain the control can produce (kMasterGainMaxDb as a ratio, ~15.849).
// Largest linear gain the control can produce (kMasterGainMaxDb as a ratio, ~15.849).
double masterGainMaxLinear();
// Knob taper: normalized [0,1] -> dB. norm <= 0 -> -infinity; else the linear-in-dB sweep
// [kMasterGainMinDb, kMasterGainMaxDb]. norm is clamped to [0,1]. Pure.
double masterGainDbFromNorm(double norm);
// Inverse taper: dB -> normalized [0,1]. -infinity (or any dB at or below kMasterGainMinDb,
// including below-floor values like -80 dB) maps to norm 0 (the -inf bottom detent) — the
// finite sweep only covers the range above kMasterGainMinDb; everything at or below it collapses
// to the same true-zero bottom. +24 -> 1. Pure.
// Anything at or below kMasterGainMinDb (including -inf) collapses to norm 0 — the finite
// sweep only covers the range above the floor.
double masterGainNormFromDb(double db);
// Knob taper composed with dB->ratio: normalized [0,1] -> LINEAR gain. norm 0 -> exactly
// 0.0 (true silence); norm 1 -> masterGainMaxLinear(). Pure.
double masterGainLinearFromNorm(double norm);
// Inverse: LINEAR gain -> normalized [0,1]. linear <= 0 -> 0 (the -inf bottom); a linear at
// or below the kMasterGainMinDb floor (e.g. 0.001 = -60 dB, or anything below) also maps to 0
// — the floor IS the -inf detent; values between true-zero and the floor cannot be represented
// on the knob and collapse to the bottom. unity -> ~0.714; masterGainMaxLinear() -> 1.
// Out-of-range/non-finite input clamps. Pure.
// linear <= 0, or at/below the kMasterGainMinDb floor, collapses to norm 0 — values between
// true-zero and the floor aren't representable on the knob. Out-of-range/non-finite clamps.
double masterGainNormFromLinear(double linear);
// The knob's hover/drag value label for a normalized value: "-inf" at the bottom, else a
// signed one-decimal dB string ("-12.0dB", "+0.0dB", "+2.4dB"). Writes at most `len` bytes
// including the terminator. Pure.
// "-inf" at the bottom, else a signed one-decimal dB string ("-12.0dB", "+2.4dB").
// Writes at most `len` bytes including the terminator.
void formatMasterGainLabel(double norm, char* buf, std::size_t len);
} // namespace reasampler::instrument::engine
+84 -121
View File
@@ -1,24 +1,15 @@
// pitch_shift — pure implementation. See pitch_shift.h for the contract, the S16-F2
// route-(b) rationale (WDL drags <windows.h>), and the GA-Preserve root cause that replaced
// the naive dual-tap OLA with correlation-aligned splices.
// NO VST3 / REAPER / SWELL / vendor includes; standard library only.
// pitch_shift — pure implementation. See pitch_shift.h for the contract and regression history.
//
// Algorithm: a delay ring of 2*window frames. The write head advances one frame per input
// sample (source rate -> duration preserved). ONE active read tap advances by the shift
// sample (source rate, duration preserved). One active read tap advances by the shift
// `ratio_` per frame, so its delay behind the writer drifts at (1 - ratio) per frame. When
// that delay leaves the safe band [dLow, dHigh], the tap is RELOCATED by a nominal jump of
// one window (+window toward older content for up-shifts, -window toward the writer for
// down-shifts) — CLAMPED to the filled span so it can never land in unwritten silence (the
// GA2 onset fix) — refined by a cross-correlation search over +/- maxLag PLUS a parabolic
// peak interpolation for a SUB-SAMPLE lag, so the relocated read point is waveform-aligned
// to a fraction of a sample (integer-lag splices left +/-0.5-sample errors: a -59 dB
// sideband comb at the splice cadence on a repitched pure sine — the GA2 "alias lines" on
// the spectrogram). Old and new taps then crossfade over fadeFrames with a raised-cosine,
// amplitude-complementary pair (in-phase content sums to exactly unity gain). For a pure
// sine the correlation snaps the jump to an (integer + fraction) period count, so the output
// stays a single tone at the shifted frequency — the GA-Preserve acceptance bar. At unity
// ratio the delay is frozen mid-band and no splice ever fires: a primed shifter passes the
// stream through with ZERO added latency; a silence-warmed one is a clean window delay.
// that delay leaves the safe band [dLow, dHigh], the tap is relocated by a nominal jump of
// one window — clamped to the filled span so it never lands in unwritten silence — refined
// by a cross-correlation search over +/- maxLag plus a parabolic peak interpolation for a
// sub-sample lag (an integer-only lag left +/-0.5-sample errors: a sideband comb at the
// splice cadence on a repitched pure sine). Old and new taps then crossfade over fadeFrames
// with a raised-cosine, amplitude-complementary pair (in-phase content sums to unity gain).
// At unity ratio the delay is frozen mid-band and no splice ever fires.
#include "core/instrument/engine/pitch_shift.h"
@@ -55,17 +46,15 @@ void PitchShifter::configure(std::int64_t windowFrames) {
ringLen_ = 2 * window_;
ring_.assign(static_cast<std::size_t>(ringLen_), 0.0f);
// Geometry (all quarters of the window):
// - fadeFrames_: the NOMINAL splice crossfade. This window/4 length is only safe when
// the outgoing tap cannot reach the writer before the fade ends; splice() scales the
// live fade length (fadeLen_) down by the current ratio for up-shifts past ~2x, so
// ordinary sampler transpositions (+24 st = ratio 4) never read stale data mid-fade.
// - maxLag_: the alignment search half-range — one window/4 covers a full period of any
// tone down to 4/window cycles-per-frame (~80 Hz at the product's 50 ms window, 44.1k).
// - dLow_/dHigh_: the safe delay band; unity parks the tap mid-band (window/2 delay).
// - corrFrames_: the correlation segment length. At an up-splice the reference segment
// reads FORWARD from the tap at delay ~dLow_, so dLow_-1 frames is exactly what exists
// between the tap and the writer — the cap expresses that safety rather than leaving
// it coincidental. 512 bounds the splice burst.
// - fadeFrames_: nominal splice crossfade; only safe while the outgoing tap can't reach
// the writer before the fade ends. splice() scales fadeLen_ down by ratio for up-shifts
// past ~2x so ordinary transpositions (+24 st) never read stale data mid-fade.
// - maxLag_: alignment search half-range — one window/4 covers a full period of any tone
// down to 4/window cycles-per-frame (~80 Hz at the product's 50 ms window, 44.1k).
// - dLow_/dHigh_: safe delay band; unity parks the tap mid-band (window/2 delay).
// - corrFrames_: at an up-splice the reference segment reads forward from the tap at
// delay ~dLow_, so dLow_-1 is exactly what exists between tap and writer; 512 bounds
// the splice burst.
fadeFrames_ = std::max<std::int64_t>(window_ / 4, 1);
maxLag_ = window_ / 4;
dLow_ = window_ / 4;
@@ -77,10 +66,9 @@ void PitchShifter::configure(std::int64_t windowFrames) {
void PitchShifter::reset() {
if (window_ > 1) {
// Zero the ring and seed the active tap one window behind the writer — the exact
// middle of the safe band [dLow, dHigh] = [w/4, 2w - w/4], so unity holds it there
// forever and either shift direction has maximal drift room. No history is declared
// (filled_ = 0): follow with prime() or warm() before streaming.
// Seed the active tap one window behind the writer — the exact middle of the safe
// band [dLow, dHigh], so unity holds it there forever with maximal drift room either
// direction. No history declared (filled_ = 0): follow with prime() or warm().
std::fill(ring_.begin(), ring_.end(), 0.0f);
writePos_ = 0;
posA_ = static_cast<double>(ringLen_ - window_);
@@ -104,15 +92,12 @@ void PitchShifter::reset() {
void PitchShifter::freezeTail() {
if (window_ <= 1 || tailFrozen_) return;
tailFrozen_ = true;
// An in-flight crossfade was sized for a RETREATING writer (outgoing tap drains at
// ratio-1 per frame); frozen, the outgoing tap closes at the full ratio. Cap the live
// fade so it completes before tap B reaches the parked writer and reads lapped (oldest-
// window) content mid-fade. fadePos_ is re-anchored to the same fractional t so gNew is
// continuous at the freeze frame (no gain step); see the re-anchor block below.
// An in-flight crossfade was sized for a retreating writer (outgoing tap drains at
// ratio-1 per frame); frozen, it closes at the full ratio instead. Cap the live fade so
// it completes before tap B reaches the parked writer and reads lapped content mid-fade.
if (fading_) {
// Preserve t = fadePos_/fadeLen_ across the shortening so gNew is continuous at the
// freeze frame (no gain step). Compute tOld BEFORE overwriting fadeLen_, then
// re-anchor fadePos_ to the same fractional position in the new (shorter) fade.
// freeze frame (no gain step). Compute tOld before overwriting fadeLen_.
const double tOld =
static_cast<double>(fadePos_) / static_cast<double>(fadeLen_);
double dB = static_cast<double>(writePos_) - posB_;
@@ -133,8 +118,8 @@ void PitchShifter::freezeTail() {
void PitchShifter::prime(const AudioSample* src, std::int64_t count) {
if (window_ <= 1) return; // pass-through needs no priming
// Clamp to one window: the intended call primes exactly window() frames, and delay ==
// count must stay inside the safe band so the seed does not itself trigger a splice.
// Clamp to one window: delay == count must stay inside the safe band so the seed itself
// never triggers a splice.
if (count < 0) count = 0;
if (count > window_) count = window_;
std::fill(ring_.begin(), ring_.end(), 0.0f);
@@ -189,24 +174,21 @@ double PitchShifter::readTap(double pos) const {
}
void PitchShifter::splice(std::int64_t nominalJump, double delay) {
// Relocate the active tap by `nominalJump` frames of ADDED delay (+window_ = jump toward
// older content, -window_ = jump toward the writer), refined by a correlation search so
// the relocated read point is waveform-aligned with the outgoing tap's upcoming content.
// The search is coarse (step 4 over +/- maxLag_) then fine (+/- 3 around the coarse best,
// then a parabolic sub-sample peak): a bounded burst of ~ (maxLag_/2 + 9) * corrFrames_
// multiply-adds, once per splice.
// Relocate the active tap by `nominalJump` frames of added delay (+window_ = toward older
// content, -window_ = toward the writer), refined by a correlation search so the relocated
// read point is waveform-aligned with the outgoing tap's upcoming content. Search is coarse
// (step 4 over +/- maxLag_) then fine (+/- 3 around the coarse best, then a parabolic
// sub-sample peak): a bounded burst of ~ (maxLag_/2 + 9) * corrFrames_ multiply-adds, once
// per splice.
const std::int64_t d = static_cast<std::int64_t>(delay);
// GA2 onset fix: an up-jump may only relocate into VALID history. The deepest slot the
// search (and the +/-1-lag parabolic refinement calls at bestLag ± 1, and the interpolator's
// read-ahead) can touch is delay d + jump + maxLag + 2 (maxLag from the coarse/fine search,
// +1 for the parabola's outer ± 1 probe, +1 for the interpolator's i1 = i0+1 read-ahead),
// so the tight cap is filled_ - d - maxLag_ - 2. The code uses - 1 here — one sample LOOSER
// than that derived cap (not extra margin); ring indexing wraps via modulo everywhere, so
// this never runs off the physical ring_ array. In steady state (filled_ == ringLen_) this is
// > window_ and the nominal jump is untouched; near a primed onset it shrinks the jump to
// what real history exists (still many source periods with a full-window prime). The floor of
// 1 is only reachable on the documented degenerate reset-without-prime path — garbage-tolerant.
// An up-jump may only relocate into valid history. The deepest slot the search (plus the
// parabola's +/-1 probe and the interpolator's read-ahead) can touch is d + jump + maxLag + 2,
// so the cap is filled_ - d - maxLag_ - 1 (one sample looser than that derived bound, not
// extra margin — ring indexing wraps via modulo everywhere regardless). In steady state
// (filled_ == ringLen_) this exceeds window_ and the nominal jump is untouched; near a primed
// onset it shrinks the jump to what real history exists. The floor of 1 only fires on the
// degenerate reset-without-prime path.
std::int64_t jump = nominalJump;
if (jump > 0) {
const std::int64_t maxJump = filled_ - d - maxLag_ - 1;
@@ -233,12 +215,10 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
if (++ia >= ringLen_) ia = 0;
if (++ic >= ringLen_) ic = 0;
}
// NORMALIZED cross-correlation (standard SOLA): a raw dot product is biased toward
// the higher-energy lag, so on a decaying tail every up-splice would prefer the
// loudest candidate over the best-ALIGNED one — a small level step per splice that
// the amplitude-complementary fade cannot hide. The reference segment's energy is
// constant across lags, so dividing by sqrt(Ec) alone ranks identically to the full
// normalized form. A zero-energy candidate scores 0 (splicing into silence is benign).
// Normalized cross-correlation: a raw dot product biases toward the higher-energy lag,
// so on a decaying tail every up-splice would prefer the loudest candidate over the
// best-aligned one. The reference segment's energy is constant across lags, so dividing
// by sqrt(Ec) alone ranks identically to the full normalized form.
return ec > 0.0 ? s / std::sqrt(ec) : 0.0;
};
@@ -261,13 +241,11 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
}
}
// SUB-SAMPLE peak (GA2 alias fix): the integer-lag best leaves a residual misalignment of
// up to half a sample; at the splice cadence that residual phase-modulates a pure tone
// into a ~-59 dB sideband comb (the DAW spectrogram "alias lines"). A parabola through
// the scores at bestLag-1/bestLag/bestLag+1 locates the correlation peak to a fraction of
// a sample; readTap()'s linear interpolation realizes the fractional tap position. The
// denominator is negative at a genuine peak — anything else (flat correlation: DC or
// silence) keeps the integer lag, which is already benign there.
// Sub-sample peak: the integer-lag best leaves a residual misalignment of up to half a
// sample, which at the splice cadence phase-modulates a pure tone into an audible sideband
// comb. A parabola through the scores at bestLag-1/bestLag/bestLag+1 locates the peak to a
// fraction of a sample; readTap()'s linear interpolation realizes it. The denominator is
// negative at a genuine peak — flat correlation (DC/silence) keeps the integer lag, benign.
double frac = 0.0;
{
const double sM = scoreAt(bestLag - 1);
@@ -287,22 +265,17 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
while (p < 0.0) p += len;
while (p >= len) p -= len;
posA_ = p;
// RATIO-SCALED fade length. At an up-splice the OUTGOING tap starts at ~dLow_ delay and
// keeps draining toward the writer at (ratio - 1) per output frame; the nominal window/4
// fade only keeps it behind the writer for ratios up to 2. Beyond that (e.g. +24 st =
// ratio 4, an ordinary sampler transposition) it would cross mid-fade and play stale
// read-ahead data at substantial gain — a periodic seam. So cap the live fade at the
// frames of drain headroom actually available, minus 2 (1 for the trigger's sub-dLow_
// undershoot, 1 for the interpolator's read-ahead). Ratios <= ~2 keep the full nominal
// fade; ratio 4 gets ~window/12 — shorter but still a smooth burst. Down-shifts grow the
// outgoing delay at (1 - ratio) < 1 per frame and cannot reach the ring end within
// window/4 frames, so they always keep the full fade. A pitch-envelope ratio slew
// mid-fade is covered by the same margin for any realistic per-frame bias.
// Ratio-scaled fade length. At an up-splice the outgoing tap keeps draining toward the
// writer at (ratio - 1) per frame; the nominal window/4 fade only keeps it behind the
// writer for ratios up to 2 — beyond that (e.g. +24 st = ratio 4) it would cross mid-fade
// and play stale read-ahead data. Cap the live fade at the drain headroom actually
// available, minus 2 (trigger undershoot + interpolator read-ahead margin). Down-shifts
// drain at (1 - ratio) < 1 per frame and can't reach the ring end within window/4 frames,
// so they always keep the full fade.
//
// TAIL-FROZEN (GA3): with the writer parked, the outgoing tap closes on it at the FULL
// ratio (there is no retreating write head), in EITHER shift direction — so the drain
// rate is ratio_ instead of (ratio_ - 1), and the cap applies at every ratio (unity
// included: splices fire in the frozen tail because the delay now drains at unity too).
// Tail-frozen: with the writer parked, the outgoing tap closes on it at the full ratio in
// either shift direction, so the drain rate is ratio_ instead of (ratio_ - 1) and the cap
// applies at every ratio (including unity, since delay now drains at unity too).
fadeLen_ = fadeFrames_;
const double drainRate = tailFrozen_ ? ratio_ : (ratio_ - 1.0);
if (drainRate > 0.0) {
@@ -316,16 +289,15 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
}
fading_ = true;
fadePos_ = 0;
// Record the decision for a linked follower channel (T1-01): the follower applies this
// verbatim so both channels share one lag and one splice schedule.
// Record the decision for a linked follower channel — applied verbatim there so both
// channels share one lag and one splice schedule.
lastSplice_ = SpliceEvent{true, jump, bestLag, frac, fadeLen_};
}
void PitchShifter::applySplice(const SpliceEvent& ev) {
// Follower half of the T1-01 linked lag: relocate + fade with the master's decision, no
// correlation search of our own. The master's jump was clamped against ITS filled_/delay,
// which match ours by the lockstep contract (identical configure/prime/ratio history);
// the fade length likewise derives only from shared geometry + ratio.
// Follower half of the linked lag: relocate + fade with the master's decision, no
// correlation search of our own — the master's jump/fade derive from shared geometry +
// ratio, which match ours by the lockstep contract (identical configure/prime/ratio history).
posB_ = posA_;
double p = posA_ - static_cast<double>(ev.jump) + static_cast<double>(ev.lag) + ev.frac;
const double len = static_cast<double>(ringLen_);
@@ -347,24 +319,21 @@ AudioSample PitchShifter::processLinked(AudioSample in, const SpliceEvent& maste
AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked) {
if (window_ <= 1) return in; // pass-through (unconfigured / degenerate)
// Copy the linked decision BEFORE clearing lastSplice_ (guards a self-aliased pointer;
// 5 plain fields, negligible on the RT path).
// Copy the linked decision before clearing lastSplice_ (guards a self-aliased pointer).
const SpliceEvent linkedEv = linked != nullptr ? *linked : SpliceEvent{};
lastSplice_ = SpliceEvent{}; // cleared every frame; set again if this frame splices
// 1. Write the incoming sample at the write head (source rate). One more slot of the
// ring now holds valid history (capped at the ring length once it has wrapped).
// TAIL-FROZEN (GA3): the source is exhausted — `in` is padding, not stream. Write
// NOTHING (the ring keeps its all-real final two windows) and hold the write head;
// the read/splice/fade machinery below runs unchanged over the frozen content.
// Tail-frozen: the source is exhausted, `in` is padding, not stream — write nothing (the
// ring keeps its all-real final two windows) and hold the write head; read/splice/fade
// below run unchanged over the frozen content.
if (!tailFrozen_) {
ring_[static_cast<std::size_t>(writePos_)] = in;
if (filled_ < ringLen_) ++filled_;
}
// 2. Read the active tap; while a splice fade is live, crossfade against the outgoing tap.
// Raised-cosine COMPLEMENTARY gains (gNew + gOld == 1): correlation-aligned content is
// in phase, so the sum holds unity amplitude through the fade (equal-power would bulge).
// Read the active tap; while a splice fade is live, crossfade against the outgoing tap.
// Raised-cosine complementary gains (gNew + gOld == 1): correlation-aligned content is in
// phase, so the sum holds unity amplitude through the fade (equal-power would bulge).
double out = readTap(posA_);
if (fading_) {
const double t = static_cast<double>(fadePos_) / static_cast<double>(fadeLen_);
@@ -372,22 +341,17 @@ AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked)
out = gNew * out + (1.0 - gNew) * readTap(posB_);
if (++fadePos_ >= fadeLen_) fading_ = false;
} else if (linked != nullptr) {
// 3a. FOLLOWER (T1-01): no trigger test, no search — splice exactly when and how the
// master channel did this frame. Lockstep state means our own trigger would have
// fired on the same frame; applying the master's decision keeps the two rings
// sample-aligned (one shared lag, one shared schedule).
// Follower: no trigger test, no search — splice exactly when and how the master did
// this frame (lockstep means our own trigger would have fired the same frame anyway).
if (linkedEv.fired) {
applySplice(linkedEv);
} else {
// Self-healing fallback (review rider): the master not firing normally means this
// channel's own trigger wouldn't fire either (lockstep). But if the processor ever
// renders a mono block mid-note, this follower channel is skipped for that block
// while the master keeps advancing — its writePos_/filled_ falls behind and, with
// only the `if (linkedEv.fired)` path above, could never resync. So check this
// follower's OWN tap distance against the safe band and splice via its own search
// when it has left [dLow_, dHigh_], exactly as the master would. Reuses splice() —
// no allocation, no new RT cost. In the normal (non-mono-block) case this branch
// never triggers: the master's trigger fires first and this whole `if` is false.
// Self-healing fallback: if the processor ever renders a mono block mid-note, this
// follower is skipped for that block while the master keeps advancing, and could
// never resync via the `linkedEv.fired` path alone. So also check this follower's
// own tap distance against the safe band and splice via its own search when it has
// left [dLow_, dHigh_] — never triggers in the normal (non-mono-block) case, since
// the master's trigger always fires first.
double d = static_cast<double>(writePos_) - posA_;
const double len = static_cast<double>(ringLen_);
while (d < 0.0) d += len;
@@ -399,10 +363,10 @@ AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked)
}
}
} else {
// 3. Splice scheduling: relocate when the active tap's delay leaves the safe band.
// Up-shifts (ratio > 1) drain the delay toward 0 -> jump one window OLDER; down-
// shifts grow it toward the ring length -> jump one window TOWARD the writer. At
// unity the delay is frozen at window/2 and neither trigger ever fires.
// Splice scheduling: relocate when the active tap's delay leaves the safe band.
// Up-shifts drain the delay toward 0 -> jump one window older; down-shifts grow it
// toward the ring length -> jump one window toward the writer. At unity the delay is
// frozen at window/2 and neither trigger ever fires.
double d = static_cast<double>(writePos_) - posA_;
const double len = static_cast<double>(ringLen_);
while (d < 0.0) d += len;
@@ -414,8 +378,7 @@ AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked)
}
}
// 4. Advance heads: write head one frame (source rate; parked while tail-frozen),
// tap(s) by the shift ratio.
// Advance heads: write head one frame (parked while tail-frozen), tap(s) by the shift ratio.
if (!tailFrozen_) {
++writePos_;
if (writePos_ >= ringLen_) writePos_ = 0;
+86 -144
View File
@@ -1,66 +1,34 @@
#pragma once
// pitch_shift — a PURE, per-voice, duration-preserving pitch shifter: the S16 "Preserve"
// engine's DSP core. Time-domain delay-line shifter with CORRELATION-ALIGNED SPLICES
// (SOLA-style): one active read tap chases the write head at the shift ratio; when it drifts
// out of its safe delay band it is relocated by a nominal window jump REFINED BY A
// CROSS-CORRELATION SEARCH so the new read point is waveform-aligned, then the old and new
// taps are crossfaded (raised-cosine, amplitude-complementary). Source is consumed 1:1 and
// output produced 1:1 (duration held); only the PITCH changes — an octave up plays the same
// wall-clock length as the root note, unlike the Varispeed `readPos_ += ratio_` resample path.
// pitch_shift — per-voice, duration-preserving pitch shifter (the Preserve engine's DSP core).
// Time-domain delay-line with correlation-aligned splices (SOLA-style): one active read tap
// chases the write head at the shift ratio; when it drifts out of its safe delay band it is
// relocated by a nominal window jump, refined by a cross-correlation search so the new read
// point is waveform-aligned, then old/new taps crossfade (raised-cosine). Source and output are
// both consumed/produced 1:1 — only pitch changes, duration is held (unlike the Varispeed
// `readPos_ += ratio_` resample path).
//
// WHY CORRELATED SPLICES (GA-Preserve fix, 2026-07). The first S16 implementation was the
// naive two-tap OLA: taps hard-locked half a window apart, Hann-crossfaded by write-head
// distance. Its taps read the same stream at delays differing by exactly w/2, so their outputs
// carried a FIXED relative phase of 2*pi*f_src*(w/2) — arbitrary and source-frequency-
// dependent. Near anti-phase (roughly half of all frequencies) every crossfade midpoint
// nearly CANCELLED: deep periodic AM + phase slew = strong sidebands. A repitched pure sine
// came out mangled ("multiple partials" on a spectrogram) while the root stayed clean (unity
// freezes the crossfade). The fix is structural: splices must be PHASE-ALIGNED, so each jump
// is snapped to the best waveform match within a bounded lag search — a pure sine's jump
// lands on an integer period count and the output stays a single shifted tone.
// Regression history — do not revert any of these:
// - Correlated splices, vs. the original two-tap OLA (taps hard-locked w/2 apart, Hann
// crossfaded by write-head distance): that fixed offset gave the two taps a fixed relative
// phase, so near-anti-phase source frequencies (roughly half of them) nearly cancelled at
// every crossfade midpoint — a repitched pure sine came out mangled while unity stayed clean.
// Splices must be phase-aligned (snapped to the best waveform match), not just distance-fired.
// - Hand-rolled, not WDL_SimplePitchShifter: its include chain pulls <windows.h> unconditionally,
// which cannot enter this REAPER/VST3-free core (sampler_core_tests links neither SDK). Swap
// to WDL, if ever wanted, happens at the shell, never in this pure core.
// - prime() fills the ring with real upcoming source before streaming starts, not silence: a
// silence-warmed ring made every early splice land in zeros — burst/gap/burst stutter at
// note onset. Since the caller owns the whole decoded sample up front, prime() can know the
// future and gives output frame 0 == source frame 0 with zero structural latency at any ratio.
// - freezeTail() parks the write head once the source is exhausted instead of feeding the last
// real sample as a DC plateau: splices against a flat plateau are unalignable and produced
// ring-modulation-like troughs near the note end. Freezing keeps late splices aligned against
// the ring's real frozen tail.
//
// WHY A HAND-ROLLED PURE MODULE, NOT WDL (S16-F2, decided at build). The spec's lean was
// route (a) `WDL_SimplePitchShifter`. But its include chain
// (simple_pitchshift.h -> queue.h -> heapbuf.h -> wdltypes.h) does `#ifdef _WIN32 ->
// #include <windows.h>` unconditionally, which CANNOT enter the pure sampler_core module
// (CLAUDE.md load-bearing split: NO vendor/host/SDK types; sampler_core_tests links neither
// SDK and compiles outside the DAW). So the Preserve DSP lands as route (b): a house-native
// pure module alongside peaks / wav_codec, CTest-testable, RT-disciplined. Same
// PitchEngine::Preserve contract behind the seam — if WDL is ever preferred it swaps in at
// the SHELL, never in the pure core.
//
// WHY PRIME WITH REAL CONTENT (GA2-Preserve onset fix, 2026-07). Splices RELOCATE the tap
// into ring HISTORY — at note onset a silence-warmed ring has none, so every early splice
// jumped into zeros: a burst/gap/burst stutter for the first ~2 windows of every off-root
// note (the DAW "zero-sample gaps in the first few ms"; at +48 st the ~300 Hz gap cadence
// reads as a square-ish buzz). But this engine is NOT a streaming context: the caller owns
// the whole decoded sample, so the FUTURE of the stream is known at note-on. `prime()`
// pre-fills the ring with the actual first window of upcoming source and parks the tap on
// its oldest frame — output frame 0 IS source frame 0 (zero structural latency at every
// ratio), and `splice()` clamps its jump to the really-filled span so no splice can ever
// land in unwritten silence.
//
// WHY FREEZE THE TAIL (GA3-Preserve tail fix, 2026-07). GA2's prime fixed the ONSET; the
// mirror problem lived at the note END. When the source ran out, the caller held the LAST
// REAL SAMPLE as the feed — a DC plateau with no waveform for the correlation to align on.
// Splices landing in or referenced against it were unalignable, so the tap alternated
// real-tone / dead-DC at the splice cadence, the dead fraction growing as the plateau
// displaced real ring history (the DAW report: periodic troughs "almost like ring
// modulation", ~1:20 tone-to-silence at the very end). freezeTail() removes the padding at
// the source: the WRITER parks, the ring keeps its all-real final two windows, and the
// aligned-splice machinery recycles that frozen tail — a continuous tone until the caller's
// own note end. See freezeTail() below.
//
// PURE MODULE: NO VST3, NO REAPER, NO SWELL, NO vendor/ includes. Standard library only.
// Shares the `AudioSample` float alias from peaks (the one house precedent — sampler_core /
// wav_codec does the same).
//
// RT DISCIPLINE (S16 hard constraint). `configure()` sizes the ring ONCE (off the audio
// thread, at voice allocation). `prime()` / `warm()` only copy into the pre-sized ring
// (bounded, allocation-free — safe on the audio thread at note-on). `process()` does
// NO allocation and NO locks — it reads/writes the pre-sized ring only. The splice-time
// correlation search is a bounded burst of multiply-adds (coarse+refine over a fixed lag
// range) that fires once per splice cadence (window / |ratio-1| frames), never per frame.
// RT discipline: configure() sizes the ring once, off the audio thread. prime()/warm() only
// copy into the pre-sized ring (bounded, allocation-free). process() does no allocation and no
// locks; the correlation search is a bounded burst that fires once per splice cadence
// (window / |ratio-1| frames), never per frame.
#include <cstddef>
#include <cstdint>
@@ -72,100 +40,76 @@ namespace reasampler::instrument::engine {
using audio::AudioSample;
// The splice decision made by the most recent process()/processLinked() call — the LINKED-LAG
// stereo contract (Q-W0 T1-01). A stereo voice runs channel 0 as the MASTER (full correlation
// search) and channel 1 as the FOLLOWER: after the master's process() for a frame, the caller
// passes master.lastSplice() to the follower's processLinked() for the SAME frame, and the
// follower applies exactly this decision instead of running its own search. Both channels
// therefore share one lag and one splice schedule (standard stereo SOLA) — per-channel
// independent searches re-drew an inter-channel offset of up to +/-maxLag at every splice:
// stereo image wander at the splice cadence plus comb coloration on any mono sum.
// The splice decision made by the most recent process()/processLinked() call — the linked-lag
// stereo contract. A stereo voice runs channel 0 as the master (full correlation search) and
// channel 1 as the follower: after the master's process() for a frame, the caller passes
// master.lastSplice() to the follower's processLinked() for the same frame, and the follower
// applies exactly this decision instead of running its own search. Both channels therefore
// share one lag and one splice schedule — independent per-channel searches drew an inter-channel
// offset of up to +/-maxLag at every splice, causing stereo image wander and comb coloration on
// a mono sum.
struct SpliceEvent {
bool fired = false; // a splice was scheduled on this frame
std::int64_t jump = 0; // the CLAMPED nominal jump actually applied (signed)
std::int64_t jump = 0; // the clamped nominal jump actually applied (signed)
std::int64_t lag = 0; // correlation best integer lag
double frac = 0.0; // parabolic sub-sample refinement, [-0.5, 0.5]
std::int64_t fadeLen = 0; // live (ratio-scaled) crossfade length chosen
};
// A per-channel time-domain splice-aligned pitch shifter. One instance transposes ONE channel;
// a stereo voice owns two, LINKED: channel 0 is the master, channel 1 follows its splice
// decisions via processLinked() (see SpliceEvent above) so the two rings stay sample-aligned.
// A per-channel time-domain splice-aligned pitch shifter. A stereo voice owns two, linked:
// channel 0 is the master, channel 1 follows its splice decisions via processLinked() so the
// two rings stay sample-aligned.
//
// The default-constructed shifter is INERT: with no configure() it passes input through
// unchanged (shift ratio 1.0, empty ring), so a Varispeed voice that never touches it is
// byte-identical to the pre-S16 engine.
// Default-constructed is inert: with no configure() it passes input through unchanged (ratio
// 1.0, empty ring), so a Varispeed voice that never touches it sees no behavior change.
class PitchShifter {
public:
// Size the delay ring for `windowFrames` (the nominal splice-jump length; the ring is 2x
// that for splice/search headroom) and derive the fade/search geometry. `windowFrames`
// <= 1 degrades to pass-through (no ring), so a degenerate configure never divides by
// zero or wraps a zero span. Called OFF the audio thread (allocates). Resets all running
// state. A larger window = fewer splices and a deeper alignment search; a PRIMED shifter
// has no added latency regardless (see prime()); the shell picks it from kPreserveWindowMs.
// Sizes the delay ring for `windowFrames` (the ring is 2x that for splice/search headroom).
// <= 1 degrades to pass-through. Off the audio thread (allocates); resets all running state.
// A larger window means fewer splices and a deeper alignment search; a primed shifter has no
// added latency regardless of window size (see prime()).
void configure(std::int64_t windowFrames);
// Pre-fill the ring with the first `count` frames of the UPCOMING source stream and park
// the tap on src[0] (delay == count, mid safe band at count == window()). The caller then
// feeds process() the stream CONTINUING at src[count]. Output frame 0 is src[0]: ZERO
// structural latency at every ratio, and splices always have `count` frames of real
// history to land in — the GA2 onset-gap fix. `count` is clamped to [0, window()].
// When the PLAYABLE source is shorter than one window, prime only the real span and call
// freezeTail() immediately after (Q-W0 T1-03): the GA3 machinery then recycles the real
// short tail. Do NOT pad with silence and declare it valid — padded zeros inside the ring
// are splice targets, re-creating the pre-GA2 burst/gap onset on sub-window material.
// RT-safe: bounded copy into the pre-sized ring, no allocation. No-op when unconfigured.
// The current shift ratio is left untouched.
// Pre-fills the ring with the first `count` frames of the upcoming source stream and parks
// the tap on src[0]; the caller then feeds process() the stream continuing at src[count].
// `count` is clamped to [0, window()]. If the playable source is shorter than one window,
// prime only the real span and call freezeTail() immediately after — never pad with silence
// and declare it valid; padded zeros are splice targets and reintroduce the onset gap.
// RT-safe: bounded copy, no allocation. No-op when unconfigured; ratio is left untouched.
void prime(const AudioSample* src, std::int64_t count);
// prime()-with-silence: zero the ring, park the tap one window behind the writer, and
// declare that window of silence as valid history. Kept for callers with no access to the
// upcoming stream (a silence-primed up-shift plays ~a window of silence before speaking
// the pre-GA2 onset; the Voice path uses prime() instead). At unity a warmed shifter is a
// bit-exact window() delay. No-op when unconfigured.
// Silence-prime: zero the ring, park the tap one window behind the writer, declare that
// window silence as valid history. Kept for callers with no access to the upcoming stream
// (a silence-primed up-shift plays ~a window of silence before speaking; the Voice path uses
// prime() instead). At unity a warmed shifter is a bit-exact window() delay.
void warm();
// The pitch shift ratio: 2^((note - root)/12) plus any per-frame pitch-envelope bias.
// 1.0 = no shift (pass-through-equivalent output, no splices ever fire). Set per frame is
// fine (cheap); the tap advance simply uses the current value. Values <= 0 are ignored
// (kept at the last valid ratio) so a bad input never runs the tap backward or stalls it.
// 2^((note - root)/12) plus any per-frame pitch-envelope bias; 1.0 = no shift, no splices
// ever fire. Cheap enough to set per frame. Values <= 0 are ignored (kept at the last valid
// ratio) so a bad input never runs the tap backward or stalls it.
void setShiftRatio(double ratio);
// Transform ONE input frame into ONE output frame (duration-preserving: 1 in, 1 out).
// RT-safe: reads/writes the pre-sized ring only, no allocation, no lock. When unconfigured
// (window <= 1) returns `in` unchanged (pass-through). Otherwise writes `in` at the write
// head, reads the active tap (crossfading against the outgoing tap while a splice fade is
// live), then advances the write head by one and the tap(s) by the shift ratio. When the
// active tap leaves its safe delay band, a correlation-aligned splice is scheduled.
// Transforms one input frame into one output frame (1 in, 1 out). RT-safe: reads/writes the
// pre-sized ring only, no allocation, no lock. Unconfigured returns `in` unchanged. Otherwise
// writes `in` at the write head, reads the active tap (crossfading against the outgoing tap
// during a splice fade), then advances the write head and tap(s). When the active tap leaves
// its safe delay band, a correlation-aligned splice is scheduled.
AudioSample process(AudioSample in);
// FOLLOWER-mode process (Q-W0 T1-01, the stereo linked lag): identical to process()
// except the splice decision is NOT computed here — when `master.fired` is true this
// frame splices with exactly the master's jump/lag/frac/fadeLen; otherwise no splice is
// considered. The caller must process the master channel FIRST each frame and pass its
// lastSplice() here, with both shifters configured/primed/ratio'd identically — their
// ring state then advances in lockstep, so the follower's own trigger would have fired
// on the same frame anyway; skipping its search only removes the second correlation
// burst (strictly cheaper, never costlier). RT-safe: same guarantees as process().
// Follower-mode process: identical to process() except the splice decision isn't computed
// here — when `master.fired` is true this frame splices with exactly the master's
// jump/lag/frac/fadeLen. The caller must process the master channel first each frame and
// pass its lastSplice() here; both shifters must be configured/primed/ratio'd identically so
// their ring state advances in lockstep. RT-safe: same guarantees as process().
AudioSample processLinked(AudioSample in, const SpliceEvent& master);
// The splice decision made by the most recent process()/processLinked() call (fired ==
// false when that frame spliced nothing). Feed to a follower channel's processLinked().
const SpliceEvent& lastSplice() const { return lastSplice_; }
// TAIL WIND-DOWN (GA3, 2026-07). Call when the SOURCE STREAM IS EXHAUSTED — no real frame
// remains to feed process(). Freezes the WRITE head: subsequent process() calls ignore
// their input and write nothing, but read, splice, and crossfade exactly as before over
// the ring's frozen (all-real) final two windows. WHY: the pre-GA3 tail held the last
// real sample as the feed — a DC plateau with no waveform to correlate on. Splices
// landing in or referenced against it were unalignable, so the tap alternated real-tone /
// dead-DC at the splice cadence (the DAW "ring modulation" troughs, growing toward the
// note end as the plateau displaced real history). With the writer frozen the padding
// never enters the ring: every splice stays waveform-aligned against real content and
// the output remains a continuous tone — the final <= one window recycles the frozen
// tail (correlation-aligned, crossfaded) instead of decaying into chopped DC, and the
// caller's own note end (its output-frame anchor) bounds how long that lasts. Idempotent;
// RT-safe (flag + bounded arithmetic, no allocation); cleared by reset()/prime()/warm().
// Call once the source stream is exhausted — no real frame remains to feed process().
// Freezes the write head: subsequent process() calls ignore input and write nothing, but
// read/splice/crossfade as before over the ring's frozen (all-real) final two windows, so
// every late splice stays waveform-aligned against real content instead of a DC plateau.
// Idempotent; RT-safe (flag + bounded arithmetic); cleared by reset()/prime()/warm().
void freezeTail();
bool tailFrozen() const { return tailFrozen_; }
@@ -188,8 +132,8 @@ private:
// the writer (the caller just computed it for the trigger test). Records the decision in
// lastSplice_ for a linked follower channel.
void splice(std::int64_t nominalJump, double delay);
// Apply a master channel's already-computed splice decision verbatim (no search) —
// the follower half of the T1-01 linked-lag contract. Mirrors it into lastSplice_.
// Applies a master channel's already-computed splice decision verbatim (no search) —
// the follower half of the linked-lag contract. Mirrors it into lastSplice_.
void applySplice(const SpliceEvent& ev);
// Shared body of process()/processLinked(); `linked` null = master mode (own trigger +
// search), non-null = follower mode (splice iff linked->fired, with linked's decision).
@@ -210,21 +154,19 @@ private:
std::int64_t maxLag_ = 0; // correlation search half-range (window_/4)
std::int64_t corrFrames_ = 0; // correlation segment length (dLow_-1, capped at 512, so
// the reference read forward from the tap stays behind
// the writer BY CONSTRUCTION at an up-splice)
// the writer by construction at an up-splice)
std::int64_t dLow_ = 0; // splice trigger: active-tap delay below this (up-shift)
std::int64_t dHigh_ = 0; // splice trigger: active-tap delay above this (down-shift)
std::int64_t filled_ = 0; // frames of VALID history behind the writer (prime count
// + frames streamed, capped at ringLen_). splice() clamps
// its up-jump to this so no splice lands in unwritten
// silence — the GA2 onset-gap fix.
std::int64_t filled_ = 0; // frames of valid history behind the writer; splice()
// clamps its up-jump to this so it never lands in
// unwritten silence
double ratio_ = 1.0; // current shift ratio (>0)
SpliceEvent lastSplice_{}; // decision of the most recent process*() frame (T1-01):
// cleared at the top of every frame, set on a splice
bool tailFrozen_ = false; // GA3 wind-down: writer frozen (source exhausted); the tap
// recycles the ring's frozen real tail, splices still
// aligned. With the writer parked, a tap drains toward it
// at ratio_ (not ratio_-1) per frame — splice() scales the
// live fade by that rate.
SpliceEvent lastSplice_{}; // decision of the most recent process*() frame; cleared
// at the top of every frame, set on a splice
bool tailFrozen_ = false; // writer frozen (source exhausted); tap recycles the
// frozen real tail, drains toward the writer at ratio_
// (not ratio_-1) per frame — splice() scales the fade
// by that rate
};
} // namespace reasampler::instrument::engine
+104 -152
View File
@@ -1,16 +1,11 @@
// sampler_core — pure sampler engine implementation. See sampler_core.h for the
// contract and the design rationale (keymap resolution, pitch ratio, ADSR shape,
// voice allocation + stealing policy). NO VST3 / REAPER / SWELL / vendor includes.
// sampler_core — pure sampler engine implementation. See sampler_core.h for the contract.
//
// DOCUMENTED HOT-PATH EXCEPTION to the Phase Q ~600-line file ceiling (Q-W2v,
// T4-14/T4-27 — Daniel-settled 2026-07-28): this TU deliberately STAYS WHOLE.
// AdsrEnvelope::tick / TriggerEnvelope::amplitudeAt / PitchEnvelope::tick are called
// per-voice-per-sample from Voice::advanceFrame, which is called per-sample from
// VoiceEngine::render — same-TU definition is what lets the compiler inline that
// stack (the build configures NO LTO). A by-class TU split would put the hottest
// inner loop across TU boundaries — the exact heuristic-(3) dispatch blowout the
// phase forbids. Do NOT "fix" this file's length; the header is split instead
// (zone_params.h carries the shared value structs).
// Documented hot-path exception to the ~600-line file ceiling: this TU deliberately stays
// whole. AdsrEnvelope::tick / TriggerEnvelope::amplitudeAt / PitchEnvelope::tick are called
// per-voice-per-sample from Voice::advanceFrame, called per-sample from VoiceEngine::render
// — same-TU definition is what lets the compiler inline that stack (no LTO configured). A
// by-class TU split would put the hottest inner loop across TU boundaries. Do not split
// this file further; the header is split instead (zone_params.h carries the value structs).
#include "core/instrument/engine/sampler_core.h"
@@ -28,11 +23,8 @@ double pitchRatio(int note, int rootNote) {
}
double keyTrackedRatio(int note, int rootNote, double keyTrack) {
// Scale the semitone offset by keyTrack before the ET conversion. keyTrack == 1.0 yields
// (note-root)*1.0, which is EXACT in IEEE-754 for an integer-valued double, so the argument
// to std::pow is bit-identical to pitchRatio(note, rootNote) — the 100% default is byte-for-
// byte unchanged from the pre-S-VIEW-6 engine. keyTrack == 0.0 -> offset 0 -> ratio 1.0 on
// every key (no tracking); keyTrack == 2.0 -> doubled offset. Root note stays unity always.
// keyTrack == 1.0 yields (note-root)*1.0, exact in IEEE-754 for an integer-valued double,
// so the argument to std::pow is bit-identical to pitchRatio(note, rootNote).
const double semis = static_cast<double>(note - rootNote) * keyTrack;
return std::pow(2.0, semis / 12.0);
}
@@ -105,8 +97,7 @@ double AdsrEnvelope::tick() {
const double out = level_;
++framesInStage_;
if (framesInStage_ >= params_.attackFrames) {
// S15: Attack -> Hold (holds 1.0 for holdFrames). holdFrames == 0 falls straight
// through Hold on the next tick to Decay, which is EXACTLY the pre-S15 A->D path.
// holdFrames == 0 falls straight through Hold on the next tick to Decay.
stage_ = Stage::Hold;
framesInStage_ = 0;
level_ = 1.0;
@@ -115,16 +106,13 @@ double AdsrEnvelope::tick() {
}
case Stage::Hold: {
// S15 hold stage: level pinned at 1.0 for holdFrames. holdFrames <= 0 leaves the
// stage on this same tick (no frame consumed at 1.0 beyond what Attack already
// emitted), so hold=0 is byte-identical to the pre-S15 envelope.
// holdFrames <= 0 leaves the stage on this same tick (no frame consumed at 1.0
// beyond what Attack already emitted) so a zero-length hold emits no extra sample.
if (params_.holdFrames <= 0) {
stage_ = Stage::Decay;
framesInStage_ = 0;
// Fall through to Decay this frame so no extra unity sample is emitted for a
// zero-length hold (preserving the exact pre-S15 sample-for-sample shape).
level_ = 1.0;
// Single re-dispatch into Decay (bounded: HoldDecay only; not a general recursion).
// Single re-dispatch into Decay (bounded: Hold->Decay only, not general recursion).
return tick();
}
level_ = 1.0;
@@ -183,7 +171,7 @@ double AdsrEnvelope::tick() {
}
// ---------------------------------------------------------------------------
// TriggerEnvelope (S15) — a time-boxed fade-in/hold/fade-out amplitude function.
// TriggerEnvelope — a time-boxed fade-in/hold/fade-out amplitude function.
// ---------------------------------------------------------------------------
void TriggerEnvelope::configure(std::int64_t playLengthFrames, std::int64_t fadeInFrames,
@@ -233,7 +221,7 @@ double TriggerEnvelope::amplitudeAt(double sourceOffset) {
}
// ---------------------------------------------------------------------------
// PitchEnvelope (S16) — AD pitch offset in semitones, off when disabled.
// PitchEnvelope — AD pitch offset in semitones, off when disabled.
// ---------------------------------------------------------------------------
double PitchEnvelope::tick() {
@@ -263,10 +251,10 @@ double PitchEnvelope::tick() {
// ---------------------------------------------------------------------------
void Voice::presizePreserveShifters(std::int64_t windowFrames) {
// OFF the audio thread (allocates). Both channels are sized so a stereo Preserve voice needs
// no allocation at note-on; a mono Preserve voice simply never process()es shiftR_. The
// prime scratch (one window, reused per channel) is sized here for the same reason: start()
// assembles the first window of the upcoming source stream into it with zero allocation.
// Off the audio thread (allocates). Both channels are sized so a stereo Preserve voice
// needs no allocation at note-on; a mono voice simply never process()es shiftR_. The
// prime scratch is sized here for the same reason: start() assembles the first window
// of the upcoming source into it with zero allocation.
shiftL_.configure(windowFrames);
shiftR_.configure(windowFrames);
primeBuf_.assign(windowFrames > 1 ? static_cast<std::size_t>(windowFrames) : 0, 0.0f);
@@ -282,20 +270,16 @@ bool Voice::sustainLoopUsable() const {
void Voice::start(int note, int velocity, const SampleData& sample, int rootNote,
double keyTrack, const VelocityCurve& velocityCurve,
bool declickTakeover) {
// Takeover declick (Phase S GA fix, rev 2): BEFORE any state reset, record the PRE-CUT
// REFERENCE — the last rendered output — and mark the compensation PENDING iff this
// start is a takeover/steal of a SOUNDING voice and the caller opted in. The ramp itself
// is seeded on the FIRST frame rendered after the restart, from the DIFFERENCE between
// this reference and the new voice's raw output that frame (seedDeclick), so the
// boundary frame reproduces the old level EXACTLY — whatever the new envelope does
// (Gate attack, zero attack, Trigger's no-fade-in instant-unity onset) and whatever
// value the new sample starts on. [Rev 1 seeded the OLD value here and gated the add by
// (1 newAmp) in the epilogue: every restart whose new amplitude was instantly ~1 got
// ZERO compensation and kept the full click — exactly the DAW-reported mono-retrig case
// on Trigger / zero-attack zones.] A fresh start (idle voice) clears the declick state —
// no phantom ramp. lastOut{L,R}_ are deliberately NOT zeroed here: a SECOND same-block
// takeover (two steals of this voice with no frame rendered between) must record the
// same pre-cut reference, not a phantom 0. The next rendered frame overwrites lastOut.
// Before any state reset, record the pre-cut reference (last rendered output) and mark
// the compensation pending iff this start is a takeover/steal of a sounding voice and the
// caller opted in. The ramp is seeded on the first frame rendered after the restart, from
// the difference between this reference and the new voice's raw output that frame
// (seedDeclick), so the boundary frame reproduces the old level exactly regardless of the
// new envelope's first value. (An earlier revision gated the add by (1 - newAmp): any
// restart whose new amplitude was instantly ~1 got zero compensation and kept the full
// click.) A fresh start (idle voice) clears the declick state. lastOut{L,R}_ are
// deliberately not zeroed here: a second same-block takeover (two steals with no frame
// rendered between) must record the same pre-cut reference, not a phantom 0.
if (declickTakeover && active_) {
// Clamp the reference to ±1.0 full scale: a bounded seed whatever the voice was doing.
declickRefL_ = (lastOutL_ > 1.0) ? 1.0 : (lastOutL_ < -1.0) ? -1.0 : lastOutL_;
@@ -313,14 +297,11 @@ void Voice::start(int note, int velocity, const SampleData& sample, int rootNote
releasing_ = false;
amplitudeDone_ = false;
note_ = note;
// S-VIEW-9: the velocity->amp transfer curve maps MIDI velocity to gain, ONCE at note-on (the
// per-frame render just multiplies the cached velocityGain_ — no new process-thread work). The
// clamp lives inside eval (velocity box-clamped to [0,127]). Replaces the pre-r10 linear
// velocity/127; the default flat y=1 curve (R10-F1 Option A) plays every velocity at unity.
// Velocity->amp mapped once at note-on; the per-frame render just multiplies the cached
// velocityGain_.
velocityGain_ = velocityCurve.eval(static_cast<double>(velocity));
// S-VIEW-6: the key-tracked repitch ratio feeds BOTH engines through baseRatio_ (Varispeed
// read-rate bias and Preserve shift amount both derive from it below). keyTrack == 1.0 is
// the pre-S-VIEW-6 pitchRatio bit-for-bit.
// Feeds both engines through baseRatio_ (Varispeed read-rate bias and Preserve shift
// amount both derive from it below).
baseRatio_ = keyTrackedRatio(note, rootNote, keyTrack);
sample_ = &sample;
@@ -328,25 +309,17 @@ void Voice::start(int note, int velocity, const SampleData& sample, int rootNote
playMode_ = p.playMode;
pitchEngine_ = p.pitchEngine;
// Initial read position honors the sample's start-point offset (S11), in BOTH modes. Clamp
// into [0, frames): a start at or past the end degrades to 0 (play from the top) rather than
// starting a voice already off the end. A negative start (shouldn't occur) is pinned to 0.
// Clamp into [0, frames): a start at or past the end degrades to 0 (play from the top)
// rather than starting a voice already off the end.
const std::int64_t frameCount = static_cast<std::int64_t>(sample.frames.size());
std::int64_t start = sample.startFrame;
if (start < 0 || start >= frameCount) start = 0;
readPos_ = static_cast<double>(start);
startFrame_ = start; // Trigger fade offset origin (readPos - startFrame = span offset)
// --- Amplitude envelope: Gate = AHDSR (fully per-zone: A/H/D/S/R all read from the zone's
// play.adsr); Trigger = the time-boxed fade-in/out over the % play length.
//
// All five AHDSR fields come from sample.play.adsr (in FRAMES), resolved by
// buildTier0Keymap / buildZonedKeymap at reload time from the stored SECONDS against
// the live sample rate.
//
// Back-compat invariant: a zone whose stored ADSR seconds carry the tier-0 defaults
// (resolved to frames at the live sample rate) sounds identical to the pre-S12 build at
// every DAW rate — now trivially true, since the times are wall-clock seconds. ---
// Amplitude envelope: Gate = AHDSR (all five fields read from the zone's play.adsr,
// resolved to frames from stored seconds at reload time); Trigger = the time-boxed
// fade-in/out over the % play length.
if (playMode_ == PlayMode::Gate) {
env_.configure(p.adsr);
env_.noteOn();
@@ -366,38 +339,34 @@ void Voice::start(int note, int velocity, const SampleData& sample, int rootNote
kDefaultFadeCurve);
}
// --- Pitch envelope (S16): per-voice AD, off by default (offset always 0). ---
pitchEnv_.configure(p.pitchEnv);
pitchEnv_.noteOn();
// --- Preserve engine (S16, GA2 onset fix): PRIME the ALREADY-SIZED per-channel shifters
// with the first window of the ACTUAL upcoming source stream (loop-unrolled under the
// sustain-loop wrap rule, silence past the sample end — that silence IS the true
// stream there). The tap parks on source frame `start`, so the voice speaks on output
// frame 0 at EVERY ratio (no ring-fill silence), and every splice has a full window
// of real history to land in — the fix for the DAW onset zero-gaps (a silence-warmed
// ring made every early splice jump into zeros). The rings and the prime scratch were
// allocated off-thread by presizePreserveShifters (the engine calls it at
// construction); this path is a bounded copy — NO allocation here. Varispeed voices
// never touch the shifters (advanceFrame checks configured()), so a Varispeed
// instrument is byte-identical to pre-S16 and pays no per-frame shifter cost. ---
// Prime the already-sized per-channel shifters with the first window of the actual
// upcoming source stream (loop-unrolled under the sustain-loop wrap rule; silence past
// the sample end, since that silence is the true stream there). The tap parks on source
// frame `start`, so the voice speaks on output frame 0 at every ratio, and every splice
// has a full window of real history to land in — a silence-warmed ring instead makes
// every early splice jump into zeros (burst/gap onset). The rings and prime scratch were
// allocated off-thread by presizePreserveShifters; this path is a bounded copy, no
// allocation. Varispeed voices never touch the shifters, so a Varispeed instrument pays
// no per-frame shifter cost.
if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
const std::int64_t w = shiftL_.window();
const bool loopWrap = sustainLoopUsable();
const SampleLoop& loop = sample.loop;
const std::int64_t loopLen = loopWrap ? (loop.end - loop.start) : 0;
const bool stereoSample = sample.channelCount() == 2 && shiftR_.configured();
// Q-W0 T1-03: the prime may only carry PLAYABLE source. The per-frame feed stops at
// feedBound (playEnd_ for a bounded Trigger span, the sample end for Gate) and
// freezes the writer there (GA3) — but the prime used to pull a FULL window bounded
// only by frameCount: a Trigger ring held real PCM past the user's chosen stop (an
// up-shifted tap could play it, transposed, before the voice freed), and a
// shorter-than-window sample got zero padding declared as valid history (splices
// landing in silence — the pre-GA2 burst/gap onset, re-entered for sub-window
// material). So bound the prime by the same playable span and, when that span is
// shorter than a window, freeze the tail IMMEDIATELY after the prime — the GA3
// machinery then recycles the real short tail, its designed behavior. The sustain-
// loop path is unbounded by construction (the wrap keeps q inside the loop forever).
// The prime may only carry playable source. The per-frame feed stops at feedBound
// (playEnd_ for a bounded Trigger span, the sample end for Gate) and freezes the
// writer there — but a full window bounded only by frameCount would let a Trigger
// ring hold real PCM past the user's chosen stop (an up-shifted tap could play it,
// transposed, before the voice freed), and a shorter-than-window sample would get
// zero padding declared as valid history (splices landing in silence). So bound the
// prime by the same playable span and, when that span is shorter than a window,
// freeze the tail immediately after the prime that machinery then recycles the
// real short tail. The sustain-loop path is unbounded by construction (the wrap
// keeps q inside the loop forever).
const std::int64_t primeBound =
(playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount)
? playEnd_ : frameCount;
@@ -422,11 +391,11 @@ void Voice::start(int note, int velocity, const SampleData& sample, int rootNote
(ch == 0 ? shiftL_ : shiftR_).prime(primeBuf_.data(), primeCount);
if (ch == 0) p = q; // capture the end position once from channel 0's walk
}
// Per-frame feed continues at `p` (== the feed bound when the prime exhausted the
// playable span — advanceFrame's own exhaustion test then holds from frame 0).
// Per-frame feed continues at `p` (the feed bound when the prime exhausted the
// playable span).
feedPos_ = p;
if (!loopWrap && primeCount < w) {
// Sub-window playable span: the source is ALREADY exhausted at prime time.
// Sub-window playable span: the source is already exhausted at prime time.
shiftL_.freezeTail();
if (stereoSample) shiftR_.freezeTail();
}
@@ -447,28 +416,26 @@ void Voice::retune(int note, int rootNote, double keyTrack) {
void Voice::release() {
if (!active_) return;
// TRIGGER ignores note-off entirely (S15): the one-shot plays through to its play length.
if (playMode_ == PlayMode::Trigger) return;
if (playMode_ == PlayMode::Trigger) return; // Trigger ignores note-off, plays through
releasing_ = true;
env_.noteOff();
}
void Voice::hardStop() {
// CC 120 (All Sounds Off): immediate silence regardless of play mode. Stops Trigger one-shots
// that ignore release(), and short-circuits Gate release tails. RT-safe: no allocation.
// Immediate silence regardless of play mode: stops Trigger one-shots that ignore
// release(), and short-circuits Gate release tails. RT-safe: no allocation.
active_ = false;
}
double Voice::tickAmplitude() {
double amp;
if (playMode_ == PlayMode::Gate) {
// AHDSR is wall-clock (one tick per output frame), independent of the read rate.
amp = env_.tick();
if (env_.finished()) amplitudeDone_ = true;
} else {
// Trigger fade shape anchored to the SOURCE offset (readPos - startFrame), so the 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.
// 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;
}
@@ -476,34 +443,26 @@ double Voice::tickAmplitude() {
}
void Voice::seedDeclick(double newOutL, double newOutR) {
// First frame after a takeover restart: ARM the bounded blend. The weight starts at 1.0
// First frame after a takeover restart: arm the bounded blend. The weight starts at 1.0
// so this frame's output is `out*(1-1) + ref*1 == ref` — exact boundary identity whatever
// the new envelope's first value. Each subsequent frame adds `w*(ref outCurrent)` then
// decays w, so output is provably bounded by max(|ref|, |outCurrent|) — mid-ramp overshoot
// is impossible even if outCurrent rises while the weight is still significant.
// [Rev 1 stored the frozen difference (ref x₀); if outₙ rose while that residue was
// still large the sum could exceed full scale. The ±2.0 clamp there was the only guard
// and it silently broke the boundary identity when |x₀| > 1. The bounded blend removes
// both the overshoot hole and the need for a clamp on the stored value.]
// newOutL/R are used only to decide whether an active ramp exists (the seed is purely
// the weight 1.0; ref was clamped to ±1 at start()). The ±2 clamp on the difference is
// gone: the blend formula keeps every output within max(|ref|,|outₙ|) by construction.
// 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.)
(void)newOutL; (void)newOutR; // consumed only for the floor guard below
declickPending_ = false;
declickWeight_ = 1.0; // ONE weight for both channels (T1-09: the per-R copy was dead state)
// The reference is already clamped to ±1.0 at start() (lines in start(): the ±1 clamp
// on lastOutL_/R_ before storing into declickRefL_/R_). No secondary clamp needed here.
// Activate only when the ref itself is above the floor — if ref ≈ 0 there is nothing to blend.
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);
}
AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
// Shared read/advance for the mono and stereo paths. The read-head geometry (loop wrap,
// bracketing indices, interpolation partner) is computed ONCE and applied identically to
// every channel — only the PCM value read differs. The amplitude + pitch envelopes tick ONCE
// per frame and scale all channels equally (a voice is one envelope). The head advances by
// exactly one source-frame step per call, so mono and stereo consume the sample at one rate.
// Shared read/advance for the mono and stereo paths: the read-head geometry is computed
// once and applied identically to every channel — only the PCM value read differs. The
// amplitude + pitch envelopes tick once per frame and scale all channels equally.
if (!active_ || sample_ == nullptr) {
if (stereo) outR = 0.0f;
return 0.0f;
@@ -516,10 +475,10 @@ AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
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, S15). If a
// valid, non-zero-length loop exists and the read head has advanced past the loop end, wrap
// it back into [start, end). A zero-length loop is treated as "no loop". Under Preserve the
// loop is over the SOURCE read (loop the source, shift the output — S15×S16 contract).
// 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) {
@@ -529,16 +488,15 @@ AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
}
}
// TRIGGER end: the voice frees once the read head reaches playEnd (source-frame stop). The
// trigger envelope also finishes at the same frame count; either latches the voice idle.
// 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. Peer path of the
// epilogue: an in-flight takeover declick RINGS OUT here instead of hard-cutting —
// dropping it would re-introduce a step on exactly the path the ramp exists for (a
// restart whose new play span ends within the ~4 ms ramp). The voice stays active only
// until the ramp floors; with no declick (the common case, and the entire opt-out
// baseline) this is byte-identical to the plain idle-out.
// 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(0.0, 0.0); // the new output here is silence
if (declickActive_) {
@@ -561,41 +519,35 @@ AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
return 0.0f;
}
// Envelopes tick once per output frame. Pitch envelope biases pitch under EITHER engine.
// 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();
// The pitch-envelope bias factor 2^(semis/12). When the envelope is off (semis exactly 0)
// this is 1.0 and we skip the pow entirely — the Varispeed-off path stays a bare ratio read
// (no per-frame transcendental), byte-identical to pre-S16.
// 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()) {
// PRESERVE: 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 — pitch bends, duration unchanged (S16
// contract). The feed runs one window AHEAD of readPos_ (the rings were primed with
// that window at start()), under the SAME sustain-loop wrap rule as the anchor, and
// reads integer source frames (readPos_ advances by exactly 1.0 under Preserve, so
// there is nothing to interpolate). Past the last real frame the shifter's writer is
// FROZEN (GA3 wind-down below) — it recycles the real tail it already holds.
// 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;
}
// GA3 tail wind-down (supersedes the GA2 hold-last-sample clamp). feedPos_ runs one
// window AHEAD of readPos_; the last real source frame is playEnd_-1 for Trigger (the
// user's chosen stop) or frameCount-1 for Gate (the sample's own end). Once feedPos_
// reaches that bound the source is EXHAUSTED — GA2 fed the held last sample from here,
// a DC plateau the splice correlation cannot align on (the DAW tail chop: periodic
// troughs at the splice cadence, growing toward the note end as the plateau displaced
// real ring history). Instead FREEZE the shifter's writer: no padding ever enters the
// ring, and the splice machinery keeps recycling the frozen all-real tail, every jump
// still waveform-aligned — a continuous tone through the final window and the release,
// bounded by the voice's own end (readPos_ >= frameCount / playEnd_ frees it). The
// sustain-loop path never gets here: the wrap above keeps feedPos_ < loop.end forever.
// 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;
+226 -346
View File
@@ -1,142 +1,101 @@
#pragma once
// sampler_core — the HEART of the Phase S MIDI-playback instrument (D3), deliberately
// free of any VST3 *and* any REAPER type so it compiles and unit-tests OUTSIDE the DAW
// and outside any plugin host. It owns the pure sampler engine: polyphonic voice
// allocation with bounded stealing, an ADSR amplitude envelope, a key/velocity keymap
// with (note, velocity) -> zone resolution, and repitch/interpolation from a root note
// with loop-point-aware sustain.
// sampler_core — the polyphonic voice engine: bounded-stealing allocation, an ADSR
// amplitude envelope, a key/velocity keymap resolving (note, velocity) -> zone, and
// repitch/interpolation from a root note with loop-point-aware sustain.
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO VST3 types, NO REAPER types, NO SWELL,
// NO vendor/ includes, no include from either SDK. Standard library only. The VST3 shell
// (src/vst/reasampler_processor.cpp) marshals MIDI events + audio buffers to and from
// this core; the core never sees a VST3 ProcessData or a REAPER MediaTrack. Enforced
// structurally: sampler_core_tests links neither SDK (see CMakeLists §2i).
//
// It shares the `AudioSample` float alias from peaks — the one house precedent for a
// pure module leaning on peaks for the audio-domain type (wav_codec does the same). The
// S2 seam fields (root note, loop points) enter as plain int / frame-index inputs; the
// core does no file I/O — it is handed decoded sample frames and produces audio frames.
// Shares the `AudioSample` float alias from peaks. Seam fields (root note, loop points)
// enter as plain int/frame-index inputs; the core does no file I/O.
#include <array>
#include <cstddef>
#include <cstdint>
#include <vector>
#include "core/audio/peaks.h" // AudioSample (float)
#include "core/instrument/engine/zone_params.h" // per-zone play params + mode enums (Q-W2v header split)
#include "core/instrument/engine/pitch_shift.h" // PitchShifter (S16 Preserve engine DSP core)
#include "core/instrument/engine/velocity_curve.h" // VelocityCurve (S-VIEW-9 velocity->amp transfer curve; eval at start)
#include "core/audio/peaks.h"
#include "core/instrument/engine/zone_params.h"
#include "core/instrument/engine/pitch_shift.h"
#include "core/instrument/engine/velocity_curve.h"
namespace reasampler {
// Q-W1 interim: the engine deps live in their sub-namespace homes now; sampler_core
// re-namespaces in its own split wave (Q-W2v).
using audio::AudioSample;
using instrument::engine::PitchShifter;
using instrument::engine::VelocityCurve;
using instrument::engine::VelocityPoint;
// The per-zone play-parameter VALUE STRUCTS + per-instance mode enums (ChannelMode /
// VoiceMode / MonoTrigger, AdsrParams / TriggerParams / PitchEnvParams / ZonePlayParams,
// SampleLoop / SampleData, and their constants) live in zone_params.h (Q-W2v header
// split, T4-14/T4-17) so param-reading TUs stop recompiling on engine-class edits.
// ---------------------------------------------------------------------------
// Keymap — the performance map (instrument-owned, D-B). A note+velocity resolves
// to at most one zone; a zone names which SampleData to play and the root note to
// repitch from. Tier-0 degenerate case: a single zone spanning [0,127] with the
// sample's own root. Tier-1: several zones, each a key range with its own root.
// Keymap — the performance map. A note+velocity resolves to at most one zone; a zone
// names which SampleData to play and the root note to repitch from. Tier-0 degenerate
// case: a single zone spanning [0,127] with the sample's own root. Tier-1: several
// zones, each a key range with its own root.
//
// TIER-2 EXTENSION (velocity layers / round-robin) — designed for, not built:
// resolution returns a zone; a zone today owns one sampleIndex. Tier 2 makes a zone
// own a *list* of (velocity-range, sampleIndex) layers (and round-robin sets), and
// resolve() gains the velocity dimension it already receives but currently ignores
// for selection. The (note, velocity) signature and the "resolve to a zone, then a
// sample within it" shape are already in place — Tier 2 fills in the second step
// without changing callers or the voice engine. See the report note.
// ---------------------------------------------------------------------------
// Tier-2 extension (velocity layers/round-robin) — designed for, not built: a zone
// today owns one sampleIndex; Tier 2 would make it own a list of (velocity-range,
// sampleIndex) layers, and resolve() would gain the velocity dimension it already
// receives but currently ignores for selection — no signature change needed.
// A key range [lowNote, highNote] (inclusive both ends) mapping to one sample, with
// the root note to repitch from (defaults to the sample's own root, overridable in
// the performance map per S5). velocityLow/High reserved for Tier-2 layers; today a
// zone accepts the full 1..127 velocity range (0 is note-off by MIDI convention).
// A key range [lowNote, highNote] (inclusive) mapping to one sample, with the root
// note to repitch from (defaults to the sample's own root, overridable per zone).
// velocityLow/High reserved for Tier-2 layers; today a zone accepts the full 1..127
// velocity range (0 is note-off by MIDI convention).
struct KeyZone {
int lowNote = 0;
int highNote = 127;
int rootNote = 60; // repitch reference for this zone
// S-VIEW-6 key-tracking scalar: how far keyboard pitch tracks the root. 1.0 (100%) is
// standard 12-tone-ET (default; bit-identical to pre-S-VIEW-6); 0.0 = no tracking (every
// key plays root pitch); 2.0 = double-rate tracking. Scales the (note-root) semitone offset
// in the repitch math (keyTrackedRatio); rides BOTH engines via the voice's baseRatio_.
// How far keyboard pitch tracks the root: 1.0 = standard 12-tone-ET (default); 0.0 =
// no tracking (every key plays root pitch); 2.0 = double-rate. Scales the (note-root)
// semitone offset in keyTrackedRatio; rides both engines via the voice's baseRatio_.
double keyTrack = 1.0;
// S-VIEW-9 velocity->amp transfer curve: maps the note-on velocity (0..127) to the voice's amp
// gain, replacing the fixed linear velocity/127. A per-zone performance characteristic (mirror
// of keyTrack), carried from PerformanceZone by resolvePerformance and eval'd ONCE in
// Voice::start (never per frame). DEFAULT flat y=1 (R10-F1 Option A) — every velocity plays at
// unity, a deliberate behavior change from the pre-r10 linear map.
// Maps note-on velocity (0..127) to the voice's amp gain, eval'd once in Voice::start
// (never per frame). Default flat y=1 — every velocity plays at unity.
VelocityCurve velocityCurve = VelocityCurve::flat();
std::size_t sampleIndex = 0; // index into Keymap::samples
};
// Result of resolving a (note, velocity). `matched == false` means the note falls in
// no zone (out-of-zone) — a defined no-play result, NOT an error and NOT voice 0.
// `matched == false` means the note falls in no zone — a defined no-play result, not an
// error and not voice 0.
struct ZoneResolution {
bool matched = false;
std::size_t zoneIndex = 0; // valid only when matched
};
// The keymap: the decoded samples plus the zones that map keys onto them. Owns
// resolution. Pure: no host types. Zones are tested first-match in order, so an
// earlier zone wins an overlap (deterministic, documented).
// Decoded samples plus the zones that map keys onto them. Zones are tested first-match
// in order, so an earlier zone wins an overlap (deterministic, documented).
struct Keymap {
std::vector<SampleData> samples;
std::vector<KeyZone> zones;
// Resolves (note, velocity) to a zone. First zone (in order) whose [low,high]
// contains `note` wins. velocity is accepted now (Tier-2 seam) but does not
// affect zone choice at Tier 0-1. Returns {matched=false} when no zone contains
// the note.
// First zone (in order) whose [low,high] contains `note` wins. velocity is accepted
// (Tier-2 seam) but doesn't affect zone choice at Tier 0-1.
ZoneResolution resolve(int note, int velocity) const;
// Convenience: build the Tier-0 degenerate keymap one sample mapped
// chromatically across the whole keyboard from its own root note.
// The Tier-0 degenerate keymap: one sample mapped chromatically across the whole
// keyboard from its own root note.
static Keymap singleSampleChromatic(SampleData sample);
};
// The chromatic pitch ratio to play `note` given a sample recorded at `rootNote`:
// 2^((note - rootNote) / 12). note == rootNote -> 1.0 (unity). One octave up -> 2.0,
// one octave down -> 0.5. Pure equal-temperament; no reference-frequency needed.
// 2^((note - rootNote) / 12). note == rootNote -> 1.0. Pure equal-temperament; no
// reference-frequency needed.
double pitchRatio(int note, int rootNote);
// The key-tracked pitch ratio (S-VIEW-6): 2^(((note - rootNote) * keyTrack) / 12). The
// keyTrack scalar scales the semitone offset before the ET conversion, so it governs how
// far playback pitch tracks the keyboard around the root:
// keyTrack == 1.0 -> standard 12-tone-ET (BIT-IDENTICAL to pitchRatio(note, rootNote) —
// (note-root)*1.0 is exact in IEEE-754, feeding the same std::pow call).
// keyTrack == 0.0 -> no tracking: every key plays the root pitch (ratio 1.0 for all notes).
// keyTrack == 2.0 -> double-rate tracking: each key is twice as far from the root in pitch.
// At the root note the offset is 0 regardless of keyTrack, so the root always plays at unity.
// Pure; both repitch engines (Varispeed read-rate, Preserve shift-amount) derive from it via
// the voice's baseRatio_.
// 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, 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. Both repitch engines derive from it via the voice's baseRatio_.
double keyTrackedRatio(int note, int rootNote, double keyTrack);
// ---------------------------------------------------------------------------
// AHDSR amplitude envelope (S15 grows the S3 ADSR with a HOLD stage). Sample-based
// (times in frames), linear segments. A gate: noteOn() enters Attack; noteOff() enters
// Release from wherever it is. Asserted against a known signal in the tests (mirror of peaks).
// AHDSR amplitude envelope, sample-based (times in frames), linear segments. A gate:
// noteOn() enters Attack; noteOff() enters Release from wherever it is.
//
// Segment math (all linear ramps):
// Segment math:
// Attack: 0 -> 1 over attackFrames
// Hold: hold 1 over holdFrames (S15: NEW stage between A and D)
// Hold: hold 1 over holdFrames
// Decay: 1 -> sustainLevel over decayFrames
// Sustain: hold sustainLevel until noteOff
// Release: currentLevel -> 0 over releaseFrames
// A zero-length attack jumps straight to 1 on the first frame; HOLDFRAMES == 0 skips Hold
// entirely, which is EXACTLY the pre-S15 ADSR (back-compat — existing Gate play is unchanged);
// zero decay jumps to sustain; a noteOff during attack/hold/decay (release-before-sustain)
// releases from the current partial level, not from sustainLevel. AdsrParams is defined above
// (with the other per-zone value structs); this section holds only the per-frame evaluator.
// ---------------------------------------------------------------------------
// A zero-length attack jumps straight to 1 on the first frame; holdFrames == 0 skips Hold
// entirely (the pre-hold-stage ADSR, back-compat); zero decay jumps to sustain; a noteOff
// during attack/hold/decay releases from the current partial level, not from sustainLevel.
class AdsrEnvelope {
public:
@@ -167,30 +126,22 @@ private:
double releaseFrom_ = 0.0; // level at the moment noteOff() was called
};
// ---------------------------------------------------------------------------
// S15 Trigger amplitude envelope (per-frame evaluator). The PlayMode / TriggerParams /
// FadeCurve value structs are defined above with the other per-zone params.
// ---------------------------------------------------------------------------
// Trigger amplitude envelope: a stateless-shape amplitude function over the play span, evaluated
// at a SOURCE-frame offset into the span. Anchoring the fades to SOURCE frames (not output
// frames) is what makes S15 compose with S16: under Preserve the read advances at source rate so
// output and source frames coincide, but under Varispeed a transposed voice consumes source
// faster — driving the fades off the read position keeps the fade-in/out anchored to the SAME
// source frames regardless of engine (the play-length end is a source-frame fact, S15×S16). The
// voice reports the read offset; this maps it to amplitude. Distinct from AHDSR — time-boxed by
// the play length and note-off-immune. Reports finished() once the offset reaches the play length.
// A stateless-shape amplitude function over the play span, evaluated at a source-frame
// offset into the span (not output frames): under Varispeed a transposed voice consumes
// source faster than output, so driving the fades off the read position keeps fade-in/out
// anchored to the same source frames regardless of engine. Distinct from AHDSR —
// time-boxed by the play length and note-off-immune.
class TriggerEnvelope {
public:
// Configure from the play span + fades. `playLengthFrames` is (playEnd - startFrame): the
// SOURCE-frame length of the play span. Fades are clamped so fadeIn + fadeOut <= playLength
// (fadeOut anchored to the end). A zero/negative play length finishes immediately.
// `playLengthFrames` is (playEnd - startFrame). Fades are clamped so
// fadeIn + fadeOut <= playLength (fadeOut anchored to the end). A zero/negative play
// length finishes immediately.
void configure(std::int64_t playLengthFrames, std::int64_t fadeInFrames,
std::int64_t fadeOutFrames, FadeCurve curve = kDefaultFadeCurve);
// Amplitude in [0,1] at `sourceOffset` = (readPos - startFrame) source frames into the play
// span. Latches finished() once the offset reaches the play length (>= playLength). Pure over
// the offset (no internal advance) so it composes with either pitch engine's read rate.
// Amplitude in [0,1] at `sourceOffset` = (readPos - startFrame). Latches finished() at
// or past playLength. Pure over the offset so it composes with either pitch engine's
// read rate.
double amplitudeAt(double sourceOffset);
bool finished() const { return finished_; }
@@ -203,21 +154,14 @@ private:
bool finished_ = false;
};
// ---------------------------------------------------------------------------
// S16 pitch envelope (per-frame evaluator). The PitchEngine / PitchEnvParams value structs
// and the kDefaultPitchEngine / kPreserveWindowMs constants are defined above.
// ---------------------------------------------------------------------------
// Per-frame AD pitch-envelope evaluator. tick() returns the CURRENT pitch offset in semitones
// (0 when disabled or past attack+decay), advancing one frame. The voice converts the semitone
// offset to a ratio multiply (Varispeed) or a shift-amount add (Preserve). Pure, unit-tested
// for offset at t=0, peak at t=attack, and 0 at t=attack+decay.
// tick() returns the current pitch offset in semitones (0 when disabled or past
// attack+decay), advancing one frame. The voice converts it to a ratio multiply
// (Varispeed) or a shift-amount add (Preserve).
class PitchEnvelope {
public:
void configure(const PitchEnvParams& params) { params_ = params; pos_ = 0; }
void noteOn() { pos_ = 0; }
// Advance one frame, return this frame's pitch offset in semitones.
double tick();
private:
@@ -225,29 +169,19 @@ private:
std::int64_t pos_ = 0;
};
// Takeover declick (Phase S GA fix, rev 2 — audible click when a sounding voice is
// restarted). A takeover restart HARD-CUTS the sounding tone: the read head and envelope
// restart in one frame, a step discontinuity that clicks. This is the same physics on EVERY
// restart-of-a-sounding-voice path — the MONO Retrigger takeover/fallback, the mono
// cross-sample legato restart, and the POLY at-cap voice steal (the editor's preview is a
// plain engine noteOn since the PreviewCard retirement, so a preview re-fire at cap is just
// an at-cap steal). When the caller opts in (start()'s declickTakeover; the engine passes
// it on all of those restart paths when constructed with takeoverDeclick),
// the restart smooths the ACTUAL output discontinuity: start() records the last rendered
// output as the pre-cut reference, and the FIRST frame rendered after the restart seeds a
// compensation equal to (reference that frame's raw new output). The compensation is
// summed into the output UNGATED and decays by kDeclickDecay per frame, so the boundary
// frame reproduces the old level EXACTLY — zero step whatever the new envelope's first
// value (Gate attack, zero attack, or Trigger's no-fade-in instant-unity onset) and
// whatever value the new sample starts on — and the residue fades in ~2-4 ms to the -80 dB
// floor across 44.1-96 kHz (a per-FRAME DSP micro-ramp, not a stored wall-clock quantity).
// [Rev 1 decayed the OLD output gated by (1 newAmp): any restart whose new amplitude was
// instantly ~1 — a Trigger zone with no fade-in, a zero-attack Gate — got ZERO compensation
// and kept the full click. The difference seed has no such hole and needs no gate: when old
// and new levels already match, the seed is ~0 and nothing is added, so the +6 dB sum the
// gate defended against is structurally impossible.] OFF by default so the bare core stays
// byte-identical to the pre-fix engine (the regression baseline); the processor shell opts
// in for the engine, mirroring the kDefaultPitchEngine layering.
// Takeover declick: a restart of a sounding voice (mono retrigger takeover/fallback, a
// cross-sample legato restart, 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)
@@ -259,119 +193,95 @@ inline constexpr double kDeclickFloor = 1e-4; // below this the ramp is done (~
class Voice {
public:
// Starts this voice on `note` at `velocity`, playing `sample` (a stable reference
// the caller must keep alive for the voice's lifetime — the Keymap owns it), repitched
// from `rootNote`. All five AHDSR fields (A/H/D/S/R) are read directly from
// sample.play.adsr — the per-zone values (in FRAMES) resolved from the stored seconds by
// buildTier0Keymap / buildZonedKeymap against the live sample rate. The S15 play MODE +
// Trigger params and the S16 pitch ENGINE + pitch envelope are read from `sample.play`.
// The 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(). The warm silence pass settles the OLA taps before the first
// output frame (no cold-start click). Byte-identical to the pre-S15 engine when sample.play
// is default (Gate + Varispeed + no pitch env).
// `keyTrack` (S-VIEW-6) scales the (note-root) semitone offset feeding the repitch ratio;
// 1.0 (the default) is standard 12-tone-ET, bit-identical to the pre-S-VIEW-6 baseRatio_.
// `velocityCurve` (S-VIEW-9) maps the note-on velocity to the voice's amp gain, evaluated ONCE
// here (off the per-frame path); defaults to flat y=1 (R10-F1) — every velocity plays at unity.
// `declickTakeover` (Phase S GA fix): when TRUE and this voice is currently ACTIVE (a
// takeover/steal restart, not a fresh start), smooth the restart's output discontinuity —
// the pre-cut output is recorded here and the difference-seeded compensation is armed on
// the first frame rendered after the restart (see the takeover-declick block above
// kDeclickDecay). A fresh start never declicks.
// Plays `sample` (a stable reference the caller must keep alive — the Keymap owns it),
// repitched from `rootNote`. AHDSR/play-mode/pitch-engine params are read from
// sample.play (frames, resolved from stored seconds at keymap build). 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(); the warm silence pass settles the OLA taps before the first output
// frame. Byte-identical to the bare engine when sample.play is default.
// `keyTrack` scales the (note-root) semitone offset feeding the repitch ratio; 1.0 is
// standard 12-tone-ET. `velocityCurve` maps note-on velocity to amp gain, evaluated once
// here (off the per-frame path); defaults to flat y=1. `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, int rootNote,
double keyTrack = 1.0,
const VelocityCurve& velocityCurve = VelocityCurve::flat(),
bool declickTakeover = false);
// MONO LEGATO takeover (Phase S): re-pitch this ACTIVE voice to `note` without touching the
// amplitude envelope, the read position, or the shifter state — pitch moves, no re-attack.
// Both engines pick the new baseRatio_ up on the next frame (Varispeed via the read rate,
// Preserve via the per-frame setShiftRatio). No-op on an idle voice. The caller guarantees
// the voice is playing the SAME SampleData the (note-resolved) zone names — a cross-sample
// takeover must restart the voice instead (see MonoTrigger).
// 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. Caller
// guarantees the voice is playing the same SampleData the resolved zone names — a
// cross-sample takeover must restart the voice instead.
void retune(int note, int rootNote, double keyTrack = 1.0);
// Gate off — begins the amplitude release. In GATE mode this enters the AHDSR release; in
// TRIGGER mode it is a NO-OP (Trigger ignores note-off and plays through to its play length).
// 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 (All Sounds Off) semantics. Immediately silences this voice regardless
// of play mode: sets active_ = false with no release ramp. Stops a ringing Trigger one-shot
// instantly (which release() cannot do). RT-safe: no allocation, no lock.
// 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();
// True while this voice is producing (or about to produce) sound (including any
// declick ring-out tail past the note's playable span).
// 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 this voice is sounding a PLAYABLE NOTE — active AND the amplitude
// envelope has not yet finished. A voice whose note has run to its end but is still
// ringing out a declick tail is active() but NOT soundingNote(). Use this to
// distinguish "note is alive" (active) from "note occupies a voice slot" (soundingNote)
// for the Preserve-cap count and the mono-Legato takeover predicate — both must ignore
// a ramp-only past-end voice or a new note-on can be dropped / silently muted.
// 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_; }
// The note this voice was started on (for note-off routing). Meaningless if idle.
int note() const { return note_; }
// Monotonic age counter — higher = started earlier relative to others. The voice
// engine uses this for its stealing policy (oldest first). Set by the engine.
// 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 S16 pitch engine this voice is running (for the engine's Preserve-voice tally). Only
// meaningful while active(). NOTE: the FA1 unity-shift demotion to Varispeed is GONE —
// it was scoped to the retired PreviewCard, and since GA2 the primed shifter speaks on
// frame 0 at every ratio, so a Preserve voice keeps its shifter at every note (one code
// path, uniform onset across the keyboard).
// The pitch engine this voice is running (for the engine's Preserve-voice tally). Only
// meaningful while active().
PitchEngine pitchEngine() const { return pitchEngine_; }
// The SampleData this voice is playing (nullptr when never started). The engine's mono
// legato path compares it against the new note's resolved sample — a same-sample takeover
// retunes; a cross-sample one restarts. Identity only; callers never mutate through it.
// Identity only, never mutated through; the engine's mono legato path compares it
// against the new note's resolved sample to decide retune vs. restart.
const SampleData* playingSample() const { return sample_; }
// Pre-SIZE this voice's Preserve pitch shifters (both channels) to `windowFrames`, OFF the
// audio thread (this allocates; also sizes the prime scratch buffer). The engine calls it
// once at construction so start() — which runs on the audio thread inside process() — never
// allocates: start() only prime()s the already-sized rings with the first window of source
// (a bounded copy). `windowFrames` <= 1 leaves the shifters as pass-through (Varispeed
// instruments pay no ring cost). Idempotent: a re-presize to the same window is a cheap
// no-op in the underlying vector.
// 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. The value is already velocity- and
// envelope-scaled — the engine sums voices directly. This is the MONO path (channel
// 0 only) — byte-identical to the pre-S7 engine, so mono play is unchanged.
// 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();
// STEREO render: writes THIS frame's per-channel contribution into `l`/`r` and advances
// the read head + envelope by exactly one frame (the same single advance the mono path
// performs — the envelope ticks ONCE per frame, shared across both channels). For a mono
// sample (channelCount()==1) both `l` and `r` receive the same value (dual-mono / centered).
// Both outputs are already velocity- and envelope-scaled. Goes idle on the same conditions
// as the mono path (envelope finished / sample exhausted with no loop) writing 0 to both.
// 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);
private:
// 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 (Varispeed read-rate bias OR Preserve shift), advances the head, and
// latches idle on exhaustion. `stereo` selects whether the second channel is read (and
// returned in `outR`); when false `outR` is left untouched. Returns the channel-0 value.
// 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.
AudioSample advanceFrame(bool stereo, AudioSample& outR);
// This frame's amplitude in [0,1] from the active envelope. GATE: the AHDSR ticks once per
// output frame (independent of the read rate — envelope time is wall-clock). TRIGGER: the
// fade shape is evaluated at the SOURCE offset (readPos - startFrame) so the fades anchor to
// source frames and compose with either pitch engine. Sets amplitudeDone_ when the envelope
// finishes (Gate: release complete; Trigger: play length reached) so advanceFrame frees the voice.
// 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();
// True when the sustain loop applies to this voice: GATE mode with a valid, non-empty loop
// inside the sample (S15 — Trigger one-shots never loop). The single source of truth for
// the wrap rule shared by the output anchor (readPos_), the Preserve feed (feedPos_), and
// the start()-time ring prime.
// 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;
bool active_ = false;
@@ -379,13 +289,13 @@ private:
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 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;
// S15 play mode + amplitude envelopes. Gate uses env_ (AHDSR); Trigger uses trigEnv_. Only
// one is active per voice (selected by playMode_ at start). playEnd_ is Trigger's source-frame
// stop (the voice frees when readPos_ >= playEnd_, mirroring the run-off-end idle).
// 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_;
@@ -393,20 +303,17 @@ private:
std::int64_t playEnd_ = 0; // Trigger: source-frame end; Gate: unused
bool amplitudeDone_ = false; // set when the active amplitude envelope finished
// S16 pitch engine + pitch envelope. pitchEngine_ selects Varispeed (ratio bias) vs Preserve
// (source-rate read + shifter). shiftL_/shiftR_ transpose the Preserve output per channel
// (one read head, per-channel shift — S7 compose). pitchEnv_ rides EITHER engine.
// pitchEngine_ selects Varispeed (ratio bias) vs Preserve (source-rate read + shifter).
// shiftL_/shiftR_ transpose the Preserve output per channel. pitchEnv_ rides either engine.
//
// GA2 onset fix: the shifter rings are PRIMED at start() with the first window of the
// actual upcoming source (loop-unrolled, 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 the shifters are fed next; it runs exactly one window AHEAD
// of readPos_ (the wall-clock output anchor) under the same sustain-loop wrap rule.
// GA3 tail wind-down: once feedPos_ passes the last real frame (Gate: sample end;
// Trigger: playEnd_) the shifters' writers are FROZEN — 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
// (never touched outside start()).
// 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_;
@@ -414,27 +321,24 @@ private:
std::int64_t feedPos_ = 0;
std::vector<AudioSample> primeBuf_;
// Seeds the takeover compensation on the FIRST frame after a restart: the ramp is the
// ACTUAL discontinuity — (pre-cut reference the new voice's raw output this frame) —
// applied ungated so the boundary frame reproduces the old level exactly. See the
// takeover-declick block above kDeclickDecay.
// 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(double newOutL, double newOutR);
// Takeover declick state (see kDeclickDecay above). lastOut{L,R}_ track the voice's most
// recent rendered output (post-gain, incl. any running declick). A takeover/steal start()
// records them as declickRef{L,R}_ (the clamped pre-cut reference) and sets declickPending_;
// the first frame rendered after the restart calls seedDeclick to arm the BOUNDED BLEND:
// outₙ = outₙ*(1w) + ref*w where w = declickWeight_ (ONE weight, deliberately shared
// by both channels so L/R can never diverge — Q-W0 T1-09 removed the dead per-R copy)
// starts at 1.0 and decays by
// kDeclickDecay each frame. This is algebraically `outₙ + w*(ref outₙ)`, so the
// boundary frame (w=1) is exactly `ref` and every subsequent output is bounded by
// max(|ref|, |outₙ|) — mid-ramp overshoot is impossible regardless of outₙ rising.
// [Rev 1 stored the frozen difference (ref x₀); when outₙ rose while that residue
// was still large the sum could exceed full scale by up to ~+3.8 dB.]
// lastOut is NOT zeroed by start() — a second same-block takeover (no frame rendered
// between) must record the same pre-cut reference, not a phantom 0.
// The whole declick state is cleared on a fresh (non-takeover) start.
// 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.
// Algebraically outₙ + w*(ref outₙ), so the boundary frame (w=1) is exactly `ref` and
// every subsequent output is bounded by max(|ref|, |outₙ|) — mid-ramp overshoot is
// impossible regardless of outₙ rising. (An earlier revision stored the frozen difference
// (ref x₀); when outₙ rose while that residue was still large, the sum could exceed
// full scale by several dB.)
// lastOut is not zeroed by start() — a second same-block takeover (no frame rendered
// between) must record the same pre-cut reference, not a phantom 0. The whole declick
// state is cleared on a fresh (non-takeover) start.
bool declickPending_ = false;
bool declickActive_ = false;
double declickRefL_ = 0.0; // clamped pre-cut reference (bounded blend target)
@@ -446,50 +350,40 @@ private:
std::uint64_t startOrder_ = 0;
};
// ---------------------------------------------------------------------------
// The polyphonic voice engine: a fixed pool of voices, note-on allocation with
// bounded voice stealing, note-off routing, and block rendering (sum of voices).
// The polyphonic voice engine: a fixed pool of voices, note-on allocation with bounded
// voice stealing, note-off routing, and block rendering (sum of voices).
//
// VOICE-STEALING POLICY (deterministic, documented): when all voices are busy and a
// new note-on arrives, steal in this priority order:
// 1. the oldest voice already in RELEASE (finishing anyway — cheapest to cut),
// Voice-stealing policy (deterministic, documented): when all voices are busy and a new
// note-on arrives, steal in this priority order:
// 1. the oldest voice already in release (finishing anyway — cheapest to cut),
// 2. else the oldest voice overall (longest-held note gives way to the new one).
// "Oldest" = smallest startOrder (assigned monotonically at note-on). This is the
// standard hardware-sampler policy: prefer to sacrifice a dying tail, and failing
// that, the note that has already had the most time.
// ---------------------------------------------------------------------------
// "Oldest" = smallest startOrder (assigned monotonically at note-on) — the standard
// hardware-sampler policy.
class VoiceEngine {
public:
// Builds an engine with `maxVoices` voices (the polyphony bound) playing from
// `keymap`. The keymap must outlive the engine (the engine holds a reference — it
// reads zones and sample data through it, never copies PCM). Every AHDSR field (A/H/D/S/R)
// + play mode + pitch engine rides on each zone's SampleData::play (in FRAMES, resolved
// from the stored seconds at keymap build); the engine holds no instrument-wide ADSR.
// `preserveVoiceCap` (S16) bounds how many Preserve-engine voices may sound at once (the
// Builds an engine with `maxVoices` voices playing from `keymap` (must outlive the
// engine — held by reference, never copies PCM). Play params ride on each zone's
// SampleData::play; the engine holds no instrument-wide ADSR.
// `preserveVoiceCap` bounds how many Preserve-engine voices may sound at once (the
// shifter is materially heavier than Varispeed) — a Preserve note-on beyond the cap is
// dropped rather than glitching; 0 means "no separate Preserve cap" (bounded only by
// maxVoices). `preserveWindowFrames` is the OLA window (in OUTPUT frames) every voice's
// Preserve pitch shifters are PRE-SIZED to at construction (OFF the audio thread), so
// note-on (which runs in process()) never allocates; 0 leaves them pass-through (a
// Varispeed-only instrument pays no ring cost). The processor derives it from the host
// sample rate (kPreserveWindowMs). Defaulted so existing callers (and the pure-core tests)
// are unaffected.
// dropped rather than glitching; 0 means no separate cap (bounded only by maxVoices).
// `preserveWindowFrames` is the OLA window every voice's Preserve shifters are
// pre-sized to at construction (off the audio thread), so note-on never allocates; 0
// leaves them pass-through. The processor derives it from the host sample rate.
//
// `voiceMode` (Phase S): POLY is the pool-with-stealing engine above; MONO drives a single
// voice (voices_[0]) with last-note priority over the held-note stack, per `monoTrigger`
// (Retrigger restarts the envelopes on every takeover/fallback; Legato retunes a same-sample
// takeover without a re-attack). Both default to today's behavior (Poly / Retrigger). The
// engine's config is immutable — a mode/count change rebuilds the engine off-thread through
// the processor's drain-slot reload, so ringing tails survive the swap.
// `voiceMode`: POLY is the pool-with-stealing engine above; MONO drives a single voice
// (voices_[0]) with last-note priority over the held-note stack, per `monoTrigger`
// (Retrigger restarts the envelopes on every takeover/fallback; Legato retunes a
// same-sample takeover without a re-attack). The engine's config is immutable — a
// mode/count change rebuilds the engine off-thread through the processor's drain-slot
// reload, so ringing tails survive the swap.
//
// `takeoverDeclick` (Phase S GA fix): when TRUE, every RESTART of a SOUNDING voice —
// the MONO Retrigger takeover, the retrigger fallback on note-off, the cross-sample
// legato restart, and the POLY at-cap voice STEAL — seeds the per-voice declick ramp
// (see kDeclickDecay) so the hard cut of the old tone does not click. start() self-gates
// on the voice being active, so a fresh start (free voice) never ramps. Default FALSE
// keeps the bare core byte-identical to the pre-fix engine (regression baseline); the
// processor shell opts in — the same layering as the kDefaultPitchEngine product default.
// `takeoverDeclick`: when true, every restart of a sounding voice (mono retrigger
// takeover/fallback, cross-sample legato restart, poly at-cap steal) seeds the
// per-voice declick ramp (see kDeclickDecay) so the hard cut doesn't click. start()
// self-gates on the voice being active, so a fresh start never ramps. Default false
// keeps the bare core byte-identical to the pre-fix engine; the processor shell opts in.
VoiceEngine(std::size_t maxVoices, const Keymap& keymap,
std::size_t preserveVoiceCap = 0, std::int64_t preserveWindowFrames = 0,
VoiceMode voiceMode = VoiceMode::Poly,
@@ -507,42 +401,32 @@ public:
// the older tail to ring — matches hardware behavior). No-op if none match.
void noteOff(int note);
// CC 123 — MIDI All-Notes-Off: clears the MONO held stack and RELEASES every active voice
// (Gate voices enter their AHDSR release tail; Trigger one-shots ignore release and play
// through their bounded play length). This is the mono stack's ONLY reset path — a phantom
// entry left by a lost note-off would otherwise be resurrected by the fallback and sustain
// forever with no key held. RT-safe (no allocation, bounded by maxVoices).
// CC 123 (All-Notes-Off): clears the mono held stack and releases every active voice
// (Gate enters AHDSR release; Trigger ignores release and plays through). The mono
// stack's only reset path — a phantom entry left by a lost note-off would otherwise be
// resurrected by the fallback and sustain forever with no key held. RT-safe.
void allNotesOff();
// CC 120 — MIDI All-Sounds-Off: hard-stops EVERY voice immediately (active_ = false, no
// release ramp), clears the MONO held stack, and silences even Trigger one-shots that would
// ignore a release. Use for panic; CC 123 for the softer "let gates release" behavior.
// RT-safe (no allocation, bounded by maxVoices); callable from the audio thread.
// CC 120 (All-Sounds-Off): hard-stops every voice immediately, clears the mono held
// stack, silences even Trigger one-shots that would ignore a release. Panic; CC 123 is
// the softer "let gates release." RT-safe, callable from the audio thread.
void allSoundsOff();
// REAL-TIME render (S4): sums all active voices into the caller-provided buffer
// `out[0..frameCount)`, ADDING to whatever is there (the caller clears or mixes —
// this never touches memory it does not own and NEVER allocates). This is the
// audio-thread entry point: the VST3 process callback passes the host's own output
// channel buffer, so no allocation, resize, or heap traffic happens under process.
// Voices that finish mid-block go idle and stop contributing. `out` must point at
// at least `frameCount` writable samples; a null `out` or zero count is a no-op.
// Sums all active voices into the caller-provided buffer `out[0..frameCount)`, adding
// to whatever is there — never allocates (the audio-thread entry point; the VST3
// process callback passes the host's own output buffer). Voices that finish mid-block
// go idle. `out` must point at least `frameCount` writable samples; null/zero is a no-op.
void render(AudioSample* out, std::size_t frameCount);
// REAL-TIME stereo render (S7): sums all active voices per-channel into the caller's two
// buffers `left`/`right` (each `frameCount` writable samples), ADDING to whatever is there
// (the caller clears/mixes). Same RT discipline as the mono overload — no allocation, no
// resize, no lock. A mono sample plays dual-mono (same value to both channels, centered);
// a stereo sample plays its two channels. A null buffer or zero count is a no-op. The mono
// and stereo render paths are independent output shapes over the SAME voice pool; the active
// channel mode (mono vs stereo bus) picks which one the process callback drives per block.
// Stereo overload: sums per-channel into `left`/`right`, same RT discipline. A mono
// sample plays dual-mono (same value both channels); a stereo sample plays its two
// channels. Mono and stereo render are independent output shapes over the same voice
// pool — the active channel mode picks which one the process callback drives per block.
void render(AudioSample* left, AudioSample* right, std::size_t frameCount);
// TEST / off-thread convenience: appends `frameCount` summed frames to `out`
// (grows it — DO NOT call on the audio thread; it allocates). Delegates to the
// real-time overload after sizing the buffer, so both paths share one mix loop.
// Does not clear existing contents — appends, matching the pre-S4 contract the
// unit tests rely on.
// Test/off-thread convenience: appends `frameCount` summed frames to `out` (grows it —
// do not call on the audio thread). Delegates to the real-time overload after sizing
// the buffer. Does not clear existing contents — appends.
void render(std::vector<AudioSample>& out, std::size_t frameCount);
// Count of currently active voices (for tests / diagnostics).
@@ -557,49 +441,45 @@ private:
// one per the documented policy. Always returns a valid index (maxVoices >= 1).
std::size_t allocateVoice();
// Count of active Preserve-engine voices (for the S16 Preserve cap). Rescanned per note-on
// Count of active Preserve-engine voices (for the Preserve cap). Rescanned per note-on
// (cheap: bounded by maxVoices) rather than maintained as a running tally.
std::size_t activePreserveVoices() const;
// --- MONO mode (Phase S): last-note priority over a held-note stack ------------
// The stack holds every currently-held, ZONE-RESOLVING note in press order (top = most
// recent = the sounding note while the voice is gated). An out-of-zone note never joins
// (it cannot sound, so it must not later take the voice back on a fallback). Re-pressing
// a held note moves it to the top. Fixed-capacity (128 distinct MIDI notes) — no
// allocation on the audio thread. Velocity is kept per held note so a RETRIGGER fallback
// re-strikes the fallen-back-to note at ITS original velocity.
// Mono mode: last-note priority over a held-note stack. The stack holds every
// currently-held, zone-resolving note in press order (top = most recent = the sounding
// note). An out-of-zone note never joins (it cannot sound, so it must not later take
// the voice back on a fallback). Re-pressing a held note moves it to the top.
// Fixed-capacity (128 distinct MIDI notes) — no allocation on the audio thread.
// Velocity is kept per held note so a retrigger fallback re-strikes at its original
// velocity.
struct HeldNote { std::uint8_t note; std::uint8_t velocity; };
// Mono note-on: push to the stack and take the voice over (legato retune on a same-sample
// takeover, else a fresh start). Returns 0 (the mono voice) or kNoVoice for out-of-zone
// or out-of-range (note outside [0,127] — rejected BEFORE the stack, which stores uint8).
// The S16 Preserve cap is NOT applied in mono — a single voice runs at most one shifter,
// inherently within any cap; applying it would wrongly drop a Preserve->Preserve takeover.
// Push to the stack and take the voice over (legato retune on a same-sample takeover,
// else a fresh start). Returns 0 (the mono voice) or kNoVoice for out-of-zone or
// out-of-range (rejected before the stack, which stores uint8). The Preserve cap is
// not applied in mono — a single voice runs at most one shifter, inherently within any
// cap; applying it would wrongly drop a Preserve->Preserve takeover.
std::size_t monoNoteOn(int note, int velocity);
// Mono note-off: pop from the stack; if the released note was sounding, fall back to the
// most-recent still-held note (retrigger or legato per monoTrigger_), else release.
// Pop from the stack; if the released note was sounding, fall back to the most-recent
// still-held note (retrigger or legato per monoTrigger_), else release.
void monoNoteOff(int note);
// Drops `note` from the held stack (order of the remaining notes preserved). No-op if absent.
void removeHeld(int note);
std::vector<Voice> voices_;
const Keymap& keymap_;
std::size_t preserveVoiceCap_ = 0; // S16: max simultaneous Preserve voices (0 = no separate cap)
std::size_t preserveVoiceCap_ = 0; // max simultaneous Preserve voices (0 = no separate cap)
std::uint64_t nextStartOrder_ = 1; // monotonic; 0 reserved for "never started"
VoiceMode voiceMode_ = VoiceMode::Poly;
MonoTrigger monoTrigger_ = MonoTrigger::Retrigger;
bool takeoverDeclick_ = false; // GA fix: declick every restart/steal of a sounding voice
bool takeoverDeclick_ = false; // declick every restart/steal of a sounding voice
std::array<HeldNote, 128> heldStack_{}; // mono held notes, press order; top = heldCount_-1
std::size_t heldCount_ = 0;
};
// NOTE (preview redesign): the Phase S PreviewCard — a dedicated preview voice isolated
// from the MIDI pool — is RETIRED. The editor's preview trigger is now a synthetic note-on
// at the loaded capture's root note through the SAME VoiceEngine host MIDI drives, so a
// preview is a real voice: it counts against the voice count, can steal / be stolen, and
// respects Poly/Mono + Retrigger/Legato (a deliberate reversal of the earlier isolation
// decision). The FA1 unity-Varispeed demotion in Voice::start went with it — since the GA2
// prime fix the shifter speaks on frame 0 at every ratio, so the demotion bought nothing
// but a second code path.
// The editor's preview trigger is a synthetic note-on at the loaded capture's root note
// through the same VoiceEngine host MIDI drives, so preview is a real voice: it counts
// against the voice count, can steal/be stolen, and respects Poly/Mono + Retrigger/Legato.
// There is no dedicated preview voice isolated from the MIDI pool.
} // namespace reasampler
+28 -56
View File
@@ -2,20 +2,19 @@
#include "core/instrument/engine/velocity_curve.h"
#include <algorithm> // std::max, std::min, std::abs, std::stable_sort
#include <cmath> // std::fabs
#include <utility> // std::move
#include <algorithm>
#include <cmath>
#include <utility>
namespace reasampler::instrument::engine {
namespace {
double clampVelocity(double v) { return std::clamp(v, kVelMin, kVelMax); }
double clampAmp(double a) { return std::clamp(a, kAmpMin, kAmpMax); }
// Pixel<->box maps (mirror of envelope_edit's timeToX/levelToY). X spans the width for [0,127]; Y
// spans (height-1) rows for amp [0,1] with amp 1 at the TOP (y increases downward).
// X spans the width for [0,127]; Y spans (height-1) rows for amp [0,1] with amp 1 at the TOP
// (pixel y increases downward, so this axis is inverted relative to amp).
double velPerPixel(const VelocityCurve::Box& box) {
const int w = std::max(0, box.width);
if (w <= 0) return 0.0;
@@ -35,7 +34,6 @@ int velToX(const VelocityCurve::Box& box, double velocity) {
int ampToY(const VelocityCurve::Box& box, double amp) {
const int h = std::max(0, box.height);
if (h <= 1) return box.top;
// amp 1 at top (box.top), amp 0 at bottom (box.top + h - 1).
const double frac = (clampAmp(amp) - kAmpMin) / (kAmpMax - kAmpMin);
return box.top + static_cast<int>((1.0 - frac) * static_cast<double>(h - 1) + 0.5);
}
@@ -44,19 +42,19 @@ int ampToY(const VelocityCurve::Box& box, double amp) {
VelocityCurve VelocityCurve::flat() {
VelocityCurve c;
c.points_ = {{kVelMin, kAmpMax}, {kVelMax, kAmpMax}}; // y = 1 everywhere (R10-F1 Option A)
c.points_ = {{kVelMin, kAmpMax}, {kVelMax, kAmpMax}};
return c;
}
VelocityCurve VelocityCurve::linear() {
VelocityCurve c;
c.points_ = {{kVelMin, kAmpMin}, {kVelMax, kAmpMax}}; // y = velocity/127
c.points_ = {{kVelMin, kAmpMin}, {kVelMax, kAmpMax}};
return c;
}
VelocityCurve VelocityCurve::fromPoints(std::vector<VelocityPoint> pts) {
// Box-clamp every point, then stable-sort by velocity (X-order; stable so coincident-X points
// keep their wire order). A stable sort keeps the eval well-defined for duplicate-X knots.
// Stable sort so coincident-X points keep their wire order (eval stays well-defined for
// duplicate-X knots).
for (VelocityPoint& p : pts) {
p.velocity = clampVelocity(p.velocity);
p.amp = clampAmp(p.amp);
@@ -65,18 +63,16 @@ VelocityCurve VelocityCurve::fromPoints(std::vector<VelocityPoint> pts) {
[](const VelocityPoint& a, const VelocityPoint& b) {
return a.velocity < b.velocity;
});
// Fewer than 2 usable points -> can't span [0,127] as a function; fall back to the flat default.
if (pts.size() < 2) return flat();
// Force endpoints present at velocity 0 and 127 (they must exist for eval to be total).
if (pts.front().velocity > kVelMin) {
pts.insert(pts.begin(), VelocityPoint{kVelMin, pts.front().amp});
} else {
pts.front().velocity = kVelMin; // snap a near-0 first point exactly onto the endpoint
pts.front().velocity = kVelMin;
}
if (pts.back().velocity < kVelMax) {
pts.push_back(VelocityPoint{kVelMax, pts.back().amp});
} else {
pts.back().velocity = kVelMax; // snap a near-127 last point exactly onto the endpoint
pts.back().velocity = kVelMax;
}
VelocityCurve c;
c.points_ = std::move(pts);
@@ -85,19 +81,12 @@ VelocityCurve VelocityCurve::fromPoints(std::vector<VelocityPoint> pts) {
namespace {
// FritschCarlson monotone-cubic tangent for one interior knot i, given the secant slopes of the
// two adjacent segments (dPrev = secant into knot i, dNext = secant out of knot i). Returns the
// limited tangent that keeps the cubic Hermite piece monotone and inside the data range.
//
// The rule: a tangent whose adjacent secants have opposite signs (or either is flat) is a local
// extremum — pin the tangent to 0 so the curve does not overshoot past the knot. Otherwise use the
// weighted-harmonic-mean tangent (FritschCarlson eq. 4), which for COLLINEAR knots (dPrev==dNext)
// reduces to that common secant — so collinear control points reproduce the straight line to within
// floating-point rounding (~1e-15), preserving the Option-B / null-response contract for linear().
// Fritsch-Carlson monotone-cubic tangent: a sign change (or flat) neighbour is a local extremum,
// so the tangent pins to 0 to avoid overshoot; otherwise the weighted-harmonic-mean tangent,
// which for collinear knots (dPrev==dNext) reduces exactly to the shared secant — this is what
// makes the spline reproduce a straight line to ~1e-15 for linear()-style input.
double fritschCarlsonTangent(double dPrev, double dNext, double spanPrev, double spanNext) {
if (dPrev * dNext <= 0.0) return 0.0; // sign change or a flat neighbour -> local extremum
// Weighted harmonic mean of the two secants (weights = the two segment widths). Collinear case:
// dPrev==dNext==d makes this (w1+w2)*d / ((w1+w2)/... ) collapse to d exactly.
if (dPrev * dNext <= 0.0) return 0.0;
const double w1 = 2.0 * spanNext + spanPrev;
const double w2 = spanNext + 2.0 * spanPrev;
return (w1 + w2) / (w1 / dPrev + w2 / dNext);
@@ -106,33 +95,23 @@ double fritschCarlsonTangent(double dPrev, double dNext, double spanPrev, double
} // namespace
double VelocityCurve::eval(double velocity) const {
if (points_.empty()) return kAmpMax; // degenerate (shouldn't occur) -> flat unity
if (points_.size() == 1) return clampAmp(points_[0].amp); // 1-point -> that point's amp
if (points_.empty()) return kAmpMax;
if (points_.size() == 1) return clampAmp(points_[0].amp);
const double v = clampVelocity(velocity);
// At or before the first point / at or after the last, read the endpoint amp (the endpoints are
// at 0 and 127, so this only fires exactly at the ends for an in-range velocity).
if (v <= points_.front().velocity) return clampAmp(points_.front().amp);
if (v >= points_.back().velocity) return clampAmp(points_.back().amp);
// Find the segment [points_[i], points_[i+1]] containing v (X-ordered, so a linear scan).
for (std::size_t i = 0; i + 1 < points_.size(); ++i) {
const VelocityPoint& a = points_[i];
const VelocityPoint& b = points_[i + 1];
if (v >= a.velocity && v <= b.velocity) {
const double span = b.velocity - a.velocity;
// Coincident-X neighbours (a step): jump straight to the later point's amp — the segment
// has zero width so there is no interior to blend.
// Coincident-X neighbours (a step): zero-width segment, no interior to blend.
if (span <= 0.0) return clampAmp(b.amp);
// --- Monotone cubic Hermite (FritschCarlson) interpolation on segment [a,b] ---------
// Curved (spline) response, not straight lines. The interpolant provably stays within
// [a.amp, b.amp] between the two knots (no bulge below 0 / above 1), and for collinear
// control points its tangents reduce to the secant slope — so it reproduces the straight
// line to within floating-point rounding (~1e-15), preserving linear()'s null-response
// contract (y = velocity/127 to ~1e-15; the test tolerance of 1e-12 is appropriate).
const double d = (b.amp - a.amp) / span; // secant of THIS segment
// Monotone cubic Hermite (Fritsch-Carlson): provably stays within [a.amp, b.amp]
// between the two knots (no overshoot), reproducing a straight line for collinear input.
const double d = (b.amp - a.amp) / span;
// Tangent at a: 0 if a is the first knot (endpoint), else the FC-limited tangent using
// the previous segment's secant. Same for the tangent at b (0 at the last knot).
double mA = d;
if (i > 0) {
const VelocityPoint& prev = points_[i - 1];
@@ -141,7 +120,7 @@ double VelocityCurve::eval(double velocity) const {
const double dPrev = (a.amp - prev.amp) / spanPrev;
mA = fritschCarlsonTangent(dPrev, d, spanPrev, span);
} else {
mA = 0.0; // coincident-X predecessor (a step at a) -> flat tangent
mA = 0.0;
}
}
double mB = d;
@@ -152,13 +131,10 @@ double VelocityCurve::eval(double velocity) const {
const double dNext = (next.amp - b.amp) / spanNext;
mB = fritschCarlsonTangent(d, dNext, span, spanNext);
} else {
mB = 0.0; // coincident-X successor (a step at b) -> flat tangent
mB = 0.0;
}
}
// Cubic Hermite basis on the normalized position t across [a,b]. For collinear knots
// mA==mB==d, so h00*a + (h10*span)*d + h01*b + (h11*span)*d collapses to the straight
// line to within floating-point rounding (~1e-15).
const double t = (v - a.velocity) / span;
const double t2 = t * t;
const double t3 = t2 * t;
@@ -175,8 +151,7 @@ double VelocityCurve::eval(double velocity) const {
std::size_t VelocityCurve::addPoint(double velocity, double amp) {
const VelocityPoint p{clampVelocity(velocity), clampAmp(amp)};
// Insert keeping X-order: first index whose velocity is STRICTLY greater than the new one, so a
// duplicate-X point lands immediately after the existing one (a later move can separate them).
// First index strictly greater, so a duplicate-X point lands immediately after the existing one.
std::size_t i = 0;
while (i < points_.size() && points_[i].velocity <= p.velocity) ++i;
points_.insert(points_.begin() + static_cast<std::ptrdiff_t>(i), p);
@@ -191,11 +166,10 @@ VelocityPoint VelocityCurve::movePoint(std::size_t index, double velocity, doubl
double newAmp = clampAmp(amp);
double newVel;
if (isFirst) {
newVel = kVelMin; // endpoint pinned in X at 0 — only amp moves
newVel = kVelMin;
} else if (isLast) {
newVel = kVelMax; // endpoint pinned in X at 127 — only amp moves
newVel = kVelMax;
} else {
// Interior point: clamp X strictly within its immediate neighbours so it can't cross them.
const double lo = points_[index - 1].velocity;
const double hi = points_[index + 1].velocity;
newVel = std::clamp(clampVelocity(velocity), lo, hi);
@@ -216,9 +190,7 @@ VelocityCurve::CurvePixel VelocityCurve::pixelFromPoint(const Box& box, const Ve
}
VelocityPoint VelocityCurve::pointFromPixel(const Box& box, int x, int y) {
// The exact inverse of velToX/ampToY (within the one-pixel rounding quantum). Degenerate
// dimensions collapse the same way the forward map does: velToX pins to box.left (velocity 0),
// ampToY pins to box.top (amp 1).
// Exact inverse of velToX/ampToY (within one pixel); degenerate dims collapse the same way.
VelocityPoint p;
const int w = std::max(0, box.width);
const int h = std::max(0, box.height);
+39 -105
View File
@@ -1,43 +1,13 @@
// velocity_curve.h — PURE velocity->amp transfer curve (S-VIEW-9, r10). NO VST3, NO REAPER, NO
// SWELL/LICE, NO vendor/ includes at the boundary. The mirror of envelope_edit / card_drag: the
// eval + the clamp/order/inverse-map arithmetic live here, unit-tested outside the DAW; the future
// editor shell (reasampler_editor.cpp, S-VIEW-10) draws the box + node handles and feeds each move's
// pixel delta back through here, committing the result to the zone through the same off-audio-thread
// path a slider edit uses.
//
// WHAT IT IS. A monotonic-in-x transfer function mapping MIDI velocity (X: 0..127) to an amp scalar
// (Y: 0..1), authored as an ordered list of control points. eval(velocity) is called ONCE per
// note-on in Voice::start() (never per frame) to set the voice's velocityGain_, replacing the fixed
// linear velocity/127 map. The curve is a per-PerformanceZone performance characteristic (D-B) — a
// sibling of the AHDSR envelope, pitch engine, and keyTrack scalar — so it varies per sound, stored
// on PerformanceZone and resolved onto the KeyZone at keymap build (mirror of keyTrack).
//
// DEFAULT — flat y=1 (fork R10-F1 Option A, Daniel 2026-07-27). VelocityCurve::flat() is the seeded
// default: EVERY velocity plays at unity amp. This is a DELIBERATE, Daniel-approved behavior change
// vs. the shipped linear velocity/127 map — soft hits are now full level until a curve is drawn.
// NOT bit-identical to the pre-r10 engine, by design; do not "preserve" the linear response.
//
// THE INVARIANT (mirror of envelope_edit's S-VIEW-F2). A drag/edit can NEVER produce a curve eval
// couldn't handle:
// * X-ORDERED — a point clamps between its predecessor's and successor's velocity, so control
// points never cross in X. This is what makes eval a well-defined FUNCTION (one amp per
// velocity): each X falls in exactly one [p_i, p_{i+1}] segment.
// * BOX-CLAMPED — velocity clamps to [0,127], amp clamps to [0,1] (the drawn box).
// Both endpoints (velocity 0 and 127) are always present so eval is total over [0,127]; delete
// refuses to remove them, and the constructors seed them.
// velocity_curve.h — velocity->amp transfer curve. eval(velocity) is called once per note-on
// in Voice::start(), never per frame. Editor hit-test/inverse-map take an explicit pixel Box
// rather than a Rect: this module sits below sampler_core in the link graph and must not gain
// a transitive dependency on editor-layout types.
#pragma once
#include <cstdint>
#include <vector>
// DELIBERATELY dependency-free at the boundary (no editor_geometry / Rect). This module sits BELOW
// sampler_core in the link graph (KeyZone carries a VelocityCurve; Voice::start calls eval), and the
// engine must not gain a transitive dependency on the editor's layout types. The editor hit-test /
// inverse-map therefore takes an explicit pixel box (boxLeft/boxTop/boxWidth/boxHeight) rather than a
// Rect — the future editor shell (S-VIEW-10) passes its box coords directly. Mirror of envelope_edit's
// role, but one layer lower, so the coupling stays out of the engine core.
namespace reasampler::instrument::engine {
// The MIDI velocity domain [0,127] and the amp range [0,1] — the box every point clamps into.
@@ -46,81 +16,56 @@ inline constexpr double kVelMax = 127.0;
inline constexpr double kAmpMin = 0.0;
inline constexpr double kAmpMax = 1.0;
// One control point: a (velocity, amp) knot the curve passes through. Both fields are box-clamped
// by the mutators; a raw-constructed point is NOT auto-clamped (the mutators own the invariant), so
// build curves through the named constructors / addPoint rather than pushing raw points.
// A raw-constructed point is NOT auto-clamped (the mutators own that invariant) — build curves
// through the named constructors / addPoint rather than pushing raw points.
struct VelocityPoint {
double velocity = 0.0; // X, [0,127]
double amp = 0.0; // Y, [0,1]
};
// The pick radius (px) around a node's drawn point for the editor hit-test. Mirrors
// envelope_edit::kNodeGrabRadius / waveform_view::kMarkerGrabWidth.
// Pick radius (px) around a node's drawn point for the editor hit-test.
inline constexpr int kCurveNodeGrabRadius = 6;
// A velocity->amp transfer curve: an X-ORDERED list of control points spanning [0,127], evaluated by
// a MONOTONE cubic Hermite spline (FritschCarlson slope limiting) through the knots — a genuine
// curved response (Daniel 2026-07-27: "straight lines sound like shit"), not a polyline. Each
// velocity still maps to exactly one amp: the interpolant is single-valued and provably stays within
// each segment's amp range, so the curve never overshoots below 0 or above 1. For COLLINEAR knots the
// FritschCarlson tangents reduce to the secant slope, so the spline reproduces the straight line to
// within floating-point rounding (~1e-15) — that preserves linear()'s null-response contract
// (y = velocity/127 to ~1e-15; the 1e-12 test tolerance is deliberately conservative). The two endpoints
// (velocity 0 and 127) are load-bearing: they keep eval total and are never deletable.
// An X-ordered list of control points spanning [0,127], evaluated by a monotone cubic Hermite
// spline (Fritsch-Carlson slope limiting) — a genuine curve, not a polyline, that provably never
// overshoots a segment's amp range. For collinear knots the tangents reduce to the secant slope,
// so the spline reproduces linear()'s straight line to within ~1e-15. The two endpoints (velocity
// 0 and 127) are load-bearing: they keep eval total over the domain and are never deletable.
class VelocityCurve {
public:
// R10-F1 default (Option A): flat y=1 — endpoints (0,1) and (127,1); every velocity -> unity.
// flat() (endpoints (0,1)/(127,1), every velocity -> unity) is the default — see
// velocity_curve in the directory CLAUDE.md for why this isn't bit-identical to the
// pre-existing linear() response.
static VelocityCurve flat();
// The classic linear ramp y = velocity/127 — endpoints (0,0) and (127,1). Retained for tests
// and as the Option-B seed; NOT the default (see R10-F1).
static VelocityCurve linear();
// Rebuild a curve from a deserialized point list, REPAIRING the invariant defensively (the
// deserialization seam, sample_map's zones-payload v7). Each point is box-clamped; the list is
// stable-sorted by velocity (X-ordered); endpoints at velocity 0 and 127 are forced present
// (an absent endpoint is synthesized at the nearest interior amp, or unity for an empty list).
// A list with fewer than 2 usable points falls back to flat(). Never trusts the wire blindly —
// a corrupt/truncated blob yields a well-formed curve, never an invariant-violating one.
// Rebuilds from a deserialized point list, repairing the invariant defensively: box-clamps
// each point, stable-sorts by velocity, forces both endpoints present (synthesized if
// missing), falls back to flat() if fewer than 2 usable points remain. A corrupt/truncated
// blob yields a well-formed curve, never an invariant-violating one.
static VelocityCurve fromPoints(std::vector<VelocityPoint> pts);
// The control points, X-ordered, first at velocity 0 and last at velocity 127 (invariant).
const std::vector<VelocityPoint>& points() const { return points_; }
std::size_t size() const { return points_.size(); }
// Evaluate the curve at `velocity` -> amp in [0,1]. Velocity is box-clamped to [0,127] first,
// so an out-of-range note (shouldn't occur) reads the nearest endpoint. Between two adjacent
// points the amp follows a MONOTONE cubic Hermite spline (FritschCarlson slope limiting) — a
// true curve that provably stays within the two knots' amp range (no overshoot below 0 / above
// 1) and reproduces the straight line to within floating-point rounding (~1e-15) for collinear
// knots. Single-valued / monotonic in X.
// Degenerate cases (shouldn't occur post-construction): an EMPTY curve returns kAmpMax (flat
// unity); a ONE-point curve returns that point's amp.
// Degenerate cases (shouldn't occur post-construction): empty curve returns kAmpMax; a
// one-point curve returns that point's amp.
double eval(double velocity) const;
// --- Editing (for the S-VIEW-10 editor UI) --------------------------------------------------
// Insert a new control point, box-clamped, keeping the list X-ordered by velocity. Returns the
// index of the inserted point. A new point at a velocity that duplicates an existing one is
// inserted immediately AFTER it (so a subsequent move can separate them); the endpoints are not
// special-cased on insert (a point at exactly 0 or 127 inserts adjacent to that endpoint).
// Inserted at a velocity duplicating an existing point lands immediately after it, so a
// subsequent move can separate them. Returns the inserted index.
std::size_t addPoint(double velocity, double amp);
// Move point `index` to (velocity, amp), box-clamped AND X-clamped between its immediate
// neighbours so it cannot cross them (monotonic-X grammar). The two ENDPOINTS are pinned in X
// (index 0 stays at velocity 0, the last stays at 127) — only their AMP moves; their velocity
// argument is ignored. An out-of-range index is a no-op. Returns the (possibly clamped)
// resulting point.
// Box-clamped and X-clamped between immediate neighbours (monotonic-X grammar). The two
// endpoints are pinned in X (only their amp moves); out-of-range index is a no-op.
VelocityPoint movePoint(std::size_t index, double velocity, double amp);
// Delete point `index`. The two endpoints (index 0 and the last) are NOT deletable — a request
// to remove either, or an out-of-range index, is a no-op returning false. Returns true iff a
// point was removed.
// Endpoints (index 0 and last) are not deletable; that or an out-of-range index is a no-op
// returning false.
bool deletePoint(std::size_t index);
// --- Editor hit-test + inverse map (mirror of envelope_edit) --------------------------------
// The drawn box, in pixels: origin (boxLeft, boxTop), `boxWidth` px wide, `boxHeight` px tall.
// X = velocity across the width (0 at boxLeft, 127 at boxLeft+boxWidth); Y = amp UP the height
// (amp 1 at boxTop, amp 0 at boxTop+boxHeight-1). Passed explicitly (not a Rect) so this module
// stays free of editor-layout types — see the header preamble.
// The drawn box, in pixels: X = velocity across the width, Y = amp UP the height (amp 1 at
// top). Passed explicitly rather than a Rect — see header preamble.
struct Box {
int left = 0;
int top = 0;
@@ -128,43 +73,32 @@ public:
int height = 0;
};
// Which control point a grab at (x,y) lands on, given the drawn `box`. Returns the index of the
// first point within the pick radius in BOTH axes, or -1 for a miss. First-match in point order
// for determinism (mirror of nodeAtPoint).
// Index of the first point within the pick radius on both axes, or -1 for a miss. First-match
// in point order for determinism.
int pointAtPixel(const Box& box, int x, int y) const;
// A node's drawn pixel position (S-VIEW-10). The ONE point->pixel mapping — the same mapping
// pointAtPixel hit-tests against — exposed so the editor shell draws the trace + node handles
// at exactly the coordinates the hit-test expects (draw and grab can never drift).
// The one point->pixel mapping, exposed so drawing and hit-testing can never drift apart.
struct CurvePixel {
int x = 0;
int y = 0;
};
static CurvePixel pixelFromPoint(const Box& box, const VelocityPoint& p);
// The absolute pixel -> (velocity, amp) inverse (S-VIEW-10): where an empty-space click lands
// as a NEW control point, box-clamped. The exact inverse of pixelFromPoint's mapping (within
// the one-pixel quantum), so an added point appears under the cursor. Degenerate box: a
// zero-width box reads velocity 0; a height <= 1 box reads amp 1 (the top row), mirroring
// pixelFromPoint's degenerate collapse.
// Exact inverse of pixelFromPoint (within the one-pixel quantum) — where an empty-space click
// lands as a new point. Degenerate box: zero-width reads velocity 0; height <= 1 reads amp 1.
static VelocityPoint pointFromPixel(const Box& box, int x, int y);
// Resolve a drag of point `index` by a pixel delta since grab, given the curve AS OF GRAB TIME
// (`grabCurve` — the shell snapshots it on mouse-down so the delta is absolute) and the box.
// Maps the pixel delta to a (velocity, amp) delta over the box, then applies movePoint's clamp
// (box + neighbour X + endpoint X-pin). A zero-width/height box or out-of-range index returns
// `grabCurve` unchanged. Pure — mirror of resolveNodeDrag.
// `grabCurve` is the curve as of mouse-down (shell snapshots it so the delta is absolute).
// Maps the pixel delta to velocity/amp over the box, then applies movePoint's clamp. Zero
// width/height box or out-of-range index returns grabCurve unchanged.
static VelocityCurve resolvePointDrag(const VelocityCurve& grabCurve, std::size_t index,
const Box& box, int dxPixels, int dyPixels);
// Equality (for tests + round-trip assertions): same point count + each point equal within a
// tight epsilon.
bool equals(const VelocityCurve& other, double eps = 1e-9) const;
private:
// Points are always X-ordered with an endpoint at 0 and 127. Constructed only through the named
// constructors + deserialize (see sample_map), which establish that invariant; the mutators
// preserve it.
// Always X-ordered with an endpoint at 0 and 127; constructors + deserialize establish the
// invariant, mutators preserve it.
std::vector<VelocityPoint> points_;
};
+72 -122
View File
@@ -1,122 +1,88 @@
#pragma once
// zone_params.h — the per-zone play-parameter VALUE STRUCTS + per-instance mode enums the
// sampler engine, the sample_map resolution layer, the ComponentState codec, and the editor
// all share (Q-W2v header split, T4-14/T4-17). Split out of sampler_core.h so a UI or codec
// TU that reads a param struct no longer recompiles when a Voice/VoiceEngine member changes.
// PURE: NO VST3, NO REAPER, NO SWELL, NO vendor/ includes — standard library + peaks only.
// The per-frame EVALUATOR classes (AdsrEnvelope / TriggerEnvelope / PitchEnvelope) and the
// engine (Keymap / Voice / VoiceEngine) stay in sampler_core.h.
// zone_params.h — per-zone play-parameter value structs + per-instance mode enums shared by
// the engine, sample_map, the ComponentState codec, and the editor. Split out of sampler_core.h
// so a UI/codec TU reading a param struct doesn't recompile when a Voice/VoiceEngine member
// changes. The per-frame evaluator classes (AdsrEnvelope/TriggerEnvelope/PitchEnvelope) and the
// engine (Keymap/Voice/VoiceEngine) stay in sampler_core.h.
#include <cstdint>
#include <vector>
#include "core/audio/peaks.h" // AudioSample (float)
#include "core/audio/peaks.h"
namespace reasampler {
// Q-W1 interim: the flat `reasampler` namespace is the engine family's home until its own
// re-namespace lands; the deps live in their sub-namespace homes.
using audio::AudioSample;
// The instrument's per-instance output channel mode (S7, D-E). MONO keeps the pre-S7
// downmix path (one channel out); STEREO negotiates a 2-channel output bus and renders
// per-channel. A PERFORMANCE choice the instrument owns (component state), never written
// to the bank. Default Mono preserves current behavior. Lives in the pure core as a plain
// value so the shell (bus negotiation, state) and the engine share one spelling; the core
// itself never branches on it — the mode only picks which render overload the shell drives.
// Decode-side downmix policy (see root CLAUDE.md — the output bus itself is permanently
// stereo; this only picks mono-downmix vs dual-mono at decode). Never written to the bank.
enum class ChannelMode { Mono, Stereo };
// The instrument's per-instance VOICE MODE (Phase S voice redesign). POLY is today's
// polyphonic engine (fixed pool + bounded stealing); MONO is a single voice with LAST-NOTE
// priority over a held-note stack (classic mono synth: a new note takes the voice over; the
// release of the top note falls back to the most-recent still-held note). A PERFORMANCE
// choice the instrument owns (component state), never a bank fact. Default Poly preserves
// current behavior.
// POLY is the fixed-pool engine with bounded stealing; MONO is a single voice with last-note
// priority over a held-note stack (a new note takes over; releasing the top note falls back to
// the most-recent still-held one). Never a bank fact. Default Poly.
enum class VoiceMode { Poly, Mono };
// How a MONO takeover treats the envelopes (Phase S — Daniel: explicitly toggleable).
// RETRIGGER restarts the amplitude (and pitch) envelope on every new mono note. LEGATO keeps
// the envelope running when a note is taken over while another is held — pitch moves without
// a re-attack (and the fallback on top-note release glides back the same way). Legato applies
// only to a SAME-SAMPLE takeover: crossing into a zone playing a different sample restarts
// the voice (one read head cannot glide between two PCM streams; a re-attack on a sample
// change is the deterministic, documented fallback). Meaningless in Poly. Default Retrigger.
// How a MONO takeover treats the envelopes. RETRIGGER restarts amp/pitch envelopes on every new
// mono note. LEGATO keeps the envelope running across a takeover (pitch moves without a
// re-attack) but only for a SAME-SAMPLE takeover — one read head can't glide between two PCM
// streams, so crossing into a different sample always restarts the voice. Meaningless in Poly.
enum class MonoTrigger { Retrigger, Legato };
// The user-parameterized polyphony bound (Phase S): a per-instance persisted voice count.
// One spelling shared by the engine, the component-state (de)serializer, and the editor's
// control so the range can never drift apart. Default 16 == the pre-Phase-S fixed pool.
// Shared range so the engine, the component-state codec, and the editor control can't drift.
inline constexpr int kMinVoiceCount = 1;
inline constexpr int kMaxVoiceCount = 32;
inline constexpr int kDefaultVoiceCount = 16;
// ---------------------------------------------------------------------------
// S15/S16 per-zone play PARAMETERS (plain data). Defined up here (before SampleData) because
// SampleData carries a ZonePlayParams by value — a voice reads it at start(). The matching
// per-frame EVALUATOR classes (AHDSR AdsrEnvelope, TriggerEnvelope, PitchEnvelope) live lower
// with the rest of the engine machinery; only the value structs need to precede SampleData.
// ---------------------------------------------------------------------------
// AHDSR amplitude envelope parameters (S15 grows the S3 ADSR with a HOLD stage between Attack
// and Decay). holdFrames == 0 is EXACTLY the pre-S15 ADSR (back-compat). See AdsrEnvelope below.
// AHDSR amplitude envelope. holdFrames == 0 is exactly the pre-hold-stage ADSR (back-compat).
struct AdsrParams {
std::int64_t attackFrames = 0;
std::int64_t holdFrames = 0; // S15: hold at 1.0 between Attack and Decay; 0 = pre-S15 ADSR
std::int64_t holdFrames = 0;
std::int64_t decayFrames = 0;
double sustainLevel = 1.0; // 0..1
std::int64_t releaseFrames = 0;
};
// S15 play mode. GATE = classic held note (AHDSR + sustain loop + note-off release, today's
// behavior grown by the hold stage). TRIGGER = one-shot: note-off-immune, no sustain loop,
// plays a % of the sample length shaped by fade-in/out. Both honor the start point. Per-zone
// (D-B); DEFAULT Gate so an instrument with no S15 params plays exactly as before.
// GATE = classic held note (AHDSR + sustain loop + note-off release). TRIGGER = one-shot:
// note-off-immune, no sustain loop, plays a % of sample length shaped by fade-in/out. Both
// honor the start point. Per-zone; default Gate so an instrument with no params set plays
// exactly as before.
enum class PlayMode { Gate, Trigger };
// Trigger amplitude envelope parameters (S15). Playback covers the source-frame span
// [startFrame, playEnd), playEnd = startFrame + round(lengthFraction*(frames - startFrame)),
// lengthFraction in (0,1]. Amplitude ramps 0->1 over fadeInFrames at the head and 1->0 over
// fadeOutFrames anchored to playEnd; unity between. Fades clamp so fadeIn + fadeOut <= play
// length. The voice frees when the head reaches playEnd. Note-off is a no-op in Trigger.
// Playback covers [startFrame, playEnd), playEnd = startFrame +
// round(lengthFraction*(frames - startFrame)). Amplitude ramps 0->1 over fadeInFrames at the
// head and 1->0 over fadeOutFrames anchored to playEnd; unity between. Fades clamp so
// fadeIn + fadeOut <= play length. The voice frees when the head reaches playEnd.
struct TriggerParams {
double lengthFraction = 1.0; // (0,1] of the post-start span to play
std::int64_t fadeInFrames = 0; // 0->1 ramp at the head
std::int64_t fadeOutFrames = 0; // 1->0 ramp anchored to playEnd
std::int64_t fadeInFrames = 0;
std::int64_t fadeOutFrames = 0;
};
// The fade curve for Trigger's ramps. EQUAL_POWER (constant-power sin/cos) is the default
// (click-free on one-shots, per spec); LINEAR is the build-time residual. An enum (not a bool)
// so a third curve can join without a signature change.
// EQUAL_POWER (constant-power sin/cos) is the click-free default for Trigger's ramps; LINEAR is
// the build-time residual.
enum class FadeCurve { EqualPower, Linear };
// The DEFAULT fade curve (S15 spec: equal-power). One constant to flip if linear is wanted.
inline constexpr FadeCurve kDefaultFadeCurve = FadeCurve::EqualPower;
// The per-zone pitch engine. VARISPEED = today's path (readPos_ += ratio_): pitch and duration
// coupled (an octave up plays half as long). PRESERVE = duration-preserving: the read advances
// at the SOURCE rate while a PitchShifter transposes the output (an octave up keeps its length).
// VARISPEED: readPos_ += ratio_, pitch and duration coupled (an octave up plays half as long).
// PRESERVE: the read advances at the source rate while a PitchShifter transposes the output
// (an octave up keeps its length).
enum class PitchEngine { Varispeed, Preserve };
// The PRODUCT DEFAULT pitch engine (S16-F1 — Daniel's "I want duration-preserving repitching"
// directive). ONE constant to flip if Varispeed should be the default instead. This is the
// default a NEW or absent-in-the-blob zone gets — APPLIED AT THE STATE BOUNDARY (sample_map's
// deserialize / editor zone-creation), NOT the pure-core struct default. The pure-core
// ZonePlayParams.pitchEngine member defaults to VARISPEED so that "no params == the pre-S16
// engine" holds for the core's own regression tests (an octave up still halves duration in the
// bare engine); the Preserve product default is layered on above at (de)serialization.
// Product default is Preserve, but applied at the state boundary (sample_map deserialize /
// editor zone-creation) for new/absent zones, NOT here: ZonePlayParams.pitchEngine itself
// defaults to Varispeed so "no params == the bare engine" holds for the core's own regression
// tests (an octave up still halves duration with no params set).
inline constexpr PitchEngine kDefaultPitchEngine = PitchEngine::Preserve;
// The OLA window (frames) the Preserve PitchShifter uses, derived from a window in milliseconds
// at the voice's sample rate. ~50 ms is the WDL quality-0 window the spec cites; larger =
// smoother on big transpositions. Onset latency is ZERO: start() primes the ring with the first
// window of real source, so output frame 0 IS source frame 0 regardless of window size (GA2 fix).
// One knob, resolved at voice allocation.
// OLA window for the Preserve PitchShifter, in ms at the voice's sample rate; larger = smoother
// on big transpositions. Onset latency is zero — start() primes the ring with the first window
// of real source, so output frame 0 is source frame 0 regardless of window size.
inline constexpr double kPreserveWindowMs = 50.0;
// A per-voice AD pitch-modulation envelope (S16), OFF by default (enabled=false -> offset always
// 0 -> playback bit-identical to the un-modulated engine). At note-on the pitch offset rises to
// peakSemitones over attackFrames, then falls to 0 (base pitch) over decayFrames. A zero attack
// gives the pure "start high, drop to base" percussive drop. peakSemitones is signed (+/-).
// AD pitch-modulation envelope, off by default (enabled=false -> offset always 0 -> bit-identical
// to the un-modulated engine). At note-on the offset rises to peakSemitones over attackFrames,
// then falls to 0 over decayFrames; a zero attack gives a pure percussive pitch drop.
struct PitchEnvParams {
bool enabled = false;
std::int64_t attackFrames = 0;
@@ -124,69 +90,53 @@ struct PitchEnvParams {
double peakSemitones = 0.0; // signed depth at the peak
};
// The bundle of S15/S16 per-zone play parameters a voice reads at start(). Lives on SampleData
// (each zone owns one SampleData in the zoned keymap). DEFAULTS are EXACTLY the pre-S15/S16
// engine: Gate mode, AHDSR with hold 0 (= the S3 ADSR), VARISPEED pitch engine, pitch envelope
// disabled — so a bare-core voice with default play is byte-identical to the pre-S15 build (the
// core regression tests rely on this). The PRODUCT default of Preserve (S16-F1) is applied one
// layer up at (de)serialization for new/absent zones — see kDefaultPitchEngine.
// Bundle a voice reads at start(). Defaults reproduce the bare engine (Gate, hold-0 AHDSR,
// Varispeed, pitch envelope off) — core regression tests rely on this; the Preserve product
// default is layered on at (de)serialization, see kDefaultPitchEngine.
struct ZonePlayParams {
PlayMode playMode = PlayMode::Gate;
AdsrParams adsr; // Gate: the AHDSR envelope
TriggerParams trigger; // Trigger: %-length + fades
AdsrParams adsr;
TriggerParams trigger;
PitchEngine pitchEngine = PitchEngine::Varispeed;
PitchEnvParams pitchEnv; // AD pitch modulation, off by default
PitchEnvParams pitchEnv;
};
// ---------------------------------------------------------------------------
// Sample data the core plays. Plain, decoded PCM + the S2 bank intrinsics that
// govern playback. The shell decodes the on-disk WAV and fills this; the core
// never touches a file.
// ---------------------------------------------------------------------------
// Sample data the core plays: plain decoded PCM + the bank intrinsics that govern playback.
// The shell decodes the on-disk WAV and fills this; the core never touches a file.
// A loop over [start, end) frames, half-open. A zero-length loop (start == end)
// is the "no sustain loop" marker — a held note past the sample end goes silent
// rather than looping a zero span. absent-loop is modeled by leaving hasLoop false.
// [start, end) frames, half-open. A zero-length loop (start == end) is the "no sustain loop"
// marker — a held note past the sample end goes silent rather than looping a zero span.
struct SampleLoop {
bool hasLoop = false;
std::int64_t start = 0; // first looped frame (inclusive)
std::int64_t end = 0; // one-past-last looped frame (exclusive); start <= end
std::int64_t start = 0;
std::int64_t end = 0;
};
// One decoded audio sample the engine can voice. DEINTERLEAVED, per-channel: `frames` is
// channel 0 (always present) and `framesR` is channel 1 (present only for a STEREO sample).
// A sample is stereo iff `framesR` is non-empty AND the same length as `frames`; otherwise
// it is mono (the degenerate, byte-identical Tier 0-1 case — `framesR` stays empty). Both
// channels share `readPos_`, `rootNote`, and `loop`, so repitch/loop are per-frame identical
// across channels; only the sampled value differs. `rootNote` is the MIDI note the file was
// recorded at (S2 intrinsic) — the pitch that plays back at unity ratio.
// Deinterleaved per-channel: `frames` is channel 0 (always present), `framesR` is channel 1
// (present only for a stereo sample). Stereo iff `framesR` is non-empty and the same length as
// `frames`; a mismatched length is treated as absent (mono) rather than half-playing. Both
// channels share `readPos_`/`rootNote`/`loop`, so repitch/loop stay per-frame identical across
// channels. `rootNote` is the MIDI note the file was recorded at — plays at unity ratio there.
struct SampleData {
std::vector<AudioSample> frames; // channel 0 PCM (mono, or L of a stereo sample)
std::vector<AudioSample> framesR; // channel 1 PCM (R); EMPTY for a mono sample
int sampleRate = 0; // frames per second (for reference; ratio is
// note-relative, so rate cancels for repitch).
// 0 is explicitly invalid — every consumer must
// receive a real rate before use.
int rootNote = 60; // MIDI note recorded at (plays at unity here)
SampleLoop loop; // sustain loop, if any
// Initial read position (frame offset) a voice starts playback at frame 0 by
// default, so an unset start point is exactly the pre-S11 behavior. S11 makes this
// an instrument-side per-zone override (the "start point" marker); S15 builds on it
// (both play modes carry a modifiable start). Clamped into [0, frames) at note-on:
// a start >= the sample length is a no-op (voice starts at 0), never out of bounds.
std::vector<AudioSample> frames;
std::vector<AudioSample> framesR; // empty for a mono sample
int sampleRate = 0; // ratio math is note-relative, so rate cancels for
// repitch; still, 0 is invalid — every consumer must
// receive a real rate before use.
int rootNote = 60;
SampleLoop loop;
// Frame offset a voice starts playback at; frame 0 default is the pre-existing behavior.
// Clamped into [0, frames) at note-on — a start >= sample length is a no-op (starts at 0).
std::int64_t startFrame = 0;
// S15/S16 per-zone play parameters (play mode, AHDSR/Trigger envelope, pitch engine, pitch
// envelope). Defaults reproduce the pre-S15 engine EXCEPT the pitch engine default is
// Preserve (S16-F1). A voice reads this at start(). Struct defined above SampleData.
ZonePlayParams play;
// 2 iff a matching-length second channel exists; else 1. A framesR of a different
// length than frames is treated as absent (mono) — a malformed pair never half-plays.
// A framesR of a different length than frames is treated as absent — a malformed pair
// never half-plays.
int channelCount() const {
return (!framesR.empty() && framesR.size() == frames.size()) ? 2 : 1;
}
};
} // namespace reasampler