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:
@@ -11,6 +11,9 @@
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <optional>
|
||||
#include <vector>
|
||||
|
||||
#include "tone_metrics.h"
|
||||
|
||||
using namespace reasampler;
|
||||
using namespace reasampler::instrument::bake;
|
||||
@@ -488,6 +491,54 @@ int main() {
|
||||
CHECK(hardPeak > softPeak * 2.0);
|
||||
}
|
||||
|
||||
// ============================ SOURCE RATE =======================================
|
||||
|
||||
// --- A capture at another rate bakes at its own pitch and its own wall-clock length --
|
||||
// 441 Hz for one second of a 44.1k file, baked at 48k: the window must hold 48000 frames
|
||||
// of sound (not the source's 44100), and the print must be 441 Hz at the render rate.
|
||||
for (PitchEngine engine : {PitchEngine::Varispeed, PitchEngine::Preserve}) {
|
||||
constexpr int kFileRate = 44100;
|
||||
constexpr double kHz = 441.0;
|
||||
SampleData s;
|
||||
s.frames.resize(static_cast<std::size_t>(kFileRate));
|
||||
for (std::size_t i = 0; i < s.frames.size(); ++i) {
|
||||
s.frames[i] = static_cast<float>(
|
||||
0.5 * std::sin(2.0 * 3.14159265358979323846 * kHz * static_cast<double>(i) /
|
||||
kFileRate));
|
||||
}
|
||||
s.sampleRate = kFileRate;
|
||||
s.renderRate = kRate;
|
||||
s.rootNote = 60;
|
||||
s.play.playMode = PlayMode::Trigger;
|
||||
s.play.pitchEngine = engine;
|
||||
s.play.trigAhd.holdFraction = 1.0;
|
||||
|
||||
CHECK(derivedFrames(s) == kRate + kPad);
|
||||
const BakeAudio audio = bakeWith(s, 0.0);
|
||||
CHECK(peakAt(audio, kRate - 1000, kRate - 10) > 0.4); // still sounding near 1 s
|
||||
CHECK(lastSoundingFrame(audio) < audio.frameCount() - 1); // and done inside the window
|
||||
|
||||
std::vector<double> mono(static_cast<std::size_t>(audio.frameCount()));
|
||||
for (std::size_t f = 0; f < mono.size(); ++f) {
|
||||
mono[f] = audio.interleaved[f * static_cast<std::size_t>(audio.channelCount)];
|
||||
}
|
||||
const double want = static_cast<double>(kRate) / kHz;
|
||||
const double got = reasampler::test_support::autocorrelationPeriod(
|
||||
mono, 10000, 8000, static_cast<std::int64_t>(want * 0.5),
|
||||
static_cast<std::int64_t>(want * 1.7));
|
||||
CHECK(std::fabs(got - want) < 0.005 * want);
|
||||
}
|
||||
|
||||
// --- A snapshot resolved for another output rate is refused, not printed off-pitch ---
|
||||
{
|
||||
SampleData s = dcSample(24000);
|
||||
s.renderRate = 44100; // resolved for 44.1k; the plan renders at 48k
|
||||
s.play.playMode = PlayMode::Trigger;
|
||||
const std::optional<BakePlan> plan = planOf(derivedProgram(s, 0.0));
|
||||
CHECK(plan.has_value());
|
||||
if (plan) CHECK(renderBake(s, *plan, kUnity, kNoLimiter).empty());
|
||||
}
|
||||
|
||||
// ============================== REFUSALS ========================================
|
||||
|
||||
// --- A legitimate dialed sound past the frame ceiling is REFUSED, and says so --------
|
||||
|
||||
@@ -79,7 +79,7 @@ static void testDetectedPeriodReachesAndMovesThePreserveRender() {
|
||||
ref.relativePath = "b/a.wav";
|
||||
ref.rootNote = 60;
|
||||
SampleData on = buildSampleData(resolveCapture(ref, InstrumentParams{}),
|
||||
DecodedPcm{pcm, rate, {}});
|
||||
DecodedPcm{pcm, rate, {}}, rate);
|
||||
CHECK(std::fabs(on.sourcePeriodFrames - 1470.0) < 2.0); // 44100 / 30 Hz, real detection
|
||||
|
||||
on.play.adsr = flatAdsr();
|
||||
|
||||
+58
-20
@@ -891,38 +891,74 @@ static void testBuildSampleDataThreadsEverything() {
|
||||
p.keyTrack = 0.25;
|
||||
p.play.adsr.attackSeconds = 0.01;
|
||||
const SampleData sd = buildSampleData(resolveCapture(ref("b/a.wav", 60), p),
|
||||
DecodedPcm{{0.1f, 0.2f, 0.3f}, 48000, {}});
|
||||
DecodedPcm{{0.1f, 0.2f, 0.3f}, 48000, {}}, 48000);
|
||||
CHECK(sd.playable());
|
||||
CHECK(sd.frames.size() == 3);
|
||||
CHECK(sd.sampleRate == 48000);
|
||||
CHECK(sd.renderRate == 48000);
|
||||
CHECK(sd.rootNote == 40); // the override, not the capture's 60
|
||||
CHECK(sd.loop.hasLoop && sd.loop.start == 10 && sd.loop.end == 90);
|
||||
CHECK(sd.startFrame == 7);
|
||||
CHECK(sd.keyTrack == 0.25);
|
||||
CHECK(sd.channelCount() == 1);
|
||||
// Wall-clock seconds resolve to frames at THIS decode's rate.
|
||||
// Wall-clock seconds resolve to frames at the render rate.
|
||||
CHECK(sd.play.adsr.attackFrames == 480);
|
||||
}
|
||||
|
||||
static void testBuildSampleDataResolvesSecondsAtTheDecodeRate() {
|
||||
// The rate that governs the conversion is the DECODE's, not a baked constant: the same
|
||||
// parameter set built against two decodes yields two different frame counts.
|
||||
static void testBuildSampleDataResolvesSecondsAtTheRenderRate() {
|
||||
// The voice counts every stage in the frames it EMITS, so the host's rate governs the
|
||||
// conversion, never the decode's: two decodes rendered at one host rate resolve alike, and
|
||||
// one decode rendered at two host rates does not.
|
||||
InstrumentParams p;
|
||||
p.play.adsr.attackSeconds = 0.1;
|
||||
p.play.adsr.releaseSeconds = 0.25;
|
||||
p.play.trigAhd.attackSeconds = 0.02;
|
||||
const ResolvedCapture rc = resolveCapture(ref("b/a.wav", 60), p);
|
||||
const SampleData at44 = buildSampleData(rc, DecodedPcm{{0.1f}, 44100, {}});
|
||||
const SampleData at96 = buildSampleData(rc, DecodedPcm{{0.1f}, 96000, {}});
|
||||
CHECK(at44.play.adsr.attackFrames == 4410);
|
||||
CHECK(at44.play.adsr.releaseFrames == 11025);
|
||||
CHECK(at96.play.adsr.attackFrames == 9600);
|
||||
CHECK(at96.play.adsr.releaseFrames == 24000);
|
||||
const SampleData file44at48 = buildSampleData(rc, DecodedPcm{{0.1f}, 44100, {}}, 48000);
|
||||
const SampleData file96at48 = buildSampleData(rc, DecodedPcm{{0.1f}, 96000, {}}, 48000);
|
||||
const SampleData file44at96 = buildSampleData(rc, DecodedPcm{{0.1f}, 44100, {}}, 96000);
|
||||
CHECK(file44at48.play.adsr.attackFrames == 4800);
|
||||
CHECK(file44at48.play.adsr.releaseFrames == 12000);
|
||||
CHECK(file44at48.play.trigAhd.attackFrames == 960);
|
||||
CHECK(file96at48.play.adsr.attackFrames == 4800);
|
||||
CHECK(file96at48.play.adsr.releaseFrames == 12000);
|
||||
CHECK(file44at96.play.adsr.attackFrames == 9600);
|
||||
CHECK(file44at96.play.adsr.releaseFrames == 24000);
|
||||
// The decode's rate stays the SOURCE's, and the conversion between the two is the ratio.
|
||||
CHECK(file44at48.sampleRate == 44100 && file44at48.renderRate == 48000);
|
||||
CHECK(file44at48.sourceRateRatio() == 44100.0 / 48000.0);
|
||||
}
|
||||
|
||||
static void testBuildSampleDataWithNoHostRateRendersAtTheDecodeRate() {
|
||||
// Before the host reports a rate there is nothing to convert to: the capture renders at its
|
||||
// own rate, exactly as a hand-built SampleData does, until a rebuild supplies one.
|
||||
InstrumentParams p;
|
||||
p.play.adsr.attackSeconds = 0.1;
|
||||
const SampleData sd = buildSampleData(resolveCapture(ref("b/a.wav", 60), p),
|
||||
DecodedPcm{{0.1f}, 44100, {}}, 0);
|
||||
CHECK(sd.playable());
|
||||
CHECK(sd.renderRate == 0);
|
||||
CHECK(sd.outputRate() == 44100);
|
||||
CHECK(sd.sourceRateRatio() == 1.0);
|
||||
CHECK(sd.play.adsr.attackFrames == 4410);
|
||||
}
|
||||
|
||||
static void testBuildSampleDataRefusesARateNoCaptureCouldHave() {
|
||||
// The ceiling is a bound on audio-thread work, so the refusal sits exactly at it.
|
||||
const ResolvedCapture rc = resolveCapture(ref("b/a.wav", 60), InstrumentParams{});
|
||||
const int host = 48000;
|
||||
const auto atCeiling = static_cast<int>(host * kMaxSourceRateRatio);
|
||||
CHECK(buildSampleData(rc, DecodedPcm{{0.1f}, atCeiling, {}}, host).playable());
|
||||
const SampleData corrupt = buildSampleData(rc, DecodedPcm{{0.1f}, atCeiling + 1, {}}, host);
|
||||
CHECK(!corrupt.playable());
|
||||
// Far BELOW the host costs nothing per frame, so it plays.
|
||||
CHECK(buildSampleData(rc, DecodedPcm{{0.1f}, 1000, {}}, host).playable());
|
||||
}
|
||||
|
||||
static void testBuildSampleDataCarriesTheSecondChannel() {
|
||||
const SampleData sd = buildSampleData(
|
||||
resolveCapture(ref("b/a.wav", 60), InstrumentParams{}),
|
||||
DecodedPcm{{0.1f, 0.2f}, 44100, {0.9f, 0.8f}});
|
||||
DecodedPcm{{0.1f, 0.2f}, 44100, {0.9f, 0.8f}}, 44100);
|
||||
CHECK(sd.channelCount() == 2);
|
||||
CHECK(sd.framesR.size() == 2 && approx(sd.framesR[0], 0.9) && approx(sd.framesR[1], 0.8));
|
||||
}
|
||||
@@ -931,7 +967,7 @@ static void testBuildSampleDataDropsMismatchedSecondChannel() {
|
||||
// A malformed pair must fall back to MONO rather than half-playing.
|
||||
const SampleData sd = buildSampleData(
|
||||
resolveCapture(ref("b/a.wav", 60), InstrumentParams{}),
|
||||
DecodedPcm{{0.1f, 0.2f, 0.3f}, 44100, {0.9f}});
|
||||
DecodedPcm{{0.1f, 0.2f, 0.3f}, 44100, {0.9f}}, 44100);
|
||||
CHECK(sd.channelCount() == 1);
|
||||
CHECK(sd.framesR.empty());
|
||||
}
|
||||
@@ -941,7 +977,7 @@ static void testBuildSampleDataEmptyPcmIsUnplayable() {
|
||||
// SampleData (silence), never a voice started on an empty read span. (A non-positive
|
||||
// rate is a programming error the build asserts on, so it is not exercised here.)
|
||||
const ResolvedCapture rc = resolveCapture(ref("b/a.wav", 60), InstrumentParams{});
|
||||
const SampleData sd = buildSampleData(rc, DecodedPcm{{}, 44100, {}});
|
||||
const SampleData sd = buildSampleData(rc, DecodedPcm{{}, 44100, {}}, 44100);
|
||||
CHECK(!sd.playable());
|
||||
CHECK(sd.frames.empty());
|
||||
}
|
||||
@@ -959,7 +995,7 @@ static void testBuildSampleDataDetectsThirtyHertzSourcePeriod() {
|
||||
std::sin(2.0 * 3.14159265358979323846 * 30.0 * static_cast<double>(i) / rate));
|
||||
}
|
||||
const SampleData sd = buildSampleData(resolveCapture(ref("b/a.wav", 60), InstrumentParams{}),
|
||||
DecodedPcm{pcm, rate, {}});
|
||||
DecodedPcm{pcm, rate, {}}, rate);
|
||||
CHECK(std::fabs(sd.sourcePeriodFrames - 1470.0) < 2.0); // 44100 / 30 Hz
|
||||
}
|
||||
|
||||
@@ -984,13 +1020,13 @@ static void testBuildSampleDataDetectsOverTheSustainLoopNotTheWholeSource() {
|
||||
|
||||
InstrumentParams noLoop;
|
||||
const SampleData bare = buildSampleData(resolveCapture(ref("b/a.wav", 60), noLoop),
|
||||
DecodedPcm{pcm, rate, {}});
|
||||
DecodedPcm{pcm, rate, {}}, rate);
|
||||
CHECK(bare.sourcePeriodFrames == 0.0); // no loop -> whole source -> no ONE period
|
||||
|
||||
InstrumentParams looped;
|
||||
looped.loopOverride = SampleLoop{true, loopStart, static_cast<std::int64_t>(frames)};
|
||||
const SampleData sd = buildSampleData(resolveCapture(ref("b/a.wav", 60), looped),
|
||||
DecodedPcm{pcm, rate, {}});
|
||||
DecodedPcm{pcm, rate, {}}, rate);
|
||||
CHECK(std::fabs(sd.sourcePeriodFrames - loopPeriod) < 2.0);
|
||||
|
||||
// A loop too short to host the full search band falls back to the whole source rather than
|
||||
@@ -1000,7 +1036,7 @@ static void testBuildSampleDataDetectsOverTheSustainLoopNotTheWholeSource() {
|
||||
InstrumentParams shortLoop;
|
||||
shortLoop.loopOverride = SampleLoop{true, 118000, static_cast<std::int64_t>(frames)};
|
||||
const SampleData shortSd = buildSampleData(resolveCapture(ref("b/a.wav", 60), shortLoop),
|
||||
DecodedPcm{pcm, rate, {}});
|
||||
DecodedPcm{pcm, rate, {}}, rate);
|
||||
CHECK(shortSd.sourcePeriodFrames == 0.0);
|
||||
}
|
||||
|
||||
@@ -1008,7 +1044,7 @@ static void testBuildSampleDataCarriesTheVelocityCurve() {
|
||||
InstrumentParams p;
|
||||
p.velocityCurve = VelocityCurve::linear();
|
||||
const SampleData sd = buildSampleData(resolveCapture(ref("b/a.wav", 60), p),
|
||||
DecodedPcm{{0.1f}, 44100, {}});
|
||||
DecodedPcm{{0.1f}, 44100, {}}, 44100);
|
||||
// The curve reaches the engine's own copy: a mid velocity maps to ~half gain, which the
|
||||
// flat default would not do.
|
||||
CHECK(std::fabs(sd.velocityCurve.eval(64.0) - 64.0 / 127.0) < 1e-6);
|
||||
@@ -1067,7 +1103,9 @@ int main() {
|
||||
testResolveNoPickAndStaleIdAreSilence();
|
||||
testResolveFromRefsNeedsNoBankAtAll();
|
||||
testBuildSampleDataThreadsEverything();
|
||||
testBuildSampleDataResolvesSecondsAtTheDecodeRate();
|
||||
testBuildSampleDataResolvesSecondsAtTheRenderRate();
|
||||
testBuildSampleDataWithNoHostRateRendersAtTheDecodeRate();
|
||||
testBuildSampleDataRefusesARateNoCaptureCouldHave();
|
||||
testBuildSampleDataCarriesTheSecondChannel();
|
||||
testBuildSampleDataDropsMismatchedSecondChannel();
|
||||
testBuildSampleDataEmptyPcmIsUnplayable();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
// a variable-length feed loop RT-safe.
|
||||
// 4. the clamp: out-of-range folds to the bounds, unusable input folds to unity (never to a
|
||||
// silent stall or a quarter-speed surprise).
|
||||
// 4b. the source/host rate ratio multiplies the CLAMPED rate, unclipped by the clamp itself.
|
||||
// 5. the sustain loop wraps the cursor and never lets it leave [start, end) — "loop the
|
||||
// source" holds at every rate, including one that steps over the loop end.
|
||||
|
||||
@@ -109,6 +110,34 @@ static void testRateClamp() {
|
||||
CHECK(d.due(50.0) <= kMaxFeedPerFrame);
|
||||
}
|
||||
|
||||
// --- 4b. The source/host rate ratio scales the feed AFTER the musical clamp. ---
|
||||
static void testSourceRateRatioScalesTheClampedRate() {
|
||||
const ResolvedLoop lp = noLoop();
|
||||
// 96k source in a 44.1k host: past the musical ceiling on its own, and not clipped by it.
|
||||
const double ratio = 96000.0 / 44100.0;
|
||||
for (double rate : {0.5, 1.0, 2.0, 50.0}) {
|
||||
StretchCursor c;
|
||||
c.start(0, ratio);
|
||||
const std::int64_t outFrames = 100000;
|
||||
std::int64_t worst = 0;
|
||||
for (std::int64_t i = 0; i < outFrames; ++i) {
|
||||
const std::int64_t due = c.due(rate);
|
||||
if (due > worst) worst = due;
|
||||
for (std::int64_t k = 0; k < due; ++k) (void)c.next(lp);
|
||||
}
|
||||
const double expected = static_cast<double>(outFrames) * clampStretchRate(rate) * ratio;
|
||||
CHECK(std::fabs(static_cast<double>(c.frame()) - expected) <= 1.0);
|
||||
CHECK(static_cast<double>(worst) <= std::ceil(kStretchRateMax * ratio));
|
||||
}
|
||||
// A ratio of exactly 1.0 is the unity schedule, frame for frame.
|
||||
StretchCursor u;
|
||||
u.start(7, 1.0);
|
||||
for (std::int64_t i = 0; i < 1000; ++i) {
|
||||
CHECK(u.due(1.0) == 1);
|
||||
CHECK(u.next(lp) == 7 + i);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 5. The loop wraps the SOURCE cursor, at every rate. ---
|
||||
static void testCursorStaysInsideTheLoopSpan() {
|
||||
const ResolvedLoop lp = loopSpan(1000, 1040); // a 40-frame loop: rate 4 steps 10% of it
|
||||
@@ -144,6 +173,7 @@ int main() {
|
||||
testTotalConsumedTracksTheRate();
|
||||
testFeedPerOutputFrameIsBounded();
|
||||
testRateClamp();
|
||||
testSourceRateRatioScalesTheClampedRate();
|
||||
testCursorStaysInsideTheLoopSpan();
|
||||
|
||||
if (g_fail == 0) {
|
||||
|
||||
Reference in New Issue
Block a user