From 3599d97836bff366fd22ba7a65dfa5a78a49ed3c Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Wed, 29 Jul 2026 20:48:32 -0400 Subject: [PATCH] Cut core/instrument/engine comment bloat ~33% (comments only, zero code change) --- src/core/instrument/engine/master_gain.cpp | 2 +- src/core/instrument/engine/master_gain.h | 50 +- src/core/instrument/engine/pitch_shift.cpp | 205 +++---- src/core/instrument/engine/pitch_shift.h | 230 +++---- src/core/instrument/engine/sampler_core.cpp | 256 ++++---- src/core/instrument/engine/sampler_core.h | 572 +++++++----------- src/core/instrument/engine/velocity_curve.cpp | 84 +-- src/core/instrument/engine/velocity_curve.h | 144 ++--- src/core/instrument/engine/zone_params.h | 194 +++--- 9 files changed, 655 insertions(+), 1082 deletions(-) diff --git a/src/core/instrument/engine/master_gain.cpp b/src/core/instrument/engine/master_gain.cpp index 02957dc..2d644df 100644 --- a/src/core/instrument/engine/master_gain.cpp +++ b/src/core/instrument/engine/master_gain.cpp @@ -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); } diff --git a/src/core/instrument/engine/master_gain.h b/src/core/instrument/engine/master_gain.h index 42e2b3b..93b7ea1 100644 --- a/src/core/instrument/engine/master_gain.h +++ b/src/core/instrument/engine/master_gain.h @@ -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 diff --git a/src/core/instrument/engine/pitch_shift.cpp b/src/core/instrument/engine/pitch_shift.cpp index 6bcdae3..8eea981 100644 --- a/src/core/instrument/engine/pitch_shift.cpp +++ b/src/core/instrument/engine/pitch_shift.cpp @@ -1,24 +1,15 @@ -// pitch_shift — pure implementation. See pitch_shift.h for the contract, the S16-F2 -// route-(b) rationale (WDL drags ), 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(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(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(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(fadePos_) / static_cast(fadeLen_); double dB = static_cast(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(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(ev.jump) + static_cast(ev.lag) + ev.frac; const double len = static_cast(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(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(fadePos_) / static_cast(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(writePos_) - posA_; const double len = static_cast(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(writePos_) - posA_; const double len = static_cast(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; diff --git a/src/core/instrument/engine/pitch_shift.h b/src/core/instrument/engine/pitch_shift.h index f9fa407..ea8fc50 100644 --- a/src/core/instrument/engine/pitch_shift.h +++ b/src/core/instrument/engine/pitch_shift.h @@ -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 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 ` 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 #include @@ -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 diff --git a/src/core/instrument/engine/sampler_core.cpp b/src/core/instrument/engine/sampler_core.cpp index 0982462..3903341 100644 --- a/src/core/instrument/engine/sampler_core.cpp +++ b/src/core/instrument/engine/sampler_core.cpp @@ -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(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: Hold→Decay 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(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(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(sample.frames.size()); std::int64_t start = sample.startFrame; if (start < 0 || start >= frameCount) start = 0; readPos_ = static_cast(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(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& 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(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(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; diff --git a/src/core/instrument/engine/sampler_core.h b/src/core/instrument/engine/sampler_core.h index e241e1a..eb0955e 100644 --- a/src/core/instrument/engine/sampler_core.h +++ b/src/core/instrument/engine/sampler_core.h @@ -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 #include #include #include -#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 samples; std::vector 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 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ₙ*(1−w) + 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ₙ*(1−w) + ref*w, w = declickWeight_ (one weight, shared by both channels so + // L/R can never diverge), starting at 1.0 and decaying by kDeclickDecay each frame. + // 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& 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 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 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 diff --git a/src/core/instrument/engine/velocity_curve.cpp b/src/core/instrument/engine/velocity_curve.cpp index 50af883..033cfa2 100644 --- a/src/core/instrument/engine/velocity_curve.cpp +++ b/src/core/instrument/engine/velocity_curve.cpp @@ -2,20 +2,19 @@ #include "core/instrument/engine/velocity_curve.h" -#include // std::max, std::min, std::abs, std::stable_sort -#include // std::fabs -#include // std::move +#include +#include +#include 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((1.0 - frac) * static_cast(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 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 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 pts) { namespace { -// Fritsch–Carlson 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 (Fritsch–Carlson 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 (Fritsch–Carlson) 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(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); diff --git a/src/core/instrument/engine/velocity_curve.h b/src/core/instrument/engine/velocity_curve.h index a2de2dc..fddfd77 100644 --- a/src/core/instrument/engine/velocity_curve.h +++ b/src/core/instrument/engine/velocity_curve.h @@ -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 #include -// 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 (Fritsch–Carlson 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 -// Fritsch–Carlson 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 pts); - // The control points, X-ordered, first at velocity 0 and last at velocity 127 (invariant). const std::vector& 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 (Fritsch–Carlson 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 points_; }; diff --git a/src/core/instrument/engine/zone_params.h b/src/core/instrument/engine/zone_params.h index 387375e..6e3f257 100644 --- a/src/core/instrument/engine/zone_params.h +++ b/src/core/instrument/engine/zone_params.h @@ -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 #include -#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 frames; // channel 0 PCM (mono, or L of a stereo sample) - std::vector 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 frames; + std::vector 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