64f314a6a4
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.
571 lines
27 KiB
C++
571 lines
27 KiB
C++
// 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_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;
|
|
|
|
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;
|
|
}
|
|
|
|
// `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, /*declickTakeover=*/false, stretch);
|
|
}
|
|
|
|
std::vector<double> render(const SampleData& s, int note, int frames, double stretch = 1.0) {
|
|
Voice v;
|
|
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, double stretch = 1.0) {
|
|
Voice v;
|
|
startVoice(v, s, note, stretch);
|
|
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);
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
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;
|
|
}
|
|
|
|
// 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();
|
|
testAMatchedRateRendersBitIdenticalToThePreFactorEngine();
|
|
if (g_fail == 0) std::printf("source_rate: all tests passed\n");
|
|
return g_fail ? 1 : 0;
|
|
}
|