Bake window: derive it from the rate the voice actually reads at, so a dialled Rate or downward Pitch no longer truncates the file
This commit is contained in:
@@ -3251,6 +3251,179 @@ static void testRateScalesTheLoopPeriodWithoutMovingItsStoredFrames() {
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve's half of the loop claim, and it is the OPPOSITE of the Varispeed one — written down
|
||||
// here because the obvious extension of the test above is WRONG. Preserve consumes the loop at
|
||||
// `rate` source frames per output frame, so the TRAVERSAL scales (the feed-side witness in
|
||||
// testPreserveStretchLoopsTheSourceSpan measures that directly); what the listener hears does
|
||||
// not, because holding the source's period while its duration changes is the definition of the
|
||||
// engine. Measured with a ring long enough to hold the whole loop, so the reading is the design
|
||||
// property rather than splice cadence — at shorter rings the same fixture measured 3064 and 4130
|
||||
// frames at rate 0.5 (windows 1024 and 2048), neither of which is the 8000 a scaling period
|
||||
// would give either.
|
||||
static void testPreserveHoldsTheLoopsAudiblePeriodWhileRateMovesItsTraversal() {
|
||||
constexpr std::int64_t kLoopStart = 4000;
|
||||
constexpr std::int64_t kLoopEnd = 8000;
|
||||
SampleData base;
|
||||
base.frames.assign(20000, 0.0f);
|
||||
for (std::int64_t i = kLoopStart; i < kLoopEnd; ++i) {
|
||||
base.frames[static_cast<std::size_t>(i)] =
|
||||
static_cast<float>(i - kLoopStart) / static_cast<float>(kLoopEnd - kLoopStart);
|
||||
}
|
||||
base.rootNote = 60;
|
||||
base.startFrame = kLoopStart;
|
||||
base.loop = SampleLoop{true, kLoopStart, kLoopEnd};
|
||||
base.play.adsr = flatAdsr();
|
||||
base.play.pitchEngine = PitchEngine::Preserve;
|
||||
|
||||
auto sawPeriod = [](const std::vector<AudioSample>& v) {
|
||||
double sum = 0.0;
|
||||
std::size_t prev = 0, count = 0;
|
||||
for (std::size_t i = 1; i < v.size(); ++i) {
|
||||
if (v[i - 1] <= 0.5f && v[i] > 0.5f) {
|
||||
if (count > 0) sum += static_cast<double>(i - prev);
|
||||
prev = i;
|
||||
++count;
|
||||
}
|
||||
}
|
||||
return count > 1 ? sum / static_cast<double>(count - 1) : 0.0;
|
||||
};
|
||||
|
||||
for (double rate : {1.0, 0.5, 2.0}) {
|
||||
SampleData s = base;
|
||||
s.play.playRate = rate;
|
||||
Voice v;
|
||||
v.presizePreserveShifters(8192); // > the 4000-frame loop
|
||||
v.start(60, 127, s, /*declickTakeover=*/false, rate);
|
||||
std::vector<AudioSample> out(40000, 0.0f);
|
||||
for (std::size_t i = 0; i < out.size(); ++i) out[i] = v.renderFrame();
|
||||
const double period = sawPeriod(out);
|
||||
CHECK(approx(period, 4000.0, 40.0));
|
||||
if (!approx(period, 4000.0, 40.0)) std::printf(" rate %.2f period %.1f\n", rate, period);
|
||||
// And the marks the waveform draws are source-frame FACTS the engine only ever reads.
|
||||
CHECK(s.loop.start == kLoopStart);
|
||||
CHECK(s.loop.end == kLoopEnd);
|
||||
CHECK(s.startFrame == kLoopStart);
|
||||
}
|
||||
}
|
||||
|
||||
// The other half of the same rule, which nothing asserted: a drawn contour is a pure function of
|
||||
// NORMALIZED sample position, so it follows the read head and its wall-clock shape scales by
|
||||
// 1/rate — under BOTH engines, since both advance that head at the rate. Measured as the output
|
||||
// frame the contour's own half-way point arrives on, which is what a listener hears move.
|
||||
static void testADrawnContourScalesWithRateInBothEngines() {
|
||||
for (PitchEngine eng : {PitchEngine::Varispeed, PitchEngine::Preserve}) {
|
||||
double atUnity = 0.0;
|
||||
for (double rate : {1.0, 0.5, 2.0}) {
|
||||
SampleData s = dcSample(24000);
|
||||
s.play.playMode = PlayMode::Trigger;
|
||||
s.play.pitchEngine = eng;
|
||||
s.play.playRate = rate;
|
||||
s.play.ampSpline.mode = EnvMode::Spline;
|
||||
s.play.ampSpline.contour = VelocityCurve::linear(); // 0 -> 1 across the sample
|
||||
Voice v;
|
||||
v.presizePreserveShifters(1024);
|
||||
v.start(60, 127, s, /*declickTakeover=*/false, rate);
|
||||
double halfway = 0.0;
|
||||
for (std::size_t i = 0; i < 80000 && v.active(); ++i) {
|
||||
const double y = static_cast<double>(v.renderFrame());
|
||||
if (halfway == 0.0 && y > 0.5) halfway = static_cast<double>(i);
|
||||
}
|
||||
CHECK(halfway > 0.0);
|
||||
if (rate == 1.0) atUnity = halfway;
|
||||
// 12000 source frames in at unity; twice as many output frames at half rate.
|
||||
else CHECK(approx(halfway, atUnity / rate, atUnity * 0.02));
|
||||
if (rate != 1.0 && !approx(halfway, atUnity / rate, atUnity * 0.02)) {
|
||||
std::printf(" eng %d rate %.2f: halfway %.0f, wanted %.0f\n",
|
||||
static_cast<int>(eng), rate, halfway, atUnity / rate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pitch is the same multiply as Rate under Varispeed, so the same rule binds it: a staged stage
|
||||
// time is OF THE PERFORMANCE and does not scale. The AHD is the case that can go wrong, since it
|
||||
// is evaluated at the SOURCE offset — which a Pitch offset advances faster or slower. Under
|
||||
// Preserve the offset never touches the read, so the same attack lands on the same frame there
|
||||
// for a different reason; asserted in both so the compensation cannot be applied to the wrong
|
||||
// engine. Key-tracking is deliberately NOT compensated, and the last block pins that too.
|
||||
static void testAPitchOffsetLeavesTheStagedAttackWallClockUnderVarispeed() {
|
||||
constexpr std::int64_t kAttack = 2000;
|
||||
SampleData base = dcSample(48000);
|
||||
base.play.playMode = PlayMode::Trigger;
|
||||
base.play.trigAhd = AhdParams{kAttack, 0, 1.0, util::kCurveNeutral, util::kCurveNeutral};
|
||||
|
||||
const auto attackFrame = [](const SampleData& s, int note) {
|
||||
Voice v;
|
||||
v.presizePreserveShifters(1024);
|
||||
v.start(note, 127, s, /*declickTakeover=*/false, s.play.playRate);
|
||||
for (std::size_t i = 0; i < 200000 && v.active(); ++i) {
|
||||
if (static_cast<double>(v.renderFrame()) > 0.99) return static_cast<double>(i);
|
||||
}
|
||||
return -1.0;
|
||||
};
|
||||
|
||||
for (PitchEngine eng : {PitchEngine::Varispeed, PitchEngine::Preserve}) {
|
||||
for (double semis : {-12.0, -5.0, 0.0, 7.0, 12.0}) {
|
||||
SampleData s = base;
|
||||
s.play.pitchEngine = eng;
|
||||
s.play.pitchOffsetSemitones = semis;
|
||||
const double got = attackFrame(s, 60);
|
||||
CHECK(approx(got, static_cast<double>(kAttack), 40.0));
|
||||
if (!approx(got, static_cast<double>(kAttack), 40.0)) {
|
||||
std::printf(" eng %d pitch %+.1f st: attack completed at %.0f\n",
|
||||
static_cast<int>(eng), semis, got);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Key-tracking stays UNCOMPENSATED on purpose — it is a shipped sound, and compensating it
|
||||
// would move every note off the root. An octave up therefore completes the attack in half
|
||||
// the output frames, which is exactly the behaviour Pitch above does not have.
|
||||
SampleData vari = base;
|
||||
vari.play.pitchEngine = PitchEngine::Varispeed;
|
||||
CHECK(approx(attackFrame(vari, 72), static_cast<double>(kAttack) / 2.0, 40.0));
|
||||
}
|
||||
|
||||
// --- The Varispeed null case, baselined so the NEXT track's claim is measured. ---
|
||||
// Unlike the Preserve hashes above, these were captured from THIS commit rather than witnessed
|
||||
// against the pre-track one, and that difference is the whole reason the comment says so: the
|
||||
// pre-track equality is proved structurally instead, and cheaply — at Rate 100 % and Pitch 0 st
|
||||
// both new factors of recomputeBaseRatio's product are EXACTLY 1.0 (semitoneRatio short-circuits
|
||||
// at zero; the clamp returns 1.0 for 1.0), and multiplying a double by 1.0 is bit-exact, so the
|
||||
// read increment is the pre-track engine's own. What these constants add is a witness for the
|
||||
// track AFTER this one. A change here is a change to what every already-saved project sounds
|
||||
// like — re-derive the cause before re-baselining.
|
||||
static void testVarispeedUnityRateAndPitchAreBitIdenticalToTheirBaseline() {
|
||||
const std::size_t n = 6000;
|
||||
struct Case { int note; bool stereo; bool loop; std::uint64_t hashL; std::uint64_t hashR; };
|
||||
const Case cases[] = {
|
||||
{60, false, false, 5964955069002935931ull, 0ull}, // on root: unity read
|
||||
{67, false, false, 134881748704183217ull, 0ull}, // +7 st
|
||||
{55, false, false, 11914283967735558216ull, 0ull}, // -5 st
|
||||
{67, true, true, 11674273643338193955ull, 15241091931688620298ull}, // stereo + loop
|
||||
};
|
||||
for (const Case& c : cases) {
|
||||
SampleData s = stretchProbeSample(4000, c.stereo);
|
||||
s.play.pitchEngine = PitchEngine::Varispeed;
|
||||
if (c.loop) {
|
||||
s.loop.hasLoop = true;
|
||||
s.loop.start = 1200;
|
||||
s.loop.end = 3600;
|
||||
s.loopCrossfadeFrames = 256;
|
||||
}
|
||||
std::vector<AudioSample> l(n), r(c.stereo ? n : 0);
|
||||
renderVoice(s, c.note, /*rate=*/1.0, /*window=*/2205, c.stereo, l, r);
|
||||
const std::uint64_t hl = hashStream(l);
|
||||
CHECK(hl == c.hashL);
|
||||
if (hl != c.hashL) std::printf(" varispeed note %d L hash %lluull\n", c.note, hl);
|
||||
if (c.stereo) {
|
||||
const std::uint64_t hr = hashStream(r);
|
||||
CHECK(hr == c.hashR);
|
||||
if (hr != c.hashR) std::printf(" varispeed note %d R hash %lluull\n", c.note, hr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The asymmetry the spec is explicit about: a contour is OF THE SAMPLE and scales with Rate, a
|
||||
// staged envelope is OF THE PERFORMANCE and does not. Trigger's AHD is the case that could go
|
||||
// wrong — it is evaluated at the SOURCE offset, which advances at the rate — so its stage frames
|
||||
@@ -3639,6 +3812,10 @@ int main() {
|
||||
testKeyTrackRateAndPitchOffsetResolveToOneMultiply();
|
||||
testPreserveRoutesRateToDurationAndTheOffsetToPitch();
|
||||
testRateScalesTheLoopPeriodWithoutMovingItsStoredFrames();
|
||||
testPreserveHoldsTheLoopsAudiblePeriodWhileRateMovesItsTraversal();
|
||||
testADrawnContourScalesWithRateInBothEngines();
|
||||
testAPitchOffsetLeavesTheStagedAttackWallClockUnderVarispeed();
|
||||
testVarispeedUnityRateAndPitchAreBitIdenticalToTheirBaseline();
|
||||
testStagedStageTimesDoNotScaleWithRateWhileTheSpanDoes();
|
||||
testPreserveStretchSpeaksOnFrameZeroAtEveryRate();
|
||||
testPreserveStretchLoopsTheSourceSpan();
|
||||
|
||||
Reference in New Issue
Block a user