// 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 #include #include #include #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(c.file) / static_cast(c.host); } SampleData sineSample(const RateCase& c, double hz, double seconds) { SampleData s; const auto n = static_cast(std::llround(seconds * c.file)); s.frames.resize(n); for (std::size_t i = 0; i < n; ++i) { s.frames[i] = static_cast( 0.5 * std::sin(2.0 * kPi * hz * static_cast(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 render(const SampleData& s, int note, int frames) { Voice v; startVoice(v, s, note); std::vector out(static_cast(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& out, std::size_t from, std::size_t len, double expected) { return autocorrelationPeriod(out, from, len, static_cast(expected * 0.5), static_cast(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& 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(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(c.host) / kHz; const std::vector 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(s.frames.size()) / ratioOf(c) + 1.0; const auto got = static_cast(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(c.host) / kHz; const double sourceSpan = static_cast(s.frames.size()) / ratioOf(c); const std::vector root = render(s, 60, 12000); CHECK(within(measuredPeriod(root, 2000, 6000, rootPeriod), rootPeriod, 0.005)); const std::vector 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(activeFrames(s, note, 200000)); CHECK(got >= sourceSpan - 1.0); CHECK(got <= sourceSpan + static_cast(kDeclickFrames) + 2.0); } } } static void testTriggerPlayPercentageIsAFractionOfTheWallClockLength() { for (const RateCase& c : kCases) { SampleData s = dcSample(c, static_cast(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(c.host) * 0.25 + 1.0; const auto got = static_cast(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(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 out = render(s, 60, static_cast(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(attack / 2)] - 0.5) < 0.01); CHECK(out[static_cast(attack + 4)] > 0.99); } } } static void testGateAdsrAttackTakesItsWallClockTime() { for (const RateCase& c : kCases) { SampleData s = dcSample(c, static_cast(c.file)); const std::int64_t attack = c.host / 50; s.play.adsr.attackFrames = attack; s.play.adsr.sustainLevel = 1.0; const std::vector out = render(s, 60, static_cast(attack * 2)); CHECK(std::fabs(out[static_cast(attack / 2)] - 0.5) < 0.01); CHECK(out[static_cast(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(c.host) / kHz; const auto mid = static_cast(static_cast(c.host) * 0.5); const std::vector out = render(s, 60, static_cast(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(c.file) / 441.0; SampleData s = sineSample(c, 441.0, 0.1); const auto loopStart = static_cast(std::llround(cycle * 10.0)); s.loop = SampleLoop{true, loopStart, loopStart + static_cast(std::llround(cycle))}; s.play.adsr.sustainLevel = 1.0; const double wantPeriod = static_cast(c.host) / (static_cast(c.file) / static_cast(s.loop.end - s.loop.start)); const int frames = c.host; // one second — ten times the source length const std::vector out = render(s, 60, frames); CHECK(within(measuredPeriod(out, static_cast(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(loopStart + loopLen), 1.0f); for (std::int64_t i = 0; i < loopStart; ++i) s.frames[static_cast(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(static_cast(c.host) / 5.0); const std::vector out = render(s, 60, pass); int dipped = 0; for (double v : out) if (v < 0.9999) ++dipped; const double want = static_cast(c.host) / 100.0; // 10 ms at the host rate CHECK(std::fabs(static_cast(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(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 got = filtered(c); const std::vector 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(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(i) / kRate; s.frames[i] = static_cast(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(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 renderStereoNote(const SampleData& s, int note) { Voice v; startVoice(v, s, note); std::vector 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 a = renderStereoNote(unstated, note); const std::vector 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; }