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:
@@ -64,6 +64,19 @@ double peakAt(const BakeAudio& audio, std::int64_t from, std::int64_t to) {
|
||||
return peak;
|
||||
}
|
||||
|
||||
// The last frame of the file that carries any signal at all — where the voice ACTUALLY stopped.
|
||||
// A measurement of the engine, never a second evaluation of the derivation under test. -1 when
|
||||
// the render is silent throughout.
|
||||
std::int64_t lastSoundingFrame(const BakeAudio& audio) {
|
||||
for (std::int64_t f = audio.frameCount() - 1; f >= 0; --f) {
|
||||
if (std::fabs(static_cast<double>(
|
||||
audio.interleaved[static_cast<std::size_t>(f * audio.channelCount)])) > kSilence) {
|
||||
return f;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// The derived program, optionally lengthened: `extraMs` widens ONLY the end offset (the same
|
||||
// sound, a longer window). It leaves the derivation itself untouched, which is what makes the
|
||||
// comparison a measurement of the derived end rather than of a second derivation.
|
||||
@@ -92,6 +105,20 @@ std::int64_t derivedFrames(const SampleData& s, Division hold = oneBar()) {
|
||||
return plan ? plan->totalFrames : -1;
|
||||
}
|
||||
|
||||
// Where the dialed sound stops when NOTHING cuts it: the same sound programmed with a
|
||||
// deliberately long note and a window to match. This is the reference a derived window is
|
||||
// judged against, and it has to be measured rather than recomputed — an under-derived Gate
|
||||
// window truncates by releasing the note EARLY, which leaves no signal outside the file at all
|
||||
// and so is invisible to "nothing past the end".
|
||||
std::int64_t freeRunningEnd(const SampleData& s, double heldSeconds) {
|
||||
NoteProgram p = defaultBakeProgram(s, kRate, oneBar(), Velocity::of(100));
|
||||
p.length = lengthOfSeconds(heldSeconds);
|
||||
p.end = EndOffset(offsetFromMs(200.0));
|
||||
const std::optional<BakePlan> plan = planOf(p);
|
||||
if (!plan) { std::printf("FAIL: fixture reference window refused\n"); ++g_fail; return -1; }
|
||||
return lastSoundingFrame(renderBake(s, *plan, kUnity));
|
||||
}
|
||||
|
||||
// The last frame of the file, which is where a hard cut shows up.
|
||||
double lastFrameLevel(const BakeAudio& audio) {
|
||||
return audio.frameCount() > 0 ? peakAt(audio, audio.frameCount() - 1, audio.frameCount())
|
||||
@@ -337,6 +364,107 @@ int main() {
|
||||
CHECK(derivedFrames(staged) == 12000 + kPad);
|
||||
}
|
||||
|
||||
// ============================ RATE AND PITCH ====================================
|
||||
|
||||
// The one judgement every case below makes: the derived window holds the WHOLE free-running
|
||||
// sound (the derived render stops exactly where the uncut one does), and it is exactly
|
||||
// enough rather than merely long. `heldSeconds` only has to exceed the free-running length.
|
||||
const auto windowHoldsTheWholeNote = [&](const SampleData& s, double heldSeconds,
|
||||
const char* what) {
|
||||
const std::int64_t trueEnd = freeRunningEnd(s, heldSeconds);
|
||||
const std::int64_t derived = derivedFrames(s);
|
||||
const std::int64_t got = lastSoundingFrame(bakeWith(s, 0.0));
|
||||
const bool held = trueEnd >= 0 && derived > trueEnd && got == trueEnd;
|
||||
CHECK(held);
|
||||
CHECK(held && derived - trueEnd <= kPad + 8);
|
||||
if (!(held && derived - trueEnd <= kPad + 8)) {
|
||||
std::printf(" %s: free-running end %lld, derived render end %lld, window %lld\n",
|
||||
what, static_cast<long long>(trueEnd), static_cast<long long>(got),
|
||||
static_cast<long long>(derived));
|
||||
}
|
||||
};
|
||||
|
||||
// --- Rate scales the window under BOTH engines, in both derived branches --------------
|
||||
// Rate IS the read rate: Varispeed folds it into the read increment, Preserve feeds the
|
||||
// stretcher at it. Either way a 50 % rate doubles how long the source takes to play out and
|
||||
// a 200 % one halves it, so a window blind to Rate truncates by half at the slow end and
|
||||
// prints a file of trailing silence at the fast one.
|
||||
{
|
||||
for (PitchEngine eng : {PitchEngine::Varispeed, PitchEngine::Preserve}) {
|
||||
for (PlayMode mode : {PlayMode::Trigger, PlayMode::Gate}) {
|
||||
for (double rate : {0.5, 0.75, 1.0, 1.5, 2.0}) {
|
||||
SampleData s = dcSample(48000); // 1 s; 2 s at the slowest rate
|
||||
s.play.playMode = mode;
|
||||
s.play.pitchEngine = eng;
|
||||
s.play.adsr.releaseFrames = 0;
|
||||
s.play.playRate = rate;
|
||||
char what[64];
|
||||
std::snprintf(what, sizeof(what), "eng %d mode %d rate %.2f",
|
||||
static_cast<int>(eng), static_cast<int>(mode), rate);
|
||||
windowHoldsTheWholeNote(s, 3.0, what);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- A downward Pitch offset stretches the window under VARISPEED only ---------------
|
||||
// It is a factor of the read increment there and a shifter transpose under Preserve, so the
|
||||
// window follows it in one engine and not the other. Both must still hold the whole note.
|
||||
{
|
||||
SampleData s = dcSample(48000);
|
||||
s.play.playMode = PlayMode::Trigger;
|
||||
s.play.pitchEngine = PitchEngine::Varispeed;
|
||||
s.play.pitchOffsetSemitones = -12.0; // half rate for the note's whole lifetime
|
||||
|
||||
CHECK(derivedFrames(s) == 96000 + kPad);
|
||||
windowHoldsTheWholeNote(s, 3.0, "varispeed pitch -12");
|
||||
|
||||
SampleData p = s;
|
||||
p.play.pitchEngine = PitchEngine::Preserve;
|
||||
CHECK(derivedFrames(p) == 48000 + kPad); // the read rate never moved
|
||||
windowHoldsTheWholeNote(p, 3.0, "preserve pitch -12");
|
||||
|
||||
// An UPWARD offset bounds nothing — the read only gets faster — so the window keeps the
|
||||
// un-stretched span and the balance is trailing silence, on the same asymmetry the
|
||||
// velocity->pitch term already takes.
|
||||
SampleData up = s;
|
||||
up.play.pitchOffsetSemitones = 12.0;
|
||||
CHECK(derivedFrames(up) == 48000 + kPad);
|
||||
const BakeAudio wideUp = bakeWith(up, /*extraMs=*/500.0);
|
||||
CHECK(peakAt(wideUp, 48000 + kPad, wideUp.frameCount()) == 0.0);
|
||||
}
|
||||
|
||||
// --- Rate and Pitch COMPOUND, because the voice folds them into one multiply ----------
|
||||
{
|
||||
SampleData s = dcSample(48000);
|
||||
s.play.playMode = PlayMode::Trigger;
|
||||
s.play.pitchEngine = PitchEngine::Varispeed;
|
||||
s.play.playRate = 0.5;
|
||||
s.play.pitchOffsetSemitones = -12.0; // together: a quarter-speed read
|
||||
|
||||
CHECK(derivedFrames(s) == 192000 + kPad);
|
||||
windowHoldsTheWholeNote(s, 5.0, "varispeed rate 0.5 x pitch -12");
|
||||
}
|
||||
|
||||
// --- Gate over a sustain loop is Hold's, and Rate does not touch it -------------------
|
||||
// The note length there is the user's Hold in wall clock and the release is ticked per
|
||||
// output frame, so neither term of the stretch applies — the one derived branch that must
|
||||
// NOT move when Rate does.
|
||||
{
|
||||
SampleData s = dcSample(48000);
|
||||
s.loop = SampleLoop{true, 0, 24000};
|
||||
s.play.playMode = PlayMode::Gate;
|
||||
s.play.adsr.releaseFrames = 4800;
|
||||
CHECK(bakeWindowNeedsHold(s));
|
||||
|
||||
const std::int64_t unity = derivedFrames(s);
|
||||
for (double rate : {0.5, 2.0}) {
|
||||
SampleData r = s;
|
||||
r.play.playRate = rate;
|
||||
CHECK(derivedFrames(r) == unity);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== VELOCITY ========================================
|
||||
|
||||
// --- The bake renders at the velocity it is handed ----------------------------------
|
||||
|
||||
Reference in New Issue
Block a user