Bake window: derive it from the rate the voice actually reads at, so a dialled Rate or downward Pitch no longer truncates the file

This commit is contained in:
2026-08-02 06:30:59 -04:00
parent 248f2f3842
commit cbe2369037
16 changed files with 609 additions and 74 deletions
+6 -4
View File
@@ -67,10 +67,12 @@ decision about what the render made obsolete.
- **`BakePlan` speaks two frame domains** — the captured file's and the render's, which are
offset from each other whenever the note and the capture window do not start together.
`bake_plan.h` says which field is in which; do not read them as one clock.
- **`defaultBakeProgram`'s Varispeed bound is an upper bound, not a model.** A downward pitch
offset makes the read head take longer to cross its span, so the window is scaled by the
deepest downward offset the voice can reach — a shallower excursion leaves trailing silence
in the file. Both the Trigger span and the Gate exhaustion length take it.
- **`defaultBakeProgram`'s read-rate bound is an upper bound, not a model.** Anything that
slows the read makes the head take longer to cross its span, so the window is scaled by the
slowest read the voice can reach — a shallower excursion leaves trailing silence in the file.
Rate is a term of it under BOTH engines and the deepest downward pitch offset under Varispeed
alone (`playbackStretch` argues each); both the Trigger span and the Gate exhaustion length
take the product, and the Gate-with-loop branch takes neither.
- **The bake fires at the instance's PREVIEW velocity, not a constant.** Three velocity curves
are live, so the velocity is a property of the sound being printed and not a detail of the
render; it also feeds the Varispeed bound above (a velocity→pitch curve moves the window).
+31 -17
View File
@@ -6,6 +6,7 @@
#include <cmath>
#include "core/instrument/engine/loop/loop_span.h" // resolveLoop (the one sustain-loop fold)
#include "core/instrument/engine/time_stretch.h" // clampStretchRate (THE rate bound)
#include "core/instrument/engine/voice.h" // kDeclickFrames (the terminal ramp length)
#include "core/instrument/map/trigger_seam.h" // triggerPlayLength (the one span formula)
@@ -28,22 +29,36 @@ bool toFrames(double seconds, int rate, std::int64_t& out) {
return true;
}
// The deepest DOWNWARD pitch offset the dialed voice can reach, in semitones (<= 0). Only
// Varispeed needs it: there the read head advances at the pitch ratio, so a downward offset
// stretches how long the source takes to play out. Preserve decouples the two, and a Gate
// release is ticked per output frame, so neither is affected.
double downwardSemitones(const PlayParams& play, int velocity) {
if (play.pitchEngine != PitchEngine::Varispeed) return 0.0;
double down = (std::min)(0.0, kVelocityPitchRangeSemitones *
play.pitchVelocityCurve.eval(velocity));
if (play.pitchEnv.enabled) {
// A drawn contour is bipolar, so it reaches -|peak| whichever way the depth points;
// the staged AHD only ever travels between 0 and the peak.
down += play.pitchSpline.mode == EnvMode::Spline
? -std::fabs(play.pitchEnv.peakSemitones)
: (std::min)(0.0, play.pitchEnv.peakSemitones);
// OUTPUT frames per source frame for the dialed voice, at its slowest reachable read — the
// factor a source span is scaled by to bound how long it takes to play out. Two terms:
//
// Rate divides, under BOTH engines: Varispeed folds it into the read increment and Preserve
// feeds the stretcher at it, so either way the source is consumed at that many frames per
// output frame. Taken through the engine's clamp, because that is the value Voice::start
// actually plays.
//
// The deepest DOWNWARD pitch offset stretches, under Varispeed ONLY, where the read head
// advances at the pitch ratio. Preserve transposes inside the shifter and leaves the read
// rate alone, which is the only sense in which the two are decoupled there.
//
// A Gate release is ticked per output frame, so neither term touches it.
double playbackStretch(const PlayParams& play, int velocity) {
double down = 0.0;
if (play.pitchEngine == PitchEngine::Varispeed) {
down = (std::min)(0.0, kVelocityPitchRangeSemitones *
play.pitchVelocityCurve.eval(velocity));
// Taken as a bound rather than exactly, like the velocity term beside it: an upward
// offset only makes the read faster, and every term in this sum is a floor.
down += (std::min)(0.0, play.pitchOffsetSemitones);
if (play.pitchEnv.enabled) {
// A drawn contour is bipolar, so it reaches -|peak| whichever way the depth points;
// the staged AHD only ever travels between 0 and the peak.
down += play.pitchSpline.mode == EnvMode::Spline
? -std::fabs(play.pitchEnv.peakSemitones)
: (std::min)(0.0, play.pitchEnv.peakSemitones);
}
}
return down;
return std::pow(2.0, -down / 12.0) / engine::clampStretchRate(play.playRate);
}
// Voice::start's own clamp: a start at or past the end degrades to 0 (play from the top)
@@ -78,8 +93,7 @@ NoteProgram defaultBakeProgram(const SampleData& dialed, int renderSampleRate,
const double rate = static_cast<double>(renderSampleRate);
const auto frameCount = static_cast<std::int64_t>(dialed.frames.size());
const std::int64_t start = effectiveStart(dialed);
const double stretch =
std::pow(2.0, -downwardSemitones(dialed.play, p.velocity.value()) / 12.0);
const double stretch = playbackStretch(dialed.play, p.velocity.value());
const double releaseSeconds = static_cast<double>(dialed.play.adsr.releaseFrames) / rate;
double endOffsetSeconds = 0.0;
+3 -2
View File
@@ -32,7 +32,8 @@ bool bakeWindowNeedsHold(const SampleData& dialed);
// the bake renders at, which is what the engine's frame counts are consumed against):
//
// Trigger — the note IS the play span (note-off is ignored anyway), stretched by the
// deepest downward Varispeed offset.
// slowest read the dialed voice can reach: Rate under BOTH engines, plus the
// deepest downward pitch offset under Varispeed.
// Gate, loop — `hold` is the note length; the end offset is the release.
// Gate, no loop— the read head runs off the source and frees the voice whatever the gate is
// doing, so the note is the whole post-start span, stretched the same way.
@@ -44,7 +45,7 @@ bool bakeWindowNeedsHold(const SampleData& dialed);
// Every case is padded by the voice's terminal declick ramp (kDeclickFrames): trailing
// silence is free, and closing the window on the frame the ramp starts is a hard cut.
// `hold` is read only in the Gate-with-loop case; `velocity` is the velocity the note fires
// at, and it feeds the Varispeed stretch as well as the render.
// at, and it feeds the Varispeed half of that stretch as well as the render.
//
// Takes no tempo: nothing derived here is beat-denominated. The one field that is — `hold` —
// meets the tempo in resolveNote, with the rest of the program's beat-denominated fields.
+10 -3
View File
@@ -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;
+22 -23
View File
@@ -70,9 +70,12 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
// 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_;
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.
@@ -133,16 +136,9 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
}
// 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 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);
// 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
@@ -273,22 +269,24 @@ void Voice::applyLive(const instrument::engine::LiveValues& live, bool snap) {
//
// 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.
// 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(rateFittedAhd(live.ampAhd));
pitchEnv_.snapLive(live.pitchEnv);
pitchEnv_.snapLive(pitchEnvSpanFrames(), live.pitchEnv);
} else {
if (gate) env_.applyLive(live.adsr);
else ampAhd_.applyLive(sourceOffset(), rateFittedAhd(live.ampAhd));
pitchEnv_.applyLive(live.pitchEnv);
pitchEnv_.applyLive(pitchEnvSpanFrames(), 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),
@@ -340,9 +338,10 @@ 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. Rate and the Pitch offset ride through too: only the note moved.
recomputeBaseRatio();
+47 -8
View File
@@ -205,22 +205,53 @@ private:
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()). 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.
// 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 {
if (stretchRate_ == 1.0) return a;
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) * stretchRate_ + 0.5);
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) * stretchRate_ + 0.5);
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.
@@ -679,6 +710,14 @@ private:
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;
+9 -4
View File
@@ -8,6 +8,7 @@
#include <cmath> // std::isfinite (wire-value validation)
#include <utility> // std::move
#include "core/instrument/engine/time_stretch.h" // clampStretchRate (THE rate bound)
#include "core/util/curve_law.h" // clampCurve / kCurveNeutral (wire validation)
#include "core/wire/bytes.h" // putLE / ByteReader / doubleToBits (the ONE LE codec)
@@ -267,9 +268,13 @@ void readLimiterEnable(ByteReader& r, InstrumentParams& p) {
// neutral the field already holds — unity rate, no offset — which is exactly what a pre-v16
// blob means and what every instance before them played.
//
// The two guards are deliberately DIFFERENT. Rate gets finiteness only, because its range is the
// stretcher's and clampStretchRate is the one authority on it — a second range test here is
// exactly the second clamp that could disagree. The offset gets a real range test, because
// The two guards are deliberately DIFFERENT. Rate is RESOLVED through clampStretchRate rather
// than merely admitted: the stretcher owns its range, so a second copy of the bounds here could
// disagree with it — but a value that only playback clamped would re-serialize out of range and
// leave the stored value disagreeing with the needle, and with the host normalization once the
// instrument reports parameters. Finiteness stays a separate test in front of it, because
// corruption is not an out-of-range value: an infinite rate degrades to the neutral, where a
// merely-too-fast one clamps to the bound. The offset gets a real range test instead, because
// nothing downstream bounds it: it reaches 2^(x/12) and then a read increment, and a wild
// exponent there is UB on the per-sample path.
void readRateAndPitchOffset(ByteReader& r, InstrumentParams& p) {
@@ -277,7 +282,7 @@ void readRateAndPitchOffset(ByteReader& r, InstrumentParams& p) {
const double rate = bitsToDouble(r.u64());
const double offset = bitsToDouble(r.u64());
if (reviveTruncatedTail(r, enteredOk)) return;
if (std::isfinite(rate) && rate > 0.0) p.play.playRate = rate;
if (std::isfinite(rate)) p.play.playRate = engine::clampStretchRate(rate);
// The throw is kVelocityPitchRangeSemitones — the SAME +/-24 the pitch envelope's depth and
// the velocity->pitch curve speak (play_params.h), reached directly rather than through the
// deck's alias of it.
+13 -3
View File
@@ -36,6 +36,15 @@ double rateSpanOctaves(double minRatio, double maxRatio) {
return std::log2(maxRatio / minRatio);
}
// The norm the general formula puts unity at, DERIVED from the bounds rather than assumed to be
// centre — it is 0.5 only when minRatio * maxRatio == 1. Both maps below pin their exact-unity
// case to this one expression, so the detent is where the curve already goes and the round trip
// closes bitwise on it. Spelling it 0.5 was correct for the shipped symmetric bounds and would
// have gone non-monotone the moment they were re-measured asymmetric.
double rateUnityNorm(double minRatio, double maxRatio) {
return -std::log2(minRatio) / rateSpanOctaves(minRatio, maxRatio);
}
} // namespace
double timeNormFromSeconds(double seconds) {
@@ -74,7 +83,8 @@ double rateNormFromRatio(double ratio, double minRatio, double maxRatio) {
if (!(maxRatio > minRatio && minRatio > 0.0)) return 0.5; // degenerate bounds: park at unity
if (!(ratio > minRatio)) return 0.0; // also catches NaN
if (ratio >= maxRatio) return 1.0;
if (ratio == 1.0) return 0.5; // the centre detent is EXACT, so unity persists as unity
// The unity detent is EXACT, so unity persists as unity.
if (ratio == 1.0) return rateUnityNorm(minRatio, maxRatio);
return std::log2(ratio / minRatio) / rateSpanOctaves(minRatio, maxRatio);
}
@@ -82,10 +92,10 @@ double rateRatioFromNorm(double norm, double minRatio, double maxRatio) {
if (!(maxRatio > minRatio && minRatio > 0.0)) return 1.0;
if (!(norm > 0.0)) return minRatio; // also catches NaN
if (norm >= 1.0) return maxRatio;
if (norm == 0.5) return 1.0;
if (norm == rateUnityNorm(minRatio, maxRatio)) return 1.0;
// NOT resolved onto a decimal quantum, unlike the two maps above, and the difference is
// principled rather than an omission: this control's only default is unity, which the exact
// centre case above already delivers bitwise, so a grid would buy no preimage it does not
// detent case above already delivers bitwise, so a grid would buy no preimage it does not
// already have — while costing accuracy at every whole semitone, none of which is a decimal
// ratio. Left as the plain exponential, accurate to an ulp.
return minRatio * std::exp2(norm * rateSpanOctaves(minRatio, maxRatio));
+5 -2
View File
@@ -82,8 +82,11 @@ double depthSemitonesFromNorm(double norm, double maxSemitones);
// stretcher, which owns the measurement they came from, and a second copy here could drift from
// it. The map is monotone and hits them exactly at norm 0 and 1, so a norm in [0,1] cannot reach
// a ratio the engine's own clamp would then move — ONE clamp, at the stretcher, not two.
// Exactly 1.0 at norm 0.5 whenever the bounds bracket it, which is this control's whole
// preimage obligation — see rateRatioFromNorm for why it carries no output quantum.
// Exactly 1.0 at the norm the bounds themselves put unity at — `-log2(minRatio) / span`, which
// is 0.5 only when minRatio * maxRatio == 1 — whenever they bracket it. That detent is this
// control's whole preimage obligation; see rateRatioFromNorm for why it carries no output
// quantum. Pinning it to 0.5 regardless of the bounds is the specific mistake to avoid: it makes
// the map non-monotone the moment the stretcher's measured range stops being symmetric.
double rateNormFromRatio(double ratio, double minRatio, double maxRatio);
double rateRatioFromNorm(double norm, double minRatio, double maxRatio);