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
+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;
}