Play cross-rate captures at their own pitch and length

The voice now converts the file's rate to the host's at note-on instead of assuming they match. Stage times resolve at the host rate, the filter solves at it, and the bake follows. Same-rate renders are unchanged bit for bit.
This commit is contained in:
2026-09-25 13:44:39 -04:00
parent 9ff34b0a9e
commit ec60cf78c6
25 changed files with 749 additions and 137 deletions
+392
View File
@@ -0,0 +1,392 @@
// A capture whose file rate differs from the host's must play at its own pitch and for its own
// wall-clock duration, with every envelope, loop and filter quantity landing where it would at a
// matched rate. Every case renders through a real Voice at (file rate, host rate) pairs in both
// 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 <cmath>
#include <cstdint>
#include <cstdio>
#include <vector>
#include "tone_metrics.h"
using namespace reasampler;
namespace flt = reasampler::instrument::engine::filter;
using reasampler::test_support::autocorrelationPeriod;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
namespace {
constexpr double kPi = 3.14159265358979323846;
struct RateCase {
int file;
int host;
};
// Up, down, and a ratio past 2 — the Preserve feed then takes more than two source frames per
// output frame, which is the case the stretcher's own [0.5, 2] bound must not clip.
constexpr RateCase kCases[] = {{44100, 48000}, {48000, 44100}, {96000, 44100}};
double ratioOf(const RateCase& c) {
return static_cast<double>(c.file) / static_cast<double>(c.host);
}
SampleData sineSample(const RateCase& c, double hz, double seconds) {
SampleData s;
const auto n = static_cast<std::size_t>(std::llround(seconds * c.file));
s.frames.resize(n);
for (std::size_t i = 0; i < n; ++i) {
s.frames[i] = static_cast<float>(
0.5 * std::sin(2.0 * kPi * hz * static_cast<double>(i) / c.file));
}
s.sampleRate = c.file;
s.renderRate = c.host;
s.rootNote = 60;
return s;
}
SampleData dcSample(const RateCase& c, std::size_t frames) {
SampleData s;
s.frames.assign(frames, 1.0f);
s.sampleRate = c.file;
s.renderRate = c.host;
return s;
}
void startVoice(Voice& v, const SampleData& s, int note) {
if (s.play.pitchEngine == PitchEngine::Preserve) v.presizePreserveShifters(preserveWindowFrames(s));
v.start(note, 100, s);
}
std::vector<double> render(const SampleData& s, int note, int frames) {
Voice v;
startVoice(v, s, note);
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) {
Voice v;
startVoice(v, s, note);
std::int64_t n = 0;
while (v.active() && n < cap) {
v.renderFrame();
++n;
}
return n;
}
double measuredPeriod(const std::vector<double>& out, std::size_t from, std::size_t len,
double expected) {
return autocorrelationPeriod(out, from, len, static_cast<std::int64_t>(expected * 0.5),
static_cast<std::int64_t>(expected * 1.7));
}
bool within(double got, double want, double relTol) {
return std::fabs(got - want) <= relTol * std::fabs(want);
}
double rms(const std::vector<double>& x, std::size_t from, std::size_t to) {
double acc = 0.0;
for (std::size_t i = from; i < to; ++i) acc += x[i] * x[i];
return std::sqrt(acc / static_cast<double>(to - from));
}
} // namespace
// ---------------------------------------------------------------------------
// Pitch and duration.
// ---------------------------------------------------------------------------
static void testVarispeedRootNotePlaysAtItsOwnPitchAndDuration() {
constexpr double kHz = 441.0;
for (const RateCase& c : kCases) {
const SampleData s = sineSample(c, kHz, 0.5);
const double wantPeriod = static_cast<double>(c.host) / kHz;
const std::vector<double> out = render(s, 60, 8000);
CHECK(within(measuredPeriod(out, 1000, 4000, wantPeriod), wantPeriod, 0.002));
// Half a second of source is half a second at the host rate, plus the one frame that
// detects the run-off; the slack is the read head's accumulated rounding at the edge.
const double wantFrames = static_cast<double>(s.frames.size()) / ratioOf(c) + 1.0;
const auto got = static_cast<double>(activeFrames(s, 60, 200000));
CHECK(std::fabs(got - wantFrames) <= 1.5);
}
}
static void testPreserveRootAndOctaveHoldPitchAndWallClockDuration() {
constexpr double kHz = 441.0;
for (const RateCase& c : kCases) {
SampleData s = sineSample(c, kHz, 0.5);
s.play.pitchEngine = PitchEngine::Preserve;
const double rootPeriod = static_cast<double>(c.host) / kHz;
const double sourceSpan = static_cast<double>(s.frames.size()) / ratioOf(c);
const std::vector<double> root = render(s, 60, 12000);
CHECK(within(measuredPeriod(root, 2000, 6000, rootPeriod), rootPeriod, 0.005));
const std::vector<double> octave = render(s, 72, 12000);
CHECK(within(measuredPeriod(octave, 2000, 6000, rootPeriod / 2.0), rootPeriod / 2.0,
0.005));
// Duration is the source's own under Preserve at ANY note: the read runs to the end of
// the source and then rings the terminal declick out.
for (int note : {60, 72}) {
const auto got = static_cast<double>(activeFrames(s, note, 200000));
CHECK(got >= sourceSpan - 1.0);
CHECK(got <= sourceSpan + static_cast<double>(kDeclickFrames) + 2.0);
}
}
}
static void testTriggerPlayPercentageIsAFractionOfTheWallClockLength() {
for (const RateCase& c : kCases) {
SampleData s = dcSample(c, static_cast<std::size_t>(c.file / 2)); // 0.5 s
s.play.playMode = PlayMode::Trigger;
s.play.trigger.lengthFraction = 0.5;
s.play.trigAhd.holdFraction = 1.0;
// A quarter second at the host rate, plus the frame that detects the span's end.
const double want = static_cast<double>(c.host) * 0.25 + 1.0;
const auto got = static_cast<double>(activeFrames(s, 60, 200000));
CHECK(std::fabs(got - want) <= 1.5);
}
}
// ---------------------------------------------------------------------------
// Envelope stage times are wall-clock: counted in OUTPUT frames at the host rate.
// ---------------------------------------------------------------------------
static void testTriggerAhdAttackTakesItsWallClockTimeUnderBothEngines() {
for (const RateCase& c : kCases) {
for (PitchEngine engine : {PitchEngine::Varispeed, PitchEngine::Preserve}) {
SampleData s = dcSample(c, static_cast<std::size_t>(c.file)); // 1 s
s.play.playMode = PlayMode::Trigger;
s.play.pitchEngine = engine;
// 20 ms, resolved at the host rate as resolvePlay does.
const std::int64_t attack = c.host / 50;
s.play.trigAhd.attackFrames = attack;
s.play.trigAhd.holdFraction = 1.0;
const std::vector<double> out = render(s, 60, static_cast<int>(attack * 2));
// Linear attack: half way at half the attack, full just past it (the fitted attack is
// rounded to whole SOURCE frames, so "just past" carries a few frames of slack).
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);
}
}
}
static void testGateAdsrAttackTakesItsWallClockTime() {
for (const RateCase& c : kCases) {
SampleData s = dcSample(c, static_cast<std::size_t>(c.file));
const std::int64_t attack = c.host / 50;
s.play.adsr.attackFrames = attack;
s.play.adsr.sustainLevel = 1.0;
const std::vector<double> out = render(s, 60, static_cast<int>(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 + 1)] > 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.
static void testPitchEnvelopeSpanIsTheSourcesWallClockLength() {
constexpr double kHz = 441.0;
for (const RateCase& c : kCases) {
SampleData s = sineSample(c, kHz, 1.0);
s.play.pitchEngine = PitchEngine::Preserve;
s.play.pitchEnv.enabled = true;
s.play.pitchEnv.peakSemitones = 12.0;
s.play.pitchEnv.shape.holdFraction = 0.5; // A = D = 0: peak for exactly half the span
const double rootPeriod = static_cast<double>(c.host) / kHz;
const auto mid = static_cast<std::size_t>(static_cast<double>(c.host) * 0.5);
const std::vector<double> out = render(s, 60, static_cast<int>(c.host));
constexpr std::size_t kLen = 3000;
constexpr std::size_t kMargin = 1500;
CHECK(within(measuredPeriod(out, mid - kMargin - kLen, kLen, rootPeriod / 2.0),
rootPeriod / 2.0, 0.01));
CHECK(within(measuredPeriod(out, mid + kMargin, kLen, rootPeriod), rootPeriod, 0.01));
}
}
// ---------------------------------------------------------------------------
// The sustain loop and its crossfade are source-frame facts that play in wall-clock time.
// ---------------------------------------------------------------------------
static void testSingleCycleLoopSustainsAtThePitchOfItsCycle() {
for (const RateCase& c : kCases) {
// One cycle of 441 Hz, looped forever: the held pitch must stay 441 Hz at the host rate.
const double cycle = static_cast<double>(c.file) / 441.0;
SampleData s = sineSample(c, 441.0, 0.1);
const auto loopStart = static_cast<std::int64_t>(std::llround(cycle * 10.0));
s.loop = SampleLoop{true, loopStart, loopStart + static_cast<std::int64_t>(std::llround(cycle))};
s.play.adsr.sustainLevel = 1.0;
const double wantPeriod = static_cast<double>(c.host) / (static_cast<double>(c.file) /
static_cast<double>(s.loop.end - s.loop.start));
const int frames = c.host; // one second — ten times the source length
const std::vector<double> out = render(s, 60, frames);
CHECK(within(measuredPeriod(out, static_cast<std::size_t>(frames) - 5000, 4000,
wantPeriod),
wantPeriod, 0.002));
}
}
static void testLoopCrossfadeTakesItsWallClockTime() {
for (const RateCase& c : kCases) {
// Silence ahead of the loop, level inside it: the pre-seam fade blends the loop's tail
// toward the silence one loop length back, so each pass dips for exactly the fade.
const std::int64_t loopStart = c.file / 10; // 100 ms of lead-in
const std::int64_t loopLen = c.file / 5; // 200 ms loop
const std::int64_t fade = c.file / 100; // 10 ms crossfade, in SOURCE frames
SampleData s;
s.sampleRate = c.file;
s.renderRate = c.host;
s.frames.assign(static_cast<std::size_t>(loopStart + loopLen), 1.0f);
for (std::int64_t i = 0; i < loopStart; ++i) s.frames[static_cast<std::size_t>(i)] = 0.0f;
s.loop = SampleLoop{true, loopStart, loopStart + loopLen};
s.loopCrossfadeFrames = fade;
s.startFrame = loopStart;
s.play.adsr.sustainLevel = 1.0;
// Exactly one loop pass at the host rate holds exactly one fade.
const auto pass = static_cast<int>(static_cast<double>(c.host) / 5.0);
const std::vector<double> out = render(s, 60, pass);
int dipped = 0;
for (double v : out) if (v < 0.9999) ++dipped;
const double want = static_cast<double>(c.host) / 100.0; // 10 ms at the host rate
CHECK(std::fabs(static_cast<double>(dipped) - want) <= 3.0);
}
}
// ---------------------------------------------------------------------------
// The filter corner is solved against the rate the voice EMITS at.
// ---------------------------------------------------------------------------
static void testFilterCornerLandsAtItsHertzAtAMismatchedRate() {
const float cutoffNorm = flt::filterNormFromCutoffHz(1000.0f);
const double toneHz = 2.0 * static_cast<double>(flt::filterCutoffHzFromNorm(cutoffNorm));
const auto filtered = [&](const RateCase& c) {
SampleData s = sineSample(c, toneHz, 0.5);
s.play.adsr.sustainLevel = 1.0;
s.play.filter.enabled = true;
s.play.filter.settings.cutoffNorm = cutoffNorm;
s.play.filter.settings.resonanceNorm = 0.0f;
s.play.filter.settings.morphNorm = 1.0f; // low-pass
s.play.filter.env.sustainLevel = 1.0;
return render(s, 60, 16000);
};
for (const RateCase& c : kCases) {
// The reference is the same tone in Hz through the same knob position at a MATCHED rate
// equal to this case's host rate. An octave above the corner a 2-pole low-pass is on its
// 12 dB/oct skirt, so a corner off by the rate ratio moves the level by over a dB.
const RateCase matched{c.host, c.host};
const std::vector<double> got = filtered(c);
const std::vector<double> ref = filtered(matched);
const double gotRms = rms(got, 4000, 16000);
const double refRms = rms(ref, 4000, 16000);
CHECK(refRms < 0.5 * 0.5 / std::sqrt(2.0)); // the corner actually attenuates
CHECK(std::fabs(20.0 * std::log10(gotRms / refRms)) < 0.2);
const double wantPeriod = static_cast<double>(c.host) / toneHz;
CHECK(within(measuredPeriod(got, 4000, 4000, wantPeriod), wantPeriod, 0.002));
}
}
// ---------------------------------------------------------------------------
// Same rate: bit-identical to the engine before the factor existed.
// ---------------------------------------------------------------------------
// Everything that touches a rate at once: both engines, stereo, the filter under an envelope,
// the pitch envelope, a Trigger AHD, and a crossfaded Gate loop.
static SampleData richSample(PitchEngine engine, PlayMode mode) {
constexpr int kRate = 44100;
SampleData s;
const std::size_t n = 30000;
s.frames.resize(n);
s.framesR.resize(n);
for (std::size_t i = 0; i < n; ++i) {
const double t = static_cast<double>(i) / kRate;
s.frames[i] = static_cast<float>(0.4 * std::sin(2.0 * kPi * 220.0 * t) +
0.2 * std::sin(2.0 * kPi * 1330.0 * t));
s.framesR[i] = static_cast<float>(0.4 * std::sin(2.0 * kPi * 330.0 * t));
}
s.sampleRate = kRate;
s.rootNote = 57;
s.loop = SampleLoop{true, 12000, 24000};
s.loopCrossfadeFrames = 800;
s.sourcePeriodFrames = kRate / 220.0;
s.play.playMode = mode;
s.play.pitchEngine = engine;
s.play.adsr = AdsrParams{300, 100, 900, 0.7, 2000, 1.5, 0.7, 1.2};
s.play.trigger.lengthFraction = 0.8;
s.play.trigAhd = AhdParams{400, 3000, 0.5, 1.3, 0.8};
s.play.pitchEnv.enabled = true;
s.play.pitchEnv.peakSemitones = 5.0;
s.play.pitchEnv.shape = AhdParams{200, 1500, 0.3, 1.0, 1.0};
s.play.filter.enabled = true;
s.play.filter.settings.cutoffNorm = 0.4f;
s.play.filter.settings.resonanceNorm = 0.5f;
s.play.filter.settings.morphNorm = 0.8f;
s.play.filter.modAmount = 0.3;
s.play.filter.keyTrack = 0.5;
s.play.filter.env = AdsrParams{500, 0, 1500, 0.4, 1000, 1.0, 1.0, 1.0};
s.play.filter.trigEnv = AhdParams{300, 2000, 0.4, 1.0, 1.0};
return s;
}
static std::vector<float> renderStereoNote(const SampleData& s, int note) {
Voice v;
startVoice(v, s, note);
std::vector<float> out;
for (int i = 0; i < 40000; ++i) {
if (i == 30000) v.release();
AudioSample l = 0.0f, r = 0.0f;
v.renderFrameStereo(l, r);
out.push_back(l);
out.push_back(r);
}
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);
}
}
}
}
int main() {
testVarispeedRootNotePlaysAtItsOwnPitchAndDuration();
testPreserveRootAndOctaveHoldPitchAndWallClockDuration();
testTriggerPlayPercentageIsAFractionOfTheWallClockLength();
testTriggerAhdAttackTakesItsWallClockTimeUnderBothEngines();
testGateAdsrAttackTakesItsWallClockTime();
testPitchEnvelopeSpanIsTheSourcesWallClockLength();
testSingleCycleLoopSustainsAtThePitchOfItsCycle();
testLoopCrossfadeTakesItsWallClockTime();
testFilterCornerLandsAtItsHertzAtAMismatchedRate();
testStatingAMatchedHostRateChangesNoBit();
if (g_fail == 0) std::printf("source_rate: all tests passed\n");
return g_fail ? 1 : 0;
}