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:
@@ -211,7 +211,7 @@ static void testPitchEnvelopeHoldsPhaseAndGlidesDepth() {
|
||||
|
||||
PitchEnvParams longer = p;
|
||||
longer.shape.decayFrames = 2000;
|
||||
b.applyLive(longer); // decay doubled mid-decay
|
||||
b.applyLive(100000, longer); // decay doubled mid-decay, same span
|
||||
CHECK(a.tick() == b.tick()); // phi held: the semitone offset is unchanged this frame
|
||||
|
||||
// A depth move is a level step, so it glides rather than jumping: the first frame after
|
||||
@@ -224,7 +224,7 @@ static void testPitchEnvelopeHoldsPhaseAndGlidesDepth() {
|
||||
for (int i = 0; i < 400; ++i) { c.tick(); d.tick(); }
|
||||
PitchEnvParams noDepth = p;
|
||||
noDepth.peakSemitones = 0.0;
|
||||
c.applyLive(noDepth); // depth to zero mid-decay
|
||||
c.applyLive(100000, noDepth); // depth to zero mid-decay
|
||||
CHECK(c.tick() == d.tick());
|
||||
// ...and it does eventually reach the new depth rather than staying put.
|
||||
for (int i = 0; i < 400; ++i) c.tick();
|
||||
@@ -259,7 +259,7 @@ static void testPitchEnvelopeHoldStagePlaysAndHoldsPhase() {
|
||||
for (int i = 0; i < 300; ++i) f.tick();
|
||||
PitchEnvParams wider = p;
|
||||
wider.shape.holdFraction = 1.0;
|
||||
f.applyLive(wider);
|
||||
f.applyLive(1000, wider);
|
||||
CHECK(f.tick() == 12.0);
|
||||
for (int i = 0; i < 1200; ++i) f.tick();
|
||||
CHECK(f.tick() == 0.0);
|
||||
@@ -307,7 +307,7 @@ static void testAFreshPitchEnvelopeTakesTheNewTimesOutright() {
|
||||
PitchEnvParams dialled = stale;
|
||||
dialled.peakSemitones = 12.0;
|
||||
dialled.shape.decayFrames = 1000;
|
||||
env.snapLive(dialled);
|
||||
env.snapLive(100000, dialled);
|
||||
CHECK(env.tick() == 12.0); // at the top of the new decay leg, not past the envelope
|
||||
for (int i = 0; i < 499; ++i) env.tick();
|
||||
CHECK(std::fabs(env.tick() - 6.0) < 1e-12);
|
||||
@@ -850,6 +850,94 @@ static void testAPitchOffsetChangeMovesTheSoundingNoteInBothEngines() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- The live Pitch offset reaches the note's TIME domains, not only its pitch -------------
|
||||
|
||||
// A block published BEFORE the note starts is the snapLive path, and the snapshot's own copy of
|
||||
// the offset is deliberately stale there — so this is where a Pitch offset has to be in hand
|
||||
// already when the note's envelopes are fitted against the read rate. Answers how many output
|
||||
// frames the voice sounded for, to a 256-frame block.
|
||||
static std::size_t soundingBlocksWithPublishedPitch(SampleData& s, double offsetSemis,
|
||||
std::size_t capFrames) {
|
||||
LiveParams block;
|
||||
LiveValues v = foldLive(s.play); // s.play keeps its own (zero) offset: the stale copy
|
||||
v.pitchOffsetSemitones = offsetSemis;
|
||||
block.publish(v);
|
||||
s.live = █
|
||||
VoiceEngine engine(1, s, /*preserveVoiceCap=*/0, /*preserveWindowFrames=*/2048);
|
||||
engine.noteOn(60, 127);
|
||||
std::vector<AudioSample> out;
|
||||
std::size_t life = 0;
|
||||
while (out.size() < capFrames && engine.activeVoiceCount() > 0) {
|
||||
engine.render(out, 256);
|
||||
life = out.size();
|
||||
}
|
||||
return life;
|
||||
}
|
||||
|
||||
// Under Varispeed the Pitch offset is a factor of the read increment, and the staged AHD is
|
||||
// evaluated at the SOURCE offset that increment advances — so its stage frames are fitted to the
|
||||
// offset the note will ACTUALLY play at, exactly as they are to Rate. The attack therefore
|
||||
// completes on the same output frame at every offset. Fitting against the snapshot's stale zero
|
||||
// instead is what this catches.
|
||||
static void testAPublishedPitchOffsetLeavesTheStagedAttackWallClock() {
|
||||
constexpr std::int64_t kAttack = 2000;
|
||||
for (double semis : {-12.0, 0.0, 12.0}) {
|
||||
SampleData s;
|
||||
s.frames.assign(96000, 1.0f); // DC: the output IS the amp envelope
|
||||
s.sampleRate = kRate;
|
||||
s.rootNote = 60;
|
||||
s.play.playMode = PlayMode::Trigger;
|
||||
s.play.pitchEngine = PitchEngine::Varispeed;
|
||||
s.play.trigAhd = AhdParams{kAttack, 0, 1.0, util::kCurveNeutral, util::kCurveNeutral};
|
||||
|
||||
LiveParams block;
|
||||
LiveValues v = foldLive(s.play);
|
||||
v.pitchOffsetSemitones = semis;
|
||||
block.publish(v);
|
||||
s.live = █
|
||||
VoiceEngine engine(1, s, /*preserveVoiceCap=*/0, /*preserveWindowFrames=*/2048);
|
||||
engine.noteOn(60, 127);
|
||||
std::vector<AudioSample> out;
|
||||
engine.render(out, 8000);
|
||||
std::size_t reachedFull = 0;
|
||||
for (std::size_t i = 0; i < out.size(); ++i) {
|
||||
if (out[i] > 0.99f) { reachedFull = i; break; }
|
||||
}
|
||||
const bool ok = reachedFull > 0 &&
|
||||
std::fabs(static_cast<double>(reachedFull) -
|
||||
static_cast<double>(kAttack)) < 40.0;
|
||||
CHECK(ok);
|
||||
if (!ok) std::printf(" pitch %+.1f st: attack completed at %zu\n", semis, reachedFull);
|
||||
}
|
||||
}
|
||||
|
||||
// The pitch envelope's SPAN is a wall-clock duration converted from the same read rate, so it
|
||||
// follows the published offset too. Read out as the note's LIFETIME: the envelope's depth
|
||||
// cancels the offset while it holds, so the read runs at unity for the hold and at the offset
|
||||
// ratio after it — which makes the lifetime a direct readout of where the hold ended.
|
||||
// 12000 source frames, offset -12 st (read at 0.5): the span is 24000 output frames, its
|
||||
// half-span hold is 12000 of them at unity, and the source is exhausted exactly there.
|
||||
// A span fitted to the stale zero offset is 12000, holds for 6000, and the remaining 6000
|
||||
// source frames then take 12000 more output frames — 18000 in total.
|
||||
static void testAPublishedPitchOffsetRefitsThePitchEnvelopeSpan() {
|
||||
SampleData s;
|
||||
s.frames.assign(12000, 1.0f);
|
||||
s.sampleRate = kRate;
|
||||
s.rootNote = 60;
|
||||
s.play.playMode = PlayMode::Trigger;
|
||||
s.play.pitchEngine = PitchEngine::Varispeed;
|
||||
s.play.trigAhd = AhdParams{0, 0, 1.0, util::kCurveNeutral, util::kCurveNeutral};
|
||||
s.play.pitchEnv.enabled = true;
|
||||
s.play.pitchEnv.peakSemitones = 12.0; // cancels the -12 offset while it holds
|
||||
s.play.pitchEnv.shape.attackFrames = 0;
|
||||
s.play.pitchEnv.shape.decayFrames = 0;
|
||||
s.play.pitchEnv.shape.holdFraction = 0.5;
|
||||
|
||||
const std::size_t life = soundingBlocksWithPublishedPitch(s, -12.0, 60000);
|
||||
CHECK(life > 11000 && life < 13000);
|
||||
if (!(life > 11000 && life < 13000)) std::printf(" refit span: life %zu\n", life);
|
||||
}
|
||||
|
||||
// --- What stays latched at note-on -------------------------------------------------------
|
||||
|
||||
static void testPitchRatioAndVelocityGainStayLatched() {
|
||||
@@ -991,6 +1079,8 @@ int main() {
|
||||
testOneBlockServesTwoIndependentObservers();
|
||||
testARateChangeSpareTheSoundingNoteAndReachesTheNextOne();
|
||||
testAPitchOffsetChangeMovesTheSoundingNoteInBothEngines();
|
||||
testAPublishedPitchOffsetLeavesTheStagedAttackWallClock();
|
||||
testAPublishedPitchOffsetRefitsThePitchEnvelopeSpan();
|
||||
testPitchRatioAndVelocityGainStayLatched();
|
||||
testVelocityGainSurvivesAHostilePublishThatReallyLands();
|
||||
if (g_fail == 0) std::printf("live_delivery tests passed\n");
|
||||
|
||||
Reference in New Issue
Block a user