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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user