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 - **`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. 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. `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 - **`defaultBakeProgram`'s read-rate bound is an upper bound, not a model.** Anything that
offset makes the read head take longer to cross its span, so the window is scaled by the slows the read makes the 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 slowest read the voice can reach — a shallower excursion leaves trailing silence in the file.
in the file. Both the Trigger span and the Gate exhaustion length take it. 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 - **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 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). 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 <cmath>
#include "core/instrument/engine/loop/loop_span.h" // resolveLoop (the one sustain-loop fold) #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/engine/voice.h" // kDeclickFrames (the terminal ramp length)
#include "core/instrument/map/trigger_seam.h" // triggerPlayLength (the one span formula) #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; return true;
} }
// The deepest DOWNWARD pitch offset the dialed voice can reach, in semitones (<= 0). Only // OUTPUT frames per source frame for the dialed voice, at its slowest reachable read — the
// Varispeed needs it: there the read head advances at the pitch ratio, so a downward offset // factor a source span is scaled by to bound how long it takes to play out. Two terms:
// 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. // Rate divides, under BOTH engines: Varispeed folds it into the read increment and Preserve
double downwardSemitones(const PlayParams& play, int velocity) { // feeds the stretcher at it, so either way the source is consumed at that many frames per
if (play.pitchEngine != PitchEngine::Varispeed) return 0.0; // output frame. Taken through the engine's clamp, because that is the value Voice::start
double down = (std::min)(0.0, kVelocityPitchRangeSemitones * // actually plays.
play.pitchVelocityCurve.eval(velocity)); //
if (play.pitchEnv.enabled) { // The deepest DOWNWARD pitch offset stretches, under Varispeed ONLY, where the read head
// A drawn contour is bipolar, so it reaches -|peak| whichever way the depth points; // advances at the pitch ratio. Preserve transposes inside the shifter and leaves the read
// the staged AHD only ever travels between 0 and the peak. // rate alone, which is the only sense in which the two are decoupled there.
down += play.pitchSpline.mode == EnvMode::Spline //
? -std::fabs(play.pitchEnv.peakSemitones) // A Gate release is ticked per output frame, so neither term touches it.
: (std::min)(0.0, play.pitchEnv.peakSemitones); 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) // 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 double rate = static_cast<double>(renderSampleRate);
const auto frameCount = static_cast<std::int64_t>(dialed.frames.size()); const auto frameCount = static_cast<std::int64_t>(dialed.frames.size());
const std::int64_t start = effectiveStart(dialed); const std::int64_t start = effectiveStart(dialed);
const double stretch = const double stretch = playbackStretch(dialed.play, p.velocity.value());
std::pow(2.0, -downwardSemitones(dialed.play, p.velocity.value()) / 12.0);
const double releaseSeconds = static_cast<double>(dialed.play.adsr.releaseFrames) / rate; const double releaseSeconds = static_cast<double>(dialed.play.adsr.releaseFrames) / rate;
double endOffsetSeconds = 0.0; 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): // 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 // 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, 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 // 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. // 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 // 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. // 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 // `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` — // 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. // 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 // 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 // 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. // 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_.peakSemitones = params.peakSemitones;
params_.shape = params.shape; params_.shape = params.shape;
fit_ = fitAhd(span_, params_.shape); fit_ = fitAhd(span_, params_.shape);
@@ -430,9 +435,11 @@ public:
// Live parameter delivery, same rule as AdsrEnvelope::applyLive: hold the normalized // 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 // position within whichever leg the envelope is in, and absorb the depth step (peak is a
// level, not a duration). // level, not a duration). A moved span re-fits under the same rule, so a live Pitch move
void applyLive(const PitchEnvParams& params) { // 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(); const double before = offsetAt();
span_ = spanFrames > 0 ? spanFrames : 0;
const AhdSpan next = fitAhd(span_, params.shape); const AhdSpan next = fitAhd(span_, params.shape);
pos_ = holdPhase(fit_, next); pos_ = holdPhase(fit_, next);
params_.peakSemitones = params.peakSemitones; 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 // 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 // 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. // the control outright — the predicate is spelled the same way advanceFrame spells it.
const bool preserveRead = (pitchEngine_ == PitchEngine::Preserve) && shiftL_.configured(); preserveRead_ = (pitchEngine_ == PitchEngine::Preserve) && shiftL_.configured();
rateRatio_ = preserveRead ? 1.0 : stretchRate_; rateRatio_ = preserveRead_ ? 1.0 : stretchRate_;
recomputeBaseRatio(); 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) // 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. // 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 // 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 // stages lay 1:1 over the waveform from the start point. The source->output conversion, and
// and this envelope counts OUTPUT frames (envelopes.h), so the span has to be divided by the // why it is only first-order, are pitchEnvSpanFrames' own (voice.h).
// rate the read head consumes source at — baseRatio_ under Varispeed, the stretch rate under pitchEnv_.configure(pitchEnvSpanFrames(), p.pitchEnv);
// 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(); pitchEnv_.noteOn();
// A restart lands every live glide back on the new note's own values, at a step derived // 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, // 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 // 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 // latched stretchRate_ is what stageFitRate carries into every conversion below, so a
// move mid-note lands in this note's own rate domain rather than resetting it. // stage-time move mid-note lands in this note's own rate domain rather than resetting it.
const bool gate = (playMode_ == PlayMode::Gate); 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 (snap) {
if (gate) env_.snapLive(live.adsr); if (gate) env_.snapLive(live.adsr);
else ampAhd_.snapLive(rateFittedAhd(live.ampAhd)); else ampAhd_.snapLive(rateFittedAhd(live.ampAhd));
pitchEnv_.snapLive(live.pitchEnv); pitchEnv_.snapLive(pitchEnvSpanFrames(), live.pitchEnv);
} else { } else {
if (gate) env_.applyLive(live.adsr); if (gate) env_.applyLive(live.adsr);
else ampAhd_.applyLive(sourceOffset(), rateFittedAhd(live.ampAhd)); 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 // 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_, // 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), // 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). // legato phrase is one gesture, one strike (classic mono-synth behavior).
if (!active_ || sample_ == nullptr) return; if (!active_ || sample_ == nullptr) return;
note_ = note; note_ = note;
// Changes baseRatio_ without re-converting pitchEnv_'s already-configured span (the // Changes baseRatio_ without re-converting pitchEnv_'s already-configured span
// baseRatio_ division in the note-on setup above), so a slide leaves that envelope on the // (pitchEnvSpanFrames, whose base rate this deliberately does not move), so a slide leaves
// first note's domain — consistent with "touch nothing else," but the drift lives here. // 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_ — // 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. // one gesture, one strike. Rate and the Pitch offset ride through too: only the note moved.
recomputeBaseRatio(); recomputeBaseRatio();
+47 -8
View File
@@ -205,22 +205,53 @@ private:
velPitchRatio_ * pitchOffsetRatio_ * rateRatio_; 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 // 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 // sustain-less envelopes are evaluated in (sourceOffset()). The read stretches the source
// those envelopes are fitted over, but a 30 ms attack is 30 ms at any rate — multiplying by // span those envelopes are fitted over, but a 30 ms attack is 30 ms at any rate —
// the read rate is exactly that conversion. The Varispeed PITCH coupling is deliberately NOT // multiplying by the read rate is exactly that conversion. A fit of exactly 1.0 (Rate 100 %,
// compensated here: it predates Rate and is the shipped behaviour. Rate 1.0 returns the // Pitch 0 st) returns the argument untouched, which is what keeps the unity render
// argument untouched, which is what keeps the unity render bit-identical. // bit-identical.
AhdParams rateFittedAhd(const AhdParams& a) const { 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; AhdParams out = a;
out.attackFrames = 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 = 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; 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 // 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 // function of. Zero-length sample leaves splineScale_ at 0, which parks every contour on
// its opening value. // its opening value.
@@ -679,6 +710,14 @@ private:
double velPitchRatio_ = 1.0; // the velocity->pitch factor alone; retune re-applies it 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 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 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 ratio_ = 1.0; // fractional source frames advanced per output frame (this frame)
double readPos_ = 0.0; // fractional frame index into the sample double readPos_ = 0.0; // fractional frame index into the sample
const SampleData* sample_ = nullptr; const SampleData* sample_ = nullptr;
+9 -4
View File
@@ -8,6 +8,7 @@
#include <cmath> // std::isfinite (wire-value validation) #include <cmath> // std::isfinite (wire-value validation)
#include <utility> // std::move #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/util/curve_law.h" // clampCurve / kCurveNeutral (wire validation)
#include "core/wire/bytes.h" // putLE / ByteReader / doubleToBits (the ONE LE codec) #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 // 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. // blob means and what every instance before them played.
// //
// The two guards are deliberately DIFFERENT. Rate gets finiteness only, because its range is the // The two guards are deliberately DIFFERENT. Rate is RESOLVED through clampStretchRate rather
// stretcher's and clampStretchRate is the one authority on it — a second range test here is // than merely admitted: the stretcher owns its range, so a second copy of the bounds here could
// exactly the second clamp that could disagree. The offset gets a real range test, because // 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 // 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. // exponent there is UB on the per-sample path.
void readRateAndPitchOffset(ByteReader& r, InstrumentParams& p) { void readRateAndPitchOffset(ByteReader& r, InstrumentParams& p) {
@@ -277,7 +282,7 @@ void readRateAndPitchOffset(ByteReader& r, InstrumentParams& p) {
const double rate = bitsToDouble(r.u64()); const double rate = bitsToDouble(r.u64());
const double offset = bitsToDouble(r.u64()); const double offset = bitsToDouble(r.u64());
if (reviveTruncatedTail(r, enteredOk)) return; 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 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 // the velocity->pitch curve speak (play_params.h), reached directly rather than through the
// deck's alias of it. // deck's alias of it.
+13 -3
View File
@@ -36,6 +36,15 @@ double rateSpanOctaves(double minRatio, double maxRatio) {
return std::log2(maxRatio / minRatio); 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 } // namespace
double timeNormFromSeconds(double seconds) { 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 (!(maxRatio > minRatio && minRatio > 0.0)) return 0.5; // degenerate bounds: park at unity
if (!(ratio > minRatio)) return 0.0; // also catches NaN if (!(ratio > minRatio)) return 0.0; // also catches NaN
if (ratio >= maxRatio) return 1.0; 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); 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 (!(maxRatio > minRatio && minRatio > 0.0)) return 1.0;
if (!(norm > 0.0)) return minRatio; // also catches NaN if (!(norm > 0.0)) return minRatio; // also catches NaN
if (norm >= 1.0) return maxRatio; 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 // 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 // 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 // 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. // ratio. Left as the plain exponential, accurate to an ulp.
return minRatio * std::exp2(norm * rateSpanOctaves(minRatio, maxRatio)); 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 // 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 // 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. // 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 // Exactly 1.0 at the norm the bounds themselves put unity at — `-log2(minRatio) / span`, which
// preimage obligation — see rateRatioFromNorm for why it carries no output quantum. // 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 rateNormFromRatio(double ratio, double minRatio, double maxRatio);
double rateRatioFromNorm(double norm, double minRatio, double maxRatio); double rateRatioFromNorm(double norm, double minRatio, double maxRatio);
+11
View File
@@ -50,6 +50,8 @@ InstrumentParams dialed() {
p.play.pitchEnv.peakSemitones = -7.0; p.play.pitchEnv.peakSemitones = -7.0;
p.play.pitchEnv.shape.attackSeconds = 0.05; p.play.pitchEnv.shape.attackSeconds = 0.05;
p.play.pitchVelocityCurve = VelocityCurve::linear(); p.play.pitchVelocityCurve = VelocityCurve::linear();
p.play.playRate = 0.5;
p.play.pitchOffsetSemitones = -7.5;
p.play.filter.enabled = true; p.play.filter.enabled = true;
p.play.filter.modAmount = -0.8; p.play.filter.modAmount = -0.8;
p.play.filter.velAmount = 0.6; p.play.filter.velAmount = 0.6;
@@ -139,6 +141,15 @@ int main() {
CHECK(after.play.pitchEnv.peakSemitones == 0.0); CHECK(after.play.pitchEnv.peakSemitones == 0.0);
CHECK(after.play.pitchEnv.shape.attackSeconds == freshPlay.pitchEnv.shape.attackSeconds); CHECK(after.play.pitchEnv.shape.attackSeconds == freshPlay.pitchEnv.shape.attackSeconds);
// --- RESET: Rate and the baseline Pitch offset -----------------------------------
// Both are processing the bake already printed, so the whitelist leaves them at their
// defaults — the safe direction. A second bake of the result at a still-dialled rate would
// otherwise re-stretch what the first one baked in.
CHECK(after.play.playRate == 1.0);
CHECK(after.play.pitchOffsetSemitones == 0.0);
CHECK(after.play.playRate == freshPlay.playRate);
CHECK(after.play.pitchOffsetSemitones == freshPlay.pitchOffsetSemitones);
// --- RESET: the filter, including its velocity/key-tracking mod ----------------- // --- RESET: the filter, including its velocity/key-tracking mod -----------------
CHECK(!after.play.filter.enabled); CHECK(!after.play.filter.enabled);
CHECK(after.play.filter.modAmount == 0.0); CHECK(after.play.filter.modAmount == 0.0);
+128
View File
@@ -64,6 +64,19 @@ double peakAt(const BakeAudio& audio, std::int64_t from, std::int64_t to) {
return peak; return peak;
} }
// The last frame of the file that carries any signal at all — where the voice ACTUALLY stopped.
// A measurement of the engine, never a second evaluation of the derivation under test. -1 when
// the render is silent throughout.
std::int64_t lastSoundingFrame(const BakeAudio& audio) {
for (std::int64_t f = audio.frameCount() - 1; f >= 0; --f) {
if (std::fabs(static_cast<double>(
audio.interleaved[static_cast<std::size_t>(f * audio.channelCount)])) > kSilence) {
return f;
}
}
return -1;
}
// The derived program, optionally lengthened: `extraMs` widens ONLY the end offset (the same // The derived program, optionally lengthened: `extraMs` widens ONLY the end offset (the same
// sound, a longer window). It leaves the derivation itself untouched, which is what makes the // sound, a longer window). It leaves the derivation itself untouched, which is what makes the
// comparison a measurement of the derived end rather than of a second derivation. // comparison a measurement of the derived end rather than of a second derivation.
@@ -92,6 +105,20 @@ std::int64_t derivedFrames(const SampleData& s, Division hold = oneBar()) {
return plan ? plan->totalFrames : -1; return plan ? plan->totalFrames : -1;
} }
// Where the dialed sound stops when NOTHING cuts it: the same sound programmed with a
// deliberately long note and a window to match. This is the reference a derived window is
// judged against, and it has to be measured rather than recomputed — an under-derived Gate
// window truncates by releasing the note EARLY, which leaves no signal outside the file at all
// and so is invisible to "nothing past the end".
std::int64_t freeRunningEnd(const SampleData& s, double heldSeconds) {
NoteProgram p = defaultBakeProgram(s, kRate, oneBar(), Velocity::of(100));
p.length = lengthOfSeconds(heldSeconds);
p.end = EndOffset(offsetFromMs(200.0));
const std::optional<BakePlan> plan = planOf(p);
if (!plan) { std::printf("FAIL: fixture reference window refused\n"); ++g_fail; return -1; }
return lastSoundingFrame(renderBake(s, *plan, kUnity));
}
// The last frame of the file, which is where a hard cut shows up. // The last frame of the file, which is where a hard cut shows up.
double lastFrameLevel(const BakeAudio& audio) { double lastFrameLevel(const BakeAudio& audio) {
return audio.frameCount() > 0 ? peakAt(audio, audio.frameCount() - 1, audio.frameCount()) return audio.frameCount() > 0 ? peakAt(audio, audio.frameCount() - 1, audio.frameCount())
@@ -337,6 +364,107 @@ int main() {
CHECK(derivedFrames(staged) == 12000 + kPad); CHECK(derivedFrames(staged) == 12000 + kPad);
} }
// ============================ RATE AND PITCH ====================================
// The one judgement every case below makes: the derived window holds the WHOLE free-running
// sound (the derived render stops exactly where the uncut one does), and it is exactly
// enough rather than merely long. `heldSeconds` only has to exceed the free-running length.
const auto windowHoldsTheWholeNote = [&](const SampleData& s, double heldSeconds,
const char* what) {
const std::int64_t trueEnd = freeRunningEnd(s, heldSeconds);
const std::int64_t derived = derivedFrames(s);
const std::int64_t got = lastSoundingFrame(bakeWith(s, 0.0));
const bool held = trueEnd >= 0 && derived > trueEnd && got == trueEnd;
CHECK(held);
CHECK(held && derived - trueEnd <= kPad + 8);
if (!(held && derived - trueEnd <= kPad + 8)) {
std::printf(" %s: free-running end %lld, derived render end %lld, window %lld\n",
what, static_cast<long long>(trueEnd), static_cast<long long>(got),
static_cast<long long>(derived));
}
};
// --- Rate scales the window under BOTH engines, in both derived branches --------------
// Rate IS the read rate: Varispeed folds it into the read increment, Preserve feeds the
// stretcher at it. Either way a 50 % rate doubles how long the source takes to play out and
// a 200 % one halves it, so a window blind to Rate truncates by half at the slow end and
// prints a file of trailing silence at the fast one.
{
for (PitchEngine eng : {PitchEngine::Varispeed, PitchEngine::Preserve}) {
for (PlayMode mode : {PlayMode::Trigger, PlayMode::Gate}) {
for (double rate : {0.5, 0.75, 1.0, 1.5, 2.0}) {
SampleData s = dcSample(48000); // 1 s; 2 s at the slowest rate
s.play.playMode = mode;
s.play.pitchEngine = eng;
s.play.adsr.releaseFrames = 0;
s.play.playRate = rate;
char what[64];
std::snprintf(what, sizeof(what), "eng %d mode %d rate %.2f",
static_cast<int>(eng), static_cast<int>(mode), rate);
windowHoldsTheWholeNote(s, 3.0, what);
}
}
}
}
// --- A downward Pitch offset stretches the window under VARISPEED only ---------------
// It is a factor of the read increment there and a shifter transpose under Preserve, so the
// window follows it in one engine and not the other. Both must still hold the whole note.
{
SampleData s = dcSample(48000);
s.play.playMode = PlayMode::Trigger;
s.play.pitchEngine = PitchEngine::Varispeed;
s.play.pitchOffsetSemitones = -12.0; // half rate for the note's whole lifetime
CHECK(derivedFrames(s) == 96000 + kPad);
windowHoldsTheWholeNote(s, 3.0, "varispeed pitch -12");
SampleData p = s;
p.play.pitchEngine = PitchEngine::Preserve;
CHECK(derivedFrames(p) == 48000 + kPad); // the read rate never moved
windowHoldsTheWholeNote(p, 3.0, "preserve pitch -12");
// An UPWARD offset bounds nothing — the read only gets faster — so the window keeps the
// un-stretched span and the balance is trailing silence, on the same asymmetry the
// velocity->pitch term already takes.
SampleData up = s;
up.play.pitchOffsetSemitones = 12.0;
CHECK(derivedFrames(up) == 48000 + kPad);
const BakeAudio wideUp = bakeWith(up, /*extraMs=*/500.0);
CHECK(peakAt(wideUp, 48000 + kPad, wideUp.frameCount()) == 0.0);
}
// --- Rate and Pitch COMPOUND, because the voice folds them into one multiply ----------
{
SampleData s = dcSample(48000);
s.play.playMode = PlayMode::Trigger;
s.play.pitchEngine = PitchEngine::Varispeed;
s.play.playRate = 0.5;
s.play.pitchOffsetSemitones = -12.0; // together: a quarter-speed read
CHECK(derivedFrames(s) == 192000 + kPad);
windowHoldsTheWholeNote(s, 5.0, "varispeed rate 0.5 x pitch -12");
}
// --- Gate over a sustain loop is Hold's, and Rate does not touch it -------------------
// The note length there is the user's Hold in wall clock and the release is ticked per
// output frame, so neither term of the stretch applies — the one derived branch that must
// NOT move when Rate does.
{
SampleData s = dcSample(48000);
s.loop = SampleLoop{true, 0, 24000};
s.play.playMode = PlayMode::Gate;
s.play.adsr.releaseFrames = 4800;
CHECK(bakeWindowNeedsHold(s));
const std::int64_t unity = derivedFrames(s);
for (double rate : {0.5, 2.0}) {
SampleData r = s;
r.play.playRate = rate;
CHECK(derivedFrames(r) == unity);
}
}
// ============================== VELOCITY ======================================== // ============================== VELOCITY ========================================
// --- The bake renders at the velocity it is handed ---------------------------------- // --- The bake renders at the velocity it is handed ----------------------------------
+13 -4
View File
@@ -10,6 +10,7 @@
#include "../src/core/instrument/engine/envelopes.h" // AhdEnvelope (header-only: the codec #include "../src/core/instrument/engine/envelopes.h" // AhdEnvelope (header-only: the codec
// links no engine, and this adds none) // links no engine, and this adds none)
#include "../src/core/instrument/engine/master_gain.h" // masterGainMaxLinear (the v8 wire cap) #include "../src/core/instrument/engine/master_gain.h" // masterGainMaxLinear (the v8 wire cap)
#include "../src/core/instrument/engine/time_stretch.h" // the rate bounds the codec clamps to
#include "../src/core/util/curve_law.h" // kCurveNeutral (the migration neutral) #include "../src/core/util/curve_law.h" // kCurveNeutral (the migration neutral)
#include <cmath> #include <cmath>
@@ -1544,10 +1545,12 @@ static void testRateAndPitchOffsetRoundTripAndV15LiftsToUnity() {
CHECK(PlaySeconds{}.pitchOffsetSemitones == 0.0); CHECK(PlaySeconds{}.pitchOffsetSemitones == 0.0);
} }
// Neither field has a clamp of its own downstream that could rescue a corrupt blob: the rate // Corruption degrades to the neutral, and an out-of-RANGE rate resolves through the stretcher's
// multiplies a read increment (the engine's own clampStretchRate is the one authority on its // own clamp rather than surviving unclamped: playback would clamp it anyway, so a stored value
// RANGE, so the codec only refuses the unusable) and the offset feeds a 2^(x/12) whose result // that did not would leave the needle — and the host normalization, once the instrument reports
// reaches a per-sample cast. Both degrade to their neutral rather than through. // parameters — disagreeing with what is actually played. The offset has no such downstream clamp
// at all (it feeds a 2^(x/12) that reaches a per-sample cast), so it gets a real range test and
// degrades whole.
static void testCorruptRateOrOffsetDegradesToTheNeutral() { static void testCorruptRateOrOffsetDegradesToTheNeutral() {
const double nan = std::numeric_limits<double>::quiet_NaN(); const double nan = std::numeric_limits<double>::quiet_NaN();
const struct { double rate; double offset; double wantRate; double wantOffset; } cases[] = { const struct { double rate; double offset; double wantRate; double wantOffset; } cases[] = {
@@ -1556,6 +1559,12 @@ static void testCorruptRateOrOffsetDegradesToTheNeutral() {
{0.0, 3.0, 1.0, 3.0}, // a zero rate would stall the read head {0.0, 3.0, 1.0, 3.0}, // a zero rate would stall the read head
{-1.0, 3.0, 1.0, 3.0}, // and a negative one would run it backwards {-1.0, 3.0, 1.0, 3.0}, // and a negative one would run it backwards
{std::numeric_limits<double>::infinity(), 3.0, 1.0, 3.0}, {std::numeric_limits<double>::infinity(), 3.0, 1.0, 3.0},
// Finite but out of the stretcher's range — reachable from a downgrade, not corruption.
// Clamped to the bound the engine would have played, not left to re-serialize.
{10.0, 3.0, instrument::engine::kStretchRateMax, 3.0},
{0.01, 3.0, instrument::engine::kStretchRateMin, 3.0},
{instrument::engine::kStretchRateMin, 3.0, instrument::engine::kStretchRateMin, 3.0}, // the bounds themselves
{instrument::engine::kStretchRateMax, 3.0, instrument::engine::kStretchRateMax, 3.0}, // survive untouched
{0.75, 1e9, 0.75, 0.0}, // past the +/-24 st throw {0.75, 1e9, 0.75, 0.0}, // past the +/-24 st throw
{0.75, -1e9, 0.75, 0.0}, {0.75, -1e9, 0.75, 0.0},
{0.75, 24.0, 0.75, 24.0}, // the throw itself is IN range {0.75, 24.0, 0.75, 24.0}, // the throw itself is IN range
+6
View File
@@ -317,6 +317,12 @@ static void testEveryDefaultHasAnExactNormalizedPreimage() {
CHECK(deckParamNorm(DeckParam::kTrigLength, d) == d.trigger.lengthFraction); CHECK(deckParamNorm(DeckParam::kTrigLength, d) == d.trigger.lengthFraction);
CHECK(deckParamNorm(DeckParam::kTrigHold, d) == d.trigAhd.holdFraction); CHECK(deckParamNorm(DeckParam::kTrigHold, d) == d.trigAhd.holdFraction);
CHECK(deckBipolarFromNorm(deckParamNorm(DeckParam::kFilterModAmt, d)) == d.filter.modAmount); CHECK(deckBipolarFromNorm(deckParamNorm(DeckParam::kFilterModAmt, d)) == d.filter.modAmount);
// The PITCH/RATE pair. Rate's preimage is the taper's unity detent, which sits at true
// centre only because these bounds are reciprocal; Pitch's is the depth taper's exact zero.
CHECK(rateRatioFromNorm(deckParamNorm(DeckParam::kRate, d), kRateMinRatio, kRateMaxRatio) ==
d.playRate);
CHECK(depthSemitonesFromNorm(deckParamNorm(DeckParam::kPitch, d), kPitchDepthMaxSemis) ==
d.pitchOffsetSemitones);
CHECK(util::curveFromKnobNorm(deckParamNorm(DeckParam::kAttackCurve, d)) == CHECK(util::curveFromKnobNorm(deckParamNorm(DeckParam::kAttackCurve, d)) ==
d.adsr.attackCurve); d.adsr.attackCurve);
// Master gain's unity: the case where a hair off is an audible gain error rather than a // Master gain's unity: the case where a hair off is an audible gain error rather than a
+94 -4
View File
@@ -211,7 +211,7 @@ static void testPitchEnvelopeHoldsPhaseAndGlidesDepth() {
PitchEnvParams longer = p; PitchEnvParams longer = p;
longer.shape.decayFrames = 2000; longer.shape.decayFrames = 2000;
b.applyLive(longer); // decay doubled mid-decay b.applyLive(100000, longer); // decay doubled mid-decay, same span
CHECK(a.tick() == b.tick()); // phi held: the semitone offset is unchanged this frame CHECK(a.tick() == b.tick()); // phi held: the semitone offset is unchanged this frame
// A depth move is a level step, so it glides rather than jumping: the first frame after // A depth move is a level step, so it glides rather than jumping: the first frame after
@@ -224,7 +224,7 @@ static void testPitchEnvelopeHoldsPhaseAndGlidesDepth() {
for (int i = 0; i < 400; ++i) { c.tick(); d.tick(); } for (int i = 0; i < 400; ++i) { c.tick(); d.tick(); }
PitchEnvParams noDepth = p; PitchEnvParams noDepth = p;
noDepth.peakSemitones = 0.0; noDepth.peakSemitones = 0.0;
c.applyLive(noDepth); // depth to zero mid-decay c.applyLive(100000, noDepth); // depth to zero mid-decay
CHECK(c.tick() == d.tick()); CHECK(c.tick() == d.tick());
// ...and it does eventually reach the new depth rather than staying put. // ...and it does eventually reach the new depth rather than staying put.
for (int i = 0; i < 400; ++i) c.tick(); for (int i = 0; i < 400; ++i) c.tick();
@@ -259,7 +259,7 @@ static void testPitchEnvelopeHoldStagePlaysAndHoldsPhase() {
for (int i = 0; i < 300; ++i) f.tick(); for (int i = 0; i < 300; ++i) f.tick();
PitchEnvParams wider = p; PitchEnvParams wider = p;
wider.shape.holdFraction = 1.0; wider.shape.holdFraction = 1.0;
f.applyLive(wider); f.applyLive(1000, wider);
CHECK(f.tick() == 12.0); CHECK(f.tick() == 12.0);
for (int i = 0; i < 1200; ++i) f.tick(); for (int i = 0; i < 1200; ++i) f.tick();
CHECK(f.tick() == 0.0); CHECK(f.tick() == 0.0);
@@ -307,7 +307,7 @@ static void testAFreshPitchEnvelopeTakesTheNewTimesOutright() {
PitchEnvParams dialled = stale; PitchEnvParams dialled = stale;
dialled.peakSemitones = 12.0; dialled.peakSemitones = 12.0;
dialled.shape.decayFrames = 1000; dialled.shape.decayFrames = 1000;
env.snapLive(dialled); env.snapLive(100000, dialled);
CHECK(env.tick() == 12.0); // at the top of the new decay leg, not past the envelope CHECK(env.tick() == 12.0); // at the top of the new decay leg, not past the envelope
for (int i = 0; i < 499; ++i) env.tick(); for (int i = 0; i < 499; ++i) env.tick();
CHECK(std::fabs(env.tick() - 6.0) < 1e-12); CHECK(std::fabs(env.tick() - 6.0) < 1e-12);
@@ -850,6 +850,94 @@ static void testAPitchOffsetChangeMovesTheSoundingNoteInBothEngines() {
} }
} }
// --- The live Pitch offset reaches the note's TIME domains, not only its pitch -------------
// A block published BEFORE the note starts is the snapLive path, and the snapshot's own copy of
// the offset is deliberately stale there — so this is where a Pitch offset has to be in hand
// already when the note's envelopes are fitted against the read rate. Answers how many output
// frames the voice sounded for, to a 256-frame block.
static std::size_t soundingBlocksWithPublishedPitch(SampleData& s, double offsetSemis,
std::size_t capFrames) {
LiveParams block;
LiveValues v = foldLive(s.play); // s.play keeps its own (zero) offset: the stale copy
v.pitchOffsetSemitones = offsetSemis;
block.publish(v);
s.live = &block;
VoiceEngine engine(1, s, /*preserveVoiceCap=*/0, /*preserveWindowFrames=*/2048);
engine.noteOn(60, 127);
std::vector<AudioSample> out;
std::size_t life = 0;
while (out.size() < capFrames && engine.activeVoiceCount() > 0) {
engine.render(out, 256);
life = out.size();
}
return life;
}
// Under Varispeed the Pitch offset is a factor of the read increment, and the staged AHD is
// evaluated at the SOURCE offset that increment advances — so its stage frames are fitted to the
// offset the note will ACTUALLY play at, exactly as they are to Rate. The attack therefore
// completes on the same output frame at every offset. Fitting against the snapshot's stale zero
// instead is what this catches.
static void testAPublishedPitchOffsetLeavesTheStagedAttackWallClock() {
constexpr std::int64_t kAttack = 2000;
for (double semis : {-12.0, 0.0, 12.0}) {
SampleData s;
s.frames.assign(96000, 1.0f); // DC: the output IS the amp envelope
s.sampleRate = kRate;
s.rootNote = 60;
s.play.playMode = PlayMode::Trigger;
s.play.pitchEngine = PitchEngine::Varispeed;
s.play.trigAhd = AhdParams{kAttack, 0, 1.0, util::kCurveNeutral, util::kCurveNeutral};
LiveParams block;
LiveValues v = foldLive(s.play);
v.pitchOffsetSemitones = semis;
block.publish(v);
s.live = &block;
VoiceEngine engine(1, s, /*preserveVoiceCap=*/0, /*preserveWindowFrames=*/2048);
engine.noteOn(60, 127);
std::vector<AudioSample> out;
engine.render(out, 8000);
std::size_t reachedFull = 0;
for (std::size_t i = 0; i < out.size(); ++i) {
if (out[i] > 0.99f) { reachedFull = i; break; }
}
const bool ok = reachedFull > 0 &&
std::fabs(static_cast<double>(reachedFull) -
static_cast<double>(kAttack)) < 40.0;
CHECK(ok);
if (!ok) std::printf(" pitch %+.1f st: attack completed at %zu\n", semis, reachedFull);
}
}
// The pitch envelope's SPAN is a wall-clock duration converted from the same read rate, so it
// follows the published offset too. Read out as the note's LIFETIME: the envelope's depth
// cancels the offset while it holds, so the read runs at unity for the hold and at the offset
// ratio after it — which makes the lifetime a direct readout of where the hold ended.
// 12000 source frames, offset -12 st (read at 0.5): the span is 24000 output frames, its
// half-span hold is 12000 of them at unity, and the source is exhausted exactly there.
// A span fitted to the stale zero offset is 12000, holds for 6000, and the remaining 6000
// source frames then take 12000 more output frames — 18000 in total.
static void testAPublishedPitchOffsetRefitsThePitchEnvelopeSpan() {
SampleData s;
s.frames.assign(12000, 1.0f);
s.sampleRate = kRate;
s.rootNote = 60;
s.play.playMode = PlayMode::Trigger;
s.play.pitchEngine = PitchEngine::Varispeed;
s.play.trigAhd = AhdParams{0, 0, 1.0, util::kCurveNeutral, util::kCurveNeutral};
s.play.pitchEnv.enabled = true;
s.play.pitchEnv.peakSemitones = 12.0; // cancels the -12 offset while it holds
s.play.pitchEnv.shape.attackFrames = 0;
s.play.pitchEnv.shape.decayFrames = 0;
s.play.pitchEnv.shape.holdFraction = 0.5;
const std::size_t life = soundingBlocksWithPublishedPitch(s, -12.0, 60000);
CHECK(life > 11000 && life < 13000);
if (!(life > 11000 && life < 13000)) std::printf(" refit span: life %zu\n", life);
}
// --- What stays latched at note-on ------------------------------------------------------- // --- What stays latched at note-on -------------------------------------------------------
static void testPitchRatioAndVelocityGainStayLatched() { static void testPitchRatioAndVelocityGainStayLatched() {
@@ -991,6 +1079,8 @@ int main() {
testOneBlockServesTwoIndependentObservers(); testOneBlockServesTwoIndependentObservers();
testARateChangeSpareTheSoundingNoteAndReachesTheNextOne(); testARateChangeSpareTheSoundingNoteAndReachesTheNextOne();
testAPitchOffsetChangeMovesTheSoundingNoteInBothEngines(); testAPitchOffsetChangeMovesTheSoundingNoteInBothEngines();
testAPublishedPitchOffsetLeavesTheStagedAttackWallClock();
testAPublishedPitchOffsetRefitsThePitchEnvelopeSpan();
testPitchRatioAndVelocityGainStayLatched(); testPitchRatioAndVelocityGainStayLatched();
testVelocityGainSurvivesAHostilePublishThatReallyLands(); testVelocityGainSurvivesAHostilePublishThatReallyLands();
if (g_fail == 0) std::printf("live_delivery tests passed\n"); if (g_fail == 0) std::printf("live_delivery tests passed\n");
+34
View File
@@ -295,6 +295,39 @@ static void testRateDefaultAndEndpointsRoundTripBitwise() {
} }
} }
// The exact-unity detent is DERIVED from the bounds, not assumed to sit at centre. The shipped
// bounds are reciprocal so the two agree today, but they are a MEASURED range: re-measure them
// asymmetric and a detent pinned to 0.5 makes the map fold back on itself around centre. Run at
// a deliberately non-reciprocal pair, which is exactly the case the ratio-of-ratios and
// round-trip tests above would still have passed.
static void testRateDetentFollowsAsymmetricBoundsInsteadOfCentre() {
constexpr double kLo = 0.4;
constexpr double kHi = 3.0; // kLo * kHi == 1.2, so unity is NOT at 0.5
const double unity = rateNormFromRatio(1.0, kLo, kHi);
CHECK(unity > 0.0 && unity < 1.0);
CHECK(std::fabs(unity - 0.5) > 0.01); // the case a 0.5 detent gets wrong
CHECK(rateRatioFromNorm(unity, kLo, kHi) == 1.0); // ...and unity is still EXACT there
double prev = -1.0;
for (int i = 0; i <= 200000; ++i) {
const double v = rateRatioFromNorm(static_cast<double>(i) / 200000.0, kLo, kHi);
CHECK(v >= prev);
if (v < prev) { std::printf(" asymmetric fold at i=%d\n", i); return; }
prev = v;
}
// That sweep steps OVER the detent rather than onto it, so walk its immediate neighbourhood
// too — a misplaced exact case shows up there and nowhere else.
for (int k = -8; k < 8; ++k) {
const double a = rateRatioFromNorm(unity + static_cast<double>(k) * 1e-9, kLo, kHi);
const double b = rateRatioFromNorm(unity + static_cast<double>(k + 1) * 1e-9, kLo, kHi);
CHECK(b >= a);
if (!(b >= a)) { std::printf(" detent fold at k=%d\n", k); return; }
}
// And the shipped reciprocal bounds still put unity at true knob centre: the general rule
// reproduces the special case rather than replacing it.
CHECK(rateNormFromRatio(1.0, kRateMin, kRateMax) == 0.5);
}
// Degenerate bounds are a caller bug, not a crash: the map collapses to unity. // Degenerate bounds are a caller bug, not a crash: the map collapses to unity.
static void testDegenerateRateBoundsCollapseToUnity() { static void testDegenerateRateBoundsCollapseToUnity() {
CHECK(rateRatioFromNorm(0.3, 2.0, 0.5) == 1.0); CHECK(rateRatioFromNorm(0.3, 2.0, 0.5) == 1.0);
@@ -394,6 +427,7 @@ int main() {
testRateIsLinearInSemitonesAcrossTheWholeTravel(); testRateIsLinearInSemitonesAcrossTheWholeTravel();
testRateIsMonotone(); testRateIsMonotone();
testRateDefaultAndEndpointsRoundTripBitwise(); testRateDefaultAndEndpointsRoundTripBitwise();
testRateDetentFollowsAsymmetricBoundsInsteadOfCentre();
testDegenerateRateBoundsCollapseToUnity(); testDegenerateRateBoundsCollapseToUnity();
testMillisecondSnap(); testMillisecondSnap();
+177
View File
@@ -3251,6 +3251,179 @@ static void testRateScalesTheLoopPeriodWithoutMovingItsStoredFrames() {
} }
} }
// Preserve's half of the loop claim, and it is the OPPOSITE of the Varispeed one — written down
// here because the obvious extension of the test above is WRONG. Preserve consumes the loop at
// `rate` source frames per output frame, so the TRAVERSAL scales (the feed-side witness in
// testPreserveStretchLoopsTheSourceSpan measures that directly); what the listener hears does
// not, because holding the source's period while its duration changes is the definition of the
// engine. Measured with a ring long enough to hold the whole loop, so the reading is the design
// property rather than splice cadence — at shorter rings the same fixture measured 3064 and 4130
// frames at rate 0.5 (windows 1024 and 2048), neither of which is the 8000 a scaling period
// would give either.
static void testPreserveHoldsTheLoopsAudiblePeriodWhileRateMovesItsTraversal() {
constexpr std::int64_t kLoopStart = 4000;
constexpr std::int64_t kLoopEnd = 8000;
SampleData base;
base.frames.assign(20000, 0.0f);
for (std::int64_t i = kLoopStart; i < kLoopEnd; ++i) {
base.frames[static_cast<std::size_t>(i)] =
static_cast<float>(i - kLoopStart) / static_cast<float>(kLoopEnd - kLoopStart);
}
base.rootNote = 60;
base.startFrame = kLoopStart;
base.loop = SampleLoop{true, kLoopStart, kLoopEnd};
base.play.adsr = flatAdsr();
base.play.pitchEngine = PitchEngine::Preserve;
auto sawPeriod = [](const std::vector<AudioSample>& v) {
double sum = 0.0;
std::size_t prev = 0, count = 0;
for (std::size_t i = 1; i < v.size(); ++i) {
if (v[i - 1] <= 0.5f && v[i] > 0.5f) {
if (count > 0) sum += static_cast<double>(i - prev);
prev = i;
++count;
}
}
return count > 1 ? sum / static_cast<double>(count - 1) : 0.0;
};
for (double rate : {1.0, 0.5, 2.0}) {
SampleData s = base;
s.play.playRate = rate;
Voice v;
v.presizePreserveShifters(8192); // > the 4000-frame loop
v.start(60, 127, s, /*declickTakeover=*/false, rate);
std::vector<AudioSample> out(40000, 0.0f);
for (std::size_t i = 0; i < out.size(); ++i) out[i] = v.renderFrame();
const double period = sawPeriod(out);
CHECK(approx(period, 4000.0, 40.0));
if (!approx(period, 4000.0, 40.0)) std::printf(" rate %.2f period %.1f\n", rate, period);
// And the marks the waveform draws are source-frame FACTS the engine only ever reads.
CHECK(s.loop.start == kLoopStart);
CHECK(s.loop.end == kLoopEnd);
CHECK(s.startFrame == kLoopStart);
}
}
// The other half of the same rule, which nothing asserted: a drawn contour is a pure function of
// NORMALIZED sample position, so it follows the read head and its wall-clock shape scales by
// 1/rate — under BOTH engines, since both advance that head at the rate. Measured as the output
// frame the contour's own half-way point arrives on, which is what a listener hears move.
static void testADrawnContourScalesWithRateInBothEngines() {
for (PitchEngine eng : {PitchEngine::Varispeed, PitchEngine::Preserve}) {
double atUnity = 0.0;
for (double rate : {1.0, 0.5, 2.0}) {
SampleData s = dcSample(24000);
s.play.playMode = PlayMode::Trigger;
s.play.pitchEngine = eng;
s.play.playRate = rate;
s.play.ampSpline.mode = EnvMode::Spline;
s.play.ampSpline.contour = VelocityCurve::linear(); // 0 -> 1 across the sample
Voice v;
v.presizePreserveShifters(1024);
v.start(60, 127, s, /*declickTakeover=*/false, rate);
double halfway = 0.0;
for (std::size_t i = 0; i < 80000 && v.active(); ++i) {
const double y = static_cast<double>(v.renderFrame());
if (halfway == 0.0 && y > 0.5) halfway = static_cast<double>(i);
}
CHECK(halfway > 0.0);
if (rate == 1.0) atUnity = halfway;
// 12000 source frames in at unity; twice as many output frames at half rate.
else CHECK(approx(halfway, atUnity / rate, atUnity * 0.02));
if (rate != 1.0 && !approx(halfway, atUnity / rate, atUnity * 0.02)) {
std::printf(" eng %d rate %.2f: halfway %.0f, wanted %.0f\n",
static_cast<int>(eng), rate, halfway, atUnity / rate);
}
}
}
}
// Pitch is the same multiply as Rate under Varispeed, so the same rule binds it: a staged stage
// time is OF THE PERFORMANCE and does not scale. The AHD is the case that can go wrong, since it
// is evaluated at the SOURCE offset — which a Pitch offset advances faster or slower. Under
// Preserve the offset never touches the read, so the same attack lands on the same frame there
// for a different reason; asserted in both so the compensation cannot be applied to the wrong
// engine. Key-tracking is deliberately NOT compensated, and the last block pins that too.
static void testAPitchOffsetLeavesTheStagedAttackWallClockUnderVarispeed() {
constexpr std::int64_t kAttack = 2000;
SampleData base = dcSample(48000);
base.play.playMode = PlayMode::Trigger;
base.play.trigAhd = AhdParams{kAttack, 0, 1.0, util::kCurveNeutral, util::kCurveNeutral};
const auto attackFrame = [](const SampleData& s, int note) {
Voice v;
v.presizePreserveShifters(1024);
v.start(note, 127, s, /*declickTakeover=*/false, s.play.playRate);
for (std::size_t i = 0; i < 200000 && v.active(); ++i) {
if (static_cast<double>(v.renderFrame()) > 0.99) return static_cast<double>(i);
}
return -1.0;
};
for (PitchEngine eng : {PitchEngine::Varispeed, PitchEngine::Preserve}) {
for (double semis : {-12.0, -5.0, 0.0, 7.0, 12.0}) {
SampleData s = base;
s.play.pitchEngine = eng;
s.play.pitchOffsetSemitones = semis;
const double got = attackFrame(s, 60);
CHECK(approx(got, static_cast<double>(kAttack), 40.0));
if (!approx(got, static_cast<double>(kAttack), 40.0)) {
std::printf(" eng %d pitch %+.1f st: attack completed at %.0f\n",
static_cast<int>(eng), semis, got);
}
}
}
// Key-tracking stays UNCOMPENSATED on purpose — it is a shipped sound, and compensating it
// would move every note off the root. An octave up therefore completes the attack in half
// the output frames, which is exactly the behaviour Pitch above does not have.
SampleData vari = base;
vari.play.pitchEngine = PitchEngine::Varispeed;
CHECK(approx(attackFrame(vari, 72), static_cast<double>(kAttack) / 2.0, 40.0));
}
// --- The Varispeed null case, baselined so the NEXT track's claim is measured. ---
// Unlike the Preserve hashes above, these were captured from THIS commit rather than witnessed
// against the pre-track one, and that difference is the whole reason the comment says so: the
// pre-track equality is proved structurally instead, and cheaply — at Rate 100 % and Pitch 0 st
// both new factors of recomputeBaseRatio's product are EXACTLY 1.0 (semitoneRatio short-circuits
// at zero; the clamp returns 1.0 for 1.0), and multiplying a double by 1.0 is bit-exact, so the
// read increment is the pre-track engine's own. What these constants add is a witness for the
// track AFTER this one. A change here is a change to what every already-saved project sounds
// like — re-derive the cause before re-baselining.
static void testVarispeedUnityRateAndPitchAreBitIdenticalToTheirBaseline() {
const std::size_t n = 6000;
struct Case { int note; bool stereo; bool loop; std::uint64_t hashL; std::uint64_t hashR; };
const Case cases[] = {
{60, false, false, 5964955069002935931ull, 0ull}, // on root: unity read
{67, false, false, 134881748704183217ull, 0ull}, // +7 st
{55, false, false, 11914283967735558216ull, 0ull}, // -5 st
{67, true, true, 11674273643338193955ull, 15241091931688620298ull}, // stereo + loop
};
for (const Case& c : cases) {
SampleData s = stretchProbeSample(4000, c.stereo);
s.play.pitchEngine = PitchEngine::Varispeed;
if (c.loop) {
s.loop.hasLoop = true;
s.loop.start = 1200;
s.loop.end = 3600;
s.loopCrossfadeFrames = 256;
}
std::vector<AudioSample> l(n), r(c.stereo ? n : 0);
renderVoice(s, c.note, /*rate=*/1.0, /*window=*/2205, c.stereo, l, r);
const std::uint64_t hl = hashStream(l);
CHECK(hl == c.hashL);
if (hl != c.hashL) std::printf(" varispeed note %d L hash %lluull\n", c.note, hl);
if (c.stereo) {
const std::uint64_t hr = hashStream(r);
CHECK(hr == c.hashR);
if (hr != c.hashR) std::printf(" varispeed note %d R hash %lluull\n", c.note, hr);
}
}
}
// The asymmetry the spec is explicit about: a contour is OF THE SAMPLE and scales with Rate, a // The asymmetry the spec is explicit about: a contour is OF THE SAMPLE and scales with Rate, a
// staged envelope is OF THE PERFORMANCE and does not. Trigger's AHD is the case that could go // staged envelope is OF THE PERFORMANCE and does not. Trigger's AHD is the case that could go
// wrong — it is evaluated at the SOURCE offset, which advances at the rate — so its stage frames // wrong — it is evaluated at the SOURCE offset, which advances at the rate — so its stage frames
@@ -3639,6 +3812,10 @@ int main() {
testKeyTrackRateAndPitchOffsetResolveToOneMultiply(); testKeyTrackRateAndPitchOffsetResolveToOneMultiply();
testPreserveRoutesRateToDurationAndTheOffsetToPitch(); testPreserveRoutesRateToDurationAndTheOffsetToPitch();
testRateScalesTheLoopPeriodWithoutMovingItsStoredFrames(); testRateScalesTheLoopPeriodWithoutMovingItsStoredFrames();
testPreserveHoldsTheLoopsAudiblePeriodWhileRateMovesItsTraversal();
testADrawnContourScalesWithRateInBothEngines();
testAPitchOffsetLeavesTheStagedAttackWallClockUnderVarispeed();
testVarispeedUnityRateAndPitchAreBitIdenticalToTheirBaseline();
testStagedStageTimesDoNotScaleWithRateWhileTheSpanDoes(); testStagedStageTimesDoNotScaleWithRateWhileTheSpanDoes();
testPreserveStretchSpeaksOnFrameZeroAtEveryRate(); testPreserveStretchSpeaksOnFrameZeroAtEveryRate();
testPreserveStretchLoopsTheSourceSpan(); testPreserveStretchLoopsTheSourceSpan();