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
+51
View File
@@ -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 --------