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:
+80
-18
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 = █
|
||||
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.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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user