Fix cross-rate Preserve splices, re-stamp on host-rate change, name the rate refusal

The splice crossfade now counts output frames and stops before the ring's far end (this also fixes a lap at matched rate). The search strides by the rate ratio. The editor takes its time base from its own decode. Same-rate output is pinned to hashes from before the change.
This commit is contained in:
2026-09-25 14:17:11 -04:00
parent ec60cf78c6
commit 64f314a6a4
24 changed files with 592 additions and 183 deletions
+80 -18
View File
@@ -748,6 +748,65 @@ static void testStretchAndShiftComposeSafely() {
}
}
// A down-splice's crossfade walks the OUTGOING tap away from the writer at (feedRate - shift)
// source frames per output frame, toward the ring's far end — a quarter window past the
// trigger. The live fade must end before that walk laps the ring, both at a matched rate (a fast
// feed under a down-shift) and at a high source ratio, where the fade is counted in output
// frames while the tap moves in source frames. The first case per ratio is also checked for
// its nominal length: window/4 source frames' worth of output time.
static void testDownSpliceFadeNeverLapsTheRingsFarEnd() {
const std::int64_t w = 2205;
const double ringLen = 2.0 * static_cast<double>(w);
const double dHigh = ringLen - static_cast<double>(w / 4);
struct Case { double feed, shift, sourceRatio; std::int64_t nominalFade; };
const Case cases[] = {
{1.0, 0.5, 1.0, w / 4}, // -12 st, matched: room to spare, the full fade
{2.0, 1.0, 1.0, -1}, // Rate 200 %, no shift: recedes one frame per frame
{2.0, 0.25, 1.0, -1}, // Rate 200 %, -24 st
{4.0, 2.0, 4.0, 138}, // -12 st at a 4x source ratio: round(551 / 4)
{4.0, 1.0, 4.0, -1}, // -24 st at a 4x source ratio
};
std::vector<AudioSample> src(400000);
for (std::size_t i = 0; i < src.size(); ++i) {
src[i] = static_cast<AudioSample>(std::sin(2.0 * kPi * static_cast<double>(i) / 196.37));
}
for (const Case& c : cases) {
PitchShifter ps;
ps.configure(w);
ps.prime(src.data(), w);
ps.setShiftRatio(c.shift);
ps.setFeedRate(c.feed);
ps.setSourceRateRatio(c.sourceRatio);
std::size_t pos = static_cast<std::size_t>(w);
double debt = 0.0;
SpliceEvent first;
for (int i = 0; i < 40000 && !first.fired; ++i) {
debt += c.feed;
const int due = static_cast<int>(debt);
debt -= static_cast<double>(due);
for (int k = 0; k + 1 < due; ++k) ps.writeFrame(src[pos++]);
if (due > 0) ps.process(src[pos++]);
else ps.processNoInput();
first = ps.lastSplice();
}
CHECK(first.fired);
CHECK(first.jump < 0); // a DOWN-splice: the tap jumped toward the writer
const double recede = c.feed - c.shift;
// Worst case: the trigger overshot dHigh by one frame's drift, and the integer feed runs
// up to one frame ahead of its average. One frame short of the ring is the interpolator's
// read-ahead.
const double worstDelay =
dHigh + recede + recede * static_cast<double>(first.fadeLen) + 1.0;
if (!(worstDelay < ringLen)) {
std::printf(" feed %.2f shift %.2f ratio %.1f: fade %lld reaches delay %.1f of %.0f\n",
c.feed, c.shift, c.sourceRatio, static_cast<long long>(first.fadeLen),
worstDelay, ringLen);
}
CHECK(worstDelay < ringLen);
if (c.nominalFade > 0) CHECK(first.fadeLen == c.nominalFade);
}
}
// The [0.5, 2.0] rate bound (time_stretch.h) narrows the splice-cadence failure onto the
// source fundamental rather than eliminating it. At rate 2.0, shift 0.25 (-24 st) — both
// inside the shipped range — the header's own derivation puts the safe-source floor at a
@@ -757,18 +816,18 @@ static void testStretchAndShiftComposeSafely() {
// render's FUNDAMENTAL within 0.03% of target by autocorrelation and spectral peak alike,
// while the zero-crossing estimator read 23% flat — splice debris adds spurious crossings
// the count cannot tell from a real detune. Energy outside the fundamental is measured here
// (same rate/shift/source, this file's own metric parameters) at 7.31% / 14.41% / 21.22% for
// P=500/600/700, against 0.10% on an alignable control (P=200, below the safe floor) at the
// (same rate/shift/source, this file's own metric parameters) at 7.08% / 13.90% / 20.92% for
// P=500/600/700, against 0.04% on an alignable control (P=200, below the safe floor) at the
// same rate and shift. Those readings are stable and are what the bounds below hold.
//
// **CORRECTED — what those three readings MEAN.** They were once read as the corner's damage.
// They are almost entirely the metric's own floor: an ideal tone at the same want-period,
// measured identically, reads 7.08% / 13.90% / 20.92% (idealToneFloorPercent, below), because
// a long period under a 32768-frame segment leaks part of its own mainlobe outside the +/-6%
// band. The corner's real EXCESS over that floor is 0.23% / 0.51% / 0.30% — small, real, and
// nothing like the headline numbers. The alignable control's 0.10% is genuinely near-zero only
// because its want-period is short enough to have almost no floor. The bounds below are kept
// as a stable regression tripwire on the raw readings; read the excess, not the reading.
// They are the metric's own floor: an ideal tone at the same want-period, measured
// identically, reads 7.08% / 13.90% / 20.92% (idealToneFloorPercent, below), because a long
// period under a 32768-frame segment leaks part of its own mainlobe outside the +/-6% band.
// The corner's EXCESS over that floor once measured 0.23% / 0.51% / 0.30% (control 0.10%); that
// residue was the down-shift crossfade (recede 1.75 frames/frame) lapping the ring's far end,
// and splice()'s far-end fade cap took it to 0.00%. The bounds below are kept as a stable
// regression tripwire on the raw readings; read the excess, not the reading.
//
// This measures the FIXED-WINDOW path — no source period is set, which is what a capture with
// no single period (percussive, polyphonic, noise) gets. What the same corner does once the
@@ -782,8 +841,8 @@ static void testStretchCadenceCornerArtifactEnergyAtRate2ShiftQuarter() {
const std::size_t from = 20000, len = 32768;
// Below the safe floor (P > 315 frames): per the header's CORRECTED note, this raw reading
// is mostly the metric's own mainlobe-leakage floor, not cadence damage (the real excess is
// ~0.2-0.5%, asserted in testCadenceCornerIsUnmovedByAPitchSynchronousSplice). The bounds
// is the metric's own mainlobe-leakage floor, not cadence damage (the excess is asserted in
// testCadenceCornerIsUnmovedByAPitchSynchronousSplice). The bounds
// below are a RAW-READING STABILITY TRIPWIRE, not a discriminator — they catch a large swing
// in the raw measurement; the ceiling (30%) is a generous margin above the highest measured
// value, there to catch a much worse regression rather than to chase today's exact number.
@@ -1001,13 +1060,15 @@ static void testTwentyNineHertzAtRateTwoKeepsItsPitch() {
// The cadence corner (rate 2.0, -24 st, source periods above the 315-frame safe floor) is the
// OTHER mechanism — a splice landing inside a single perceived cycle. Measured against the
// metric's own floor, PSOLA moves it by nothing: 0.23/0.51/0.30% excess becomes 0.24/0.49/0.30%.
// metric's own floor, PSOLA moves it by nothing: 0.00% excess either way (it was 0.23/0.51/0.30%
// both ways until the far-end fade cap removed the lapped crossfade behind it).
//
// That is not a shortfall, it is what the corner turned out to be. Correcting the previous
// test's reading (see its comment) shrank the corner from a 7-21% headline to a sub-1% excess,
// which leaves PSOLA nothing to recover there — a pitch-synchronous jump makes each splice
// land in phase, and these splices already did; what it cannot do is make them less frequent.
// So this asserts NO REGRESSION, not an improvement, and says so rather than claiming one.
// test's reading (see its comment) shrank the corner from a 7-21% headline to no measurable
// excess, which leaves PSOLA nothing to recover there — a pitch-synchronous jump makes each
// splice land in phase, and these splices already did; what it cannot do is make them less
// frequent. So this asserts NO REGRESSION, not an improvement, and says so rather than
// claiming one.
static void testCadenceCornerIsUnmovedByAPitchSynchronousSplice() {
using reasampler::test_support::energyOutsideFundamentalPercent;
const std::int64_t w = 2205;
@@ -1045,7 +1106,7 @@ static void testCadenceCornerIsUnmovedByAPitchSynchronousSplice() {
//
// MEASURED (Debug, this machine): metric floor 55.86% (P=1470's want-period of 5880 fr under a
// 32768-frame segment leaks a lot of mainlobe, same effect as the P=500-700 rows, just larger),
// fixed-window excess 18.52%, pitch-synchronous excess 0.00%. The faster cadence does NOT
// fixed-window excess 18.61%, pitch-synchronous excess 0.00%. The faster cadence does NOT
// regress this corner — every splice at n=1 lands exactly one source period away, so despite
// firing 1.5x as often each one is phase-perfect rather than merely aligned-on-average, and the
// corner clears rather than worsens. Recorded as read, not tuned: if a future change moves
@@ -1073,7 +1134,7 @@ static void testCadenceCollapseBandAtThirtyHertzUnderPitchSynchronousSplice() {
"excess %.2f%% -> %.2f%%\n", period, want, floor, pctOff - floor, pctOn - floor);
CHECK(pctOn - floor < 1.0); // measured 0.00%: phase-perfect at n=1, not merely aligned
// Vacuity guard (shape of testThirtyHertzSplicesAlignOnceTheSourcePeriodIsKnown's :961): the
// FIXED-WINDOW arm is asserted too (measured 18.52% excess), so a setSourcePeriod that
// FIXED-WINDOW arm is asserted too (measured 18.61% excess), so a setSourcePeriod that
// silently did nothing would render both arms identically and pass the bound above by luck.
CHECK(pctOff - floor > pctOn - floor + 1.0);
}
@@ -1099,6 +1160,7 @@ int main() {
testStereoLinkedLagSharedSchedule();
testStretchMovesDurationNotPitch();
testStretchAndShiftComposeSafely();
testDownSpliceFadeNeverLapsTheRingsFarEnd();
testStretchCadenceCornerArtifactEnergyAtRate2ShiftQuarter();
testPeriodAlignedJumpSnapsToWholePeriodsWithinTheReachableBound();
testAnUnknownPeriodIsBitIdenticalToTheFixedWindowGeometry();
+1 -1
View File
@@ -270,7 +270,7 @@ static SpliceStats spliceGeometry(const std::vector<AudioSample>& src, std::int6
ps.setFeedRate(rate);
ps.setSourcePeriod(sourcePeriod);
StretchCursor cur;
cur.start(window);
cur.start(window, 1.0);
loop::ResolvedLoop lp{}; // inactive: the source is long enough to run straight through
SpliceStats st;
+7
View File
@@ -953,6 +953,13 @@ static void testBuildSampleDataRefusesARateNoCaptureCouldHave() {
CHECK(!corrupt.playable());
// Far BELOW the host costs nothing per frame, so it plays.
CHECK(buildSampleData(rc, DecodedPcm{{0.1f}, 1000, {}}, host).playable());
// The shell tells the user WHY through the predicate the build applies, so the message and
// the silence share one boundary.
CHECK(sourceRateSupported(atCeiling, host));
CHECK(!sourceRateSupported(atCeiling + 1, host));
// No host rate reported yet: the file plays at its own rate, whatever that is.
CHECK(sourceRateSupported(atCeiling + 1, 0));
CHECK(buildSampleData(rc, DecodedPcm{{0.1f}, atCeiling + 1, {}}, 0).playable());
}
static void testBuildSampleDataCarriesTheSecondChannel() {
+205 -27
View File
@@ -4,16 +4,20 @@
// directions and at a large ratio, and asserts in wall-clock / Hz terms — output frames at the
// host rate — never in source frames.
#include "../src/core/instrument/engine/voice.h"
#include "../src/core/instrument/engine/voice_engine.h"
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <vector>
#include "tone_metrics.h"
using namespace reasampler;
using instrument::engine::LiveParams;
using instrument::engine::LiveValues;
using instrument::engine::foldLive;
namespace flt = reasampler::instrument::engine::filter;
using reasampler::test_support::autocorrelationPeriod;
@@ -59,23 +63,24 @@ SampleData dcSample(const RateCase& c, std::size_t frames) {
return s;
}
void startVoice(Voice& v, const SampleData& s, int note) {
// `stretch` is the Rate a VoiceEngine would hand start() from the published block.
void startVoice(Voice& v, const SampleData& s, int note, double stretch = 1.0) {
if (s.play.pitchEngine == PitchEngine::Preserve) v.presizePreserveShifters(preserveWindowFrames(s));
v.start(note, 100, s);
v.start(note, 100, s, /*declickTakeover=*/false, stretch);
}
std::vector<double> render(const SampleData& s, int note, int frames) {
std::vector<double> render(const SampleData& s, int note, int frames, double stretch = 1.0) {
Voice v;
startVoice(v, s, note);
startVoice(v, s, note, stretch);
std::vector<double> out(static_cast<std::size_t>(frames), 0.0);
for (auto& x : out) x = v.renderFrame();
return out;
}
// Output frames the voice renders before it goes idle, capped.
std::int64_t activeFrames(const SampleData& s, int note, std::int64_t cap) {
std::int64_t activeFrames(const SampleData& s, int note, std::int64_t cap, double stretch = 1.0) {
Voice v;
startVoice(v, s, note);
startVoice(v, s, note, stretch);
std::int64_t n = 0;
while (v.active() && n < cap) {
v.renderFrame();
@@ -159,6 +164,39 @@ static void testTriggerPlayPercentageIsAFractionOfTheWallClockLength() {
}
}
// A down-shift's splice crossfade walks the outgoing tap AWAY from the writer, toward the ring's
// far end. The fade is counted in output frames while the tap moves in source frames, so at a
// high ratio a fade sized in source frames outlasts the ring and reads audio the writer has
// already replaced — splice debris a matched-rate render of the same note does not have.
static void testPreserveDownShiftAtAHighRatioSplicesAsCleanlyAsAMatchedRate() {
using reasampler::test_support::energyOutsideFundamentalPercent;
constexpr double kHz = 441.0;
constexpr std::size_t kFrom = 6000, kLen = 16384;
for (const RateCase& c : {RateCase{192000, 48000}, RateCase{96000, 44100}}) {
for (int note : {48, 36}) {
const double wantPeriod =
static_cast<double>(c.host) / (kHz * std::pow(2.0, (note - 60) / 12.0));
const auto debris = [&](const RateCase& rc) {
SampleData s = sineSample(rc, kHz, 1.0);
s.play.pitchEngine = PitchEngine::Preserve;
s.play.adsr.sustainLevel = 1.0;
return energyOutsideFundamentalPercent(render(s, note, 24000), kFrom, kLen,
wantPeriod);
};
const double got = debris(c);
const double ref = debris(RateCase{c.host, c.host});
// Measured with a source-frame fade and no far-end cap: 0.398 / 2.631 / 0.227 %
// against 0.001 / 0.031 / 0.012 % matched (the 96k -12 st case recedes too little to
// show). After, every case reads its matched reference to the third decimal.
if (!(got < ref + 0.05)) {
std::printf(" debris %d/%d note %d: %.3f%% vs matched %.3f%%\n", c.file,
c.host, note, got, ref);
}
CHECK(got < ref + 0.05);
}
}
}
// ---------------------------------------------------------------------------
// Envelope stage times are wall-clock: counted in OUTPUT frames at the host rate.
// ---------------------------------------------------------------------------
@@ -194,6 +232,112 @@ static void testGateAdsrAttackTakesItsWallClockTime() {
}
}
// Rate and the Pitch offset are the two factors stageFitRate compensates, and the source/host
// ratio rides inside the same product. All three at once, under both engines: the Trigger AHD's
// attack still takes 20 ms, the note lasts the source's wall-clock length over the read's own
// rate, and the pitch is the offset's (Preserve) or the offset's times Rate's (Varispeed).
static void testRateAndPitchOffsetAtAMismatchedRateKeepWallClockAndPitch() {
const RateCase c{96000, 44100};
constexpr double kStretch = 1.5, kOffset = -5.0, kHz = 441.0;
const double offsetRatio = std::pow(2.0, kOffset / 12.0);
for (PitchEngine engine : {PitchEngine::Varispeed, PitchEngine::Preserve}) {
const bool vari = engine == PitchEngine::Varispeed;
const auto dial = [&](SampleData& s) {
s.play.playMode = PlayMode::Trigger;
s.play.pitchEngine = engine;
s.play.pitchOffsetSemitones = kOffset;
s.play.trigger.lengthFraction = 1.0;
s.play.trigAhd.holdFraction = 1.0;
};
SampleData dc = dcSample(c, static_cast<std::size_t>(c.file));
dial(dc);
const std::int64_t attack = c.host / 50; // 20 ms, resolved at the host rate
dc.play.trigAhd.attackFrames = attack;
const std::vector<double> ramp = render(dc, 60, static_cast<int>(attack * 2), kStretch);
CHECK(std::fabs(ramp[static_cast<std::size_t>(attack / 2)] - 0.5) < 0.01);
CHECK(ramp[static_cast<std::size_t>(attack + 4)] > 0.99);
SampleData tone = sineSample(c, kHz, 1.0);
dial(tone);
const double wantPeriod =
static_cast<double>(c.host) / (kHz * offsetRatio * (vari ? kStretch : 1.0));
const std::vector<double> out = render(tone, 60, 12000, kStretch);
CHECK(within(measuredPeriod(out, 2000, 6000, wantPeriod), wantPeriod, 0.005));
const double readRate = kStretch * (vari ? offsetRatio : 1.0);
const double span = static_cast<double>(tone.frames.size()) / ratioOf(c) / readRate;
const auto got = static_cast<double>(activeFrames(tone, 60, 400000, kStretch));
if (vari) {
CHECK(std::fabs(got - (span + 1.0)) <= 1.5); // + the frame that detects the end
} else {
CHECK(got >= span - 1.0);
CHECK(got <= span + static_cast<double>(kDeclickFrames) + 2.0);
}
}
}
// A live block reaches a fresh voice through snapLive and a sounding one through applyLive, and
// both convert the new stage frames through stageFitRate — so both must land in wall-clock time
// at a mismatched rate too.
static void testLiveStageTimesLandInWallClockAtAMismatchedRate() {
const RateCase c{96000, 44100};
constexpr std::size_t kBlock = 441; // 10 ms at the host rate
for (PitchEngine engine : {PitchEngine::Varispeed, PitchEngine::Preserve}) {
const auto oneSecondAttack = [&]() {
SampleData s = dcSample(c, static_cast<std::size_t>(c.file) * 2);
s.play.playMode = PlayMode::Trigger;
s.play.pitchEngine = engine;
s.play.trigger.lengthFraction = 1.0;
s.play.trigAhd.holdFraction = 1.0;
s.play.trigAhd.attackFrames = c.host;
return s;
};
// Fresh: the snapshot says 1 s, a block published before the note says 20 ms.
{
SampleData s = oneSecondAttack();
LiveParams block;
s.live = &block;
LiveValues v = foldLive(s.play, s.keyTrack);
const std::int64_t attack = c.host / 50;
v.ampAhd.attackFrames = attack;
block.publish(v);
VoiceEngine eng(1, s, 0, preserveWindowFrames(s));
eng.noteOn(60, 100);
std::vector<AudioSample> out;
eng.render(out, static_cast<std::size_t>(attack * 2));
CHECK(std::fabs(out[static_cast<std::size_t>(attack / 2)] - 0.5) < 0.01);
CHECK(out[static_cast<std::size_t>(attack + 4)] > 0.99);
}
// Sounding: the 1 s attack redialled to 200 ms, 100 ms in. The AHD is positional, so the
// level re-lands on the new shape at the current read position (the step smoothed out
// within ~5 ms): three quarters up at 150 ms, full by 200 ms.
{
SampleData s = oneSecondAttack();
LiveParams block;
s.live = &block;
block.publish(foldLive(s.play, s.keyTrack));
VoiceEngine eng(1, s, 0, preserveWindowFrames(s));
eng.noteOn(60, 100);
std::vector<AudioSample> out;
for (int b = 0; b < 10; ++b) eng.render(out, kBlock);
LiveValues v = foldLive(s.play, s.keyTrack);
v.ampAhd.attackFrames = c.host / 5;
block.publish(v);
for (int b = 0; b < 12; ++b) eng.render(out, kBlock);
const auto at = [&](double seconds) {
return static_cast<double>(
out[static_cast<std::size_t>(seconds * static_cast<double>(c.host))]);
};
CHECK(std::fabs(at(0.05) - 0.05) < 0.01); // the 1 s attack, before the redial
CHECK(std::fabs(at(0.15) - 0.75) < 0.01);
CHECK(at(0.205) > 0.99);
}
}
}
// The pitch AHD's Hold fraction is taken against the playable span in OUTPUT frames. Under
// Preserve the read runs at the source rate exactly, so a half-span hold must end half way
// through the sample's wall-clock length — not half way through its SOURCE frame count.
@@ -353,40 +497,74 @@ static std::vector<float> renderStereoNote(const SampleData& s, int note) {
return out;
}
// renderRate 0 is how every other core test builds its SampleData, so those suites' exact-value
// assertions pin that path; the loader always states the host rate. The two must agree bit for
// bit at a matched rate, or a same-rate capture would sound different in the plugin than in the
// suites that vouch for it.
static void testStatingAMatchedHostRateChangesNoBit() {
for (PitchEngine engine : {PitchEngine::Varispeed, PitchEngine::Preserve}) {
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
for (int note : {57, 64, 45}) {
const SampleData unstated = richSample(engine, mode); // renderRate 0
SampleData stated = unstated;
stated.renderRate = stated.sampleRate;
const std::vector<float> a = renderStereoNote(unstated, note);
const std::vector<float> b = renderStereoNote(stated, note);
bool identical = a.size() == b.size();
for (std::size_t i = 0; identical && i < a.size(); ++i) {
if (a[i] != b[i]) identical = false;
}
CHECK(identical);
}
// FNV-1a 64 over the float bit patterns, in render order.
static std::uint64_t bitHash(const std::vector<float>& v) {
std::uint64_t h = 14695981039346656037ull;
for (float x : v) {
std::uint32_t bits;
std::memcpy(&bits, &x, sizeof bits);
for (int b = 0; b < 4; ++b) {
h ^= (bits >> (8 * b)) & 0xFFu;
h *= 1099511628211ull;
}
}
return h;
}
// These exact renders on the engine BEFORE the source/host factor existed (9ff34b0), hashed by
// building richSample + renderStereoNote against that tree — MSVC x64, identical in Debug and
// Release. A matched rate must reproduce them bit for bit whether the host rate is stated (as
// the loader always builds) or left 0 (as every other core suite does). The inputs come from
// libm sin/pow, so another toolchain's libm can move them with no engine change; the pin is
// therefore MSVC-only, and stated == unstated is checked everywhere.
static void testAMatchedRateRendersBitIdenticalToThePreFactorEngine() {
struct Golden { PitchEngine engine; PlayMode mode; int note; std::uint64_t hash; };
const Golden golden[] = {
{PitchEngine::Varispeed, PlayMode::Gate, 57, 0x3e684c7e820ad514ull},
{PitchEngine::Varispeed, PlayMode::Gate, 64, 0xcdd495f5475d63adull},
{PitchEngine::Varispeed, PlayMode::Gate, 45, 0x9dda14cb03b2b989ull},
{PitchEngine::Varispeed, PlayMode::Trigger, 57, 0x270baad4faaba382ull},
{PitchEngine::Varispeed, PlayMode::Trigger, 64, 0xa636d603e07b39daull},
{PitchEngine::Varispeed, PlayMode::Trigger, 45, 0xbc1444e4548cf811ull},
{PitchEngine::Preserve, PlayMode::Gate, 57, 0x8b56731872c91234ull},
{PitchEngine::Preserve, PlayMode::Gate, 64, 0x51b52f2f814d684bull},
{PitchEngine::Preserve, PlayMode::Gate, 45, 0x8b62423be1525216ull},
{PitchEngine::Preserve, PlayMode::Trigger, 57, 0xf0d55633547c325full},
{PitchEngine::Preserve, PlayMode::Trigger, 64, 0x73ddee69c67183c3ull},
{PitchEngine::Preserve, PlayMode::Trigger, 45, 0xaa9f43fa7c64c2dfull},
};
for (const Golden& g : golden) {
const SampleData unstated = richSample(g.engine, g.mode); // renderRate 0
SampleData stated = unstated;
stated.renderRate = stated.sampleRate;
const std::uint64_t a = bitHash(renderStereoNote(unstated, g.note));
const std::uint64_t b = bitHash(renderStereoNote(stated, g.note));
CHECK(a == b);
#if defined(_MSC_VER) && defined(_M_X64)
if (b != g.hash) {
std::printf(" pre-factor mismatch: engine %d mode %d note %d: 0x%016llx\n",
static_cast<int>(g.engine), static_cast<int>(g.mode), g.note,
static_cast<unsigned long long>(b));
}
CHECK(b == g.hash);
#endif
}
}
int main() {
testVarispeedRootNotePlaysAtItsOwnPitchAndDuration();
testPreserveRootAndOctaveHoldPitchAndWallClockDuration();
testTriggerPlayPercentageIsAFractionOfTheWallClockLength();
testPreserveDownShiftAtAHighRatioSplicesAsCleanlyAsAMatchedRate();
testTriggerAhdAttackTakesItsWallClockTimeUnderBothEngines();
testGateAdsrAttackTakesItsWallClockTime();
testRateAndPitchOffsetAtAMismatchedRateKeepWallClockAndPitch();
testLiveStageTimesLandInWallClockAtAMismatchedRate();
testPitchEnvelopeSpanIsTheSourcesWallClockLength();
testSingleCycleLoopSustainsAtThePitchOfItsCycle();
testLoopCrossfadeTakesItsWallClockTime();
testFilterCornerLandsAtItsHertzAtAMismatchedRate();
testStatingAMatchedHostRateChangesNoBit();
testAMatchedRateRendersBitIdenticalToThePreFactorEngine();
if (g_fail == 0) std::printf("source_rate: all tests passed\n");
return g_fail ? 1 : 0;
}
+15 -11
View File
@@ -6,8 +6,8 @@
// the mechanism behind the "unity is bit-identical to the shipped Preserve read" gate.
// 2. the schedule tracks the rate: over N output frames the cursor consumes N*rate source
// frames to within one, at rates either side of unity and at irrational ones.
// 3. the per-output-frame feed count never exceeds kMaxFeedPerFrame — the bound that makes
// a variable-length feed loop RT-safe.
// 3. the per-output-frame feed count never exceeds ceil(kStretchRateMax) at a matched rate —
// the bound that makes a variable-length feed loop RT-safe.
// 4. the clamp: out-of-range folds to the bounds, unusable input folds to unity (never to a
// silent stall or a quarter-speed surprise).
// 4b. the source/host rate ratio multiplies the CLAMPED rate, unclipped by the clamp itself.
@@ -29,6 +29,10 @@ static int g_fail = 0;
static ResolvedLoop noLoop() { return ResolvedLoop{}; }
// The most source frames one output frame can make due at a matched rate.
static const std::int64_t kMatchedFeedBound =
static_cast<std::int64_t>(std::ceil(kStretchRateMax));
static ResolvedLoop loopSpan(std::int64_t start, std::int64_t end) {
ResolvedLoop lp;
lp.active = true;
@@ -41,7 +45,7 @@ static ResolvedLoop loopSpan(std::int64_t start, std::int64_t end) {
// --- 1. Unity is exactly one frame per output frame, with no drifting residue. ---
static void testUnityRateFeedsExactlyOneFramePerOutputFrame() {
StretchCursor c;
c.start(100);
c.start(100, 1.0);
const ResolvedLoop lp = noLoop();
for (std::int64_t i = 0; i < 200000; ++i) {
CHECK(c.due(1.0) == 1);
@@ -60,7 +64,7 @@ static void testTotalConsumedTracksTheRate() {
// would drift without bound rather than carrying the residue.
for (double rate : {0.5, 0.75, 1.0, 1.3333333333333333, 2.0, 1.0 / 3.0 + 1.0}) {
StretchCursor c;
c.start(0);
c.start(0, 1.0);
const std::int64_t outFrames = 100000;
for (std::int64_t i = 0; i < outFrames; ++i) {
const std::int64_t due = c.due(rate);
@@ -78,14 +82,14 @@ static void testFeedPerOutputFrameIsBounded() {
// for as many iterations as the rate names.
for (double rate : {kStretchRateMax, kStretchRateMax * 100.0, 3.99, 2.5}) {
StretchCursor c;
c.start(0);
c.start(0, 1.0);
std::int64_t worst = 0;
for (std::int64_t i = 0; i < 20000; ++i) {
const std::int64_t due = c.due(rate);
if (due > worst) worst = due;
for (std::int64_t k = 0; k < due; ++k) (void)c.next(lp);
}
CHECK(worst <= kMaxFeedPerFrame);
CHECK(worst <= kMatchedFeedBound);
CHECK(worst >= 1); // and the bound is not vacuous — frames genuinely fell due
}
}
@@ -103,11 +107,11 @@ static void testRateClamp() {
CHECK(clampStretchRate(std::nan("")) == 1.0);
// ...and the cursor honours it rather than looping on the raw value.
StretchCursor c;
c.start(0);
c.start(0, 1.0);
CHECK(c.due(-5.0) == 1); // folded to unity
StretchCursor d;
d.start(0);
CHECK(d.due(50.0) <= kMaxFeedPerFrame);
d.start(0, 1.0);
CHECK(d.due(50.0) <= kMatchedFeedBound);
}
// --- 4b. The source/host rate ratio scales the feed AFTER the musical clamp. ---
@@ -143,7 +147,7 @@ static void testCursorStaysInsideTheLoopSpan() {
const ResolvedLoop lp = loopSpan(1000, 1040); // a 40-frame loop: rate 4 steps 10% of it
for (double rate : {0.5, 1.0, 2.0, 4.0}) {
StretchCursor c;
c.start(1000);
c.start(1000, 1.0);
std::int64_t lowest = 1 << 30, highest = -1;
for (std::int64_t i = 0; i < 50000; ++i) {
const std::int64_t due = c.due(rate);
@@ -163,7 +167,7 @@ static void testCursorStaysInsideTheLoopSpan() {
// A cursor started BEYOND the loop end (the start-point-past-the-loop case) is pulled in on
// its first take rather than reading off the end.
StretchCursor c;
c.start(5000);
c.start(5000, 1.0);
const std::int64_t q = c.next(lp);
CHECK(q >= lp.start && q < lp.end);
}