Ξ-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:
2026-08-01 17:05:28 -04:00
parent 60308a3655
commit 39c2d1cdb4
31 changed files with 679 additions and 201 deletions
+133 -15
View File
@@ -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
View File
@@ -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");
+17 -1
View File
@@ -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);
+84 -8
View File
@@ -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");