Bake window derives itself: %-knob fold, declick pad, Gate held to exhaustion, preview velocity; Hold is the one knob a loop needs
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
// 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, and that every rung is reachable (no rung is skipped by the rounding).
|
||||
|
||||
#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)
|
||||
|
||||
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));
|
||||
|
||||
// --- 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));
|
||||
|
||||
// --- 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: turning the knob up never shortens the note -------------
|
||||
{
|
||||
int previous = -1;
|
||||
for (int step = 0; step <= 4000; ++step) {
|
||||
const int index = divisionIndex(bakeHoldFromNorm(static_cast<double>(step) / 4000.0));
|
||||
CHECK(index >= previous);
|
||||
previous = index;
|
||||
}
|
||||
}
|
||||
|
||||
if (g_fail == 0) std::printf("bake_hold: all tests passed\n");
|
||||
return g_fail ? 1 : 0;
|
||||
}
|
||||
+111
-46
@@ -1,13 +1,15 @@
|
||||
// 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 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 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 +31,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 +42,44 @@ 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); }
|
||||
|
||||
NoteProgram derived(const SampleData& s, Tempo t) {
|
||||
return defaultBakeProgram(s, kRate, t, 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 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.
|
||||
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));
|
||||
|
||||
// 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.
|
||||
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)));
|
||||
}
|
||||
|
||||
// --- Trigger: the window is the play span, which ignores the note's length ----------
|
||||
@@ -65,18 +87,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.
|
||||
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 +106,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, at(120.0), oneBar(), Velocity::of(1));
|
||||
const NoteProgram hard =
|
||||
defaultBakeProgram(s, kRate, at(120.0), 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, at(120.0), oneBar(), Velocity{}), at(120.0));
|
||||
const ResolvedNote twoBars = resolveNote(
|
||||
defaultBakeProgram(s, kRate, at(120.0),
|
||||
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 +163,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 +181,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 +197,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 +209,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 +232,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 +272,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);
|
||||
|
||||
+163
-102
@@ -1,11 +1,12 @@
|
||||
// Standalone audit of the DERIVED bake window: does defaultBakeProgram's window hold the
|
||||
// whole audible result of the dialed sound, in both play modes? 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.
|
||||
// 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>
|
||||
@@ -25,6 +26,14 @@ 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 tempo() {
|
||||
const std::optional<Tempo> t = Tempo::fromBpm(kBpm);
|
||||
if (!t) { std::printf("FAIL: fixture tempo rejected\n"); ++g_fail; }
|
||||
@@ -53,125 +62,143 @@ double peakAt(const BakeAudio& audio, std::int64_t from, std::int64_t to) {
|
||||
}
|
||||
|
||||
// The derived program, optionally lengthened: `extraMs` widens ONLY the end offset (the same
|
||||
// sound, a longer window), `length` overrides the programmed note length. Both leave 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) {
|
||||
NoteProgram p = defaultBakeProgram(s, kRate, tempo());
|
||||
// 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, tempo(), 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);
|
||||
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) {
|
||||
const std::optional<BakePlan> plan = planOf(derivedProgram(s, extraMs));
|
||||
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) {
|
||||
const std::optional<BakePlan> plan = planOf(derivedProgram(s, 0.0));
|
||||
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, staged AHDSR: the derived end is EXACT on the release ----------
|
||||
// note-off at the quarter note, release_frames after it, and not one frame of signal
|
||||
// past that — the end offset is the release, so the two must coincide exactly.
|
||||
// --- 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) == 28800); // 24000 (quarter) + 4800 (release)
|
||||
CHECK(derivedFrames(s) == 96000 + 4800 + kPad);
|
||||
|
||||
const BakeAudio wide = bakeWith(s, /*extraMs=*/200.0);
|
||||
CHECK(wide.frameCount() == 38400);
|
||||
// Still releasing on the last ten frames the derived window would have kept…
|
||||
CHECK(peakAt(wide, 28790, 28800) > kSilence);
|
||||
// …and EXACTLY silent from there on: nothing was cut.
|
||||
CHECK(peakAt(wide, 28800, 38400) == 0.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 with a sustain loop: there is NO intrinsic end to derive -----------------
|
||||
// The window holds the whole programmed note, but the note length itself is a constant
|
||||
// quarter note — hold the same dialed sound longer and it keeps sounding, indefinitely.
|
||||
// This is the one number a derivation cannot supply.
|
||||
// --- 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 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;
|
||||
|
||||
const BakeAudio derived = bakeWith(s, 0.0);
|
||||
CHECK(derived.frameCount() == 28800);
|
||||
// The derived window does bound the render: full level while the note is held, and
|
||||
// down to the release's own floor at the end — the loop is not left cycling.
|
||||
CHECK(peakAt(derived, 20000, 24000) > 0.4);
|
||||
CHECK(peakAt(derived, 28790, 28800) < 0.01);
|
||||
CHECK(bakeWindowNeedsHold(s));
|
||||
|
||||
// The SAME dialed sound held for a whole note instead of a quarter: four times the
|
||||
// audio, all of it real, none of it reachable from the derivation.
|
||||
NoteProgram longer = derivedProgram(s, 0.0);
|
||||
longer.length = makeDivision(2, DivisionModifier::Straight); // 4 beats == 2 s
|
||||
const std::optional<BakePlan> plan = planOf(longer);
|
||||
CHECK(plan.has_value());
|
||||
const BakeAudio held = renderBake(s, *plan, kUnity);
|
||||
CHECK(held.frameCount() == 100800); // 96000 + 4800
|
||||
CHECK(peakAt(held, 28800, 96000) > 0.4); // full level, well past the derived end
|
||||
// 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
|
||||
}
|
||||
|
||||
// --- Gate: the constant quarter-note hold TRUNCATES a slow attack ------------------
|
||||
// The window is correct for the note it programs; the note is what is wrong. A 2 s
|
||||
// attack never reaches its peak, so the bake prints a sound the user never dialed.
|
||||
// --- The Hold predicate is the ENGINE's loop fold, not a reading of the fields -------
|
||||
{
|
||||
SampleData s = dcSample(192000);
|
||||
SampleData s = dcSample(48000);
|
||||
s.play.playMode = PlayMode::Gate;
|
||||
s.play.adsr.attackFrames = 96000; // 2 s, four times the programmed note
|
||||
s.play.adsr.releaseFrames = 0;
|
||||
|
||||
const BakeAudio derived = bakeWith(s, 0.0);
|
||||
CHECK(derived.frameCount() == 24000);
|
||||
// A quarter of the way up the attack at most.
|
||||
const double derivedPeak = peakAt(derived, 0, 24000);
|
||||
CHECK(derivedPeak > 0.1 && derivedPeak < 0.14);
|
||||
|
||||
NoteProgram longer = derivedProgram(s, 0.0);
|
||||
longer.length = makeDivision(3, DivisionModifier::Straight); // 8 beats == 4 s
|
||||
const std::optional<BakePlan> plan = planOf(longer);
|
||||
CHECK(plan.has_value());
|
||||
const BakeAudio held = renderBake(s, *plan, kUnity);
|
||||
CHECK(peakAt(held, 0, held.frameCount()) > 0.49); // the dialed sound, in full
|
||||
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 cut when the source ends AT the window
|
||||
// Preserve rings its last real output out (~4 ms) instead of hard-cutting it; that ramp
|
||||
// lands past the derived end whenever the voice's own end lands on it.
|
||||
// --- 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 exactly at the quarter-note note-off
|
||||
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);
|
||||
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, 23990, 24000) > 0.4); // hard cut at full level…
|
||||
CHECK(peakAt(wide, 24000, 24100) > 0.05); // …with the ring-out outside the window
|
||||
CHECK(peakAt(wide, 24400, wide.frameCount()) < kSilence); // and it is bounded
|
||||
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 release the window already holds. Not a tail contributor.
|
||||
// gated by the envelope the window already holds. Not a tail contributor.
|
||||
{
|
||||
SampleData s = dcSample(96000);
|
||||
s.play.playMode = PlayMode::Gate;
|
||||
@@ -181,8 +208,8 @@ int main() {
|
||||
s.play.filter.settings.resonanceNorm = 1.0f;
|
||||
|
||||
const BakeAudio wide = bakeWith(s, /*extraMs=*/500.0);
|
||||
CHECK(peakAt(wide, 0, 28800) > kSilence); // the filtered note sounded
|
||||
CHECK(peakAt(wide, 28800, wide.frameCount()) == 0.0); // and nothing rang past it
|
||||
CHECK(peakAt(wide, 0, 96000) > kSilence); // the filtered note sounded
|
||||
CHECK(peakAt(wide, 96000, wide.frameCount()) == 0.0); // and nothing rang past it
|
||||
}
|
||||
|
||||
// ================================ TRIGGER =======================================
|
||||
@@ -199,7 +226,7 @@ int main() {
|
||||
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);
|
||||
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.
|
||||
@@ -207,50 +234,72 @@ int main() {
|
||||
CHECK(peakAt(derived, 90000, 192000) < kSilence);
|
||||
}
|
||||
|
||||
// --- Trigger + Preserve (the product-default engine): the ring-out is TRUNCATED -----
|
||||
// Preserve's read reaches playEnd on the exact frame the derived window closes, and the
|
||||
// ~4 ms terminal declick that follows is entirely outside it. The baked file therefore
|
||||
// ends on a hard cut at full level — reintroducing the click the ramp exists to remove.
|
||||
// --- 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);
|
||||
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, 47990, 48000) > 0.4); // full level on the derived last frame
|
||||
CHECK(peakAt(wide, 48000, 48100) > 0.05); // real signal past the derived end
|
||||
// Measured: the ramp opens at the last in-window level (0.500) and reaches exact
|
||||
// zero 180 frames later — the whole 3.75 ms sits outside the derived window.
|
||||
CHECK(peakAt(wide, 48000, 48001) > 0.49);
|
||||
CHECK(peakAt(wide, 48179, 48180) > 0.0);
|
||||
CHECK(peakAt(wide, 48180, wide.frameCount()) == 0.0);
|
||||
CHECK(peakAt(wide, 48000 + kPad, wide.frameCount()) == 0.0);
|
||||
}
|
||||
|
||||
// --- Trigger + a DRAWN amp EG: the window keeps a %-length the engine IGNORES -------
|
||||
// A drawn contour covers the full sample length, so Voice::start forces lengthFraction
|
||||
// to 1.0 (splineActive, play_params.h) — but defaultBakeProgram reads the stored,
|
||||
// UI-inert knob straight through triggerPlayLength. The window is a quarter of the take.
|
||||
// --- 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
|
||||
// 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, live in the derivation
|
||||
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) == 12000); // 0.25 s of a 1 s take
|
||||
CHECK(derivedFrames(s) == 48000 + kPad); // the whole take, not 12000
|
||||
|
||||
const BakeAudio wide = bakeWith(s, /*extraMs=*/1000.0);
|
||||
// The engine really did play the whole sample: full level right up to 48000…
|
||||
CHECK(peakAt(wide, 12000, 48000) > 0.4);
|
||||
CHECK(peakAt(wide, 47000, 48000) > 0.4);
|
||||
// …and stopped at its own span end, so the 36000 lost frames are the take, not tail.
|
||||
CHECK(peakAt(wide, 48200, wide.frameCount()) < kSilence);
|
||||
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);
|
||||
}
|
||||
|
||||
// --- A legitimate dialed sound past the frame ceiling is REFUSED, not cut -----------
|
||||
// 1.1e6 source frames stretched by 2^(48/12) == 16 exceeds kMaxBakeFrames. The refusal
|
||||
// is the safe direction (the shell reports it), but it is a sound that cannot be baked.
|
||||
// ============================== 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;
|
||||
@@ -258,7 +307,10 @@ int main() {
|
||||
s.play.pitchEnv.enabled = true;
|
||||
s.play.pitchEnv.peakSemitones = -48.0;
|
||||
s.play.pitchEnv.shape.holdFraction = 1.0;
|
||||
CHECK(!planOf(derivedProgram(s, 0.0)).has_value());
|
||||
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.
|
||||
@@ -266,6 +318,15 @@ int main() {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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,19 @@ 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 at its one-bar default, re-appended after a splice so the record still ends
|
||||
// where the reader expects it to.
|
||||
static void putDefaultBakeHoldTail(std::vector<std::uint8_t>& out) {
|
||||
legacy::u32v(out, 2); // quarterExponent 2 == 1/1
|
||||
legacy::u8v(out, 0); // Straight
|
||||
}
|
||||
|
||||
// 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 +832,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 +842,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.
|
||||
@@ -862,8 +881,8 @@ 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.
|
||||
@@ -873,6 +892,7 @@ static void testV13HardFlagOutOfBoundsCountSurvivesWithoutWipingTheRecord() {
|
||||
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);
|
||||
// The whole record survives — including everything the v13 section itself carries ahead of
|
||||
@@ -904,8 +924,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 +940,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 +1168,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 +1747,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");
|
||||
@@ -1748,6 +1863,10 @@ int main() {
|
||||
testV13HardFlagInBoundsMismatchDropsFlagsOnly();
|
||||
testV13HardFlagOutOfBoundsCountSurvivesWithoutWipingTheRecord();
|
||||
testV13HardFlagTailTruncatedMidCountSurvivesWithoutWipingTheRecord();
|
||||
testBakeHoldRoundTripsAndDisturbsNothingElse();
|
||||
testV13BlobLiftsToTheDefaultHold();
|
||||
testBakeHoldCorruptPairClampsToTheLadder();
|
||||
testBakeHoldTruncatedTailSurvivesWithoutWipingTheRecord();
|
||||
if (failures == 0) {
|
||||
std::printf("component_state_io_tests: all tests passed\n");
|
||||
return 0;
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
// 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
|
||||
// fields, measured through the readers rather than by comparing two clamped values.
|
||||
// fields, measured through the readers rather than by comparing two clamped values; and
|
||||
// shortestDivisionAtLeast's never-short contract across the rung boundaries.
|
||||
|
||||
#include "../src/core/instrument/note/musical_division.h"
|
||||
|
||||
@@ -203,7 +204,49 @@ static void testOutOfRangeIndexClampsIntoTheSet() {
|
||||
CHECK(divisionAt(kDivisionCount) == divisionAt(kDivisionCount - 1));
|
||||
}
|
||||
|
||||
// --- shortestDivisionAtLeast --------------------------------------------------
|
||||
|
||||
// The contract is never-short: whatever comes back is at least the requested length, and
|
||||
// nothing shorter on the ladder also is. Swept over every rung and over the gaps between them.
|
||||
static void testShortestAtLeastIsNeverShortAndNeverLonger() {
|
||||
for (int i = 0; i < kDivisionCount; ++i) {
|
||||
const double target = divisionBeats(divisionAt(i));
|
||||
for (const double want : {target, target * 0.99, target * 0.5}) {
|
||||
const Division got = shortestDivisionAtLeast(want);
|
||||
CHECK(divisionBeats(got) >= want - 1e-12);
|
||||
// Nothing on the ladder is both long enough and shorter than the answer.
|
||||
for (int j = 0; j < kDivisionCount; ++j) {
|
||||
const double other = divisionBeats(divisionAt(j));
|
||||
CHECK(!(other >= want && other < divisionBeats(got) - 1e-12));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Picker order is NOT length order — a rung's triplet is shorter than the previous rung's
|
||||
// dotted — so the answer is not simply "the next index up".
|
||||
static void testShortestAtLeastCrossesRungBoundaries() {
|
||||
// 5 beats: the 2/1 triplet (8 * 2/3 == 5.33) beats the dotted half (6) above it.
|
||||
CHECK(shortestDivisionAtLeast(5.0) == makeDivision(3, DivisionModifier::Triplet));
|
||||
// An exact rung length answers with that rung, not the one above it.
|
||||
CHECK(shortestDivisionAtLeast(4.0) == makeDivision(2, DivisionModifier::Straight));
|
||||
}
|
||||
|
||||
// Degenerate inputs land on an end rather than anywhere in the middle.
|
||||
static void testShortestAtLeastDegenerateInputs() {
|
||||
CHECK(shortestDivisionAtLeast(0.0) == divisionAt(0));
|
||||
CHECK(shortestDivisionAtLeast(-10.0) == divisionAt(0));
|
||||
// Past the longest programmable note (the dotted top rung), the top rung is the answer.
|
||||
const Division longest = makeDivision(kMaxQuarterExponent, DivisionModifier::Dotted);
|
||||
CHECK(almostEqual(divisionBeats(longest), kMaxDivisionBeats));
|
||||
CHECK(shortestDivisionAtLeast(kMaxDivisionBeats * 2.0) == longest);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testShortestAtLeastIsNeverShortAndNeverLonger();
|
||||
testShortestAtLeastCrossesRungBoundaries();
|
||||
testShortestAtLeastDegenerateInputs();
|
||||
|
||||
testLadderSpansSixtyfourthToSixtyFourWhole();
|
||||
testEveryStraightRungHasItsWrittenBeatLength();
|
||||
testDottedIsHalfAgainAndTripletIsTwoThirds();
|
||||
|
||||
@@ -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 — 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.
|
||||
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();
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
//
|
||||
// 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).
|
||||
// this module existed); plus effectiveLengthFraction, the spline fold every consumer of the
|
||||
// span must go through.
|
||||
|
||||
#include "../src/core/instrument/map/trigger_seam.h"
|
||||
|
||||
@@ -57,7 +58,46 @@ 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);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testDrawnEnvelopeFoldsTheFractionToOne();
|
||||
testDisabledEnvelopesDoNotFoldTheFraction();
|
||||
testPlayLengthNoStartPoint();
|
||||
testPlayLengthWithStartPoint();
|
||||
testPlayLengthZeroFrameCount();
|
||||
|
||||
Reference in New Issue
Block a user