Merge dev into phase-psi: take Ξ-W3 before Ψ lands

# Conflicts:
#	docs/COMPLETED.md
#	docs/TODO.md
This commit is contained in:
2026-08-01 23:39:24 -04:00
46 changed files with 1931 additions and 313 deletions
+103
View File
@@ -0,0 +1,103 @@
// Standalone tests for reasampler::instrument::ui::bake_hold — the Hold knob's map onto the
// 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, 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"
#include <cmath>
#include <cstdio>
#include <vector>
using namespace reasampler::instrument::ui;
using namespace reasampler::instrument::note;
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 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) == 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) {
const Division d = divisionAt(i);
CHECK(bakeHoldFromNorm(bakeHoldNorm(d)) == d);
}
// --- Every rung is reachable from the knob, and each owns a contiguous slice ------
// Swept finely enough to catch a rounding that skipped one: 39 rungs over [0,1].
{
std::vector<bool> seen(static_cast<std::size_t>(kDivisionCount), false);
for (int step = 0; step <= 4000; ++step) {
const Division d = bakeHoldFromNorm(static_cast<double>(step) / 4000.0);
seen[static_cast<std::size_t>(divisionIndex(d))] = true;
}
for (int i = 0; i < kDivisionCount; ++i) CHECK(seen[static_cast<std::size_t>(i)]);
}
// --- 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.
{
double previous = 0.0;
for (int step = 0; step <= 4000; ++step) {
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)));
}
}
if (g_fail == 0) std::printf("bake_hold: all tests passed\n");
return g_fail ? 1 : 0;
}
+152 -48
View File
@@ -1,13 +1,16 @@
// Standalone tests for reasampler::instrument::bake::bake_plan — no VST3, no REAPER, no
// framework. Same fast assert loop as the sibling pure tests.
//
// Covers: the default program's window derived from the dialed sound (a Gate release, a
// Trigger play span, and the Varispeed read-stretch bound); the frame window and both event
// frames against hand-computed values; a capture opening BEFORE note-on and one opening
// AFTER it; the refusals — a collapsed window, a non-positive rate, a window that rounds to
// nothing, and one past the frame ceiling; and root/velocity clamping.
// Covers: the default program's window derived from the dialed sound (Gate's hold to source
// 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
// frame ceiling; and root/velocity clamping.
#include "../src/core/instrument/bake/bake_plan.h"
#include "../src/core/instrument/engine/voice.h" // kDeclickFrames (the window's tail pad)
#include <cstdio>
@@ -29,6 +32,9 @@ namespace {
constexpr int kRate = 48000;
// Every derived window carries the voice's terminal declick ramp as trailing silence.
const double kPadSeconds = static_cast<double>(kDeclickFrames) / kRate;
SampleData dialedSample(std::size_t frames = 96000) {
SampleData s;
s.frames.assign(frames, 0.5f);
@@ -37,27 +43,82 @@ SampleData dialedSample(std::size_t frames = 96000) {
return s;
}
// The Hold default; read only where the window is underivable, which is nowhere in this file.
Division oneBar() { return makeDivision(2, DivisionModifier::Straight); }
// `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; }
} // namespace
int main() {
// --- The default program's window comes from the DIALED release, not a constant -----
// --- Gate with no loop: held to source exhaustion, then the DIALED release ----------
{
SampleData s = dialedSample();
SampleData s = dialedSample(); // 2 s of source == 4 beats
s.play.playMode = PlayMode::Gate;
s.play.adsr.releaseFrames = kRate * 3 / 2; // 1.5 s — past any fixed tail
const NoteProgram p = defaultBakeProgram(s, kRate, at(120.0));
const ResolvedNote r = resolveNote(p, at(120.0));
// A quarter note at 120 BPM is 0.5 s; the window must hold the whole 1.5 s release.
CHECK(r.noteOffSeconds > 0.499 && r.noteOffSeconds < 0.501);
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 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(r.captureEndSeconds > 1.99 && r.captureEndSeconds < 2.01);
CHECK(near(r.captureEndSeconds, 2.0 + 1.5 + kPadSeconds));
CHECK(!r.windowCollapsed);
// A shorter release yields a shorter window — the derivation really reads the knob.
s.play.adsr.releaseFrames = kRate / 10; // 0.1 s
const ResolvedNote shorter = resolveNote(defaultBakeProgram(s, kRate, at(120.0)),
at(120.0));
CHECK(shorter.captureEndSeconds > 0.599 && shorter.captureEndSeconds < 0.601);
const ResolvedNote shorter = resolveNote(derived(s, at(120.0)), at(120.0));
CHECK(near(shorter.captureEndSeconds, 2.0 + 0.1 + kPadSeconds));
// 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;
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 ----------
@@ -65,18 +126,16 @@ int main() {
SampleData s = dialedSample(/*frames=*/kRate * 2); // 2 s of source
s.play.playMode = PlayMode::Trigger;
s.play.trigger.lengthFraction = 0.75; // 1.5 s of it
const ResolvedNote r = resolveNote(defaultBakeProgram(s, kRate, at(120.0)),
at(120.0));
const ResolvedNote r = resolveNote(derived(s, at(120.0)), at(120.0));
CHECK(r.captureStartSeconds == 0.0);
CHECK(r.captureEndSeconds > 1.49 && r.captureEndSeconds < 1.51);
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(defaultBakeProgram(s, kRate, at(120.0)),
at(120.0));
const ResolvedNote brief = resolveNote(derived(s, at(120.0)), at(120.0));
CHECK(!brief.windowCollapsed);
CHECK(brief.captureEndSeconds > 0.199 && brief.captureEndSeconds < 0.201);
CHECK(near(brief.captureEndSeconds, 0.2 + kPadSeconds));
}
// --- Trigger under Varispeed: a downward pitch offset stretches the read -----------
@@ -86,16 +145,56 @@ int main() {
s.play.pitchEngine = PitchEngine::Varispeed;
s.play.pitchEnv.enabled = true;
s.play.pitchEnv.peakSemitones = -12.0; // an octave down = half speed at the peak
const ResolvedNote r = resolveNote(defaultBakeProgram(s, kRate, at(120.0)),
at(120.0));
const ResolvedNote r = resolveNote(derived(s, at(120.0)), at(120.0));
// Bounded at the deepest offset: 1 s of source can take up to 2 s to cross.
CHECK(r.captureEndSeconds > 1.99 && r.captureEndSeconds < 2.01);
CHECK(near(r.captureEndSeconds, 2.0 + kPadSeconds));
// Preserve decouples pitch from the read rate, so the same dial bounds nothing.
s.play.pitchEngine = PitchEngine::Preserve;
const ResolvedNote kept = resolveNote(defaultBakeProgram(s, kRate, at(120.0)),
at(120.0));
CHECK(kept.captureEndSeconds > 0.99 && kept.captureEndSeconds < 1.01);
const ResolvedNote kept = resolveNote(derived(s, at(120.0)), at(120.0));
CHECK(near(kept.captureEndSeconds, 1.0 + kPadSeconds));
}
// --- The bake fires at the velocity it is handed, and the Varispeed bound reads it ---
{
SampleData s = dialedSample(/*frames=*/kRate);
s.play.playMode = PlayMode::Trigger;
s.play.pitchEngine = PitchEngine::Varispeed;
// A bipolar velocity->pitch curve pulling a full octave down at velocity 0 and
// nothing at 127: the two velocities must therefore derive different windows.
s.play.pitchVelocityCurve = VelocityCurve::fromPoints(
{{0.0, -0.5}, {127.0, 0.0}}, reasampler::instrument::engine::CurveDomain::Bipolar);
const NoteProgram soft =
defaultBakeProgram(s, kRate, oneBar(), Velocity::of(1));
const NoteProgram hard =
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));
const ResolvedNote hardR = resolveNote(hard, at(120.0));
CHECK(near(hardR.captureEndSeconds, 1.0 + kPadSeconds)); // no offset at 127
CHECK(softR.captureEndSeconds > hardR.captureEndSeconds * 1.9); // ~an octave down
// …and the velocity reaches the plan, which is what the render fires.
CHECK(planBake(softR, kRate, 60).plan->velocity == 1);
}
// --- Gate with an ACTIVE sustain loop is the one case that needs Hold ---------------
{
SampleData s = dialedSample(/*frames=*/kRate);
s.play.playMode = PlayMode::Gate;
s.loop = SampleLoop{true, 0, kRate / 2};
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, oneBar(), Velocity{}), at(120.0));
const ResolvedNote twoBars = resolveNote(
defaultBakeProgram(s, kRate,
makeDivision(3, DivisionModifier::Straight), Velocity{}),
at(120.0));
CHECK(near(bar.noteOffSeconds, 2.0));
CHECK(near(twoBars.noteOffSeconds, 4.0));
CHECK(near(twoBars.captureEndSeconds - bar.captureEndSeconds, 2.0));
}
// --- The frame window and both event frames ----------------------------------
@@ -103,7 +202,7 @@ int main() {
NoteProgram p; // 1/4 straight, velocity 100
p.end = EndOffset(offsetFromMs(250.0));
const ResolvedNote r = resolveNote(p, at(120.0)); // note-off 0.5 s, end 0.75 s
const auto plan = planBake(r, 48000, 60);
const auto plan = planBake(r, 48000, 60).plan;
CHECK(plan.has_value());
CHECK(plan->totalFrames == 36000); // 0.75 s * 48 kHz
CHECK(plan->leadInFrames == 0);
@@ -121,7 +220,7 @@ int main() {
p.start = StartOffset(offsetFromMs(-100.0)); // negative = earlier
p.end = EndOffset(offsetFromMs(100.0));
const ResolvedNote r = resolveNote(p, at(120.0));
const auto plan = planBake(r, 44100, 60);
const auto plan = planBake(r, 44100, 60).plan;
CHECK(plan.has_value());
// Window is [-0.1, 0.6] s = 0.7 s; note-on sits 0.1 s in, note-off 0.5 s after it.
CHECK(plan->totalFrames == 30870);
@@ -137,7 +236,7 @@ int main() {
p.start = StartOffset(offsetFromMs(100.0)); // positive = later: the head is cut
p.end = EndOffset(offsetFromMs(100.0));
const ResolvedNote r = resolveNote(p, at(120.0));
const auto plan = planBake(r, 48000, 60);
const auto plan = planBake(r, 48000, 60).plan;
CHECK(plan.has_value());
// Window is [0.1, 0.6] s = 0.5 s of FILE, but the note starts 0.1 s before it, so
// the render must produce that head and throw it away rather than shift the note.
@@ -149,20 +248,20 @@ int main() {
CHECK(plan->noteOffFrame - plan->noteOnFrame == 24000);
}
// --- Refusals -----------------------------------------------------------------
// --- Refusals, and which one each condition reports -----------------------------
{
NoteProgram p;
// An end offset more negative than the note length inverts the window.
p.end = EndOffset(offsetFromMs(-10000.0));
const ResolvedNote r = resolveNote(p, at(120.0));
CHECK(r.windowCollapsed);
CHECK(!planBake(r, 48000, 60).has_value());
CHECK(planBake(r, 48000, 60).refusal == BakeRefusal::EmptyWindow);
}
{
NoteProgram plain;
const ResolvedNote r = resolveNote(plain, at(120.0));
CHECK(!planBake(r, 0, 60).has_value());
CHECK(!planBake(r, -48000, 60).has_value());
CHECK(planBake(r, 0, 60).refusal == BakeRefusal::EmptyWindow);
CHECK(planBake(r, -48000, 60).refusal == BakeRefusal::EmptyWindow);
}
{
// A legal but sub-frame window rounds to nothing and is refused rather than
@@ -172,34 +271,39 @@ int main() {
const ResolvedNote r = resolveNote(p, at(120.0));
CHECK(!r.windowCollapsed);
CHECK(r.captureLengthSeconds() == 0.0);
CHECK(!planBake(r, 48000, 60).has_value());
CHECK(planBake(r, 48000, 60).refusal == BakeRefusal::EmptyWindow);
}
{
// A legal offset magnitude reaches days: refused at the ceiling, not attempted as
// an allocation (and never narrowed out of int64's range on the way there).
// an allocation (and never narrowed out of int64's range on the way there). This
// refusal reads differently to the user — a real sound that will not fit, not an
// empty window — so it must be a distinct value, not just a nullopt.
const double overSeconds =
(static_cast<double>(kMaxBakeFrames) / 48000.0) + 1.0;
NoteProgram p;
p.end = EndOffset(offsetFromMs(overSeconds * 1000.0));
const ResolvedNote big = resolveNote(p, at(120.0));
CHECK(!big.windowCollapsed);
CHECK(!planBake(big, 48000, 60).has_value());
CHECK(planBake(big, 48000, 60).refusal == BakeRefusal::PastFrameCeiling);
// The extreme a legal OffsetAmount can hold, in both directions.
NoteProgram huge;
huge.end = EndOffset(offsetFromMs(kMaxConvertibleMagnitude));
CHECK(!planBake(resolveNote(huge, at(120.0)), 48000, 60).has_value());
CHECK(planBake(resolveNote(huge, at(120.0)), 48000, 60).refusal ==
BakeRefusal::PastFrameCeiling);
NoteProgram far;
far.start = StartOffset(offsetFromMs(-kMaxConvertibleMagnitude));
CHECK(!planBake(resolveNote(far, at(120.0)), 48000, 60).has_value());
CHECK(planBake(resolveNote(far, at(120.0)), 48000, 60).refusal ==
BakeRefusal::PastFrameCeiling);
// And just under it still plans, so the ceiling is a bound, not a blanket refusal.
NoteProgram fits;
fits.end = EndOffset(offsetFromMs(
(static_cast<double>(kMaxBakeFrames) / 48000.0 - 1.0) * 1000.0));
const auto planned = planBake(resolveNote(fits, at(120.0)), 48000, 60);
CHECK(planned.has_value());
CHECK(planned && planned->renderFrames() <= kMaxBakeFrames);
const PlannedBake planned = planBake(resolveNote(fits, at(120.0)), 48000, 60);
CHECK(planned.plan.has_value());
CHECK(planned.refusal == BakeRefusal::None);
CHECK(planned.plan && planned.plan->renderFrames() <= kMaxBakeFrames);
}
// --- Domain clamps -------------------------------------------------------------
@@ -207,9 +311,9 @@ int main() {
NoteProgram p;
p.end = EndOffset(offsetFromMs(100.0));
const ResolvedNote r = resolveNote(p, at(120.0));
const auto low = planBake(r, 48000, -5);
const auto high = planBake(r, 48000, 900);
const auto mid = planBake(r, 48000, 60);
const auto low = planBake(r, 48000, -5).plan;
const auto high = planBake(r, 48000, 900).plan;
const auto mid = planBake(r, 48000, 60).plan;
CHECK(low.has_value() && high.has_value() && mid.has_value());
if (low && high && mid) {
CHECK(low->note == 0);
+392
View File
@@ -0,0 +1,392 @@
// The DERIVED bake window, end to end: does defaultBakeProgram's window hold the whole
// audible result of the dialed sound? Every case renders through the real chain
// (defaultBakeProgram -> resolveNote -> planBake -> renderBake) and then re-renders the SAME
// sound with a longer window, so "what fell outside" is measured rather than argued.
// Trailing silence is a pass; signal past the derived end is a truncation.
#include "../src/core/instrument/bake/bake_plan.h"
#include "../src/core/instrument/bake/bake_render.h"
#include "../src/core/instrument/engine/voice.h" // kDeclickFrames (the pad under test)
#include <cmath>
#include <cstdio>
#include <optional>
using namespace reasampler;
using namespace reasampler::instrument::bake;
using namespace reasampler::instrument::note;
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 {
constexpr int kRate = 48000;
constexpr double kBpm = 120.0; // a quarter note is 0.5 s == 24000 frames
constexpr double kUnity = 1.0;
constexpr double kSilence = 1e-6;
// The declick pad every derived window carries. Read off the engine's own constants, so a
// retuned ramp moves this file's expectations with it rather than against them.
constexpr std::int64_t kPad = kDeclickFrames;
// One bar at 120 BPM == 2 s == 96000 frames. The Hold default, spelled out so the
// expectations below read as arithmetic rather than as magic.
Division oneBar() { return makeDivision(2, DivisionModifier::Straight); }
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) {
SampleData s;
s.frames.assign(frames, 0.5f);
s.sampleRate = kRate;
s.rootNote = 60;
return s;
}
double peakAt(const BakeAudio& audio, std::int64_t from, std::int64_t to) {
double peak = 0.0;
if (from < 0) from = 0;
for (std::int64_t f = from; f < to && f < audio.frameCount(); ++f) {
const double v = std::fabs(static_cast<double>(
audio.interleaved[static_cast<std::size_t>(f * audio.channelCount)]));
if (v > peak) peak = v;
}
return peak;
}
// The derived program, optionally lengthened: `extraMs` widens ONLY the end offset (the same
// sound, a longer window). It leaves the derivation itself untouched, which is what makes the
// comparison a measurement of the derived end rather than of a second derivation.
NoteProgram derivedProgram(const SampleData& s, double extraMs, Division hold = oneBar(),
int velocity = 100) {
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;
}
std::optional<BakePlan> planOf(const NoteProgram& p) {
return planBake(resolveNote(p, tempo()), kRate, 60).plan;
}
// The render the shell would produce, plus `extraMs` of extra window.
BakeAudio bakeWith(const SampleData& s, double extraMs, Division hold = oneBar(),
int velocity = 100) {
const std::optional<BakePlan> plan = planOf(derivedProgram(s, extraMs, hold, velocity));
if (!plan) { std::printf("FAIL: fixture window refused\n"); ++g_fail; return BakeAudio{}; }
return renderBake(s, *plan, kUnity);
}
std::int64_t derivedFrames(const SampleData& s, Division hold = oneBar()) {
const std::optional<BakePlan> plan = planOf(derivedProgram(s, 0.0, hold));
return plan ? plan->totalFrames : -1;
}
// The last frame of the file, which is where a hard cut shows up.
double lastFrameLevel(const BakeAudio& audio) {
return audio.frameCount() > 0 ? peakAt(audio, audio.frameCount() - 1, audio.frameCount())
: 0.0;
}
} // namespace
int main() {
// ================================ GATE ==========================================
// --- Gate, no loop: the note is held until the SOURCE runs out ---------------------
// The read head frees the voice at exhaustion whether or not the gate is still down, so
// the maximal sound is the whole take. 2 s of source == 4 beats, exactly one bar.
{
SampleData s = dcSample(96000);
s.play.playMode = PlayMode::Gate;
s.play.adsr.releaseFrames = 4800; // 100 ms
CHECK(derivedFrames(s) == 96000 + 4800 + kPad);
const BakeAudio wide = bakeWith(s, /*extraMs=*/200.0);
// Full level right up to exhaustion, and nothing at all past it — the note outlived
// its own release, so the release window is trailing silence, not a truncated tail.
CHECK(peakAt(wide, 95000, 96000) > 0.4);
CHECK(peakAt(wide, 96000, wide.frameCount()) == 0.0);
}
// --- Gate, no loop, a SLOW ATTACK: the derived note reaches the dialed peak ---------
// The window used to be a constant quarter note, which released a 2 s attack a quarter of
// the way up. Deriving the hold from source exhaustion is what closes that.
{
SampleData s = dcSample(192000); // 4 s
s.play.playMode = PlayMode::Gate;
s.play.adsr.attackFrames = 96000; // 2 s
s.play.adsr.releaseFrames = 0;
CHECK(derivedFrames(s) == 192000 + kPad);
const BakeAudio derived = bakeWith(s, 0.0);
// The whole attack, at full level — under the old constant quarter note this peaked
// between 0.10 and 0.14.
CHECK(peakAt(derived, 0, derived.frameCount()) > 0.49);
// …and a longer window adds nothing: the derivation already held everything.
const BakeAudio wide = bakeWith(s, /*extraMs=*/1000.0);
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.
{
SampleData s = dcSample(48000);
s.loop = SampleLoop{true, 0, 24000};
s.play.playMode = PlayMode::Gate;
s.play.adsr.releaseFrames = 4800;
CHECK(bakeWindowNeedsHold(s));
// One bar at 120 BPM == 96000 frames, plus the release, plus the pad.
CHECK(derivedFrames(s) == 96000 + 4800 + kPad);
const BakeAudio bar = bakeWith(s, 0.0);
CHECK(peakAt(bar, 90000, 96000) > 0.4); // still cycling at note-off
CHECK(peakAt(bar, 96000 + 4790, 96000 + 4800) < 0.01); // released to its own floor
// A different Hold is a different window, in the same proportion — the knob really is
// what the derivation reads here.
const Division twoBars = makeDivision(3, DivisionModifier::Straight);
CHECK(derivedFrames(s, twoBars) == 192000 + 4800 + kPad);
const BakeAudio held = bakeWith(s, 0.0, twoBars);
CHECK(peakAt(held, 96000, 192000) > 0.4); // full level well past one bar
}
// --- The Hold predicate is the ENGINE's loop fold, not a reading of the fields -------
{
SampleData s = dcSample(48000);
s.play.playMode = PlayMode::Gate;
CHECK(!bakeWindowNeedsHold(s)); // no loop at all
s.loop = SampleLoop{true, 0, 24000};
CHECK(bakeWindowNeedsHold(s));
s.loop = SampleLoop{true, 0, 48001}; // reaches past the PCM
CHECK(!bakeWindowNeedsHold(s)); // …which resolveLoop refuses
s.loop = SampleLoop{true, 24000, 12000}; // inverted
CHECK(!bakeWindowNeedsHold(s));
s.loop = SampleLoop{true, 0, 24000};
s.play.playMode = PlayMode::Trigger; // Trigger has no sustain loop
CHECK(!bakeWindowNeedsHold(s));
}
// --- Gate + Preserve: the terminal ring-out is INSIDE the window ---------------------
// Preserve rings its last real output out instead of hard-cutting it; the derived window
// is padded by exactly that ramp, so the file ends at silence.
{
SampleData s = dcSample(24000); // exhausts at half a bar
s.play.playMode = PlayMode::Gate;
s.play.pitchEngine = PitchEngine::Preserve;
s.play.adsr.releaseFrames = 0;
CHECK(derivedFrames(s) == 24000 + kPad);
const BakeAudio derived = bakeWith(s, 0.0);
CHECK(peakAt(derived, 23990, 24000) > 0.4); // full level while the source lasts
CHECK(peakAt(derived, 24000, 24000 + kPad) > 0.05); // the ramp, inside the file
CHECK(lastFrameLevel(derived) < 1e-3); // and the file ends at silence
// Nothing at all past the pad: the window is not merely long, it is exactly enough.
const BakeAudio wide = bakeWith(s, /*extraMs=*/50.0);
CHECK(peakAt(wide, 24000 + kPad, wide.frameCount()) == 0.0);
}
// --- The resonant filter cannot ring past the amp gate ------------------------------
// pitch -> filter -> amp: the amp multiply is last, so a high-Q filter's ring-out is
// gated by the envelope the window already holds. Not a tail contributor.
{
SampleData s = dcSample(96000);
s.play.playMode = PlayMode::Gate;
s.play.adsr.releaseFrames = 4800;
s.play.filter.enabled = true;
s.play.filter.settings.cutoffNorm = 0.05f;
s.play.filter.settings.resonanceNorm = 1.0f;
const BakeAudio wide = bakeWith(s, /*extraMs=*/500.0);
CHECK(peakAt(wide, 0, 96000) > kSilence); // the filtered note sounded
CHECK(peakAt(wide, 96000, wide.frameCount()) == 0.0); // and nothing rang past it
}
// ================================ TRIGGER =======================================
// --- Trigger, Varispeed, a constant deep downward pitch offset: UPPER BOUND ---------
// The window is scaled by the deepest reachable offset, so a shallower excursion leaves
// trailing silence — long, but never short.
{
SampleData s = dcSample(48000);
s.play.playMode = PlayMode::Trigger;
s.play.pitchEngine = PitchEngine::Varispeed;
s.play.pitchEnv.enabled = true;
s.play.pitchEnv.peakSemitones = -24.0; // two octaves down
s.play.pitchEnv.shape.holdFraction = 1.0; // held down for the whole span
// 1 s of source stretched by 2^(24/12) == 4.
CHECK(derivedFrames(s) == 192000 + kPad);
const BakeAudio derived = bakeWith(s, 0.0);
// The offset only holds for the envelope's own span, so the read finishes near
// 84000 frames — inside the window, with the balance as trailing silence.
CHECK(peakAt(derived, 80000, 84000) > 0.4);
CHECK(peakAt(derived, 90000, 192000) < kSilence);
}
// --- Trigger + Preserve (the product-default engine): the ring-out is HELD -----------
// Preserve's read reaches playEnd where the un-padded window used to close, leaving the
// whole terminal declick outside it — a hard cut at full level, the very click the ramp
// exists to remove. The pad is what closes that.
{
SampleData s = dcSample(48000);
s.play.playMode = PlayMode::Trigger;
s.play.pitchEngine = PitchEngine::Preserve;
CHECK(derivedFrames(s) == 48000 + kPad);
const BakeAudio derived = bakeWith(s, 0.0);
CHECK(peakAt(derived, 47990, 48000) > 0.4); // full level at the source's own end
CHECK(peakAt(derived, 48000, 48001) > 0.49); // the ramp opens at that same level…
CHECK(lastFrameLevel(derived) < 1e-3); // …and the file ends at silence
const BakeAudio wide = bakeWith(s, /*extraMs=*/50.0);
CHECK(peakAt(wide, 48000 + kPad, wide.frameCount()) == 0.0);
}
// --- 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, 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);
s.play.playMode = PlayMode::Trigger;
s.play.trigger.lengthFraction = 0.25; // inert in the engine, and now in the window
s.play.ampSpline.mode = EnvMode::Spline;
s.play.ampSpline.contour = VelocityCurve::flat(); // full level across the sample
CHECK(derivedFrames(s) == 48000 + kPad); // the whole take, not 12000
const BakeAudio derived = bakeWith(s, 0.0);
// Full level across the 36000 frames a raw read of the stored knob used to cut. Both
// spans start past the old 12000-frame end, so a truncated window reads 0 here.
CHECK(peakAt(derived, 36000, 40000) > 0.4);
CHECK(peakAt(derived, 47000, 48000) > 0.4);
// A %-knob that IS live still shortens the window — the fold is conditional, not a
// blanket ignore.
SampleData staged = s;
staged.play.ampSpline.mode = EnvMode::Staged;
CHECK(derivedFrames(staged) == 12000 + kPad);
}
// ============================== VELOCITY ========================================
// --- The bake renders at the velocity it is handed ----------------------------------
// Three velocity curves are live, so a sound auditioned at 120 does not bake as one
// auditioned at 40. linear() maps velocity/127 onto amp gain.
{
SampleData s = dcSample(24000);
s.play.playMode = PlayMode::Trigger;
s.velocityCurve = VelocityCurve::linear();
const BakeAudio soft = bakeWith(s, 0.0, oneBar(), /*velocity=*/40);
const BakeAudio hard = bakeWith(s, 0.0, oneBar(), /*velocity=*/120);
const double softPeak = peakAt(soft, 0, 24000);
const double hardPeak = peakAt(hard, 0, 24000);
// 0.5 * 40/127 == 0.157, 0.5 * 120/127 == 0.472.
CHECK(softPeak > 0.15 && softPeak < 0.17);
CHECK(hardPeak > 0.46 && hardPeak < 0.48);
CHECK(hardPeak > softPeak * 2.0);
}
// ============================== REFUSALS ========================================
// --- A legitimate dialed sound past the frame ceiling is REFUSED, and says so --------
{
SampleData s = dcSample(1'100'000);
s.play.playMode = PlayMode::Trigger;
s.play.pitchEngine = PitchEngine::Varispeed;
s.play.pitchEnv.enabled = true;
s.play.pitchEnv.peakSemitones = -48.0;
s.play.pitchEnv.shape.holdFraction = 1.0;
const PlannedBake tooLong =
planBake(resolveNote(derivedProgram(s, 0.0), tempo()), kRate, 60);
CHECK(!tooLong.plan.has_value());
CHECK(tooLong.refusal == BakeRefusal::PastFrameCeiling);
// One octave shallower is inside the ceiling — the refusal above is the window, not
// the fixture.
s.play.pitchEnv.peakSemitones = -24.0;
CHECK(planOf(derivedProgram(s, 0.0)).has_value());
}
// --- An empty window is a DIFFERENT refusal, so the shell can say a different thing ---
{
NoteProgram p;
p.end = EndOffset(offsetFromMs(-5000.0)); // ends long before note-off: collapsed
const PlannedBake empty = planBake(resolveNote(p, tempo()), kRate, 60);
CHECK(!empty.plan.has_value());
CHECK(empty.refusal == BakeRefusal::EmptyWindow);
}
if (g_fail == 0) std::printf("bake_window: all tests passed\n");
return g_fail ? 1 : 0;
}
+255 -32
View File
@@ -21,6 +21,7 @@
using namespace reasampler;
using namespace reasampler::instrument::map;
namespace note = reasampler::instrument::note; // the bake Hold's ladder
static int failures = 0;
@@ -452,7 +453,7 @@ static void testGoldenFullBlobFixture() {
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x00,
0x00,0x05,0x00,0x00,0x00,0x53,0x6e,0x61,0x72,0x65,0x13,0x00,0x00,0x00,0x67,0x75,
0x69,0x64,0x2d,0x31,0x32,0x33,0x34,0x2d,0x35,0x36,0x37,0x38,0x2d,0x61,0x62,0x63,
0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x0d,0x00,0x00,
0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x0e,0x00,0x00,
0x00,0x01,0x24,0x00,0x00,0x00,0x01,0x01,0xe8,0x03,0x00,0x00,0x00,0x00,0x00,0x00,
0x88,0x13,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0xfa,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x01,0x9a,0x99,0x99,0x99,0x99,0x99,0xa9,0x3f,0x00,0x00,0x00,0x00,0x00,0x00,
@@ -545,6 +546,9 @@ static void testGoldenFullBlobFixture() {
0x03,0x00,0x00,0x00, 0x00,0x00,0x00, // amp curve hard flags (3 points)
0x02,0x00,0x00,0x00, 0x00,0x00, // filter curve hard flags
0x02,0x00,0x00,0x00, 0x00,0x00, // pitch curve hard flags
// --- payload v14 bake Hold, at its one-bar default ---
0x02,0x00,0x00,0x00, // quarterExponent 2 (== 1/1)
0x00, // Straight
};
// clang-format on
CHECK(bytes.size() == sizeof(kGolden));
@@ -592,18 +596,19 @@ static void testEnvelopePrefixBytesFrozen() {
CHECK(bytes[4] == 0); // ChannelMode::Mono
}
CHECK(kComponentStateVersion == 11);
CHECK(kParamsPayloadVersion == 13);
CHECK(kParamsPayloadVersion == 14);
CHECK(kParamsSingleRecordVersion == 8);
CHECK(kParamsFormatMarker == 0xFFFFFF00u);
// The filter, staged-curve, loop, velocity and spline tails rode PAYLOAD bumps, not
// envelope ones — the two axes stay independent, so a future envelope field cannot collide
// with any of them on one number.
// The filter, staged-curve, loop, velocity, spline and bake-Hold tails rode PAYLOAD bumps,
// not envelope ones — the two axes stay independent, so a future envelope field cannot
// collide with any of them on one number.
CHECK(kParamsFilterVersion > kParamsSingleRecordVersion);
CHECK(kParamsCurveVersion > kParamsFilterVersion);
CHECK(kParamsLoopVersion > kParamsCurveVersion);
CHECK(kParamsVelocityVersion > kParamsLoopVersion);
CHECK(kParamsSplineVersion > kParamsVelocityVersion);
CHECK(kParamsPayloadVersion == kParamsSplineVersion);
CHECK(kParamsBakeHoldVersion > kParamsSplineVersion);
CHECK(kParamsPayloadVersion == kParamsBakeHoldVersion);
}
// --- The filter tail (payload v9) --------------------------------------------
@@ -782,6 +787,23 @@ static void testNonFiniteAhdSecondsLiftToZero() {
// --- The v13 hard-flag tail: corruption must never widen past its own three curves -----------
// The two trailing blocks of a CURRENT blob, so the splice tests below can cut back to the
// hard flags and rewrite them without hand-counting the payload twice. Every velocity curve
// in those fixtures is at its default 2-point shape, which is what pins the flag block sizes.
static constexpr std::size_t kHardFlagTailBytes = 4 + 2 + 4 + 2 + 4 + 2;
static constexpr std::size_t kBakeHoldTailBytes = 4 + 1;
// 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) {
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
// IN-BOUNDS (the blob really does carry that many bytes) — the documented promise
// (component_state_io.h) is that the tail is dropped, never misapplied, and nothing else in
@@ -814,8 +836,8 @@ static void testV13HardFlagInBoundsMismatchDropsFlagsOnly() {
// order in params_payload.cpp) is deterministic and this test can splice it exactly.
std::vector<std::uint8_t> bytes = serializeComponentState(in);
CHECK(bytes.size() >= 18);
bytes.resize(bytes.size() - 18); // drop the three well-formed 4+2-byte blocks
CHECK(bytes.size() >= kHardFlagTailBytes + kBakeHoldTailBytes);
bytes.resize(bytes.size() - kHardFlagTailBytes - kBakeHoldTailBytes);
legacy::u32v(bytes, 5); // amp: bogus count...
for (int i = 0; i < 5; ++i) legacy::u8v(bytes, 0); // ...with 5 REAL bytes, so nothing shifts
legacy::u32v(bytes, 2); // filter: correct count, unchanged
@@ -824,6 +846,7 @@ static void testV13HardFlagInBoundsMismatchDropsFlagsOnly() {
legacy::u32v(bytes, 2); // pitch: correct count, unchanged
legacy::u8v(bytes, 0);
legacy::u8v(bytes, 0);
putDefaultBakeHoldTail(bytes);
const ComponentState out = deserializeComponentState(bytes, 48000.0);
// Every param preceding AND following the corrupted amp tail survives untouched.
@@ -847,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";
@@ -862,22 +884,15 @@ static void testV13HardFlagOutOfBoundsCountSurvivesWithoutWipingTheRecord() {
in.params.loopCrossfadeFrames = 321;
std::vector<std::uint8_t> bytes = serializeComponentState(in);
CHECK(bytes.size() >= 18);
bytes.resize(bytes.size() - 18); // drop the three well-formed hard-flag blocks
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);
// …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);
@@ -887,6 +902,112 @@ 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; a NaN keyTrack reaches keyTrackedRatio ->
// baseRatio_ -> readPos_'s per-sample static_cast<std::int64_t> (voice.h); a NaN v9 filter
// env second reaches secToFrames the same way the trigAhd/filter.trigEnv pair already covered
// by testNonFiniteAhdSecondsLiftToZero do; 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;
const InstrumentParams paramDefaults;
ComponentState in;
in.selectionId = "pad";
in.params.keyTrack = std::numeric_limits<double>::quiet_NaN();
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();
in.params.play.filter.enabled = true;
in.params.play.filter.env.attackSeconds = std::numeric_limits<double>::quiet_NaN();
in.params.play.filter.env.holdSeconds = std::numeric_limits<double>::infinity();
in.params.play.filter.env.decaySeconds = -std::numeric_limits<double>::infinity();
in.params.play.filter.env.sustainLevel = std::numeric_limits<double>::quiet_NaN();
in.params.play.filter.env.releaseSeconds = std::numeric_limits<double>::quiet_NaN();
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 == paramDefaults.keyTrack);
CHECK(out.params.play.filter.enabled); // the fallback is per-field, not per-record
CHECK(out.params.play.filter.env.attackSeconds == defaults.filter.env.attackSeconds);
CHECK(out.params.play.filter.env.holdSeconds == defaults.filter.env.holdSeconds);
CHECK(out.params.play.filter.env.decaySeconds == defaults.filter.env.decaySeconds);
CHECK(out.params.play.filter.env.sustainLevel == defaults.filter.env.sustainLevel);
CHECK(out.params.play.filter.env.releaseSeconds == defaults.filter.env.releaseSeconds);
ComponentState low = in;
low.params.rootOverride = -5;
const ComponentState lowOut = deserializeComponentState(serializeComponentState(low), 48000.0);
CHECK(lowOut.params.rootOverride && *lowOut.params.rootOverride == 0);
}
// The keyTrack guard's OTHER site: a pre-v7 (legacy zone-list) blob's own keyTrack field
// (params_payload.cpp's readLegacyZonePayload, pv >= 6) is a second, independent read of the
// same wire double — same hazard, same fallback, must not be missed just because the v8+
// single-record reader above was fixed.
static void testLegacyZoneKeyTrackNaNLiftsToDefault() {
legacy::Zone z;
z.sampleId = "kick";
z.keyTrack = std::numeric_limits<double>::quiet_NaN();
const ComponentState out =
deserializeComponentState(legacy::envelopeWithZones("kick", {z}, 7), 48000.0);
CHECK(out.params.keyTrack == InstrumentParams{}.keyTrack);
}
// A hard-flag tail truncated mid-COUNT-FIELD (only 2 of its 4 length bytes present, and
@@ -904,8 +1025,10 @@ static void testV13HardFlagTailTruncatedMidCountSurvivesWithoutWipingTheRecord()
in.params.loopCrossfadeFrames = 5;
std::vector<std::uint8_t> bytes = serializeComponentState(in);
CHECK(bytes.size() >= 18);
bytes.resize(bytes.size() - 18); // drop the three well-formed hard-flag blocks
CHECK(bytes.size() >= kHardFlagTailBytes + kBakeHoldTailBytes);
// Drops the bake-Hold tail with the flags: the truncation strands everything after it,
// which is the whole point — Hold lifts to its default alongside the flags.
bytes.resize(bytes.size() - kHardFlagTailBytes - kBakeHoldTailBytes);
legacy::u8v(bytes, 0x02); // half of the amp tail's 4-byte LE count, then nothing
legacy::u8v(bytes, 0x00);
@@ -918,6 +1041,7 @@ static void testV13HardFlagTailTruncatedMidCountSurvivesWithoutWipingTheRecord()
CHECK(out.params.loopCrossfadeFrames == 5);
CHECK(out.params.velocityCurve.size() == 2);
CHECK(!out.params.velocityCurve.points()[0].hard);
CHECK(out.params.bakeHold == InstrumentParams{}.bakeHold);
}
// --- The loop tail (payload v11) ---------------------------------------------
@@ -1145,6 +1269,98 @@ static void testPreV12FilterVelocityLiftsAsAPureDomainReTag() {
CHECK(back.params.play.filter.velAmount == -0.75);
}
// --- The bake Hold tail (payload v14) -----------------------------------------
// Hold survives a save/reload as its {rung, modifier} pair, and it is the ONLY field the v14
// bump touches — everything either side of it in the record comes back untouched.
static void testBakeHoldRoundTripsAndDisturbsNothingElse() {
ComponentState in;
in.selectionId = "pad";
in.params.rootOverride = 33;
in.params.keyTrack = 0.25;
in.params.loopCrossfadeFrames = 64;
in.params.play.adsr.releaseSeconds = 0.31;
in.params.play.ampSpline.mode = EnvMode::Spline;
// A triplet on a rung well away from the default, so neither field can be read off the
// other's default and still pass.
in.params.bakeHold = note::makeDivision(-2, note::DivisionModifier::Triplet);
const ComponentState out =
deserializeComponentState(serializeComponentState(in), 48000.0);
CHECK(out.params.bakeHold == note::makeDivision(-2, note::DivisionModifier::Triplet));
CHECK(out.params.bakeHold != InstrumentParams{}.bakeHold);
CHECK(out.selectionId == "pad");
CHECK(out.params.rootOverride && *out.params.rootOverride == 33);
CHECK(out.params.keyTrack == 0.25);
CHECK(out.params.loopCrossfadeFrames == 64);
CHECK(out.params.play.adsr.releaseSeconds == 0.31);
CHECK(out.params.play.ampSpline.mode == EnvMode::Spline);
}
// A v13 blob is a strict PREFIX of v14, so it must lift to the one-bar Hold default with
// every other field intact — the reason a project saved before Hold existed reopens the same.
static void testV13BlobLiftsToTheDefaultHold() {
ComponentState in;
in.selectionId = "pad";
in.params.rootOverride = 55;
in.params.play.adsr.decaySeconds = 0.09;
in.params.play.filter.enabled = true;
in.params.loopCrossfadeFrames = 128;
in.params.bakeHold = note::makeDivision(5, note::DivisionModifier::Dotted);
// Stamp the payload back to v13 and drop exactly the v14 tail: byte-for-byte what the
// previous binary would have written.
const std::vector<std::uint8_t> v13 =
payloadDowngradedTo(in, kParamsSplineVersion, kBakeHoldTailBytes);
const ComponentState out = deserializeComponentState(v13, 48000.0);
CHECK(out.params.bakeHold == InstrumentParams{}.bakeHold);
CHECK(out.selectionId == "pad");
CHECK(out.params.rootOverride && *out.params.rootOverride == 55);
CHECK(out.params.play.adsr.decaySeconds == 0.09);
CHECK(out.params.play.filter.enabled);
CHECK(out.params.loopCrossfadeFrames == 128);
}
// A corrupt rung/modifier pair clamps to the nearest legal division rather than being held as
// an unrepresentable one — makeDivision is the only door, and the codec goes through it.
static void testBakeHoldCorruptPairClampsToTheLadder() {
ComponentState in;
in.selectionId = "pad";
std::vector<std::uint8_t> bytes = serializeComponentState(in);
CHECK(bytes.size() >= kBakeHoldTailBytes);
bytes.resize(bytes.size() - kBakeHoldTailBytes);
legacy::u32v(bytes, static_cast<std::uint32_t>(static_cast<std::int32_t>(9999)));
legacy::u8v(bytes, 200); // an unnamed modifier byte
const ComponentState out = deserializeComponentState(bytes, 48000.0);
CHECK(out.params.bakeHold ==
note::makeDivision(note::kMaxQuarterExponent, note::DivisionModifier::Straight));
}
// A blob truncated INSIDE the v14 tail costs the Hold alone. It sits last, so without the
// revive a stray missing byte would reset every parameter ahead of it to defaults.
static void testBakeHoldTruncatedTailSurvivesWithoutWipingTheRecord() {
ComponentState in;
in.selectionId = "pad";
in.params.rootOverride = 71;
in.params.play.adsr.attackSeconds = 0.017;
in.params.loopCrossfadeFrames = 96;
in.params.bakeHold = note::makeDivision(4, note::DivisionModifier::Triplet);
std::vector<std::uint8_t> bytes = serializeComponentState(in);
CHECK(bytes.size() >= kBakeHoldTailBytes);
bytes.resize(bytes.size() - kBakeHoldTailBytes);
legacy::u8v(bytes, 0x02); // two of the exponent's four bytes, then nothing
legacy::u8v(bytes, 0x00);
const ComponentState out = deserializeComponentState(bytes, 48000.0);
CHECK(out.params.bakeHold == InstrumentParams{}.bakeHold);
CHECK(out.selectionId == "pad");
CHECK(out.params.rootOverride && *out.params.rootOverride == 71);
CHECK(out.params.play.adsr.attackSeconds == 0.017);
CHECK(out.params.loopCrossfadeFrames == 96);
}
// The WRITER emits the CURRENT payload version, and the marker + version sit at the head of
// the payload — the self-describing property every legacy branch depends on. Asserted
// against the semantic constants, not literals.
@@ -1632,11 +1848,11 @@ static void testSampleRefsTruncatedMidEntry() {
// 91-byte play tail + keyTrack8 + curve(4+2*16, the flat 2-point default) + the 134-byte
// v9 filter tail + the 152-byte v10 staged-curve tail + the 8-byte v11 crossfade + the
// 36-byte v12 pitch curve + the 135-byte v13 dual-state tail, three 39-byte spline EGs and
// three 6-byte hard-flag tails) = 623 bytes; entry two is 47 bytes (id 4+3, path 4+7,
// root4, loop 1+8+8, channels4, name 4+0). Cutting 643 keeps the first 27 of entry two's
// 47 — mid loop.start (offset 23..31).
CHECK(bytes.size() > 643);
bytes.resize(bytes.size() - 643);
// three 6-byte hard-flag tails + the 5-byte v14 bake-Hold tail) = 628 bytes; entry two is
// 47 bytes (id 4+3, path 4+7, root4, loop 1+8+8, channels4, name 4+0). Cutting 648 keeps
// the first 27 of entry two's 47 — mid loop.start (offset 23..31).
CHECK(bytes.size() > 648);
bytes.resize(bytes.size() - 648);
const ComponentState back = deserializeComponentState(bytes, 44100.0);
CHECK(back.sampleRefs.size() == 1);
CHECK(back.sampleRefs.size() == 1 && back.sampleRefs[0].sampleId == "kick");
@@ -1747,7 +1963,14 @@ int main() {
testNonFiniteAhdSecondsLiftToZero();
testV13HardFlagInBoundsMismatchDropsFlagsOnly();
testV13HardFlagOutOfBoundsCountSurvivesWithoutWipingTheRecord();
testV13HardFlagCountThatStrandsAlignmentLeavesTheHoldAbsentNotFabricated();
testOutOfDomainWireValuesAreBoundedAtTheCodec();
testLegacyZoneKeyTrackNaNLiftsToDefault();
testV13HardFlagTailTruncatedMidCountSurvivesWithoutWipingTheRecord();
testBakeHoldRoundTripsAndDisturbsNothingElse();
testV13BlobLiftsToTheDefaultHold();
testBakeHoldCorruptPairClampsToTheLadder();
testBakeHoldTruncatedTailSurvivesWithoutWipingTheRecord();
if (failures == 0) {
std::printf("component_state_io_tests: all tests passed\n");
return 0;
+2 -1
View File
@@ -3,11 +3,12 @@
//
// Covers: the beat length of all 39 divisions against a literal rung table (NOT the module's
// own exponent formula); the 1/64 and 64/1 extremes; the four named example divisions; the
// label notation; picker order and index round-trip; off-ladder clamping of BOTH persisted
// label notation; picker order and index round-trip; and off-ladder clamping of BOTH persisted
// fields, measured through the readers rather than by comparing two clamped values.
#include "../src/core/instrument/note/musical_division.h"
#include <cmath>
#include <cstdio>
using namespace reasampler::instrument::note;
+55 -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,54 @@ 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:
// its longest rung (the dotted top) IS kMaxDivisionBeats, so nothing on it reaches
// pastTheLadder.
CHECK(almostEqual(divisionBeats(makeDivision(kMaxQuarterExponent, DivisionModifier::Dotted)),
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 +328,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 +575,9 @@ int main() {
testEveryDivisionResolvesToItsDuration();
testExtremeAndNamedDivisionsInSeconds();
testNoteLengthIsProportionalToTempo();
testAnExactLengthIsTempoIndependentAndAPickedOneIsNot();
testAnExactLengthCarriesDurationsPastTheLaddersTopRung();
testTheExactLengthDoorBoundsItsDomain();
testWindowAnchorsStartToNoteOnAndEndToNoteOff();
testEndOffsetMovesWithTheNoteLength();
+36 -12
View File
@@ -2,9 +2,10 @@
// test framework.
//
// Covers: the chrome band's two rows (toolbar over strip row, tiling the band exactly); the
// toolbar's fixed right-anchored run in order (preview, velocity cell, Mono|Stereo,
// Browse) with the title taking the remainder; the velocity knob centred in its
// cell above its label; the piano strip owning its whole row at every width; no rect on the
// toolbar's fixed right-anchored run in order (Hold, bake, preview, velocity cell,
// Mono|Stereo, Browse) with the title taking the remainder; the velocity and Hold knobs
// centred in their cells above their labels; the piano strip owning its whole row at every
// width; no rect on the
// toolbar overlapping any other; degenerate bands yielding no inverted rects; and the preview
// button's play-triangle glyph, which sits inside the button without changing its rect.
@@ -63,12 +64,13 @@ static void testToolbarRunIsOrderedRightToLeftWithoutOverlap() {
CHECK(r.bake.width == kBakeButtonWidth);
CHECK(r.bake.y == r.preview.y); // shares the run's button baseline
CHECK(r.bake.height == r.preview.height);
CHECK(r.title.right() <= r.bake.x); // the title yields to the bake, not the preview
CHECK(r.holdCell.right() <= r.bake.x);
CHECK(r.title.right() <= r.holdCell.x); // the title yields to the whole run
CHECK(r.title.x == band.x + kPad);
CHECK(r.title.width > 0);
// Every toolbar rect sits inside the toolbar row.
const Rect items[] = {r.title, r.bake, r.preview, r.velCell, r.chanMono,
const Rect items[] = {r.title, r.holdCell, r.bake, r.preview, r.velCell, r.chanMono,
r.chanStereo, r.navBrowse};
for (const Rect& it : items) {
CHECK(it.y >= r.toolbar.y && it.bottom() <= r.toolbar.bottom());
@@ -82,16 +84,17 @@ static void testChromePartsNeverOverlapAtAnyWidth() {
// stay inside its own row, clear of every control.
CHECK(!overlaps(r.toolbar, r.rootStrip));
CHECK(r.rootStrip.y >= r.controls.y && r.rootStrip.bottom() <= r.controls.bottom());
const Rect items[] = {r.bake, r.preview, r.velCell, r.chanMono, r.chanStereo,
r.navBrowse};
const Rect items[] = {r.holdCell, r.bake, r.preview, r.velCell, r.chanMono,
r.chanStereo, r.navBrowse};
for (const Rect& it : items) {
CHECK(!overlaps(it, r.rootStrip));
CHECK(!overlaps(it, r.title));
}
// The run's own members are pairwise disjoint (velKnob/velLabel are inside velCell,
// so they are checked against the cell's neighbours, not the cell).
for (int i = 0; i < 5; ++i) {
for (int j = i + 1; j < 5; ++j) CHECK(!overlaps(items[i], items[j]));
// The run's own members are pairwise disjoint (the knob/label pairs are inside their
// cells, so they are checked against the cell's neighbours, not the cell).
constexpr int kRunCount = static_cast<int>(sizeof(items) / sizeof(items[0]));
for (int i = 0; i < kRunCount; ++i) {
for (int j = i + 1; j < kRunCount; ++j) CHECK(!overlaps(items[i], items[j]));
}
}
}
@@ -125,6 +128,25 @@ 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, 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);
CHECK(r.holdCell.y == r.velCell.y && r.holdCell.height == r.velCell.height);
CHECK(r.holdKnob.width == kKnob && r.holdKnob.height == kKnob);
CHECK(r.holdKnob.y == r.holdCell.y);
CHECK(r.holdKnob.x - r.holdCell.x == r.holdCell.right() - r.holdKnob.right());
CHECK(r.holdLabel.y == r.holdKnob.bottom());
CHECK(r.holdLabel.bottom() == r.holdCell.bottom());
CHECK(r.holdLabel.x == r.holdCell.x && r.holdLabel.right() == r.holdCell.right());
// Widening the window leaves the reservation alone; the title takes the extra pixels.
const ChromeRects wide = chromeRects(chromeBand(1000, 620), kKnob);
CHECK(wide.holdCell.width == r.holdCell.width);
}
static void testDegenerateBandYieldsNoInvertedRects() {
const ChromeRects empty = chromeRects(Rect{}, kKnob);
CHECK(empty.toolbar.empty() && empty.controls.empty());
@@ -133,7 +155,8 @@ static void testDegenerateBandYieldsNoInvertedRects() {
// A band far too narrow for the fixed run: everything collapses left, nothing inverts.
const ChromeRects tiny = chromeRects(Rect::ltrb(0, 0, 40, kTitleHeight + kChromeRowHeight),
kKnob);
const Rect items[] = {tiny.title, tiny.preview, tiny.velCell, tiny.velKnob, tiny.velLabel,
const Rect items[] = {tiny.title, tiny.holdCell, tiny.holdKnob, tiny.holdLabel,
tiny.bake, tiny.preview, tiny.velCell, tiny.velKnob, tiny.velLabel,
tiny.chanMono, tiny.chanStereo, tiny.navBrowse,
tiny.rootStrip};
for (const Rect& it : items) CHECK(it.right() >= it.x && it.bottom() >= it.y);
@@ -182,6 +205,7 @@ int main() {
testChromePartsNeverOverlapAtAnyWidth();
testStripOwnsItsWholeRowAndGrowsWithTheWindow();
testVelocityKnobIsCentredInItsCellAboveTheLabel();
testHoldCellIsReservedAndFollowsTheVelocityCellGrammar();
testDegenerateBandYieldsNoInvertedRects();
testPreviewGlyphSitsInsideTheButtonAndPointsRight();
testPreviewGlyphDegradesRatherThanOverflowing();
+30
View File
@@ -1380,6 +1380,34 @@ static void testTriggerEdgeCases() {
}
}
// --- Trigger out-of-domain lengthFraction: Voice::start's inline copy of trigger_seam's
// formula (map/trigger_seam.h) must clamp the same way the shared formula does — a
// frac > 1.0 never plays past the post-start span, and a NaN frees immediately rather than
// reaching the round()/static_cast<int64_t> undefined on a non-finite value. Pins the second
// implementation directly; test_trigger_seam.cpp pins the first.
static void testTriggerFracAboveOneClampsToSpan() {
// 150% length on a 200-frame sample -> clamped to the full 200-frame post-start span,
// not 300 frames (which would read off the end of the PCM).
SampleData km = (triggerSample(200, 1.5, 0, 0));
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 220);
for (std::size_t i = 0; i < 200; ++i) CHECK(out[i] > 0.5f);
for (std::size_t i = 200; i < 220; ++i) CHECK(approx(out[i], 0.0, 1e-6));
CHECK(eng.activeVoiceCount() == 0); // frees exactly at frameCount
}
static void testTriggerFracNaNFreesImmediately() {
SampleData km = (triggerSample(200, std::nan(""), 5, 5));
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 50);
for (float v : out) CHECK(approx(v, 0.0, 1e-6));
CHECK(eng.activeVoiceCount() == 0); // playLen 0 -> frees at start, no sound
}
// --- Trigger ignores note-off (S15): the one-shot plays through regardless. ---
static void testTriggerIgnoresNoteOff() {
SampleData km = (triggerSample(200, 0.5, 0, 0));
@@ -2908,6 +2936,8 @@ int main() {
testTriggerLengthWithStart();
testTriggerAhdFadeShape();
testTriggerEdgeCases();
testTriggerFracAboveOneClampsToSpan();
testTriggerFracNaNFreesImmediately();
testTriggerIgnoresNoteOff();
// S16 — pitch engine + pitch envelope.
+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");
+18 -2
View File
@@ -2,12 +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).
// 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;
@@ -57,7 +61,19 @@ static void testPlayLengthRounding() {
CHECK(triggerPlayLength(0.6, 3, 0) == 2);
}
// 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() {
testOutOfDomainFractionsClampToTheSpan();
testPlayLengthNoStartPoint();
testPlayLengthWithStartPoint();
testPlayLengthZeroFrameCount();