diff --git a/src/core/instrument/bake/CLAUDE.md b/src/core/instrument/bake/CLAUDE.md index 5a9075a..6c3246a 100644 --- a/src/core/instrument/bake/CLAUDE.md +++ b/src/core/instrument/bake/CLAUDE.md @@ -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). diff --git a/src/core/instrument/bake/bake_plan.cpp b/src/core/instrument/bake/bake_plan.cpp index d991ea5..04820be 100644 --- a/src/core/instrument/bake/bake_plan.cpp +++ b/src/core/instrument/bake/bake_plan.cpp @@ -6,6 +6,7 @@ #include #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(renderSampleRate); const auto frameCount = static_cast(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(dialed.play.adsr.releaseFrames) / rate; double endOffsetSeconds = 0.0; diff --git a/src/core/instrument/bake/bake_plan.h b/src/core/instrument/bake/bake_plan.h index b5ea3a1..c8def2c 100644 --- a/src/core/instrument/bake/bake_plan.h +++ b/src/core/instrument/bake/bake_plan.h @@ -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. diff --git a/src/core/instrument/engine/envelopes.h b/src/core/instrument/engine/envelopes.h index 7818a96..53baef1 100644 --- a/src/core/instrument/engine/envelopes.h +++ b/src/core/instrument/engine/envelopes.h @@ -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; diff --git a/src/core/instrument/engine/voice.cpp b/src/core/instrument/engine/voice.cpp index eb0e5cc..73d12f8 100644 --- a/src/core/instrument/engine/voice.cpp +++ b/src/core/instrument/engine/voice.cpp @@ -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(postStart) / readRate - : static_cast(postStart); - pitchEnv_.configure(static_cast(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(); diff --git a/src/core/instrument/engine/voice.h b/src/core/instrument/engine/voice.h index 7c119ee..e104eb8 100644 --- a/src/core/instrument/engine/voice.h +++ b/src/core/instrument/engine/voice.h @@ -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(static_cast(a.attackFrames) * stretchRate_ + 0.5); + static_cast(static_cast(a.attackFrames) * fit + 0.5); out.decayFrames = - static_cast(static_cast(a.decayFrames) * stretchRate_ + 0.5); + static_cast(static_cast(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( + static_cast(sample_->frames.size()) - startFrame_); + const double readRate = + preserveRead_ ? stretchRate_ : pitchSpanBaseRate_ * pitchOffsetRatio_; + const double span = (readRate > 0.0) ? postStart / readRate : postStart; + return static_cast(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; diff --git a/src/core/instrument/map/params_payload.cpp b/src/core/instrument/map/params_payload.cpp index 60081e9..a92a85e 100644 --- a/src/core/instrument/map/params_payload.cpp +++ b/src/core/instrument/map/params_payload.cpp @@ -8,6 +8,7 @@ #include // std::isfinite (wire-value validation) #include // 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. diff --git a/src/core/instrument/ui/param_taper.cpp b/src/core/instrument/ui/param_taper.cpp index cec5549..8e61026 100644 --- a/src/core/instrument/ui/param_taper.cpp +++ b/src/core/instrument/ui/param_taper.cpp @@ -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)); diff --git a/src/core/instrument/ui/param_taper.h b/src/core/instrument/ui/param_taper.h index a0f7f22..37b5484 100644 --- a/src/core/instrument/ui/param_taper.h +++ b/src/core/instrument/ui/param_taper.h @@ -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); diff --git a/tests/test_bake_reset.cpp b/tests/test_bake_reset.cpp index 3612ac9..2d14513 100644 --- a/tests/test_bake_reset.cpp +++ b/tests/test_bake_reset.cpp @@ -50,6 +50,8 @@ InstrumentParams dialed() { p.play.pitchEnv.peakSemitones = -7.0; p.play.pitchEnv.shape.attackSeconds = 0.05; p.play.pitchVelocityCurve = VelocityCurve::linear(); + p.play.playRate = 0.5; + p.play.pitchOffsetSemitones = -7.5; p.play.filter.enabled = true; p.play.filter.modAmount = -0.8; p.play.filter.velAmount = 0.6; @@ -139,6 +141,15 @@ int main() { CHECK(after.play.pitchEnv.peakSemitones == 0.0); 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 ----------------- CHECK(!after.play.filter.enabled); CHECK(after.play.filter.modAmount == 0.0); diff --git a/tests/test_bake_window.cpp b/tests/test_bake_window.cpp index 02e43b9..8e8945c 100644 --- a/tests/test_bake_window.cpp +++ b/tests/test_bake_window.cpp @@ -64,6 +64,19 @@ double peakAt(const BakeAudio& audio, std::int64_t from, std::int64_t to) { 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( + audio.interleaved[static_cast(f * audio.channelCount)])) > kSilence) { + return f; + } + } + return -1; +} + // 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 // 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; } +// 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 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. double lastFrameLevel(const BakeAudio& audio) { return audio.frameCount() > 0 ? peakAt(audio, audio.frameCount() - 1, audio.frameCount()) @@ -337,6 +364,107 @@ int main() { 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(trueEnd), static_cast(got), + static_cast(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(eng), static_cast(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 ======================================== // --- The bake renders at the velocity it is handed ---------------------------------- diff --git a/tests/test_component_state_io.cpp b/tests/test_component_state_io.cpp index 5bc8ae9..adf8fa4 100644 --- a/tests/test_component_state_io.cpp +++ b/tests/test_component_state_io.cpp @@ -10,6 +10,7 @@ #include "../src/core/instrument/engine/envelopes.h" // AhdEnvelope (header-only: the codec // 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/time_stretch.h" // the rate bounds the codec clamps to #include "../src/core/util/curve_law.h" // kCurveNeutral (the migration neutral) #include @@ -1544,10 +1545,12 @@ static void testRateAndPitchOffsetRoundTripAndV15LiftsToUnity() { CHECK(PlaySeconds{}.pitchOffsetSemitones == 0.0); } -// Neither field has a clamp of its own downstream that could rescue a corrupt blob: the rate -// multiplies a read increment (the engine's own clampStretchRate is the one authority on its -// RANGE, so the codec only refuses the unusable) and the offset feeds a 2^(x/12) whose result -// reaches a per-sample cast. Both degrade to their neutral rather than through. +// Corruption degrades to the neutral, and an out-of-RANGE rate resolves through the stretcher's +// own clamp rather than surviving unclamped: playback would clamp it anyway, so a stored value +// that did not would leave the needle — and the host normalization, once the instrument reports +// 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() { const double nan = std::numeric_limits::quiet_NaN(); 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 {-1.0, 3.0, 1.0, 3.0}, // and a negative one would run it backwards {std::numeric_limits::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}, {0.75, 24.0, 0.75, 24.0}, // the throw itself is IN range diff --git a/tests/test_deck_values.cpp b/tests/test_deck_values.cpp index ab2a3d4..65eaef6 100644 --- a/tests/test_deck_values.cpp +++ b/tests/test_deck_values.cpp @@ -317,6 +317,12 @@ static void testEveryDefaultHasAnExactNormalizedPreimage() { CHECK(deckParamNorm(DeckParam::kTrigLength, d) == d.trigger.lengthFraction); CHECK(deckParamNorm(DeckParam::kTrigHold, d) == d.trigAhd.holdFraction); 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)) == d.adsr.attackCurve); // Master gain's unity: the case where a hair off is an audible gain error rather than a diff --git a/tests/test_live_delivery.cpp b/tests/test_live_delivery.cpp index 7705d36..a580aa0 100644 --- a/tests/test_live_delivery.cpp +++ b/tests/test_live_delivery.cpp @@ -211,7 +211,7 @@ static void testPitchEnvelopeHoldsPhaseAndGlidesDepth() { PitchEnvParams longer = p; 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 // 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(); } PitchEnvParams noDepth = p; 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()); // ...and it does eventually reach the new depth rather than staying put. 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(); PitchEnvParams wider = p; wider.shape.holdFraction = 1.0; - f.applyLive(wider); + f.applyLive(1000, wider); CHECK(f.tick() == 12.0); for (int i = 0; i < 1200; ++i) f.tick(); CHECK(f.tick() == 0.0); @@ -307,7 +307,7 @@ static void testAFreshPitchEnvelopeTakesTheNewTimesOutright() { PitchEnvParams dialled = stale; dialled.peakSemitones = 12.0; 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 for (int i = 0; i < 499; ++i) env.tick(); 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 = █ + VoiceEngine engine(1, s, /*preserveVoiceCap=*/0, /*preserveWindowFrames=*/2048); + engine.noteOn(60, 127); + std::vector 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 = █ + VoiceEngine engine(1, s, /*preserveVoiceCap=*/0, /*preserveWindowFrames=*/2048); + engine.noteOn(60, 127); + std::vector 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(reachedFull) - + static_cast(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 ------------------------------------------------------- static void testPitchRatioAndVelocityGainStayLatched() { @@ -991,6 +1079,8 @@ int main() { testOneBlockServesTwoIndependentObservers(); testARateChangeSpareTheSoundingNoteAndReachesTheNextOne(); testAPitchOffsetChangeMovesTheSoundingNoteInBothEngines(); + testAPublishedPitchOffsetLeavesTheStagedAttackWallClock(); + testAPublishedPitchOffsetRefitsThePitchEnvelopeSpan(); testPitchRatioAndVelocityGainStayLatched(); testVelocityGainSurvivesAHostilePublishThatReallyLands(); if (g_fail == 0) std::printf("live_delivery tests passed\n"); diff --git a/tests/test_param_taper.cpp b/tests/test_param_taper.cpp index 61b35d3..a4ef4e2 100644 --- a/tests/test_param_taper.cpp +++ b/tests/test_param_taper.cpp @@ -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(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(k) * 1e-9, kLo, kHi); + const double b = rateRatioFromNorm(unity + static_cast(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. static void testDegenerateRateBoundsCollapseToUnity() { CHECK(rateRatioFromNorm(0.3, 2.0, 0.5) == 1.0); @@ -394,6 +427,7 @@ int main() { testRateIsLinearInSemitonesAcrossTheWholeTravel(); testRateIsMonotone(); testRateDefaultAndEndpointsRoundTripBitwise(); + testRateDetentFollowsAsymmetricBoundsInsteadOfCentre(); testDegenerateRateBoundsCollapseToUnity(); testMillisecondSnap(); diff --git a/tests/test_sampler_core.cpp b/tests/test_sampler_core.cpp index 3294b89..adc3efc 100644 --- a/tests/test_sampler_core.cpp +++ b/tests/test_sampler_core.cpp @@ -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(i)] = + static_cast(i - kLoopStart) / static_cast(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& 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(i - prev); + prev = i; + ++count; + } + } + return count > 1 ? sum / static_cast(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 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(v.renderFrame()); + if (halfway == 0.0 && y > 0.5) halfway = static_cast(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(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(v.renderFrame()) > 0.99) return static_cast(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(kAttack), 40.0)); + if (!approx(got, static_cast(kAttack), 40.0)) { + std::printf(" eng %d pitch %+.1f st: attack completed at %.0f\n", + static_cast(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(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 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 // 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 @@ -3639,6 +3812,10 @@ int main() { testKeyTrackRateAndPitchOffsetResolveToOneMultiply(); testPreserveRoutesRateToDurationAndTheOffsetToPitch(); testRateScalesTheLoopPeriodWithoutMovingItsStoredFrames(); + testPreserveHoldsTheLoopsAudiblePeriodWhileRateMovesItsTraversal(); + testADrawnContourScalesWithRateInBothEngines(); + testAPitchOffsetLeavesTheStagedAttackWallClockUnderVarispeed(); + testVarispeedUnityRateAndPitchAreBitIdenticalToTheirBaseline(); testStagedStageTimesDoNotScaleWithRateWhileTheSpanDoes(); testPreserveStretchSpeaksOnFrameZeroAtEveryRate(); testPreserveStretchLoopsTheSourceSpan();