Merge Γ-W2-T1: Rate and Pitch compound into one read increment, on a three-state commit predicate and payload v16
This commit is contained in:
@@ -421,7 +421,12 @@ public:
|
||||
// Peer of AdsrEnvelope::snapLive (see it for why the two paths cannot share code): a voice
|
||||
// that has rendered nothing takes the new shape and depth outright. `enabled` is a discrete
|
||||
// toggle travelling by reload, so the caller's copy of it is deliberately ignored.
|
||||
void snapLive(const PitchEnvParams& params) {
|
||||
//
|
||||
// Both live entry points re-take `spanFrames` rather than keeping configure()'s: the span is
|
||||
// an OUTPUT-frame duration the caller converts from the read rate, and that rate carries a
|
||||
// live control (voice.h's pitchEnvSpanFrames). Passing the span back unchanged is exact.
|
||||
void snapLive(std::int64_t spanFrames, const PitchEnvParams& params) {
|
||||
span_ = spanFrames > 0 ? spanFrames : 0;
|
||||
params_.peakSemitones = params.peakSemitones;
|
||||
params_.shape = params.shape;
|
||||
fit_ = fitAhd(span_, params_.shape);
|
||||
@@ -430,9 +435,11 @@ public:
|
||||
|
||||
// Live parameter delivery, same rule as AdsrEnvelope::applyLive: hold the normalized
|
||||
// position within whichever leg the envelope is in, and absorb the depth step (peak is a
|
||||
// level, not a duration).
|
||||
void applyLive(const PitchEnvParams& params) {
|
||||
// level, not a duration). A moved span re-fits under the same rule, so a live Pitch move
|
||||
// reshapes this envelope continuously instead of leaving it on the note-on read rate.
|
||||
void applyLive(std::int64_t spanFrames, const PitchEnvParams& params) {
|
||||
const double before = offsetAt();
|
||||
span_ = spanFrames > 0 ? spanFrames : 0;
|
||||
const AhdSpan next = fitAhd(span_, params.shape);
|
||||
pos_ = holdPhase(fit_, next);
|
||||
params_.peakSemitones = params.peakSemitones;
|
||||
|
||||
@@ -16,6 +16,8 @@ LiveValues foldLive(const PlayParams& params) {
|
||||
v.adsr = params.adsr;
|
||||
v.ampAhd = params.trigAhd;
|
||||
v.pitchEnv = params.pitchEnv;
|
||||
v.playRate = params.playRate;
|
||||
v.pitchOffsetSemitones = params.pitchOffsetSemitones;
|
||||
return v;
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,14 @@ struct LiveValues {
|
||||
AdsrParams adsr{};
|
||||
AhdParams ampAhd{};
|
||||
PitchEnvParams pitchEnv{};
|
||||
// The block's THIRD commit class, and the reason this comment is here rather than at the
|
||||
// predicate: playRate is published like any live control but read ONLY at note-on, by
|
||||
// Voice::start via VoiceEngine::startVoice — never by applyLive on a sounding voice. A live
|
||||
// rate would mean re-folding an already-resolved sustain loop and re-mapping a contour
|
||||
// mid-note, both of which are note-on folds. pitchOffsetSemitones has no such tie and is
|
||||
// ordinarily live.
|
||||
double playRate = 1.0;
|
||||
double pitchOffsetSemitones = 0.0;
|
||||
};
|
||||
|
||||
// The seqlock copies the block as raw bytes, which is only defensible for a plain value type.
|
||||
|
||||
@@ -107,7 +107,7 @@ PeriodEstimate detectPeriod(const std::vector<AudioSample>& pcm, int sampleRate)
|
||||
// It takes NO play mode, deliberately, even though loop_span's resolveLoop does and refuses the
|
||||
// loop outright under Trigger. A loop edit is structurally reload-bound — it moves the PCM span
|
||||
// this cache was derived from — whereas play mode's exclusion from live delivery is a listed,
|
||||
// reversible decision (deck_groups' isLiveDeckParam). Keying a load-time cache on it would work
|
||||
// reversible decision (deck_groups' deckParamCommit). Keying a load-time cache on it would work
|
||||
// today and silently serve a stale period the day that decision is revisited.
|
||||
//
|
||||
// The read path's loop-validity authority is loop_span's resolveLoop; the bounds check here is
|
||||
|
||||
@@ -158,6 +158,15 @@ struct PlayParams {
|
||||
TriggerParams trigger; // Trigger play span
|
||||
AhdParams trigAhd; // Trigger amp
|
||||
PitchEngine pitchEngine = PitchEngine::Varispeed;
|
||||
// Playback RATE, as source frames consumed per output frame. Under Varispeed it is one more
|
||||
// factor of the read increment, so it moves pitch and duration together; under Preserve it
|
||||
// drives duration alone and the shifter holds the pitch. Latched at note-on either way (the
|
||||
// loop fold and the contour scale it composes with are both note-on folds), and clamped by
|
||||
// the stretcher's own clampStretchRate — never here. 1.0 is the bare engine, bit for bit.
|
||||
double playRate = 1.0;
|
||||
// A baseline pitch offset in semitones, folded into the note's ratio beside key-tracking and
|
||||
// the velocity->pitch transpose. Live on a sounding voice under both engines.
|
||||
double pitchOffsetSemitones = 0.0;
|
||||
PitchEnvParams pitchEnv;
|
||||
// Velocity -> pitch offset, scaled by kVelocityPitchRangeSemitones. Bipolar and flat at 0
|
||||
// by default, so it transposes nothing until a curve is drawn. Folded into the voice's
|
||||
|
||||
@@ -53,16 +53,29 @@ 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.
|
||||
preserveRead_ = (pitchEngine_ == PitchEngine::Preserve) && shiftL_.configured();
|
||||
rateRatio_ = preserveRead_ ? 1.0 : stretchRate_;
|
||||
recomputeBaseRatio();
|
||||
// pitchOffsetRatio_ is a power of 2 and never zero, so this inverse is well-defined — and at
|
||||
// Pitch 0 it is a division by exactly 1.0.
|
||||
pitchSpanBaseRate_ = baseRatio_ / pitchOffsetRatio_;
|
||||
|
||||
// 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,23 +132,13 @@ 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_;
|
||||
pitchEnv_.configure(static_cast<std::int64_t>(pitchSpan + 0.5), p.pitchEnv);
|
||||
// stages lay 1:1 over the waveform from the start point. The source->output conversion, and
|
||||
// why it is only first-order, are pitchEnvSpanFrames' own (voice.h).
|
||||
pitchEnv_.configure(pitchEnvSpanFrames(), p.pitchEnv);
|
||||
pitchEnv_.noteOn();
|
||||
|
||||
// A restart lands every live glide back on the new note's own values, at a step derived
|
||||
@@ -169,7 +172,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,15 +266,26 @@ 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 stageFitRate carries into every conversion below, 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);
|
||||
// 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.
|
||||
// Applied BEFORE the envelopes below, because under Varispeed it is a factor of the read rate
|
||||
// both of them are fitted against — a stale offset here would fit them to the previous move.
|
||||
pitchOffsetRatio_ = semitoneRatio(live.pitchOffsetSemitones);
|
||||
recomputeBaseRatio();
|
||||
if (snap) {
|
||||
if (gate) env_.snapLive(live.adsr);
|
||||
else ampAhd_.snapLive(live.ampAhd);
|
||||
pitchEnv_.snapLive(live.pitchEnv);
|
||||
else ampAhd_.snapLive(rateFittedAhd(live.ampAhd));
|
||||
pitchEnv_.snapLive(pitchEnvSpanFrames(), live.pitchEnv);
|
||||
} else {
|
||||
if (gate) env_.applyLive(live.adsr);
|
||||
else ampAhd_.applyLive(sourceOffset(), live.ampAhd);
|
||||
pitchEnv_.applyLive(live.pitchEnv);
|
||||
else ampAhd_.applyLive(sourceOffset(), rateFittedAhd(live.ampAhd));
|
||||
pitchEnv_.applyLive(pitchEnvSpanFrames(), live.pitchEnv);
|
||||
}
|
||||
// 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_,
|
||||
@@ -283,10 +297,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;
|
||||
@@ -324,12 +338,13 @@ void Voice::retune(int note) {
|
||||
// legato phrase is one gesture, one strike (classic mono-synth behavior).
|
||||
if (!active_ || sample_ == nullptr) return;
|
||||
note_ = note;
|
||||
// Changes baseRatio_ without re-converting pitchEnv_'s already-configured span (the
|
||||
// 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.
|
||||
// Changes baseRatio_ without re-converting pitchEnv_'s already-configured span
|
||||
// (pitchEnvSpanFrames, whose base rate this deliberately does not move), 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);
|
||||
|
||||
@@ -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,65 @@ 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_;
|
||||
}
|
||||
|
||||
// The rate the read head consumes SOURCE at, counting only the factors whose stage-time
|
||||
// coupling is compensated. Under Preserve that is the stretch rate alone — the Pitch offset
|
||||
// transposes inside the shifter and never touches the read. Under Varispeed both Rate and
|
||||
// Pitch are factors of the read increment and both are compensated: they are two views of one
|
||||
// multiply, so the "30 ms is 30 ms" rule binds them identically. Key-tracking and the
|
||||
// velocity->pitch transpose are deliberately LEFT OUT — those predate Rate, are shipped
|
||||
// sounds, and compensating them would move every note off the root.
|
||||
double stageFitRate() const {
|
||||
return preserveRead_ ? stretchRate_ : stretchRate_ * pitchOffsetRatio_;
|
||||
}
|
||||
|
||||
// A staged AHD's wall-clock stage frames converted into the SOURCE-offset domain the
|
||||
// sustain-less envelopes are evaluated in (sourceOffset()). The read 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. A fit of exactly 1.0 (Rate 100 %,
|
||||
// Pitch 0 st) returns the argument untouched, which is what keeps the unity render
|
||||
// bit-identical.
|
||||
AhdParams rateFittedAhd(const AhdParams& a) const {
|
||||
const double fit = stageFitRate();
|
||||
if (fit == 1.0) return a;
|
||||
AhdParams out = a;
|
||||
out.attackFrames =
|
||||
static_cast<std::int64_t>(static_cast<double>(a.attackFrames) * fit + 0.5);
|
||||
out.decayFrames =
|
||||
static_cast<std::int64_t>(static_cast<double>(a.decayFrames) * fit + 0.5);
|
||||
return out;
|
||||
}
|
||||
|
||||
// The pitch AHD's span. That envelope counts OUTPUT frames while its Hold fraction is taken
|
||||
// against the playable SOURCE span, so the span converts by the rate the read head consumes
|
||||
// source at. Divides by that alone though the Varispeed read rate is really baseRatio_ x
|
||||
// envFactor: a deep pitch envelope makes it a first-order approximation, not exact.
|
||||
//
|
||||
// Shared by note-on and every live re-application, so a live Pitch move re-fits the envelope
|
||||
// rather than leaving it on the offset the note started at. Only that live factor is
|
||||
// re-read — pitchSpanBaseRate_ has it divided out — which is what leaves a legato retune's
|
||||
// documented drift (retune) exactly where it was.
|
||||
std::int64_t pitchEnvSpanFrames() const {
|
||||
if (sample_ == nullptr) return 0;
|
||||
const double postStart = static_cast<double>(
|
||||
static_cast<std::int64_t>(sample_->frames.size()) - startFrame_);
|
||||
const double readRate =
|
||||
preserveRead_ ? stretchRate_ : pitchSpanBaseRate_ * pitchOffsetRatio_;
|
||||
const double span = (readRate > 0.0) ? postStart / readRate : postStart;
|
||||
return static_cast<std::int64_t>(span + 0.5);
|
||||
}
|
||||
|
||||
// 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 +260,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 +600,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 +706,18 @@ 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
|
||||
// Whether this note is ACTUALLY taking the Preserve read — a Preserve voice whose shifters
|
||||
// were never sized falls back to the varispeed one, and the two domains differ. Latched at
|
||||
// note-on beside rateRatio_, which start() resolves from the same predicate.
|
||||
bool preserveRead_ = false;
|
||||
// baseRatio_ with the live Pitch factor divided back out, latched at note-on: what
|
||||
// pitchEnvSpanFrames multiplies the CURRENT offset onto. Exact at Pitch 0 (the factor is
|
||||
// exactly 1.0), which is what keeps the unity span bit-identical.
|
||||
double pitchSpanBaseRate_ = 1.0;
|
||||
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;
|
||||
|
||||
@@ -54,7 +54,11 @@ void VoiceEngine::applyLiveToActive() {
|
||||
|
||||
void VoiceEngine::startVoice(Voice& voice, int note, int velocity) {
|
||||
refreshLive();
|
||||
voice.start(note, velocity, sample_, /*declickTakeover=*/takeoverDeclick_);
|
||||
// THE read of the note-on-latched commit class, and the only one: a published block outranks
|
||||
// the snapshot's own copy (a live edit deliberately leaves that stale), and applyLive below
|
||||
// never touches the rate — so a Rate move reaches the next note and no sounding one.
|
||||
const double rate = haveLive_ ? live_.playRate : sample_.play.playRate;
|
||||
voice.start(note, velocity, sample_, /*declickTakeover=*/takeoverDeclick_, rate);
|
||||
if (haveLive_) voice.applyLive(live_, /*snap=*/true);
|
||||
voice.setStartOrder(nextStartOrder_++);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user