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:
+56
-12
@@ -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
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user