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
+56 -12
View File
@@ -2,7 +2,8 @@
// note-length ladder. No VST3, no REAPER, no framework.
//
// Covers: both ends of the knob, the round trip from every rung, out-of-range and non-finite
// input, and that every rung is reachable (no rung is skipped by the rounding).
// input, that every rung is reachable (no rung is skipped by the rounding), that the travel is
// monotone in DURATION, and that each rung owns an equal slice of it.
#include "../src/core/instrument/ui/bake_hold.h"
@@ -17,15 +18,37 @@ static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
namespace {
// The shortest and longest lengths ON THE LADDER, found by scanning rather than by indexing an
// end of the picker order — which is exactly the assumption under test.
Division shortestRung() {
Division best = divisionAt(0);
for (int i = 1; i < kDivisionCount; ++i)
if (divisionBeats(divisionAt(i)) < divisionBeats(best)) best = divisionAt(i);
return best;
}
Division longestRung() {
Division best = divisionAt(0);
for (int i = 1; i < kDivisionCount; ++i)
if (divisionBeats(divisionAt(i)) > divisionBeats(best)) best = divisionAt(i);
return best;
}
} // namespace
int main() {
// --- The ends of the travel are the ends of the ladder ---------------------------
CHECK(bakeHoldFromNorm(0.0) == divisionAt(0));
CHECK(bakeHoldFromNorm(1.0) == divisionAt(kDivisionCount - 1));
// --- The ends of the travel are the SHORTEST and LONGEST notes -------------------
// Not divisionAt(0) / divisionAt(kDivisionCount - 1): those are the ends of the picker's
// presentation order, which is not length order.
CHECK(bakeHoldFromNorm(0.0) == shortestRung());
CHECK(bakeHoldFromNorm(1.0) == longestRung());
// --- Out of range clamps rather than wrapping ------------------------------------
CHECK(bakeHoldFromNorm(-3.0) == divisionAt(0));
CHECK(bakeHoldFromNorm(9.5) == divisionAt(kDivisionCount - 1));
CHECK(bakeHoldFromNorm(std::nan("")) == divisionAt(0));
CHECK(bakeHoldFromNorm(-3.0) == shortestRung());
CHECK(bakeHoldFromNorm(9.5) == longestRung());
CHECK(bakeHoldFromNorm(std::nan("")) == shortestRung());
// --- Round trip: a knob painted from a stored rung and released reproduces it -----
for (int i = 0; i < kDivisionCount; ++i) {
@@ -44,13 +67,34 @@ int main() {
for (int i = 0; i < kDivisionCount; ++i) CHECK(seen[static_cast<std::size_t>(i)]);
}
// --- The map is monotone: turning the knob up never shortens the note -------------
// --- The map is monotone IN DURATION: turning the knob up never shortens the note --
// Asserted over divisionBeats, not divisionIndex: the index is presentation order, in which
// a rung's triplet sits after (and is shorter than) the previous rung's dotted — so an
// index sweep can be non-decreasing while the note it selects gets shorter.
{
int previous = -1;
double previous = 0.0;
for (int step = 0; step <= 4000; ++step) {
const int index = divisionIndex(bakeHoldFromNorm(static_cast<double>(step) / 4000.0));
CHECK(index >= previous);
previous = index;
const double beats =
divisionBeats(bakeHoldFromNorm(static_cast<double>(step) / 4000.0));
CHECK(beats >= previous);
previous = beats;
}
}
// --- Each rung owns an EQUAL slice, centred on its own position -------------------
// A floor-based map passes every property above while giving each rung the slice ABOVE its
// position; only the boundaries tell the two apart. Slice k spans [(k-0.5)/L, (k+0.5)/L).
{
constexpr int last = kDivisionCount - 1;
const double slice = 1.0 / static_cast<double>(last);
const double eps = slice / 1000.0;
for (int k = 1; k < last; ++k) {
const double centre = static_cast<double>(k) * slice;
CHECK(bakeHoldFromNorm(centre) == bakeHoldFromNorm(centre - slice * 0.5 + eps));
CHECK(bakeHoldFromNorm(centre) == bakeHoldFromNorm(centre + slice * 0.5 - eps));
// …and one step past the upper boundary is already the NEXT rung.
CHECK(divisionBeats(bakeHoldFromNorm(centre + slice * 0.5 + eps)) >
divisionBeats(bakeHoldFromNorm(centre)));
}
}
+56 -17
View File
@@ -2,7 +2,8 @@
// framework. Same fast assert loop as the sibling pure tests.
//
// Covers: the default program's window derived from the dialed sound (Gate's hold to source
// exhaustion plus its release, a Trigger play span, and the Varispeed read-stretch bound);
// exhaustion plus its release, a start point, a Trigger play span, and the Varispeed
// read-stretch bound on both branches that take it);
// the frame window and both event frames against hand-computed values; a capture opening
// BEFORE note-on and one opening AFTER it; the refusals and what each one reports — a
// collapsed window, a non-positive rate, a window that rounds to nothing, and one past the
@@ -45,8 +46,10 @@ SampleData dialedSample(std::size_t frames = 96000) {
// The Hold default; read only where the window is underivable, which is nowhere in this file.
Division oneBar() { return makeDivision(2, DivisionModifier::Straight); }
NoteProgram derived(const SampleData& s, Tempo t) {
return defaultBakeProgram(s, kRate, t, oneBar(), Velocity{});
// `t` is unused by the derivation itself — it takes no tempo — but every caller here resolves
// against the same one, so threading it keeps each case's two halves visibly paired.
NoteProgram derived(const SampleData& s, Tempo) {
return defaultBakeProgram(s, kRate, oneBar(), Velocity{});
}
bool near(double a, double b) { return a > b - 1e-6 && a < b + 1e-6; }
@@ -60,8 +63,8 @@ int main() {
s.play.playMode = PlayMode::Gate;
s.play.adsr.releaseFrames = kRate * 3 / 2; // 1.5 s — past any fixed tail
const ResolvedNote r = resolveNote(derived(s, at(120.0)), at(120.0));
// The note is rounded up to the shortest length outlasting the source — 2 s exactly,
// one bar at 120 BPM — instead of the constant quarter note that released it early.
// The note is held to exhaustion — 2 s exactly — instead of the constant quarter note
// that released it early.
CHECK(near(r.noteOffSeconds, 2.0));
CHECK(r.captureStartSeconds == 0.0);
CHECK(near(r.captureEndSeconds, 2.0 + 1.5 + kPadSeconds));
@@ -72,14 +75,50 @@ int main() {
const ResolvedNote shorter = resolveNote(derived(s, at(120.0)), at(120.0));
CHECK(near(shorter.captureEndSeconds, 2.0 + 0.1 + kPadSeconds));
// A source that does not land on a rung rounds UP. 2.5 s is 5 beats, and the shortest
// rung at or above that is the 2/1 triplet (8 * 2/3 == 5.33 beats) — NOT the dotted
// half above it, which is why picker order is not length order.
// The length is EXACT, not rounded onto the ladder: 2.5 s is 5 beats, which no rung
// hits — the ladder's nearest never-short answer is the 2/1 triplet at 5.33 beats, and
// taking it would buy a third of a second of silence for nothing. The tempo is not an
// input to a derived length at all, so the same sound derives the same seconds at any.
SampleData odd = dialedSample(static_cast<std::size_t>(kRate * 5 / 2)); // 2.5 s
odd.play.playMode = PlayMode::Gate;
const ResolvedNote up = resolveNote(derived(odd, at(120.0)), at(120.0));
CHECK(up.noteOffSeconds >= 2.5); // the property that matters: never short
CHECK(near(up.noteOffSeconds, at(120.0).beatsToSeconds(8.0 * 2.0 / 3.0)));
CHECK(near(resolveNote(derived(odd, at(120.0)), at(120.0)).noteOffSeconds, 2.5));
CHECK(near(resolveNote(derived(odd, at(97.0)), at(97.0)).noteOffSeconds, 2.5));
}
// --- Gate with no loop, a START POINT set: only the post-start span is held ----------
// effectiveStart's whole reason to exist. A start marker means the head begins there, so
// holding the note for the WHOLE source would pad the window with silence the voice
// already finished; holding it for the source minus the start is exact.
{
SampleData s = dialedSample(/*frames=*/kRate * 2); // 2 s
s.play.playMode = PlayMode::Gate;
s.play.adsr.releaseFrames = 0;
s.startFrame = kRate / 2; // start half a second in -> 1.5 s of sound left
CHECK(near(resolveNote(derived(s, at(120.0)), at(120.0)).noteOffSeconds, 1.5));
// Voice::start's own degenerate rule: a start at or past the end plays from the top,
// so the window covers the whole source rather than nothing.
s.startFrame = kRate * 2;
CHECK(near(resolveNote(derived(s, at(120.0)), at(120.0)).noteOffSeconds, 2.0));
s.startFrame = -1;
CHECK(near(resolveNote(derived(s, at(120.0)), at(120.0)).noteOffSeconds, 2.0));
}
// --- Gate with no loop under Varispeed: the read-stretch bound applies here too -------
// The Trigger branch has its own coverage below; this pins that the Gate exhaustion length
// takes the same bound, which is the branch a downward offset would otherwise truncate.
{
SampleData s = dialedSample(/*frames=*/kRate); // 1 s
s.play.playMode = PlayMode::Gate;
s.play.adsr.releaseFrames = 0;
s.play.pitchEngine = PitchEngine::Varispeed;
s.play.pitchEnv.enabled = true;
s.play.pitchEnv.peakSemitones = -12.0; // an octave down == half speed at the peak
CHECK(near(resolveNote(derived(s, at(120.0)), at(120.0)).noteOffSeconds, 2.0));
// Preserve reads at the source rate, so the same dial bounds nothing.
s.play.pitchEngine = PitchEngine::Preserve;
CHECK(near(resolveNote(derived(s, at(120.0)), at(120.0)).noteOffSeconds, 1.0));
}
// --- Trigger: the window is the play span, which ignores the note's length ----------
@@ -91,8 +130,8 @@ int main() {
CHECK(r.captureStartSeconds == 0.0);
CHECK(near(r.captureEndSeconds, 1.5 + kPadSeconds));
// A span SHORTER than the quarter note closes the window early rather than padding
// it out to note-off — the sound is over, and a negative end offset is legal.
// A shorter %-length is a shorter window, with no floor under it: the sound is over
// when the span is, and note-off means nothing to a Trigger voice.
s.play.trigger.lengthFraction = 0.1; // 0.2 s
const ResolvedNote brief = resolveNote(derived(s, at(120.0)), at(120.0));
CHECK(!brief.windowCollapsed);
@@ -127,9 +166,9 @@ int main() {
{{0.0, -0.5}, {127.0, 0.0}}, reasampler::instrument::engine::CurveDomain::Bipolar);
const NoteProgram soft =
defaultBakeProgram(s, kRate, at(120.0), oneBar(), Velocity::of(1));
defaultBakeProgram(s, kRate, oneBar(), Velocity::of(1));
const NoteProgram hard =
defaultBakeProgram(s, kRate, at(120.0), oneBar(), Velocity::of(127));
defaultBakeProgram(s, kRate, oneBar(), Velocity::of(127));
CHECK(soft.velocity.value() == 1);
CHECK(hard.velocity.value() == 127);
const ResolvedNote softR = resolveNote(soft, at(120.0));
@@ -148,9 +187,9 @@ int main() {
CHECK(bakeWindowNeedsHold(s));
// The window follows Hold rather than the source, so a longer Hold is a longer file.
const ResolvedNote bar = resolveNote(
defaultBakeProgram(s, kRate, at(120.0), oneBar(), Velocity{}), at(120.0));
defaultBakeProgram(s, kRate, oneBar(), Velocity{}), at(120.0));
const ResolvedNote twoBars = resolveNote(
defaultBakeProgram(s, kRate, at(120.0),
defaultBakeProgram(s, kRate,
makeDivision(3, DivisionModifier::Straight), Velocity{}),
at(120.0));
CHECK(near(bar.noteOffSeconds, 2.0));
+65 -5
View File
@@ -10,6 +10,7 @@
#include <cmath>
#include <cstdio>
#include <optional>
using namespace reasampler;
using namespace reasampler::instrument::bake;
@@ -34,12 +35,14 @@ constexpr std::int64_t kPad = kDeclickFrames;
// expectations below read as arithmetic rather than as magic.
Division oneBar() { return makeDivision(2, DivisionModifier::Straight); }
Tempo tempo() {
const std::optional<Tempo> t = Tempo::fromBpm(kBpm);
if (!t) { std::printf("FAIL: fixture tempo rejected\n"); ++g_fail; }
Tempo tempoOf(double bpm) {
const std::optional<Tempo> t = Tempo::fromBpm(bpm);
if (!t) { std::printf("FAIL: fixture tempo %f rejected\n", bpm); ++g_fail; }
return t.value_or(Tempo::fromBpm(120.0).value());
}
Tempo tempo() { return tempoOf(kBpm); }
// Flat DC so a level reading is unambiguous: any departure from 0.5 is the envelope, the
// filter or a ring-out, never the source's own shape.
SampleData dcSample(std::size_t frames) {
@@ -66,7 +69,7 @@ double peakAt(const BakeAudio& audio, std::int64_t from, std::int64_t to) {
// comparison a measurement of the derived end rather than of a second derivation.
NoteProgram derivedProgram(const SampleData& s, double extraMs, Division hold = oneBar(),
int velocity = 100) {
NoteProgram p = defaultBakeProgram(s, kRate, tempo(), hold, Velocity::of(velocity));
NoteProgram p = defaultBakeProgram(s, kRate, hold, Velocity::of(velocity));
if (extraMs != 0.0)
p.end = EndOffset(offsetFromMs(offsetMs(p.end.amount(), tempo()) + extraMs));
return p;
@@ -136,6 +139,63 @@ int main() {
CHECK(peakAt(wide, 192000, wide.frameCount()) == 0.0);
}
// --- Gate, no loop, a source LONGER than the note-length ladder ---------------------
// The regression the exact-duration seam exists for. A derived length used to be rounded
// onto the musical-division ladder, whose longest rung is kMaxDivisionBeats (384 beats):
// a source past that took the top rung, so note-off — and the window with it — landed
// INSIDE the sound. Rendered at a low rate and a fast tempo so the case is 400 beats long
// without being twenty million frames; nothing here depends on either number but the
// beats it puts the source at.
{
constexpr int kSlowRate = 8000;
const Tempo fast = tempoOf(480.0); // 384 beats == 48 s at this tempo
constexpr std::int64_t kFrames = kSlowRate * 50; // 50 s == 400 beats: past the ladder
SampleData s = dcSample(static_cast<std::size_t>(kFrames));
s.sampleRate = kSlowRate;
s.play.playMode = PlayMode::Gate;
s.play.adsr.releaseFrames = 0;
const NoteProgram p =
defaultBakeProgram(s, kSlowRate, oneBar(), Velocity::of(100));
const ResolvedNote r = resolveNote(p, fast);
CHECK(r.noteOffSeconds > 49.9 && r.noteOffSeconds < 50.1); // 50 s, not the 48 s rung
const std::optional<BakePlan> plan = planBake(r, kSlowRate, 60).plan;
CHECK(plan.has_value());
if (plan) {
CHECK(plan->totalFrames == kFrames + kPad);
const BakeAudio whole = renderBake(s, *plan, kUnity);
// Full level across the two seconds the saturated rung used to cut, and the file
// still ends on the declick ramp rather than on a hard edge.
CHECK(peakAt(whole, kSlowRate * 48, kFrames) > 0.4);
CHECK(lastFrameLevel(whole) < 1e-3);
}
}
// --- Gate, no loop, a START POINT under Varispeed: both terms of the exhaustion ------
// The two derivation terms this branch has that the cases above do not exercise: the
// window covers frameCount MINUS the start, and that remainder is stretched by the
// deepest downward offset. Getting either wrong shortens the window.
{
SampleData s = dcSample(48000); // 1 s of source
s.play.playMode = PlayMode::Gate;
s.play.adsr.releaseFrames = 0;
s.startFrame = 24000; // half of it left to play
s.play.pitchEngine = PitchEngine::Varispeed;
// A flat velocity->pitch curve at half depth: an octave down (the range is 24
// semitones) for the note's whole lifetime, so the read really does run at half rate
// to the end rather than for one envelope stage.
s.play.pitchVelocityCurve = VelocityCurve::fromPoints(
{{0.0, -0.5}, {127.0, -0.5}}, reasampler::instrument::engine::CurveDomain::Bipolar);
// (48000 - 24000) source frames at half rate == 48000 output frames. Reading either
// term wrong halves or doubles this.
CHECK(derivedFrames(s) == 48000 + kPad);
const BakeAudio derived = bakeWith(s, 0.0);
CHECK(peakAt(derived, 47000, 48000) > 0.4); // still sounding at the derived end
const BakeAudio wide = bakeWith(s, /*extraMs=*/200.0);
CHECK(peakAt(wide, 48000 + kPad, wide.frameCount()) == 0.0); // and nothing past it
}
// --- Gate WITH a sustain loop: Hold is the note length, and the ONLY user input ------
// A looped Gate voice sounds for as long as it is held, so no derivation supplies a
// duration — this is the one case the predicate names, and the window follows Hold.
@@ -254,7 +314,7 @@ int main() {
// --- Trigger + a DRAWN amp EG: the window holds the WHOLE take -----------------------
// A drawn contour covers the full sample length, so the engine folds lengthFraction to
// 1.0 (effectiveLengthFraction, trigger_seam.h). The stored %-knob is inert but still
// 1.0 (effectiveLengthFraction, play_params.h). The stored %-knob is inert but still
// saved, and reading it raw here cut this window to a quarter of the take.
{
SampleData s = dcSample(48000);
+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();
+7
View File
@@ -9,7 +9,9 @@
#include "../src/core/instrument/note/musical_division.h"
#include <cmath>
#include <cstdio>
#include <limits>
using namespace reasampler::instrument::note;
@@ -240,6 +242,11 @@ static void testShortestAtLeastDegenerateInputs() {
const Division longest = makeDivision(kMaxQuarterExponent, DivisionModifier::Dotted);
CHECK(almostEqual(divisionBeats(longest), kMaxDivisionBeats));
CHECK(shortestDivisionAtLeast(kMaxDivisionBeats * 2.0) == longest);
// A non-finite request names no length to be at least, so it takes the SAME never-short
// answer as one nothing covers. Landing on the bottom rung instead would turn a corrupt
// value into a near-instant note.
CHECK(shortestDivisionAtLeast(std::nan("")) == longest);
CHECK(shortestDivisionAtLeast(std::numeric_limits<double>::infinity()) == longest);
}
int main() {
+53 -3
View File
@@ -1,7 +1,8 @@
// Standalone tests for reasampler::instrument::note::note_program — no VST3, no REAPER, no
// framework. Same fast assert loop as the sibling pure tests.
//
// Covers: velocity clamping; the ms/beats denomination seam and its round-trip; anchoring
// Covers: velocity clamping; the picked/exact note-length seam and its bounded door; the
// ms/beats denomination seam and its round-trip; anchoring
// (start to note-on, end to note-off); the resolved window against hand-computed values and
// its windowCollapsed flag, including the zero-length window the flag exists to distinguish;
// every division resolving to its duration in seconds; proportionality across two tempos;
@@ -39,7 +40,7 @@ static const double kStraightBeats[kRungCount] = {
static NoteProgram program(Division length, OffsetAmount start, OffsetAmount end, int velocity) {
NoteProgram p;
p.length = length;
p.length = lengthOfDivision(length);
p.start = StartOffset(start);
p.end = EndOffset(end);
p.velocity = Velocity::of(velocity);
@@ -194,6 +195,52 @@ static void testNoteLengthIsProportionalToTempo() {
}
}
// --- NoteLength: the two denominations -----------------------------------------
// A picked length follows the tempo (that is what a picker means); a derived one does not,
// because it was computed against a concrete sound and a tempo change did not lengthen that
// sound. Both resolve through the one function, so no reader chooses.
static void testAnExactLengthIsTempoIndependentAndAPickedOneIsNot() {
const NoteLength picked = lengthOfDivision(makeDivision(0, DivisionModifier::Straight));
const NoteLength exact = lengthOfSeconds(0.5);
CHECK(almostEqual(noteLengthSeconds(picked, at(120.0)), 0.5));
CHECK(almostEqual(noteLengthSeconds(picked, at(60.0)), 1.0));
CHECK(almostEqual(noteLengthSeconds(exact, at(120.0)), 0.5));
CHECK(almostEqual(noteLengthSeconds(exact, at(60.0)), 0.5));
// Equal at one tempo is not equal as records — the denomination IS part of the value.
CHECK(picked != exact);
CHECK(lengthOfSeconds(0.5) == exact);
CHECK(lengthOfSeconds(0.5) != lengthOfSeconds(0.5000001));
}
// The whole point of the exact denomination: a duration past the longest ladder rung is
// representable, where quantizing onto the ladder would saturate at kMaxDivisionBeats.
static void testAnExactLengthCarriesDurationsPastTheLaddersTopRung() {
const Tempo t = at(120.0);
const double pastTheLadder = t.beatsToSeconds(kMaxDivisionBeats) * 3.0;
CHECK(almostEqual(noteLengthSeconds(lengthOfSeconds(pastTheLadder), t), pastTheLadder));
// …and the ladder really does saturate there, which is what makes the seam load-bearing.
CHECK(almostEqual(divisionBeats(shortestDivisionAtLeast(kMaxDivisionBeats * 3.0)),
kMaxDivisionBeats));
}
// The door establishes the domain, like every other value type here: a corrupt or absurd
// duration becomes a representable one rather than reaching resolveNote as a poison value.
static void testTheExactLengthDoorBoundsItsDomain() {
const Tempo t = at(120.0);
CHECK(almostEqual(noteLengthSeconds(lengthOfSeconds(-4.0), t), 0.0));
CHECK(almostEqual(noteLengthSeconds(lengthOfSeconds(std::nan("")), t), 0.0));
CHECK(almostEqual(noteLengthSeconds(lengthOfSeconds(kMaxLengthSeconds * 10.0), t),
kMaxLengthSeconds));
CHECK(std::isfinite(
noteLengthSeconds(lengthOfSeconds(std::numeric_limits<double>::infinity()), t)));
// And the whole resolve stays finite over it, which is the module's headline claim.
NoteProgram p;
p.length = lengthOfSeconds(std::numeric_limits<double>::infinity());
const ResolvedNote r = resolveNote(p, t);
CHECK(std::isfinite(r.noteOffSeconds) && std::isfinite(r.captureEndSeconds));
}
// --- The resolved window -------------------------------------------------------
static void testWindowAnchorsStartToNoteOnAndEndToNoteOff() {
@@ -279,7 +326,7 @@ static void testRecordRoundTripsAsAWhole() {
offsetFromMs(-20.0), offsetFromBeats(2.0), 96);
const NoteProgram copy = original;
CHECK(copy == original);
CHECK(copy.length == makeDivision(-1, DivisionModifier::Dotted));
CHECK(copy.length == lengthOfDivision(makeDivision(-1, DivisionModifier::Dotted)));
CHECK(copy.start.amount() == offsetFromMs(-20.0));
CHECK(copy.end.amount() == offsetFromBeats(2.0));
CHECK(copy.velocity.value() == 96);
@@ -526,6 +573,9 @@ int main() {
testEveryDivisionResolvesToItsDuration();
testExtremeAndNamedDivisionsInSeconds();
testNoteLengthIsProportionalToTempo();
testAnExactLengthIsTempoIndependentAndAPickedOneIsNot();
testAnExactLengthCarriesDurationsPastTheLaddersTopRung();
testTheExactLengthDoorBoundsItsDomain();
testWindowAnchorsStartToNoteOnAndEndToNoteOff();
testEndOffsetMovesWithTheNoteLength();
+3 -3
View File
@@ -128,9 +128,9 @@ static void testVelocityKnobIsCentredInItsCellAboveTheLabel() {
CHECK(r.velLabel.x == r.velCell.x && r.velLabel.right() == r.velCell.right());
}
// The Hold cell reserves its width whether or not the control is drawn — a run laid out
// conditionally would slide Bake and Preview out from under the pointer whenever a loop is
// dialled in or out. Its interior follows the velocity cell's grammar exactly.
// The Hold cell reserves its width whether or not the control is drawn, so the title slot
// beside it holds still (sample_chrome.h owns the reasoning). Its interior follows the
// velocity cell's grammar exactly.
static void testHoldCellIsReservedAndFollowsTheVelocityCellGrammar() {
const ChromeRects r = chromeRects(chromeBand(), kKnob);
CHECK(r.holdCell.width == r.velCell.width);
+48
View File
@@ -491,6 +491,52 @@ static void testEnforceGateUnavailableWhileDrawnForcesTriggerOnBothRepresentatio
CHECK(frames.playMode == PlayMode::Trigger);
}
// effectiveLengthFraction is the third member of the same rule family, and the one every
// consumer of the Trigger span (the voice, the overlay, the bake's window) must fold through:
// a drawn contour covers the FULL sample length, so the stored %-knob goes inert. It is NOT
// cleared — a pre-spline value survives in the record, and a reader that takes it raw plays,
// draws or bakes a fraction of the take. Gated on the same per-envelope `enabled` flags for the
// same reason the predicate above is.
static void testEffectiveLengthFractionFoldsToOneOnlyWhileAnEgIsActuallyDrawn() {
PlayParams p;
p.trigger.lengthFraction = 0.25;
CHECK(effectiveLengthFraction(p) == 0.25); // staged: the knob is what it says
p.ampSpline.mode = EnvMode::Spline;
CHECK(effectiveLengthFraction(p) == 1.0); // drawn: the whole take
CHECK(p.trigger.lengthFraction == 0.25); // …and the stored value is untouched
// Flipping back restores the stored fraction, which is why the fold cannot be a write.
p.ampSpline.mode = EnvMode::Staged;
CHECK(effectiveLengthFraction(p) == 0.25);
p.pitchSpline.mode = EnvMode::Spline;
CHECK(effectiveLengthFraction(p) == 0.25); // pitchEnv.enabled is still false
p.pitchEnv.enabled = true;
CHECK(effectiveLengthFraction(p) == 1.0);
p.pitchEnv.enabled = false;
p.pitchSpline.mode = EnvMode::Staged;
p.filterSpline.mode = EnvMode::Spline;
CHECK(effectiveLengthFraction(p) == 0.25); // filter.enabled is still false
p.filter.enabled = true;
CHECK(effectiveLengthFraction(p) == 1.0);
}
// effectivePlayMode is what a READ-ONLY consumer asks instead of re-testing the fields — the
// editor's bake-Hold predicate is one. It must answer exactly what the enforcement writes.
static void testEffectivePlayModeAgreesWithTheEnforcementItShares() {
PlaySeconds stored;
stored.playMode = PlayMode::Gate;
CHECK(effectivePlayMode(stored) == PlayMode::Gate);
stored.ampSpline.mode = EnvMode::Spline;
CHECK(effectivePlayMode(stored) == PlayMode::Trigger);
CHECK(stored.playMode == PlayMode::Gate); // a read, never a write
PlaySeconds enforced = stored;
enforceGateUnavailableWhileDrawn(enforced);
CHECK(enforced.playMode == effectivePlayMode(stored));
}
// --- 11. The velocity->amp curve is the same grammar -------------------------
static void testTheVelocityAmpCurveGainsTheToggleAndKeepsItsDelete() {
@@ -563,6 +609,8 @@ int main() {
testAFreshSplineEgDefaultsToTheSmoothDownwardSlope();
testGateIsUnavailableWhileASplineEgIsActiveAndReturnsAfterwards();
testEnforceGateUnavailableWhileDrawnForcesTriggerOnBothRepresentations();
testEffectiveLengthFractionFoldsToOneOnlyWhileAnEgIsActuallyDrawn();
testEffectivePlayModeAgreesWithTheEnforcementItShares();
testTheVelocityAmpCurveGainsTheToggleAndKeepsItsDelete();
testSplineCursorBinarySearchAgreesWithTheColdReaderOnAJumpingRead();
if (g_fail == 0) std::printf("spline_egs: all tests passed\n");
+16 -40
View File
@@ -2,13 +2,16 @@
// Same fast assert loop as the sibling pure tests.
//
// Covers triggerPlayLength: zero play length, startFrame set, startFrame past frameCount,
// rounding, and the Finding 1 regression (start-point set — the case that was broken before
// this module existed); plus effectiveLengthFraction, the spline fold every consumer of the
// span must go through.
// rounding, the out-of-domain clamps that keep it identical to Voice::start's inline copy of
// the same formula, and the Finding 1 regression (start-point set — the case that was broken
// before this module existed). The spline fold that used to live here is now beside its
// siblings in play_params.h, and pinned with them in test_spline_egs.
#include "../src/core/instrument/map/trigger_seam.h"
#include <cmath>
#include <cstdio>
#include <limits>
using namespace reasampler;
using namespace reasampler::instrument::map;
@@ -58,46 +61,19 @@ static void testPlayLengthRounding() {
CHECK(triggerPlayLength(0.6, 3, 0) == 2);
}
// --- effectiveLengthFraction --------------------------------------------------
// A drawn contour covers the FULL sample length, so the stored %-knob goes inert. It is not
// cleared, though — a pre-spline value survives in the record, and every reader that takes it
// raw plays, draws or bakes a fraction of the take.
static void testDrawnEnvelopeFoldsTheFractionToOne() {
PlayParams p;
p.trigger.lengthFraction = 0.25;
CHECK(effectiveLengthFraction(p) == 0.25); // staged: the knob is what it says
p.ampSpline.mode = EnvMode::Spline;
CHECK(effectiveLengthFraction(p) == 1.0); // drawn: the whole take
CHECK(p.trigger.lengthFraction == 0.25); // …and the stored value is untouched
// Flipping back restores the stored fraction, which is why the fold cannot be a write.
p.ampSpline.mode = EnvMode::Staged;
CHECK(effectiveLengthFraction(p) == 0.25);
}
// The fold reads the SAME predicate the engine's Gate refusal does, gating flags included: a
// Spline mode on a DISABLED pitch/filter envelope binds no cursor, so it must not fold.
static void testDisabledEnvelopesDoNotFoldTheFraction() {
PlayParams p;
p.trigger.lengthFraction = 0.5;
p.pitchSpline.mode = EnvMode::Spline;
CHECK(effectiveLengthFraction(p) == 0.5); // pitchEnv.enabled is still false
p.pitchEnv.enabled = true;
CHECK(effectiveLengthFraction(p) == 1.0);
p.pitchEnv.enabled = false;
p.filterSpline.mode = EnvMode::Spline;
CHECK(effectiveLengthFraction(p) == 0.5); // filter.enabled is still false
p.filter.enabled = true;
CHECK(effectiveLengthFraction(p) == 1.0);
// The stored fraction is a wire double with no codec-side range check beyond finiteness, and
// Voice::start clamps every one of these the same way. A span that ran past the source would
// read off the end of the PCM; one derived from NaN would reach an undefined narrowing.
static void testOutOfDomainFractionsClampToTheSpan() {
CHECK(triggerPlayLength(1.5, 1000, 0) == 1000); // never past the post-start span
CHECK(triggerPlayLength(1.5, 1000, 200) == 800);
CHECK(triggerPlayLength(-0.5, 1000, 0) == 0);
CHECK(triggerPlayLength(std::nan(""), 1000, 0) == 0);
CHECK(triggerPlayLength(std::numeric_limits<double>::infinity(), 1000, 0) == 1000);
}
int main() {
testDrawnEnvelopeFoldsTheFractionToOne();
testDisabledEnvelopesDoNotFoldTheFraction();
testOutOfDomainFractionsClampToTheSpan();
testPlayLengthNoStartPoint();
testPlayLengthWithStartPoint();
testPlayLengthZeroFrameCount();