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:
2026-08-02 05:34:33 -04:00
parent ef59265e7a
commit 248f2f3842
32 changed files with 1098 additions and 163 deletions
+41 -25
View File
@@ -53,16 +53,26 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
sample_ = &sample;
const PlayParams& p = sample.play;
// Velocity->pitch is fixed for the note's lifetime, so it folds into baseRatio_ here rather
// than costing a per-frame multiply. Feeds both engines through baseRatio_ (Varispeed
// read-rate bias and Preserve shift amount both derive from it below).
// Velocity->pitch is fixed for the note's lifetime, so it folds into baseRatio_ rather than
// costing a per-frame multiply. Feeds both engines through baseRatio_ (Varispeed read-rate
// bias and Preserve shift amount both derive from it below).
velPitchRatio_ = velocityPitchRatio(p.pitchVelocityCurve, velocity);
baseRatio_ = keyTrackedRatio(note, sample.rootNote, sample.keyTrack) * velPitchRatio_;
pitchOffsetRatio_ = semitoneRatio(p.pitchOffsetSemitones);
playMode_ = p.playMode;
pitchEngine_ = p.pitchEngine;
// Clamped once here so the read head's increment and the feed cursor's debt accumulate the
// SAME value — they must stay exactly one window apart for the note's whole life.
// THE clamp for both engines — the taper's ends are these bounds, so a knob can never ask for
// a rate this moves. Clamped once here so the read head's increment and the feed cursor's
// debt accumulate the SAME value: they must stay exactly one window apart for the note's
// whole life.
stretchRate_ = instrument::engine::clampStretchRate(stretchRate);
// Keyed on the read path this note will ACTUALLY take, which is not the same question as
// the stored engine: advanceFrame runs the Preserve branch only while the shifters are
// configured, and a Preserve voice whose shifters were never sized falls back to the
// varispeed read. Rate has to reach the increment there too, or that fallback would ignore
// the control outright — the predicate is spelled the same way advanceFrame spells it.
const bool preserveRead = (pitchEngine_ == PitchEngine::Preserve) && shiftL_.configured();
rateRatio_ = preserveRead ? 1.0 : stretchRate_;
recomputeBaseRatio();
// 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.
@@ -119,22 +129,19 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
if (playLen > postStart) playLen = postStart;
playEnd_ = start + playLen;
trigSpan = playLen;
ampAhd_.configure(playLen, p.trigAhd);
ampAhd_.configure(playLen, rateFittedAhd(p.trigAhd));
}
// The pitch AHD's Hold fraction is taken against the whole playable span, so its three
// stages lay 1:1 over the waveform from the start point. postStart is a SOURCE-frame count
// and this envelope counts OUTPUT frames (envelopes.h), so Varispeed — which consumes
// baseRatio_ source frames per output frame — needs the span converted, or a transposed
// note's envelope outruns (or outlives) the note it shapes. Preserve reads at the source
// rate, so its two domains already coincide.
// Divides by baseRatio_ alone, though the actual Varispeed read rate is baseRatio_ x
// envFactor — a deep pitch envelope makes this a first-order approximation, not exact.
// Strictly better than the un-converted source-frame span it replaced.
const double pitchSpan =
(pitchEngine_ == PitchEngine::Preserve || !(baseRatio_ > 0.0))
? static_cast<double>(postStart)
: static_cast<double>(postStart) / baseRatio_;
// and this envelope counts OUTPUT frames (envelopes.h), so the span has to be divided by the
// rate the read head consumes source at — baseRatio_ under Varispeed, the stretch rate under
// Preserve — or a transposed (or re-rated) note's envelope outruns the note it shapes.
// Divides by baseRatio_ alone under Varispeed, though the actual read rate is baseRatio_ x
// envFactor — a deep pitch envelope makes that a first-order approximation, not exact.
const double readRate = preserveRead ? stretchRate_ : baseRatio_;
const double pitchSpan = (readRate > 0.0) ? static_cast<double>(postStart) / readRate
: static_cast<double>(postStart);
pitchEnv_.configure(static_cast<std::int64_t>(pitchSpan + 0.5), p.pitchEnv);
pitchEnv_.noteOn();
@@ -169,7 +176,7 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
filterEnv_.configure(p.filter.env);
filterEnv_.noteOn();
} else {
filterAhd_.configure(trigSpan, p.filter.trigEnv);
filterAhd_.configure(trigSpan, rateFittedAhd(p.filter.trigEnv));
}
filter_.reset();
updateFilterCutoffBase(note);
@@ -263,16 +270,25 @@ void Voice::applyLive(const instrument::engine::LiveValues& live, bool snap) {
// A fresh note and a sounding one take DIFFERENT envelope entry points, never one with a
// flag: a voice that has rendered nothing has no phase to hold and nothing to be
// continuous with, and the mid-stage rule misreads its stage-0 position (envelopes.h).
//
// live.playRate is deliberately NOT read on either path: Rate is the note-on-latched class,
// delivered as start()'s argument by VoiceEngine::startVoice (live_params.h owns why). The
// latched stretchRate_ is what rateFittedAhd converts a live AHD against, so a stage-time
// move mid-note lands in this note's own rate domain rather than resetting it.
const bool gate = (playMode_ == PlayMode::Gate);
if (snap) {
if (gate) env_.snapLive(live.adsr);
else ampAhd_.snapLive(live.ampAhd);
else ampAhd_.snapLive(rateFittedAhd(live.ampAhd));
pitchEnv_.snapLive(live.pitchEnv);
} else {
if (gate) env_.applyLive(live.adsr);
else ampAhd_.applyLive(sourceOffset(), live.ampAhd);
else ampAhd_.applyLive(sourceOffset(), rateFittedAhd(live.ampAhd));
pitchEnv_.applyLive(live.pitchEnv);
}
// The baseline Pitch offset IS live, under both engines: Varispeed picks the new baseRatio_
// up as one more factor of next frame's read increment, Preserve as the shifter's transpose.
pitchOffsetRatio_ = semitoneRatio(live.pitchOffsetSemitones);
recomputeBaseRatio();
// The pitch DEPTH knob stays live under a spline (core/instrument/CLAUDE.md), but
// pitchSplineDepth_ is a plain member latched at note-on — unlike filter's modAmount_,
// which already glides through rModAmount_'s live ramp regardless of spline state (below),
@@ -283,10 +299,10 @@ void Voice::applyLive(const instrument::engine::LiveValues& live, bool snap) {
if (snap) {
if (gate) filterEnv_.snapLive(live.filterEnv);
else filterAhd_.snapLive(live.filterAhd);
else filterAhd_.snapLive(rateFittedAhd(live.filterAhd));
} else {
if (gate) filterEnv_.applyLive(live.filterEnv);
else filterAhd_.applyLive(sourceOffset(), live.filterAhd);
else filterAhd_.applyLive(sourceOffset(), rateFittedAhd(live.filterAhd));
}
filterCutoffNorm_ = static_cast<double>(live.filterSettings.cutoffNorm);
filterKeyTrack_ = live.filterKeyTrack;
@@ -328,8 +344,8 @@ void Voice::retune(int note) {
// baseRatio_ division in the note-on setup above), so a slide leaves that envelope on the
// first note's domain — consistent with "touch nothing else," but the drift lives here.
// The velocity->pitch factor rides through the slide unchanged, matching velocityGain_ —
// one gesture, one strike.
baseRatio_ = keyTrackedRatio(note, sample_->rootNote, sample_->keyTrack) * velPitchRatio_;
// one gesture, one strike. Rate and the Pitch offset ride through too: only the note moved.
recomputeBaseRatio();
// Filter key-tracking follows the pitch: it is a function of the note, so a slide moves it
// too. The velocity offset deliberately stays the first note's, matching velocityGain_.
if (filterOn_) updateFilterCutoffBase(note);