Ξ-W2-T1 remediation: print master gain into the bake, derive the window from the dialed sound, reset play mode to Trigger
This commit is contained in:
+133
-15
@@ -1,15 +1,17 @@
|
||||
// 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 (a note length plus a release tail, so the bake is
|
||||
// not truncated at note-off); the frame window and both event frames against hand-computed
|
||||
// values; a capture opening BEFORE note-on; the refusals — a collapsed window, a
|
||||
// non-positive rate, and a window that rounds to nothing; and root/velocity clamping.
|
||||
// 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.
|
||||
|
||||
#include "../src/core/instrument/bake/bake_plan.h"
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
using namespace reasampler;
|
||||
using namespace reasampler::instrument::bake;
|
||||
using namespace reasampler::instrument::note;
|
||||
|
||||
@@ -23,18 +25,77 @@ static Tempo at(double bpm) {
|
||||
return t.value_or(Tempo::fromBpm(120.0).value());
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int kRate = 48000;
|
||||
|
||||
SampleData dialedSample(std::size_t frames = 96000) {
|
||||
SampleData s;
|
||||
s.frames.assign(frames, 0.5f);
|
||||
s.sampleRate = kRate;
|
||||
s.rootNote = 60;
|
||||
return s;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
// --- The default program opens the window past note-off -----------------------
|
||||
// --- The default program's window comes from the DIALED release, not a constant -----
|
||||
{
|
||||
const NoteProgram p = defaultBakeProgram();
|
||||
SampleData s = dialedSample();
|
||||
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 tail is kDefaultReleaseTailMs past it.
|
||||
// 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);
|
||||
CHECK(r.captureStartSeconds == 0.0);
|
||||
// The whole point of the default: the window must extend past the release, or
|
||||
// every bake would be cut at note-off.
|
||||
CHECK(r.captureEndSeconds > r.noteOffSeconds);
|
||||
CHECK(r.captureEndSeconds > 1.99 && r.captureEndSeconds < 2.01);
|
||||
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);
|
||||
}
|
||||
|
||||
// --- Trigger: the window is the play span, which ignores the note's length ----------
|
||||
{
|
||||
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));
|
||||
CHECK(r.captureStartSeconds == 0.0);
|
||||
CHECK(r.captureEndSeconds > 1.49 && r.captureEndSeconds < 1.51);
|
||||
|
||||
// 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));
|
||||
CHECK(!brief.windowCollapsed);
|
||||
CHECK(brief.captureEndSeconds > 0.199 && brief.captureEndSeconds < 0.201);
|
||||
}
|
||||
|
||||
// --- Trigger under Varispeed: a downward pitch offset stretches the read -----------
|
||||
{
|
||||
SampleData s = dialedSample(/*frames=*/kRate); // 1 s, played whole
|
||||
s.play.playMode = PlayMode::Trigger;
|
||||
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));
|
||||
// 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);
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// --- The frame window and both event frames ----------------------------------
|
||||
@@ -45,6 +106,8 @@ int main() {
|
||||
const auto plan = planBake(r, 48000, 60);
|
||||
CHECK(plan.has_value());
|
||||
CHECK(plan->totalFrames == 36000); // 0.75 s * 48 kHz
|
||||
CHECK(plan->leadInFrames == 0);
|
||||
CHECK(plan->renderFrames() == 36000);
|
||||
CHECK(plan->noteOnFrame == 0);
|
||||
CHECK(plan->noteOffFrame == 24000); // 0.5 s * 48 kHz
|
||||
CHECK(plan->sampleRate == 48000);
|
||||
@@ -62,11 +125,30 @@ int main() {
|
||||
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);
|
||||
CHECK(plan->leadInFrames == 0); // nothing to discard: the file opens first
|
||||
CHECK(plan->noteOnFrame == 4410);
|
||||
CHECK(plan->noteOffFrame == 26460);
|
||||
CHECK(plan->noteOffFrame - plan->noteOnFrame == 22050); // the note's own length
|
||||
}
|
||||
|
||||
// --- A capture that opens AFTER note-on (a positive start trims the attack) ---
|
||||
{
|
||||
NoteProgram p;
|
||||
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);
|
||||
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.
|
||||
CHECK(plan->totalFrames == 24000);
|
||||
CHECK(plan->leadInFrames == 4800);
|
||||
CHECK(plan->renderFrames() == 28800);
|
||||
CHECK(plan->noteOnFrame == 0); // the note is at the START of the render
|
||||
CHECK(plan->noteOffFrame == 24000); // still its full 0.5 s length
|
||||
CHECK(plan->noteOffFrame - plan->noteOnFrame == 24000);
|
||||
}
|
||||
|
||||
// --- Refusals -----------------------------------------------------------------
|
||||
{
|
||||
NoteProgram p;
|
||||
@@ -77,7 +159,8 @@ int main() {
|
||||
CHECK(!planBake(r, 48000, 60).has_value());
|
||||
}
|
||||
{
|
||||
const ResolvedNote r = resolveNote(defaultBakeProgram(), at(120.0));
|
||||
NoteProgram plain;
|
||||
const ResolvedNote r = resolveNote(plain, at(120.0));
|
||||
CHECK(!planBake(r, 0, 60).has_value());
|
||||
CHECK(!planBake(r, -48000, 60).has_value());
|
||||
}
|
||||
@@ -91,13 +174,48 @@ int main() {
|
||||
CHECK(r.captureLengthSeconds() == 0.0);
|
||||
CHECK(!planBake(r, 48000, 60).has_value());
|
||||
}
|
||||
{
|
||||
// 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).
|
||||
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());
|
||||
|
||||
// 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());
|
||||
NoteProgram far;
|
||||
far.start = StartOffset(offsetFromMs(-kMaxConvertibleMagnitude));
|
||||
CHECK(!planBake(resolveNote(far, at(120.0)), 48000, 60).has_value());
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// --- Domain clamps -------------------------------------------------------------
|
||||
{
|
||||
const ResolvedNote r = resolveNote(defaultBakeProgram(), at(120.0));
|
||||
CHECK(planBake(r, 48000, -5)->note == 0);
|
||||
CHECK(planBake(r, 48000, 900)->note == 127);
|
||||
CHECK(planBake(r, 48000, 60)->note == 60);
|
||||
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);
|
||||
CHECK(low.has_value() && high.has_value() && mid.has_value());
|
||||
if (low && high && mid) {
|
||||
CHECK(low->note == 0);
|
||||
CHECK(high->note == 127);
|
||||
CHECK(mid->note == 60);
|
||||
}
|
||||
}
|
||||
|
||||
if (g_fail == 0) std::printf("bake_plan: all tests passed\n");
|
||||
|
||||
+69
-12
@@ -4,8 +4,9 @@
|
||||
// Covers: Gate termination WITH a sustain loop active (the render must end at the window,
|
||||
// and the tail must be silent because the gate actually released — not merely because the
|
||||
// buffer ran out); Trigger termination on its own play span; channel-count preservation
|
||||
// with no stereo fold; byte-identical repeats; and the refusals (unplayable sample, empty
|
||||
// window).
|
||||
// with no stereo fold; the master gain being PRINTED into the output; a lead-in rendered
|
||||
// and discarded; byte-identical repeats; and the refusals (unplayable sample, empty
|
||||
// window, a window past the frame ceiling).
|
||||
|
||||
#include "../src/core/instrument/bake/bake_render.h"
|
||||
|
||||
@@ -48,9 +49,11 @@ double peakAt(const BakeAudio& audio, std::int64_t from, std::int64_t to) {
|
||||
return peak;
|
||||
}
|
||||
|
||||
BakePlan planOf(std::int64_t total, std::int64_t noteOn, std::int64_t noteOff) {
|
||||
BakePlan planOf(std::int64_t total, std::int64_t noteOn, std::int64_t noteOff,
|
||||
std::int64_t leadIn = 0) {
|
||||
BakePlan p;
|
||||
p.totalFrames = total;
|
||||
p.leadInFrames = leadIn;
|
||||
p.noteOnFrame = noteOn;
|
||||
p.noteOffFrame = noteOff;
|
||||
p.note = 60;
|
||||
@@ -59,6 +62,8 @@ BakePlan planOf(std::int64_t total, std::int64_t noteOn, std::int64_t noteOff) {
|
||||
return p;
|
||||
}
|
||||
|
||||
constexpr double kUnity = 1.0;
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
@@ -72,7 +77,7 @@ int main() {
|
||||
s.play.adsr.releaseFrames = 480; // 10 ms — short enough to finish inside the tail
|
||||
|
||||
const BakePlan plan = planOf(/*total=*/9600, /*noteOn=*/0, /*noteOff=*/4800);
|
||||
const BakeAudio audio = renderBake(s, plan);
|
||||
const BakeAudio audio = renderBake(s, plan, kUnity);
|
||||
|
||||
CHECK(audio.frameCount() == 9600); // bounded, not a runaway
|
||||
CHECK(audio.channelCount == 1);
|
||||
@@ -90,7 +95,7 @@ int main() {
|
||||
s.play.trigger.lengthFraction = 0.5; // 500 source frames at unity ratio
|
||||
|
||||
const BakePlan plan = planOf(/*total=*/2000, /*noteOn=*/0, /*noteOff=*/100);
|
||||
const BakeAudio audio = renderBake(s, plan);
|
||||
const BakeAudio audio = renderBake(s, plan, kUnity);
|
||||
|
||||
CHECK(audio.frameCount() == 2000);
|
||||
// Still sounding past the note-off Trigger ignores…
|
||||
@@ -105,7 +110,7 @@ int main() {
|
||||
s.play.playMode = PlayMode::Trigger;
|
||||
|
||||
const BakePlan plan = planOf(/*total=*/500, /*noteOn=*/0, /*noteOff=*/500);
|
||||
const BakeAudio audio = renderBake(s, plan);
|
||||
const BakeAudio audio = renderBake(s, plan, kUnity);
|
||||
|
||||
CHECK(audio.channelCount == 2);
|
||||
CHECK(audio.frameCount() == 500);
|
||||
@@ -117,6 +122,54 @@ int main() {
|
||||
CHECK(audio.interleaved[201] > -0.3f);
|
||||
}
|
||||
|
||||
// --- The master gain is PRINTED into the file ---------------------------------------
|
||||
// The reset hands the control back at unity, so a render that summed voices alone would
|
||||
// shift every iteration by 1/gain — and a gain dialed to silence would come back loud.
|
||||
{
|
||||
SampleData s = makeSample(/*stereo=*/true, /*frames=*/1000);
|
||||
s.play.playMode = PlayMode::Trigger;
|
||||
const BakePlan plan = planOf(/*total=*/500, /*noteOn=*/0, /*noteOff=*/500);
|
||||
|
||||
const BakeAudio unity = renderBake(s, plan, kUnity);
|
||||
const BakeAudio quiet = renderBake(s, plan, 0.25);
|
||||
const BakeAudio loud = renderBake(s, plan, 4.0);
|
||||
const BakeAudio silent = renderBake(s, plan, 0.0);
|
||||
|
||||
CHECK(unity.interleaved.size() == quiet.interleaved.size());
|
||||
bool scaled = !unity.interleaved.empty();
|
||||
for (std::size_t i = 0; scaled && i < unity.interleaved.size(); ++i) {
|
||||
scaled = std::fabs(quiet.interleaved[i] - unity.interleaved[i] * 0.25f) < 1e-6f &&
|
||||
std::fabs(loud.interleaved[i] - unity.interleaved[i] * 4.0f) < 1e-5f;
|
||||
}
|
||||
CHECK(scaled);
|
||||
// Both channels, not just the one the peak helper reads.
|
||||
CHECK(quiet.interleaved[201] < 0.f && quiet.interleaved[201] > -0.1f);
|
||||
// A gain of zero prints silence rather than returning the sound at full level.
|
||||
CHECK(peakAt(silent, 0, 500) == 0.0);
|
||||
CHECK(peakAt(unity, 0, 500) > 0.4);
|
||||
}
|
||||
|
||||
// --- A lead-in is rendered and then discarded ---------------------------------------
|
||||
// A positive start offset trims the note's head: the frames before the window must be
|
||||
// produced (so the envelope really is mid-flight when the file opens) and dropped.
|
||||
{
|
||||
SampleData s = makeSample(/*stereo=*/false, /*frames=*/4000);
|
||||
s.play.playMode = PlayMode::Trigger;
|
||||
s.play.trigAhd.attackFrames = 1000; // still climbing when the window opens
|
||||
|
||||
const BakePlan trimmed = planOf(/*total=*/1000, /*noteOn=*/0, /*noteOff=*/2000,
|
||||
/*leadIn=*/1000);
|
||||
const BakeAudio audio = renderBake(s, trimmed, kUnity);
|
||||
CHECK(audio.frameCount() == 1000); // the FILE is the window, not the render
|
||||
|
||||
// Frame 0 of the file is frame 1000 of the render — the attack's end, not its
|
||||
// start. A clamped-away lead-in would put the attack's silent onset here instead.
|
||||
const BakeAudio whole = renderBake(s, planOf(/*total=*/2000, 0, 2000), kUnity);
|
||||
CHECK(peakAt(audio, 0, 1) > peakAt(whole, 0, 1));
|
||||
CHECK(std::fabs(static_cast<double>(audio.interleaved[0]) -
|
||||
static_cast<double>(whole.interleaved[1000])) < 1e-6);
|
||||
}
|
||||
|
||||
// --- Bit-identical repeats ---------------------------------------------------------
|
||||
{
|
||||
SampleData s = makeSample(/*stereo=*/true, /*frames=*/1000);
|
||||
@@ -125,8 +178,8 @@ int main() {
|
||||
s.play.adsr.releaseFrames = 211;
|
||||
|
||||
const BakePlan plan = planOf(/*total=*/4096, /*noteOn=*/13, /*noteOff=*/2731);
|
||||
const BakeAudio a = renderBake(s, plan);
|
||||
const BakeAudio b = renderBake(s, plan);
|
||||
const BakeAudio a = renderBake(s, plan, kUnity);
|
||||
const BakeAudio b = renderBake(s, plan, kUnity);
|
||||
|
||||
CHECK(a.interleaved.size() == b.interleaved.size());
|
||||
CHECK(!a.interleaved.empty());
|
||||
@@ -160,7 +213,7 @@ int main() {
|
||||
}
|
||||
|
||||
const BakePlan plan = planOf(/*total=*/1000, /*noteOn=*/0, /*noteOff=*/1000);
|
||||
const BakeAudio audio = renderBake(s, plan);
|
||||
const BakeAudio audio = renderBake(s, plan, kUnity);
|
||||
|
||||
CHECK(s.live == &block); // the caller's own snapshot was not detached
|
||||
// At frame 100 the dialed instant attack is at full level; the published 900-frame
|
||||
@@ -170,7 +223,7 @@ int main() {
|
||||
// And it matches a render from a block-free copy exactly.
|
||||
SampleData detached = s;
|
||||
detached.live = nullptr;
|
||||
const BakeAudio reference = renderBake(detached, plan);
|
||||
const BakeAudio reference = renderBake(detached, plan, kUnity);
|
||||
bool identical = audio.interleaved.size() == reference.interleaved.size();
|
||||
for (std::size_t i = 0; identical && i < audio.interleaved.size(); ++i)
|
||||
identical = (audio.interleaved[i] == reference.interleaved[i]);
|
||||
@@ -181,10 +234,14 @@ int main() {
|
||||
{
|
||||
SampleData empty; // nothing decoded
|
||||
empty.sampleRate = kRate;
|
||||
CHECK(renderBake(empty, planOf(1000, 0, 500)).empty());
|
||||
CHECK(renderBake(empty, planOf(1000, 0, 500), kUnity).empty());
|
||||
|
||||
SampleData s = makeSample(false);
|
||||
CHECK(renderBake(s, planOf(0, 0, 0)).empty());
|
||||
CHECK(renderBake(s, planOf(0, 0, 0), kUnity).empty());
|
||||
// The ceiling planBake enforces is re-checked here: a hand-built plan must not be
|
||||
// able to walk the render into an allocation it cannot hold.
|
||||
CHECK(renderBake(s, planOf(kMaxBakeFrames, 0, 0, /*leadIn=*/1), kUnity).empty());
|
||||
CHECK(renderBake(s, planOf(1000, 0, 500, /*leadIn=*/-1), kUnity).empty());
|
||||
}
|
||||
|
||||
if (g_fail == 0) std::printf("bake_render: all tests passed\n");
|
||||
|
||||
@@ -100,8 +100,24 @@ int main() {
|
||||
CHECK(sameCurve(after.play.pitchVelocityCurve, VelocityCurve::zero()));
|
||||
CHECK(sameCurve(after.play.filter.velocityCurve, VelocityCurve::zero()));
|
||||
|
||||
// --- RESET: play mode, to TRIGGER rather than to the struct's Gate default -------
|
||||
// The bake's product is a finished one-shot; Trigger plays it back verbatim, Gate would
|
||||
// re-gate its printed release tail and each iteration would truncate the last one's.
|
||||
CHECK(after.play.playMode == PlayMode::Trigger);
|
||||
CHECK(freshPlay.playMode == PlayMode::Gate); // and that really is NOT the default
|
||||
// The Trigger face it lands on plays the whole file flat: full span, unity throughout.
|
||||
CHECK(after.play.trigger.lengthFraction == 1.0);
|
||||
CHECK(after.play.trigAhd.attackSeconds == 0.0);
|
||||
CHECK(after.play.trigAhd.decaySeconds == 0.0);
|
||||
{
|
||||
// …and a GATE-dialed instrument lands there too: this is a reset to a chosen
|
||||
// neutral, not the dialed value surviving.
|
||||
InstrumentParams gated = dialed();
|
||||
gated.play.playMode = PlayMode::Gate;
|
||||
CHECK(resetAfterBake(gated).params.play.playMode == PlayMode::Trigger);
|
||||
}
|
||||
|
||||
// --- RESET: the amp envelope, staged, every stage and every curve exponent ------
|
||||
CHECK(after.play.playMode == freshPlay.playMode);
|
||||
CHECK(after.play.adsr.attackSeconds == freshPlay.adsr.attackSeconds);
|
||||
CHECK(after.play.adsr.holdSeconds == freshPlay.adsr.holdSeconds);
|
||||
CHECK(after.play.adsr.decaySeconds == freshPlay.adsr.decaySeconds);
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
// Standalone tests for reasampler::wire::bake_wire — no VST3, no REAPER, no framework.
|
||||
// Same fast assert loop as the sibling wire tests.
|
||||
//
|
||||
// Covers: request + outcome round-trips including bytes that would break a delimiter-based
|
||||
// format; the refusals every house wire record shares (wrong tag, truncation, trailing
|
||||
// garbage, a swapped record kind); an unrecognized status integer degrading to Failed
|
||||
// rather than to Ok; and the action lookup name's leading underscore.
|
||||
// Covers: the exact bytes each record encodes to (the two artifacts ship independently, so
|
||||
// a field reorder or an inserted field must fail here rather than pass a round-trip and
|
||||
// break a mixed-version pair); request + outcome round-trips including bytes that would
|
||||
// break a delimiter-based format; the refusals every house wire record shares (wrong tag,
|
||||
// truncation, trailing garbage, a swapped record kind); an unrecognized status integer
|
||||
// degrading to Failed rather than to Ok; and the action lookup name's leading underscore.
|
||||
|
||||
#include "../src/core/wire/bake_wire.h"
|
||||
|
||||
#include "../src/core/version/app_version.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
@@ -18,6 +22,54 @@ static int g_fail = 0;
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
int main() {
|
||||
// --- The exact bytes on the wire -------------------------------------------------
|
||||
// A round-trip alone would pass a reordered or inserted field; the tags exist to guard
|
||||
// the LAYOUT, so the layout is what is pinned. Changing either literal below means an
|
||||
// already-shipped pair of artifacts can no longer talk — bump the tag, don't edit it.
|
||||
{
|
||||
BakeRequest req;
|
||||
req.instanceGuid = "abcd";
|
||||
req.stagedFilePath = "T/b.wav";
|
||||
req.sourceSampleId = "cap-1";
|
||||
req.sourceRelativePath = "bank/k.wav";
|
||||
req.sourceDisplayName = "Kick";
|
||||
req.ownUsageKey = "rsusage_abcd";
|
||||
req.rootNote = 36;
|
||||
req.generation = 1893456000;
|
||||
CHECK(encodeBakeRequest(req) ==
|
||||
"rsbakereq1"
|
||||
"4:abcd"
|
||||
"7:T/b.wav"
|
||||
"5:cap-1"
|
||||
"10:bank/k.wav"
|
||||
"4:Kick"
|
||||
"12:rsusage_abcd"
|
||||
"2:36"
|
||||
"10:1893456000");
|
||||
|
||||
BakeOutcome out;
|
||||
out.status = BakeStatus::Ok;
|
||||
out.sampleId = "bake-1";
|
||||
out.relativePath = "bank/k2.wav";
|
||||
out.displayName = "Kick r2";
|
||||
out.rootNote = 36;
|
||||
out.channelCount = 2;
|
||||
out.replaced = true;
|
||||
out.message = "replaced";
|
||||
out.generation = 1893456000;
|
||||
CHECK(encodeBakeOutcome(out) ==
|
||||
"rsbakeout1"
|
||||
"1:0"
|
||||
"6:bake-1"
|
||||
"11:bank/k2.wav"
|
||||
"7:Kick r2"
|
||||
"2:36"
|
||||
"1:2"
|
||||
"1:1"
|
||||
"8:replaced"
|
||||
"10:1893456000");
|
||||
}
|
||||
|
||||
// --- Request round-trip, with hostile field content -----------------------------
|
||||
{
|
||||
BakeRequest req;
|
||||
@@ -96,15 +148,39 @@ int main() {
|
||||
CHECK(decoded.has_value());
|
||||
CHECK(decoded->status == BakeStatus::Failed); // never Ok
|
||||
CHECK(decoded->generation == 7);
|
||||
|
||||
// Every status this build DOES know survives its own round trip — including the
|
||||
// most recently appended one, which an older reader will see as Failed.
|
||||
for (const BakeStatus s :
|
||||
{BakeStatus::Ok, BakeStatus::Failed, BakeStatus::NoProject,
|
||||
BakeStatus::StagedMissing, BakeStatus::NoSource, BakeStatus::IndexRejected,
|
||||
BakeStatus::WrongProject}) {
|
||||
BakeOutcome one;
|
||||
one.status = s;
|
||||
const auto back = decodeBakeOutcome(encodeBakeOutcome(one));
|
||||
CHECK(back.has_value() && back->status == s);
|
||||
}
|
||||
}
|
||||
|
||||
// --- The lookup name carries the underscore the registration string does not -------
|
||||
// The load-bearing half is the REGISTRATION string: main.cpp registers that spelling
|
||||
// verbatim, and NamedCommandLookup needs exactly one underscore in front of it. If
|
||||
// channelCommandId ever grew one of its own, the lookup would carry two and resolve to
|
||||
// nothing.
|
||||
{
|
||||
const std::string registered =
|
||||
reasampler::version::channelCommandId(kBakeActionSuffix);
|
||||
const std::string lookup = bakeActionLookupName();
|
||||
CHECK(!lookup.empty());
|
||||
CHECK(lookup[0] == '_');
|
||||
CHECK(lookup.find(kBakeActionSuffix) != std::string::npos);
|
||||
CHECK(lookup.find(kBakeActionSuffix) > 0u);
|
||||
const std::size_t suffixLen = std::string(kBakeActionSuffix).size();
|
||||
CHECK(!registered.empty());
|
||||
CHECK(registered.front() != '_');
|
||||
CHECK(lookup.size() == registered.size() + 1);
|
||||
CHECK(lookup.front() == '_' && lookup[1] != '_');
|
||||
CHECK(lookup.compare(1, std::string::npos, registered) == 0);
|
||||
// The suffix is the TAIL of the id — the channel prefix goes in front of it, and a
|
||||
// suffix that drifted into the middle would name a different action.
|
||||
CHECK(lookup.size() > suffixLen);
|
||||
CHECK(lookup.compare(lookup.size() - suffixLen, suffixLen, kBakeActionSuffix) == 0);
|
||||
}
|
||||
|
||||
if (g_fail == 0) std::printf("bake_wire: all tests passed\n");
|
||||
|
||||
Reference in New Issue
Block a user