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
+11
View File
@@ -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);
+128
View File
@@ -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<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
// 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<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.
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<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 ========================================
// --- 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
// 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 <cmath>
@@ -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<double>::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<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},
{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::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
+94 -4
View File
@@ -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 = &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 -------------------------------------------------------
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");
+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.
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();
+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
// 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();