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:
+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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user