Bake window: derived note lengths carry exact durations, not ladder rungs — a long take is no longer cut at 384 beats

Hold keeps its picker. Also: one home for the %-fold, duration-ordered Hold travel, and a corrupt tail degrades to absent rather than fabricating one.
This commit is contained in:
2026-08-01 21:10:38 -04:00
parent 19aeb92775
commit 65f6070348
35 changed files with 772 additions and 226 deletions
+93 -20
View File
@@ -793,11 +793,15 @@ static void testNonFiniteAhdSecondsLiftToZero() {
static constexpr std::size_t kHardFlagTailBytes = 4 + 2 + 4 + 2 + 4 + 2;
static constexpr std::size_t kBakeHoldTailBytes = 4 + 1;
// The v14 tail at its one-bar default, re-appended after a splice so the record still ends
// where the reader expects it to.
// The v14 tail, re-appended after a splice so the record still ends where the reader expects.
static void putBakeHoldTail(std::vector<std::uint8_t>& out, int quarterExponent,
note::DivisionModifier modifier) {
legacy::u32v(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(quarterExponent)));
legacy::u8v(out, static_cast<std::uint8_t>(modifier));
}
static void putDefaultBakeHoldTail(std::vector<std::uint8_t>& out) {
legacy::u32v(out, 2); // quarterExponent 2 == 1/1
legacy::u8v(out, 0); // Straight
putBakeHoldTail(out, 2, note::DivisionModifier::Straight); // 1/1, the field's default
}
// A hard-flag COUNT that disagrees with the curve fromPoints already built, but is still
@@ -866,11 +870,10 @@ static void testV13HardFlagInBoundsMismatchDropsFlagsOnly() {
CHECK(out.params.velocityCurve.equals(reasampler::instrument::engine::VelocityCurve::flat()));
}
// A hard-flag COUNT that exceeds what its OWN tail carries a genuinely corrupt/out-of-bounds
// count — must be BOUND-AND-SKIPPED without consuming any of the following bytes, so the
// FILTER/PITCH tails immediately after the AMP block still parse at their correct offset. The
// old behavior (r.ok = false) reset the ENTIRE params record to defaults on this path, which is
// strictly worse than the documented "drops only the hard points" promise.
// A hard-flag COUNT that exceeds what its own tail carries is a genuinely corrupt count: the
// record parsed AHEAD of it survives (never the old "reset everything to defaults"), and the
// stream is drained rather than guessed at — see the stranding test below for why guessing is
// worse. Here nothing follows that the drain can cost, so the two behaviours coincide.
static void testV13HardFlagOutOfBoundsCountSurvivesWithoutWipingTheRecord() {
ComponentState in;
in.selectionId = "pad";
@@ -884,20 +887,12 @@ static void testV13HardFlagOutOfBoundsCountSurvivesWithoutWipingTheRecord() {
CHECK(bytes.size() >= kHardFlagTailBytes + kBakeHoldTailBytes);
bytes.resize(bytes.size() - kHardFlagTailBytes - kBakeHoldTailBytes);
legacy::u32v(bytes, 1000); // amp: a count its own tail cannot possibly carry
// No amp flag bytes follow — bound-and-skip must consume none, so the well-formed
// filter/pitch blocks right after it land exactly where they belong.
legacy::u32v(bytes, 2); // filter: correct count, unchanged
legacy::u8v(bytes, 0);
legacy::u8v(bytes, 0);
legacy::u32v(bytes, 2); // pitch: correct count, unchanged
legacy::u8v(bytes, 0);
legacy::u8v(bytes, 0);
putDefaultBakeHoldTail(bytes);
// …and nothing at all after it, so the blob simply ends inside the v13 tail.
const ComponentState out = deserializeComponentState(bytes, 48000.0);
// The whole record survives — including everything the v13 section itself carries ahead of
// the hard-flag tail (the three spline EGs) and the two well-formed tails after the
// corrupted one — only the AMP curve's hard-flag application is lost.
// the hard-flag tail (the three spline EGs). Only the hard-flag applications and the tail
// that never arrived are lost.
CHECK(out.selectionId == "pad");
CHECK(out.params.rootOverride && *out.params.rootOverride == 44);
CHECK(out.params.play.adsr.releaseSeconds == 0.44);
@@ -907,6 +902,82 @@ static void testV13HardFlagOutOfBoundsCountSurvivesWithoutWipingTheRecord() {
CHECK(out.params.play.ampSpline.mode == EnvMode::Staged);
CHECK(out.params.velocityCurve.size() == 2); // unaffected: not misapplied, not discarded
CHECK(!out.params.velocityCurve.points()[0].hard);
CHECK(out.params.bakeHold == InstrumentParams{}.bakeHold);
}
// The stranding case, and the reason a bogus count DRAINS rather than skipping in place: the
// blob keeps going after the corrupt block, so "skip nothing and read on" starts every later
// tail mid-block. The bake Hold is the tail that makes it visible — it CLAMPS whatever it
// reads, so a misaligned read installs a legal-looking division rather than failing loudly.
// The bar is that it degrades to ABSENT (the field's own default), never to a fabricated value
// — and in particular never to the top rung the misread count used to clamp to.
static void testV13HardFlagCountThatStrandsAlignmentLeavesTheHoldAbsentNotFabricated() {
ComponentState in;
in.selectionId = "pad";
in.params.rootOverride = 44;
in.params.play.adsr.releaseSeconds = 0.44;
in.params.loopCrossfadeFrames = 321;
in.params.bakeHold = note::makeDivision(-2, note::DivisionModifier::Triplet);
// A THREE-point amp curve, so its flag block is three bytes rather than two: the
// misaligned reads below then land on bytes that decode to something other than the
// default, which is what makes "absent" and "fabricated" distinguishable at all.
in.params.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(
{VelocityPoint{0.0, 0.0}, VelocityPoint{64.0, 0.5, /*hard=*/true},
VelocityPoint{127.0, 1.0}},
reasampler::instrument::engine::CurveDomain::Unipolar);
// A REAL blob with exactly ONE corrupt field: the amp hard-flag count, patched in place.
// Everything after it — the amp flags, both well-formed neighbour blocks, and the Hold —
// is exactly what the serializer wrote, which is the whole hazard.
constexpr std::size_t kThreePointFlagTail = (4 + 3) + (4 + 2) + (4 + 2);
std::vector<std::uint8_t> bytes = serializeComponentState(in);
CHECK(bytes.size() >= kThreePointFlagTail + kBakeHoldTailBytes);
const std::size_t ampCountAt = bytes.size() - kThreePointFlagTail - kBakeHoldTailBytes;
for (std::size_t i = 0; i < 4; ++i) bytes[ampCountAt + i] = i == 0 ? 0x00 : 0xFF;
const ComponentState out = deserializeComponentState(bytes, 48000.0);
CHECK(out.selectionId == "pad");
CHECK(out.params.rootOverride && *out.params.rootOverride == 44);
CHECK(out.params.play.adsr.releaseSeconds == 0.44);
CHECK(out.params.loopCrossfadeFrames == 321);
CHECK(out.params.play.ampSpline.mode == EnvMode::Staged);
// Absent, not the stored value (its tail is past the damage and cannot be trusted)…
CHECK(out.params.bakeHold == InstrumentParams{}.bakeHold);
CHECK(out.params.bakeHold != note::makeDivision(-2, note::DivisionModifier::Triplet));
// …and above all not the division a misaligned read manufactures: the flag bytes read as
// the next count, and the next-but-one block's bytes read as the Hold, whose exponent
// clamps to the top rung.
CHECK(out.params.bakeHold !=
note::makeDivision(note::kMaxQuarterExponent, note::DivisionModifier::Straight));
}
// Numeric domains are established at the DOOR, not at each consumer. A NaN pitch depth reaches
// the bake's pow() and the voice's ratio multiply; a NaN %-length and a NaN stage time reach
// narrowing casts that are undefined on one; and a root override outside MIDI range makes the
// bake's render note and the sample's own root disagree, which is a read rate other than 1 and
// therefore a window sized in the truncating direction.
static void testOutOfDomainWireValuesAreBoundedAtTheCodec() {
const PlaySeconds defaults;
ComponentState in;
in.selectionId = "pad";
in.params.keyTrack = 0.5; // a neighbouring field, to show the guards are per-field
in.params.rootOverride = 9999;
in.params.play.pitchEnv.peakSemitones = std::numeric_limits<double>::quiet_NaN();
in.params.play.trigger.lengthFraction = std::numeric_limits<double>::quiet_NaN();
in.params.play.adsr.releaseSeconds = std::numeric_limits<double>::infinity();
const ComponentState out = deserializeComponentState(serializeComponentState(in), 48000.0);
CHECK(out.params.rootOverride && *out.params.rootOverride == 127);
CHECK(out.params.play.pitchEnv.peakSemitones == defaults.pitchEnv.peakSemitones);
CHECK(out.params.play.trigger.lengthFraction == defaults.trigger.lengthFraction);
CHECK(out.params.play.adsr.releaseSeconds == defaults.adsr.releaseSeconds);
CHECK(out.params.keyTrack == 0.5);
ComponentState low = in;
low.params.rootOverride = -5;
const ComponentState lowOut = deserializeComponentState(serializeComponentState(low), 48000.0);
CHECK(lowOut.params.rootOverride && *lowOut.params.rootOverride == 0);
}
// A hard-flag tail truncated mid-COUNT-FIELD (only 2 of its 4 length bytes present, and
@@ -1862,6 +1933,8 @@ int main() {
testNonFiniteAhdSecondsLiftToZero();
testV13HardFlagInBoundsMismatchDropsFlagsOnly();
testV13HardFlagOutOfBoundsCountSurvivesWithoutWipingTheRecord();
testV13HardFlagCountThatStrandsAlignmentLeavesTheHoldAbsentNotFabricated();
testOutOfDomainWireValuesAreBoundedAtTheCodec();
testV13HardFlagTailTruncatedMidCountSurvivesWithoutWipingTheRecord();
testBakeHoldRoundTripsAndDisturbsNothingElse();
testV13BlobLiftsToTheDefaultHold();