PITCH/RATE deck: Rate and Pitch knobs compounded into one read increment, on a three-state commit predicate and payload v16
This commit is contained in:
@@ -50,12 +50,17 @@ inline double keyTrackedRatio(int note, int rootNote, double keyTrack) {
|
||||
return std::pow(2.0, semis / 12.0);
|
||||
}
|
||||
|
||||
// 2^(curve(velocity) * kVelocityPitchRangeSemitones / 12): the velocity->pitch transpose, which
|
||||
// the voice folds into baseRatio_ once at note-on. A curve flat at 0 — the default — yields
|
||||
// EXACTLY 1.0 at every velocity and skips the pow, so an undrawn curve transposes nothing.
|
||||
// 2^(semitones/12). Exactly 1.0 at zero — and it SKIPS the pow there, so an unset offset
|
||||
// transposes nothing and costs nothing.
|
||||
inline double semitoneRatio(double semitones) {
|
||||
return (semitones == 0.0) ? 1.0 : std::pow(2.0, semitones / 12.0);
|
||||
}
|
||||
|
||||
// The velocity->pitch transpose, which the voice folds into baseRatio_ once at note-on. A curve
|
||||
// flat at 0 — the default — yields EXACTLY 1.0 at every velocity.
|
||||
inline double velocityPitchRatio(const VelocityCurve& curve, int velocity) {
|
||||
const double semis = curve.eval(static_cast<double>(velocity)) * kVelocityPitchRangeSemitones;
|
||||
return (semis == 0.0) ? 1.0 : std::pow(2.0, semis / 12.0);
|
||||
return semitoneRatio(curve.eval(static_cast<double>(velocity)) *
|
||||
kVelocityPitchRangeSemitones);
|
||||
}
|
||||
|
||||
// One octave expressed in the cutoff control's normalized domain, read out of the filter
|
||||
@@ -110,11 +115,15 @@ public:
|
||||
// the difference-seeded declick compensation on the first frame after the restart (see
|
||||
// kDeclickDecay above). A fresh start never declicks.
|
||||
//
|
||||
// `stretchRate` is the PRESERVE playback rate — source frames consumed per output frame,
|
||||
// clamped to [kStretchRateMin, kStretchRateMax]. It is a note-on latch by construction (an
|
||||
// argument, not a member set separately) because the loop fold and the contour scale it
|
||||
// composes with are both note-on folds. Varispeed ignores it: there, rate is a factor of the
|
||||
// read increment, not a second rate. 1.0 is the shipped Preserve read, bit for bit.
|
||||
// `stretchRate` is the playback rate — source frames consumed per output frame, clamped to
|
||||
// [kStretchRateMin, kStretchRateMax]. It is a note-on latch by construction (an argument, not
|
||||
// a member set separately) because the loop fold and the contour scale it composes with are
|
||||
// both note-on folds. Under Preserve it is the stretcher's feed rate and duration alone moves;
|
||||
// under Varispeed it folds into the read increment beside key-tracking, so pitch moves with
|
||||
// it. 1.0 is the bare engine, bit for bit, in both. Defaulted so a caller with no live block
|
||||
// to consult gets exactly that; VoiceEngine::startVoice is what resolves the real value —
|
||||
// sample.play.playRate is NOT read here, because the published block outranks the snapshot's
|
||||
// possibly-stale copy of it.
|
||||
void start(int note, int velocity, const SampleData& sample, bool declickTakeover = false,
|
||||
double stretchRate = 1.0);
|
||||
|
||||
@@ -184,6 +193,34 @@ public:
|
||||
}
|
||||
|
||||
private:
|
||||
// THE fold of every pitch factor that is constant for the note into one number, so
|
||||
// advanceFrame's read increment stays the single multiply `baseRatio_ * envFactor` it has
|
||||
// always been: key-tracked repitch, the velocity->pitch transpose, the baseline Pitch offset,
|
||||
// and the Rate ratio — which start() zeroes out of this product when the note is running the
|
||||
// Preserve read, since Rate feeds stretch_ (duration) there and must never reach the
|
||||
// shifter's transpose. Cold: note-on, legato retune, and a live block, never per frame.
|
||||
void recomputeBaseRatio() {
|
||||
if (sample_ == nullptr) return;
|
||||
baseRatio_ = keyTrackedRatio(note_, sample_->rootNote, sample_->keyTrack) *
|
||||
velPitchRatio_ * pitchOffsetRatio_ * rateRatio_;
|
||||
}
|
||||
|
||||
// A staged AHD's wall-clock stage frames converted into the SOURCE-offset domain the
|
||||
// sustain-less envelopes are evaluated in (sourceOffset()). Rate stretches the source span
|
||||
// those envelopes are fitted over, but a 30 ms attack is 30 ms at any rate — multiplying by
|
||||
// the read rate is exactly that conversion. The Varispeed PITCH coupling is deliberately NOT
|
||||
// compensated here: it predates Rate and is the shipped behaviour. Rate 1.0 returns the
|
||||
// argument untouched, which is what keeps the unity render bit-identical.
|
||||
AhdParams rateFittedAhd(const AhdParams& a) const {
|
||||
if (stretchRate_ == 1.0) return a;
|
||||
AhdParams out = a;
|
||||
out.attackFrames =
|
||||
static_cast<std::int64_t>(static_cast<double>(a.attackFrames) * stretchRate_ + 0.5);
|
||||
out.decayFrames =
|
||||
static_cast<std::int64_t>(static_cast<double>(a.decayFrames) * stretchRate_ + 0.5);
|
||||
return out;
|
||||
}
|
||||
|
||||
// The read head as a fraction of the whole sample — the domain every spline EG is a pure
|
||||
// function of. Zero-length sample leaves splineScale_ at 0, which parks every contour on
|
||||
// its opening value.
|
||||
@@ -192,9 +229,9 @@ private:
|
||||
// This frame's amplitude in [0,1] from the active envelope. Spline: the drawn contour read
|
||||
// at the normalized position (one cached-segment compare per frame). Gate: AHDSR ticks once
|
||||
// per output frame (envelope time is wall-clock, independent of read rate). Trigger: the AHD
|
||||
// is evaluated at the source offset (readPos - startFrame) — see the `ratio_ = stretchRate_`
|
||||
// note below for what that means for Preserve's stage-time/rate coupling. Sets
|
||||
// amplitudeDone_ on finish so advanceFrame frees the voice.
|
||||
// is evaluated at the source offset (readPos - startFrame), which is why its stage frames are
|
||||
// fitted to the read rate at configure time (rateFittedAhd). Sets amplitudeDone_ on finish so
|
||||
// advanceFrame frees the voice.
|
||||
double tickAmplitude() {
|
||||
double amp;
|
||||
// playMode_ is Trigger whenever a spline is genuinely reachable (resolvePlay forces it —
|
||||
@@ -532,15 +569,8 @@ private:
|
||||
// Everything downstream of it (the loop wrap, the Trigger span, the spline phase)
|
||||
// therefore stays a source-frame fact and scales by construction.
|
||||
//
|
||||
// Consequence (§2.4 of instrument-control-surface.md is explicit that staged
|
||||
// envelopes' stage times are wall-clock and do NOT scale with rate): Trigger's amp
|
||||
// AHD and filter AHD are both evaluated at sourceOffset() = readPos_ - startFrame_
|
||||
// (tickAmplitude/tickFilterCutoff above), which now advances at stretchRate_ instead
|
||||
// of always 1.0 — so those two envelopes will scale with a future non-unity Rate.
|
||||
// This is NEW here: Preserve's ratio_ was pinned at 1.0 before this track, so those
|
||||
// stage times were exact wall-clock. It is latent (nothing publishes a non-unity
|
||||
// rate yet) and owned by the track that adds the Rate control, not this one — Gate's
|
||||
// AHDSR (env_.tick(), per-output-frame) and every spline contour are unaffected.
|
||||
// The two sustain-less envelopes are evaluated at sourceOffset(), which advances at
|
||||
// this rate — rateFittedAhd is what keeps their stage times wall-clock anyway.
|
||||
ratio_ = stretchRate_;
|
||||
} else {
|
||||
// VARISPEED: pitch and duration coupled. The read rate carries the repitch; the
|
||||
@@ -645,8 +675,10 @@ private:
|
||||
bool releasing_ = false;
|
||||
int note_ = 0;
|
||||
double velocityGain_ = 1.0;
|
||||
double baseRatio_ = 1.0; // key-tracked repitch ratio, with velocity->pitch folded in
|
||||
double baseRatio_ = 1.0; // recomputeBaseRatio's product: every constant pitch factor
|
||||
double velPitchRatio_ = 1.0; // the velocity->pitch factor alone; retune re-applies it
|
||||
double pitchOffsetRatio_ = 1.0; // the Pitch knob's factor — LIVE, re-applied by applyLive
|
||||
double rateRatio_ = 1.0; // Rate's factor of the read increment; start() owns when it is 1
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user