1283 lines
60 KiB
C++
1283 lines
60 KiB
C++
// component_state_io unit tests — the codec's whole suite: the current envelope + params
|
|
// payload round-trip, the frozen prefix bytes, the ENVELOPE ladder (v1..v11) with each
|
|
// version's documented lift, and the RETIRED-ZONE-PAYLOAD migration ladder (payload v1..v7
|
|
// -> the one parameter set, adopting zone one). The codec's own executable is also the
|
|
// STRUCTURAL PROOF it links WITHOUT the voice engine: it links component_state_io +
|
|
// velocity_curve + master_gain only, so a sampler_core/pitch_shift symbol reaching this
|
|
// link is a regression.
|
|
|
|
#include "../src/core/instrument/map/component_state_io.h"
|
|
#include "../src/core/instrument/engine/envelopes.h" // AhdEnvelope (header-only: the codec
|
|
// links no engine, and this adds none)
|
|
#include "../src/core/instrument/engine/master_gain.h" // masterGainMaxLinear (the v8 wire cap)
|
|
#include "../src/core/util/curve_law.h" // kCurveNeutral (the migration neutral)
|
|
|
|
#include <cmath>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <limits>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
using namespace reasampler;
|
|
using namespace reasampler::instrument::map;
|
|
|
|
static int failures = 0;
|
|
|
|
#define CHECK(cond) \
|
|
do { \
|
|
if (!(cond)) { \
|
|
std::printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \
|
|
++failures; \
|
|
} \
|
|
} while (0)
|
|
|
|
// --- A writer for the RETIRED zone-list payloads -----------------------------
|
|
//
|
|
// The shipping codec no longer EMITS a zone list, so the migration ladder can only be
|
|
// tested against bytes this suite lays out itself. These helpers mirror the frozen v1..v7
|
|
// record shapes documented in component_state_io.h; if they and the reader ever disagree,
|
|
// the migration tests below fail — which is the point.
|
|
|
|
namespace legacy {
|
|
|
|
static void u8v(std::vector<std::uint8_t>& out, std::uint8_t v) { out.push_back(v); }
|
|
|
|
static void u32v(std::vector<std::uint8_t>& out, std::uint32_t v) {
|
|
for (int i = 0; i < 4; ++i) out.push_back(static_cast<std::uint8_t>((v >> (8 * i)) & 0xFF));
|
|
}
|
|
|
|
static void i64v(std::vector<std::uint8_t>& out, std::int64_t v) {
|
|
const auto u = static_cast<std::uint64_t>(v);
|
|
for (int i = 0; i < 8; ++i) out.push_back(static_cast<std::uint8_t>((u >> (8 * i)) & 0xFF));
|
|
}
|
|
|
|
static void f64v(std::vector<std::uint8_t>& out, double v) {
|
|
std::uint64_t bits = 0;
|
|
std::memcpy(&bits, &v, sizeof(bits));
|
|
for (int i = 0; i < 8; ++i) out.push_back(static_cast<std::uint8_t>((bits >> (8 * i)) & 0xFF));
|
|
}
|
|
|
|
static void strv(std::vector<std::uint8_t>& out, const std::string& s) {
|
|
u32v(out, static_cast<std::uint32_t>(s.size()));
|
|
out.insert(out.end(), s.begin(), s.end());
|
|
}
|
|
|
|
// One zone's worth of the retired per-zone record, in the v7 (fullest) shape.
|
|
struct Zone {
|
|
std::string sampleId;
|
|
int lowNote = 0;
|
|
int highNote = 127;
|
|
int rootOverride = -1; // < 0 = absent
|
|
bool hasLoopOverride = false;
|
|
// The override's OWN hasLoop bit — distinct from hasLoopOverride above. An override can
|
|
// itself say "disable the loop" (loopOverrideHasLoop = false): the field is present but
|
|
// sets no sustain loop, as opposed to no override at all (the sample's own intrinsic loop
|
|
// applies). Defaults true so existing callers that only set hasLoopOverride keep writing
|
|
// the enabled-loop shape they always did.
|
|
bool loopOverrideHasLoop = true;
|
|
std::int64_t loopStart = 0;
|
|
std::int64_t loopEnd = 0;
|
|
std::int64_t startPoint = -1; // < 0 = absent
|
|
bool trigger = false;
|
|
double holdSeconds = 0.0;
|
|
double lengthFraction = 1.0;
|
|
std::int64_t fadeIn = 0;
|
|
std::int64_t fadeOut = 0;
|
|
bool preserve = false;
|
|
bool pitchEnvEnabled = false;
|
|
double pitchAttack = 0.0;
|
|
double pitchDecay = 0.0;
|
|
double peakSemis = 0.0;
|
|
double attackSeconds = 0.003;
|
|
double decaySeconds = 0.0;
|
|
double sustainLevel = 1.0;
|
|
double releaseSeconds = 0.060;
|
|
double keyTrack = 1.0;
|
|
std::vector<VelocityPoint> curve; // empty -> the flat endpoints
|
|
};
|
|
|
|
// Everything after a zone's id and key range — which is EXACTLY the whole v8 single record,
|
|
// so the two shapes are written from one place here just as the codec writes them from one
|
|
// place (putOverrides + the shared play tail).
|
|
static void putRecordBody(std::vector<std::uint8_t>& out, const Zone& z, std::uint32_t pv) {
|
|
u8v(out, z.rootOverride >= 0 ? 1 : 0);
|
|
if (z.rootOverride >= 0) u32v(out, static_cast<std::uint32_t>(z.rootOverride));
|
|
if (pv >= 2) {
|
|
u8v(out, z.hasLoopOverride ? 1 : 0);
|
|
if (z.hasLoopOverride) {
|
|
u8v(out, z.loopOverrideHasLoop ? 1 : 0);
|
|
i64v(out, z.loopStart);
|
|
i64v(out, z.loopEnd);
|
|
}
|
|
u8v(out, z.startPoint >= 0 ? 1 : 0);
|
|
if (z.startPoint >= 0) i64v(out, z.startPoint);
|
|
}
|
|
if (pv >= 5) {
|
|
u8v(out, z.trigger ? 1 : 0);
|
|
f64v(out, z.holdSeconds);
|
|
f64v(out, z.lengthFraction);
|
|
i64v(out, z.fadeIn);
|
|
i64v(out, z.fadeOut);
|
|
u8v(out, z.preserve ? 1 : 0);
|
|
u8v(out, z.pitchEnvEnabled ? 1 : 0);
|
|
f64v(out, z.pitchAttack);
|
|
f64v(out, z.pitchDecay);
|
|
f64v(out, z.peakSemis);
|
|
f64v(out, z.attackSeconds);
|
|
f64v(out, z.decaySeconds);
|
|
f64v(out, z.sustainLevel);
|
|
f64v(out, z.releaseSeconds);
|
|
}
|
|
if (pv >= 6) f64v(out, z.keyTrack);
|
|
if (pv >= 7) {
|
|
const std::vector<VelocityPoint> pts =
|
|
z.curve.empty() ? std::vector<VelocityPoint>{{0.0, 1.0}, {127.0, 1.0}} : z.curve;
|
|
u32v(out, static_cast<std::uint32_t>(pts.size()));
|
|
for (const VelocityPoint& p : pts) { f64v(out, p.velocity); f64v(out, p.amp); }
|
|
}
|
|
}
|
|
|
|
static void putZone(std::vector<std::uint8_t>& out, const Zone& z, std::uint32_t pv) {
|
|
strv(out, z.sampleId);
|
|
u32v(out, static_cast<std::uint32_t>(z.lowNote));
|
|
u32v(out, static_cast<std::uint32_t>(z.highNote));
|
|
putRecordBody(out, z, pv);
|
|
}
|
|
|
|
// The envelope fields, in wire order. A builder at version N emits only the prefix fields
|
|
// version N carried, so each lift can be asserted against a blob shaped exactly as that
|
|
// version's writer produced.
|
|
struct Envelope {
|
|
std::uint32_t version = kComponentStateVersion;
|
|
std::uint8_t modeByte = 0; // v4+ 0 mono / 1 stereo
|
|
std::int64_t assignGeneration = 0; // v5+
|
|
std::uint8_t previewVelocity = kPreviewVelocityDefault; // v6+
|
|
std::uint8_t voiceCount = static_cast<std::uint8_t>(kDefaultVoiceCount); // v7+
|
|
std::uint8_t voiceMode = 0; // v7+ 0 poly / 1 mono
|
|
std::uint8_t monoTrigger = 0; // v7+ 0 retrigger / 1 legato
|
|
double masterGain = 1.0; // v8+
|
|
std::uint8_t channelModeExplicit = 0; // v9+
|
|
std::string instanceGuid; // v11+
|
|
std::string selectionId; // v3+
|
|
};
|
|
|
|
// A complete envelope at `env.version` whose tail is a RETIRED zone-list payload at version
|
|
// `pv`. `env.selectionId` is the envelope's own stored pick — which the adoption rule
|
|
// overrides when the payload carries a zone. The sample-refs table (v10+) is always empty:
|
|
// its own shape is covered by the round-trip test.
|
|
static std::vector<std::uint8_t> envelopeWithZones(const Envelope& env,
|
|
const std::vector<Zone>& zones,
|
|
std::uint32_t pv) {
|
|
std::vector<std::uint8_t> out;
|
|
const std::uint32_t v = env.version;
|
|
u32v(out, v);
|
|
if (v >= 4) u8v(out, env.modeByte);
|
|
if (v >= 5) i64v(out, env.assignGeneration);
|
|
if (v >= 6) u8v(out, env.previewVelocity);
|
|
if (v >= 7) { u8v(out, env.voiceCount); u8v(out, env.voiceMode); u8v(out, env.monoTrigger); }
|
|
if (v >= 8) f64v(out, env.masterGain);
|
|
if (v >= 9) u8v(out, env.channelModeExplicit);
|
|
if (v >= 10) u32v(out, 0); // sample-refs: empty table
|
|
if (v >= 11) strv(out, env.instanceGuid);
|
|
if (v >= 3) strv(out, env.selectionId); // v2 was zones-only, no selection
|
|
if (pv >= 2) {
|
|
u32v(out, kParamsFormatMarker);
|
|
u32v(out, pv);
|
|
}
|
|
u32v(out, static_cast<std::uint32_t>(zones.size()));
|
|
for (const Zone& z : zones) putZone(out, z, pv);
|
|
return out;
|
|
}
|
|
|
|
// The CURRENT envelope carrying a payload-v8 SINGLE RECORD — the shape immediately before the
|
|
// filter tail. The shipping writer only emits v9, so a v8 blob can come from nowhere but
|
|
// bytes laid out here, which is what makes the off/neutral filter lift provable rather than
|
|
// assumed.
|
|
static std::vector<std::uint8_t> envelopeWithV8Record(const std::string& selectionId,
|
|
const Zone& record) {
|
|
Envelope env;
|
|
env.selectionId = selectionId;
|
|
std::vector<std::uint8_t> out;
|
|
u32v(out, env.version);
|
|
u8v(out, env.modeByte);
|
|
i64v(out, env.assignGeneration);
|
|
u8v(out, env.previewVelocity);
|
|
u8v(out, env.voiceCount);
|
|
u8v(out, env.voiceMode);
|
|
u8v(out, env.monoTrigger);
|
|
f64v(out, env.masterGain);
|
|
u8v(out, env.channelModeExplicit);
|
|
u32v(out, 0); // sample-refs: empty table
|
|
strv(out, env.instanceGuid);
|
|
strv(out, env.selectionId);
|
|
u32v(out, kParamsFormatMarker);
|
|
u32v(out, 8);
|
|
putRecordBody(out, record, 7); // the v7 zone tail IS the v8 single record's body
|
|
return out;
|
|
}
|
|
|
|
// Shorthand for the common case: the CURRENT envelope version carrying a zone payload.
|
|
static std::vector<std::uint8_t> envelopeWithZones(const std::string& selectionId,
|
|
const std::vector<Zone>& zones,
|
|
std::uint32_t pv) {
|
|
Envelope env;
|
|
env.selectionId = selectionId;
|
|
return envelopeWithZones(env, zones, pv);
|
|
}
|
|
|
|
} // namespace legacy
|
|
|
|
// --- The current format -------------------------------------------------------
|
|
|
|
// Builds a SampleRefEntry with the intrinsics fields the refs-robustness tests below need to
|
|
// set individually (root/loop/channels), mirroring the codec's own field names.
|
|
static SampleRefEntry refEntry(const std::string& id, const std::string& rel, int root,
|
|
bool hasLoop = false, std::int64_t loopStart = 0,
|
|
std::int64_t loopEnd = 0, int channels = 0,
|
|
const std::string& name = "") {
|
|
SampleRefEntry e;
|
|
e.sampleId = id;
|
|
e.ref.relativePath = rel;
|
|
e.ref.rootNote = root;
|
|
e.ref.loop.hasLoop = hasLoop;
|
|
e.ref.loop.start = loopStart;
|
|
e.ref.loop.end = loopEnd;
|
|
e.ref.channelCount = channels;
|
|
e.displayName = name;
|
|
return e;
|
|
}
|
|
|
|
// A full round-trip through the CURRENT envelope (v11) + params payload (v8): every field
|
|
// survives. This is the "one parameter set round-trips save/reload intact" contract.
|
|
static void testComponentStateRoundTrip() {
|
|
ComponentState in;
|
|
in.selectionId = "smp-1";
|
|
in.channelMode = ChannelMode::Stereo;
|
|
in.channelModeExplicit = true;
|
|
in.lastConsumedAssignGeneration = 42;
|
|
in.previewVelocity = 99;
|
|
in.voiceCount = 7;
|
|
in.voiceMode = VoiceMode::Mono;
|
|
in.monoTrigger = MonoTrigger::Legato;
|
|
in.masterGainLinear = 0.5;
|
|
in.instanceGuid = "0123456789abcdef0123456789abcdef";
|
|
SampleRefEntry e;
|
|
e.sampleId = "smp-1";
|
|
e.ref.relativePath = "bank/smp-1.wav";
|
|
e.ref.rootNote = 64;
|
|
e.ref.loop.hasLoop = true;
|
|
e.ref.loop.start = 100;
|
|
e.ref.loop.end = 2000;
|
|
e.ref.channelCount = 2;
|
|
e.displayName = "My Capture";
|
|
in.sampleRefs.push_back(e);
|
|
in.params.rootOverride = 61;
|
|
SampleLoop lp;
|
|
lp.hasLoop = true;
|
|
lp.start = 7;
|
|
lp.end = 900;
|
|
in.params.loopOverride = lp;
|
|
in.params.startPoint = 5;
|
|
in.params.keyTrack = 1.5;
|
|
in.params.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(
|
|
{VelocityPoint{0.0, 0.2}, VelocityPoint{64.0, 0.6}, VelocityPoint{127.0, 1.0}});
|
|
in.params.play.playMode = PlayMode::Trigger;
|
|
in.params.play.adsr.attackSeconds = 0.01;
|
|
in.params.play.adsr.holdSeconds = 0.05;
|
|
in.params.play.adsr.decaySeconds = 0.02;
|
|
in.params.play.adsr.sustainLevel = 0.8;
|
|
in.params.play.adsr.releaseSeconds = 0.15;
|
|
in.params.play.trigger.lengthFraction = 0.75;
|
|
in.params.play.trigAhd = AhdSeconds{0.011, 0.022, 0.65, 2.5, 0.4};
|
|
in.params.play.adsr.attackCurve = 3.0;
|
|
in.params.play.adsr.decayCurve = 0.3;
|
|
in.params.play.adsr.releaseCurve = 6.0;
|
|
in.params.play.filter.env.attackCurve = 1.25;
|
|
in.params.play.filter.env.decayCurve = 0.75;
|
|
in.params.play.filter.env.releaseCurve = 8.0;
|
|
in.params.play.filter.trigEnv = AhdSeconds{0.033, 0.044, 0.15, 0.2, 9.0};
|
|
in.params.play.pitchEngine = PitchEngine::Preserve;
|
|
in.params.play.pitchEnv.enabled = true;
|
|
in.params.play.pitchEnv.shape = AhdSeconds{0.02, 0.03, 0.45, 1.5, 0.6};
|
|
in.params.play.pitchEnv.peakSemitones = 5.0;
|
|
|
|
const std::vector<std::uint8_t> bytes = serializeComponentState(in);
|
|
const ComponentState out = deserializeComponentState(bytes, 48000.0);
|
|
|
|
CHECK(out.selectionId == "smp-1");
|
|
CHECK(out.channelMode == ChannelMode::Stereo);
|
|
CHECK(out.channelModeExplicit);
|
|
CHECK(out.lastConsumedAssignGeneration == 42);
|
|
CHECK(out.previewVelocity == 99);
|
|
CHECK(out.voiceCount == 7);
|
|
CHECK(out.voiceMode == VoiceMode::Mono);
|
|
CHECK(out.monoTrigger == MonoTrigger::Legato);
|
|
CHECK(out.masterGainLinear == 0.5);
|
|
CHECK(out.instanceGuid == "0123456789abcdef0123456789abcdef");
|
|
CHECK(out.sampleRefs.size() == 1);
|
|
if (out.sampleRefs.size() == 1) {
|
|
CHECK(out.sampleRefs[0].sampleId == "smp-1");
|
|
CHECK(out.sampleRefs[0].ref.relativePath == "bank/smp-1.wav");
|
|
CHECK(out.sampleRefs[0].ref.rootNote == 64);
|
|
CHECK(out.sampleRefs[0].ref.loop.hasLoop);
|
|
CHECK(out.sampleRefs[0].ref.loop.start == 100);
|
|
CHECK(out.sampleRefs[0].ref.loop.end == 2000);
|
|
CHECK(out.sampleRefs[0].ref.channelCount == 2);
|
|
CHECK(out.sampleRefs[0].displayName == "My Capture");
|
|
}
|
|
const InstrumentParams& p = out.params;
|
|
CHECK(p.rootOverride && *p.rootOverride == 61);
|
|
CHECK(p.loopOverride && p.loopOverride->hasLoop);
|
|
CHECK(p.loopOverride && p.loopOverride->start == 7 && p.loopOverride->end == 900);
|
|
CHECK(p.startPoint && *p.startPoint == 5);
|
|
CHECK(p.keyTrack == 1.5);
|
|
CHECK(p.velocityCurve.points().size() == 3);
|
|
CHECK(p.play.playMode == PlayMode::Trigger);
|
|
CHECK(p.play.adsr.attackSeconds == 0.01);
|
|
CHECK(p.play.adsr.holdSeconds == 0.05);
|
|
CHECK(p.play.adsr.decaySeconds == 0.02);
|
|
CHECK(p.play.adsr.sustainLevel == 0.8);
|
|
CHECK(p.play.adsr.releaseSeconds == 0.15);
|
|
CHECK(p.play.trigger.lengthFraction == 0.75);
|
|
// Every curve exponent, hold fraction and Trigger AHD field survives the round trip
|
|
// EXACTLY — the tail is doubles all the way down, so nothing quantizes.
|
|
CHECK(p.play.adsr.attackCurve == 3.0);
|
|
CHECK(p.play.adsr.decayCurve == 0.3);
|
|
CHECK(p.play.adsr.releaseCurve == 6.0);
|
|
CHECK(p.play.trigAhd.attackSeconds == 0.011);
|
|
CHECK(p.play.trigAhd.decaySeconds == 0.022);
|
|
CHECK(p.play.trigAhd.holdFraction == 0.65);
|
|
CHECK(p.play.trigAhd.attackCurve == 2.5);
|
|
CHECK(p.play.trigAhd.decayCurve == 0.4);
|
|
CHECK(p.play.filter.env.attackCurve == 1.25);
|
|
CHECK(p.play.filter.env.decayCurve == 0.75);
|
|
CHECK(p.play.filter.env.releaseCurve == 8.0);
|
|
CHECK(p.play.filter.trigEnv.attackSeconds == 0.033);
|
|
CHECK(p.play.filter.trigEnv.decaySeconds == 0.044);
|
|
CHECK(p.play.filter.trigEnv.holdFraction == 0.15);
|
|
CHECK(p.play.filter.trigEnv.attackCurve == 0.2);
|
|
CHECK(p.play.filter.trigEnv.decayCurve == 9.0);
|
|
CHECK(p.play.pitchEngine == PitchEngine::Preserve);
|
|
CHECK(p.play.pitchEnv.enabled);
|
|
CHECK(p.play.pitchEnv.shape.attackSeconds == 0.02);
|
|
CHECK(p.play.pitchEnv.shape.decaySeconds == 0.03);
|
|
CHECK(p.play.pitchEnv.shape.holdFraction == 0.45);
|
|
CHECK(p.play.pitchEnv.shape.attackCurve == 1.5);
|
|
CHECK(p.play.pitchEnv.shape.decayCurve == 0.6);
|
|
CHECK(p.play.pitchEnv.peakSemitones == 5.0);
|
|
}
|
|
|
|
// GOLDEN FULL-BLOB FIXTURE (reviewer follow-up). testEnvelopePrefixBytesFrozen below only
|
|
// pins the first 5 bytes of a near-EMPTY blob; it cannot catch a drift anywhere past the mode
|
|
// byte (a field re-ordered or dropped inside the voice/gain/refs/guid/params tail would still
|
|
// pass it). This builds a canonical v11 ComponentState/v8-params blob that exercises every
|
|
// field family at once (a two-entry sample-refs table — one with a loop, one without — every
|
|
// optional param field present, a non-flat velocity curve, Trigger mode with a pitch envelope)
|
|
// and asserts the encoded bytes equal an EXACT expected vector, captured from the current
|
|
// writer's output and checked field-for-field against the v8/v11 layout documented in
|
|
// component_state_io.h.
|
|
static void testGoldenFullBlobFixture() {
|
|
ComponentState in;
|
|
in.selectionId = "kick";
|
|
in.channelMode = ChannelMode::Stereo;
|
|
in.channelModeExplicit = true;
|
|
in.lastConsumedAssignGeneration = 12345;
|
|
in.previewVelocity = 100;
|
|
in.voiceCount = 24;
|
|
in.voiceMode = VoiceMode::Mono;
|
|
in.monoTrigger = MonoTrigger::Legato;
|
|
in.masterGainLinear = 2.0;
|
|
in.instanceGuid = "guid-1234-5678-abcd";
|
|
|
|
SampleRefEntry kickRef;
|
|
kickRef.sampleId = "kick";
|
|
kickRef.ref.relativePath = "bank/kick.wav";
|
|
kickRef.ref.rootNote = 36;
|
|
kickRef.ref.loop.hasLoop = true;
|
|
kickRef.ref.loop.start = 1000;
|
|
kickRef.ref.loop.end = 5000;
|
|
kickRef.ref.channelCount = 2;
|
|
kickRef.displayName = "Kick Drum";
|
|
in.sampleRefs.push_back(kickRef);
|
|
|
|
SampleRefEntry snareRef;
|
|
snareRef.sampleId = "snare";
|
|
snareRef.ref.relativePath = "bank/snare.wav";
|
|
snareRef.ref.rootNote = 38;
|
|
snareRef.ref.loop.hasLoop = false;
|
|
snareRef.ref.loop.start = 0;
|
|
snareRef.ref.loop.end = 0;
|
|
snareRef.ref.channelCount = 1;
|
|
snareRef.displayName = "Snare";
|
|
in.sampleRefs.push_back(snareRef);
|
|
|
|
in.params.rootOverride = 36;
|
|
SampleLoop loopA;
|
|
loopA.hasLoop = true;
|
|
loopA.start = 1000;
|
|
loopA.end = 5000;
|
|
in.params.loopOverride = loopA;
|
|
in.params.startPoint = 250;
|
|
in.params.keyTrack = 0.5;
|
|
in.params.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(
|
|
{VelocityPoint{0.0, 0.2}, VelocityPoint{64.0, 0.6}, VelocityPoint{127.0, 1.0}});
|
|
in.params.play.playMode = PlayMode::Trigger;
|
|
in.params.play.adsr.attackSeconds = 0.01;
|
|
in.params.play.adsr.holdSeconds = 0.05;
|
|
in.params.play.adsr.decaySeconds = 0.02;
|
|
in.params.play.adsr.sustainLevel = 0.8;
|
|
in.params.play.adsr.releaseSeconds = 0.15;
|
|
in.params.play.trigger.lengthFraction = 0.75;
|
|
in.params.play.pitchEngine = PitchEngine::Preserve;
|
|
in.params.play.pitchEnv.enabled = true;
|
|
in.params.play.pitchEnv.shape.attackSeconds = 0.02;
|
|
in.params.play.pitchEnv.shape.decaySeconds = 0.03;
|
|
in.params.play.pitchEnv.peakSemitones = 5.0;
|
|
|
|
const std::vector<std::uint8_t> bytes = serializeComponentState(in);
|
|
// clang-format off
|
|
static const std::uint8_t kGolden[] = {
|
|
0x0b,0x00,0x00,0x00,0x01,0x39,0x30,0x00,0x00,0x00,0x00,0x00,0x00,0x64,0x18,0x01,
|
|
0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x40,0x01,0x02,0x00,0x00,0x00,0x04,0x00,
|
|
0x00,0x00,0x6b,0x69,0x63,0x6b,0x0d,0x00,0x00,0x00,0x62,0x61,0x6e,0x6b,0x2f,0x6b,
|
|
0x69,0x63,0x6b,0x2e,0x77,0x61,0x76,0x24,0x00,0x00,0x00,0x01,0xe8,0x03,0x00,0x00,
|
|
0x00,0x00,0x00,0x00,0x88,0x13,0x00,0x00,0x00,0x00,0x00,0x00,0x02,0x00,0x00,0x00,
|
|
0x09,0x00,0x00,0x00,0x4b,0x69,0x63,0x6b,0x20,0x44,0x72,0x75,0x6d,0x05,0x00,0x00,
|
|
0x00,0x73,0x6e,0x61,0x72,0x65,0x0e,0x00,0x00,0x00,0x62,0x61,0x6e,0x6b,0x2f,0x73,
|
|
0x6e,0x61,0x72,0x65,0x2e,0x77,0x61,0x76,0x26,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
|
|
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,0x0a,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,
|
|
0xe8,0x3f,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
|
|
0x00,0x00,0x01,0x01,0x7b,0x14,0xae,0x47,0xe1,0x7a,0x94,0x3f,0xb8,0x1e,0x85,0xeb,
|
|
0x51,0xb8,0x9e,0x3f,0x00,0x00,0x00,0x00,0x00,0x00,0x14,0x40,0x7b,0x14,0xae,0x47,
|
|
0xe1,0x7a,0x84,0x3f,0x7b,0x14,0xae,0x47,0xe1,0x7a,0x94,0x3f,0x9a,0x99,0x99,0x99,
|
|
0x99,0x99,0xe9,0x3f,0x33,0x33,0x33,0x33,0x33,0x33,0xc3,0x3f,0x00,0x00,0x00,0x00,
|
|
0x00,0x00,0xe0,0x3f,0x03,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
|
|
0x9a,0x99,0x99,0x99,0x99,0x99,0xc9,0x3f,0x00,0x00,0x00,0x00,0x00,0x00,0x50,0x40,
|
|
0x33,0x33,0x33,0x33,0x33,0x33,0xe3,0x3f,0x00,0x00,0x00,0x00,0x00,0xc0,0x5f,0x40,
|
|
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f,
|
|
// --- payload v9 filter tail, at its OFF/NEUTRAL default (this fixture sets no
|
|
// filter field), in the header's documented order ---
|
|
0x00, // enabled = false
|
|
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // cutoffNorm 1.0
|
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // resonanceNorm 0.0
|
|
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // morphNorm 1.0
|
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // driveNorm 0.0
|
|
0x00, // morphLaw = HighBandLow
|
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // modAmount 0.0
|
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // velAmount 0.0
|
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // keyTrack 0.0
|
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // env attack 0.0
|
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // env hold 0.0
|
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // env decay 0.0
|
|
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // env sustain 1.0
|
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // env release 0.0
|
|
0x02,0x00,0x00,0x00, // filter curve: 2 points (linear)
|
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // velocity 0.0
|
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // amp 0.0
|
|
0x00,0x00,0x00,0x00,0x00,0xc0,0x5f,0x40, // velocity 127.0
|
|
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // amp 1.0
|
|
// --- payload v10 staged-curve tail, at its NEUTRAL default (this fixture sets no
|
|
// curve or AHD field), in the header's documented order ---
|
|
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // amp attack curve 1.0
|
|
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // amp decay curve 1.0
|
|
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // amp release curve 1.0
|
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // trig AHD attack 0.0 s
|
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // trig AHD decay 0.0 s
|
|
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // trig AHD hold 1.0
|
|
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // trig AHD att curve 1.0
|
|
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // trig AHD dec curve 1.0
|
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // pitch hold 0.0
|
|
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // pitch attack curve 1.0
|
|
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // pitch decay curve 1.0
|
|
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // filt attack curve 1.0
|
|
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // filt decay curve 1.0
|
|
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // filt release curve 1.0
|
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // filt AHD attack 0.0 s
|
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // filt AHD decay 0.0 s
|
|
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // filt AHD hold 1.0
|
|
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // filt AHD att curve 1.0
|
|
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // filt AHD dec curve 1.0
|
|
};
|
|
// clang-format on
|
|
CHECK(bytes.size() == sizeof(kGolden));
|
|
if (bytes.size() == sizeof(kGolden)) {
|
|
bool same = true;
|
|
for (std::size_t i = 0; i < bytes.size(); ++i) {
|
|
if (bytes[i] != kGolden[i]) {
|
|
std::printf(" golden byte %zu: got 0x%02x, want 0x%02x\n", i,
|
|
bytes[i], kGolden[i]);
|
|
same = false;
|
|
break;
|
|
}
|
|
}
|
|
CHECK(same);
|
|
}
|
|
}
|
|
|
|
// A DEFAULT parameter set must round-trip to defaults — the "no pick, nothing configured"
|
|
// blob restores as the silent empty state, not as a set of accidental values.
|
|
static void testDefaultStateRoundTripsToDefaults() {
|
|
const ComponentState out =
|
|
deserializeComponentState(serializeComponentState(ComponentState{}), 48000.0);
|
|
CHECK(out.selectionId.empty());
|
|
CHECK(!out.params.rootOverride);
|
|
CHECK(!out.params.loopOverride);
|
|
CHECK(!out.params.startPoint);
|
|
CHECK(out.params.keyTrack == 1.0);
|
|
CHECK(out.params.play.playMode == PlayMode::Gate);
|
|
CHECK(out.params.play.pitchEngine == kDefaultPitchEngine);
|
|
CHECK(!out.params.play.pitchEnv.enabled);
|
|
CHECK(out.params.play.adsr.attackSeconds == AdsrSeconds{}.attackSeconds);
|
|
CHECK(out.params.play.adsr.releaseSeconds == AdsrSeconds{}.releaseSeconds);
|
|
}
|
|
|
|
// The FROZEN envelope prefix: version tag v11 LE, then the mode byte — a drift in either is
|
|
// a byte-format break the round-trip alone can't prove (both sides could drift together).
|
|
// Also pins the payload version + marker as SEMANTIC constants, so a bump has to be
|
|
// deliberate rather than incidental.
|
|
static void testEnvelopePrefixBytesFrozen() {
|
|
ComponentState in; // defaults: mono, implicit, no refs, no selection, default params
|
|
const std::vector<std::uint8_t> bytes = serializeComponentState(in);
|
|
CHECK(bytes.size() > 5);
|
|
if (bytes.size() > 5) {
|
|
CHECK(bytes[0] == 11 && bytes[1] == 0 && bytes[2] == 0 && bytes[3] == 0);
|
|
CHECK(bytes[4] == 0); // ChannelMode::Mono
|
|
}
|
|
CHECK(kComponentStateVersion == 11);
|
|
CHECK(kParamsPayloadVersion == 10);
|
|
CHECK(kParamsSingleRecordVersion == 8);
|
|
CHECK(kParamsFormatMarker == 0xFFFFFF00u);
|
|
// The filter and staged-curve tails rode PAYLOAD bumps, not envelope ones — the two axes
|
|
// stay independent, so a future envelope field cannot collide with either on one number.
|
|
CHECK(kParamsFilterVersion > kParamsSingleRecordVersion);
|
|
CHECK(kParamsCurveVersion > kParamsFilterVersion);
|
|
}
|
|
|
|
// --- The filter tail (payload v9) --------------------------------------------
|
|
|
|
// A v8 blob is a strict prefix of v9, so it must lift to the OFF/NEUTRAL filter — the reason
|
|
// a project saved before the filter existed reopens sounding identical. Everything the v8
|
|
// record did carry must survive alongside it.
|
|
static void testV8RecordLiftsToTheOffNeutralFilter() {
|
|
legacy::Zone rec;
|
|
rec.rootOverride = 48;
|
|
rec.startPoint = 512;
|
|
rec.holdSeconds = 0.25;
|
|
rec.attackSeconds = 0.011;
|
|
rec.releaseSeconds = 0.222;
|
|
rec.keyTrack = 0.75;
|
|
rec.preserve = true;
|
|
const ComponentState out =
|
|
deserializeComponentState(envelopeWithV8Record("kick", rec), 48000.0);
|
|
|
|
CHECK(out.selectionId == "kick");
|
|
CHECK(out.params.rootOverride && *out.params.rootOverride == 48);
|
|
CHECK(out.params.startPoint && *out.params.startPoint == 512);
|
|
CHECK(out.params.keyTrack == 0.75);
|
|
CHECK(out.params.play.adsr.holdSeconds == 0.25);
|
|
CHECK(out.params.play.adsr.releaseSeconds == 0.222);
|
|
CHECK(out.params.play.pitchEngine == PitchEngine::Preserve);
|
|
|
|
// The lift, field by field: nothing engaged, nothing modulating, a flat unity envelope.
|
|
const FilterSeconds& f = out.params.play.filter;
|
|
const FilterSeconds def;
|
|
CHECK(!f.enabled);
|
|
CHECK(f.settings.cutoffNorm == def.settings.cutoffNorm);
|
|
CHECK(f.settings.resonanceNorm == def.settings.resonanceNorm);
|
|
CHECK(f.settings.morphNorm == def.settings.morphNorm);
|
|
CHECK(f.settings.driveNorm == def.settings.driveNorm);
|
|
CHECK(f.settings.morphLaw == reasampler::instrument::engine::filter::MorphLaw::HighBandLow);
|
|
CHECK(f.modAmount == 0.0);
|
|
CHECK(f.velAmount == 0.0);
|
|
CHECK(f.keyTrack == 0.0);
|
|
CHECK(f.env.sustainLevel == 1.0);
|
|
CHECK(f.env.attackSeconds == 0.0 && f.env.decaySeconds == 0.0 &&
|
|
f.env.releaseSeconds == 0.0 && f.env.holdSeconds == 0.0);
|
|
|
|
// Re-saving lifts it into the current format, and that blob is what the writer would have
|
|
// produced for the same state — so the lift is stable, not one-way lossy.
|
|
ComponentState resaved = out;
|
|
CHECK(serializeComponentState(resaved) ==
|
|
serializeComponentState(deserializeComponentState(
|
|
serializeComponentState(resaved), 48000.0)));
|
|
}
|
|
|
|
// The v9 tail round-trips losslessly, including the morph law's non-default leg and a filter
|
|
// velocity curve distinct from the amp's.
|
|
static void testFilterTailRoundTripsLosslessly() {
|
|
ComponentState in;
|
|
in.selectionId = "pad";
|
|
FilterSeconds& f = in.params.play.filter;
|
|
f.enabled = true;
|
|
f.settings.cutoffNorm = 0.375f;
|
|
f.settings.resonanceNorm = 0.8125f;
|
|
f.settings.morphNorm = 0.25f;
|
|
f.settings.driveNorm = 0.5f;
|
|
f.settings.morphLaw = reasampler::instrument::engine::filter::MorphLaw::HighNotchLow;
|
|
f.modAmount = -0.625;
|
|
f.velAmount = 0.5;
|
|
f.keyTrack = 1.5;
|
|
f.env.attackSeconds = 0.031;
|
|
f.env.holdSeconds = 0.062;
|
|
f.env.decaySeconds = 0.125;
|
|
f.env.sustainLevel = 0.25;
|
|
f.env.releaseSeconds = 0.5;
|
|
f.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(
|
|
{VelocityPoint{0.0, 0.1}, VelocityPoint{100.0, 0.4}, VelocityPoint{127.0, 0.9}});
|
|
// The amp's own curve stays different, so a codec that read one into the other fails here.
|
|
in.params.velocityCurve = reasampler::instrument::engine::VelocityCurve::flat();
|
|
|
|
const ComponentState out =
|
|
deserializeComponentState(serializeComponentState(in), 48000.0);
|
|
const FilterSeconds& g = out.params.play.filter;
|
|
CHECK(g.enabled);
|
|
CHECK(g.settings.cutoffNorm == f.settings.cutoffNorm);
|
|
CHECK(g.settings.resonanceNorm == f.settings.resonanceNorm);
|
|
CHECK(g.settings.morphNorm == f.settings.morphNorm);
|
|
CHECK(g.settings.driveNorm == f.settings.driveNorm);
|
|
CHECK(g.settings.morphLaw == reasampler::instrument::engine::filter::MorphLaw::HighNotchLow);
|
|
CHECK(g.modAmount == f.modAmount);
|
|
CHECK(g.velAmount == f.velAmount);
|
|
CHECK(g.keyTrack == f.keyTrack);
|
|
CHECK(g.env.attackSeconds == f.env.attackSeconds);
|
|
CHECK(g.env.holdSeconds == f.env.holdSeconds);
|
|
CHECK(g.env.decaySeconds == f.env.decaySeconds);
|
|
CHECK(g.env.sustainLevel == f.env.sustainLevel);
|
|
CHECK(g.env.releaseSeconds == f.env.releaseSeconds);
|
|
CHECK(g.velocityCurve.size() == 3);
|
|
CHECK(g.velocityCurve.equals(f.velocityCurve));
|
|
CHECK(out.params.velocityCurve.equals(
|
|
reasampler::instrument::engine::VelocityCurve::flat()));
|
|
}
|
|
|
|
// A non-finite modAmount/velAmount/keyTrack (a corrupt blob, or any writer that skipped the
|
|
// same guard the v8 master gain already applies) must lift to the neutral default rather than
|
|
// reach Voice::tickFilterCutoff, where both clamp compares are false against NaN and the
|
|
// static_cast<int> is UB. Mirrors testCorruptFieldsFallBackToDefaults' per-field precedent.
|
|
static void testNonFiniteFilterFieldsLiftToTheNeutralDefault() {
|
|
ComponentState in;
|
|
in.selectionId = "pad";
|
|
FilterSeconds& f = in.params.play.filter;
|
|
f.enabled = true;
|
|
f.modAmount = std::numeric_limits<double>::quiet_NaN();
|
|
f.velAmount = std::numeric_limits<double>::infinity();
|
|
f.keyTrack = -std::numeric_limits<double>::infinity();
|
|
|
|
const ComponentState out =
|
|
deserializeComponentState(serializeComponentState(in), 48000.0);
|
|
const FilterSeconds& g = out.params.play.filter;
|
|
const FilterSeconds def;
|
|
CHECK(g.modAmount == def.modAmount);
|
|
CHECK(g.velAmount == def.velAmount);
|
|
CHECK(g.keyTrack == def.keyTrack);
|
|
// The fallback is per-field, not per-record: the untouched fields still round-trip.
|
|
CHECK(g.enabled);
|
|
}
|
|
|
|
// 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.
|
|
static void testWriterEmitsCurrentPayloadVersion() {
|
|
ComponentState in;
|
|
in.selectionId = "id";
|
|
const std::vector<std::uint8_t> bytes = serializeComponentState(in);
|
|
// Scan for the marker; the four bytes after it are the payload version.
|
|
bool found = false;
|
|
for (std::size_t i = 0; i + 8 <= bytes.size(); ++i) {
|
|
const std::uint32_t m = static_cast<std::uint32_t>(bytes[i]) |
|
|
(static_cast<std::uint32_t>(bytes[i + 1]) << 8) |
|
|
(static_cast<std::uint32_t>(bytes[i + 2]) << 16) |
|
|
(static_cast<std::uint32_t>(bytes[i + 3]) << 24);
|
|
if (m != kParamsFormatMarker) continue;
|
|
const std::uint32_t v = static_cast<std::uint32_t>(bytes[i + 4]) |
|
|
(static_cast<std::uint32_t>(bytes[i + 5]) << 8) |
|
|
(static_cast<std::uint32_t>(bytes[i + 6]) << 16) |
|
|
(static_cast<std::uint32_t>(bytes[i + 7]) << 24);
|
|
CHECK(v == kParamsPayloadVersion);
|
|
found = true;
|
|
break;
|
|
}
|
|
CHECK(found);
|
|
}
|
|
|
|
// --- The retired-zone-payload migration ladder --------------------------------
|
|
|
|
// SINGLE-ZONE LIFT IS LOSSLESS: a single-capture instance saved under the zone model
|
|
// restores with the same capture, the same root, and the same parameters.
|
|
static void testSingleZoneMigrationIsLossless() {
|
|
legacy::Zone z;
|
|
z.sampleId = "kick";
|
|
z.lowNote = 0;
|
|
z.highNote = 127;
|
|
z.rootOverride = 36;
|
|
z.hasLoopOverride = true;
|
|
z.loopStart = 1000;
|
|
z.loopEnd = 5000;
|
|
z.startPoint = 250;
|
|
z.trigger = true;
|
|
z.holdSeconds = 0.05;
|
|
z.lengthFraction = 0.75;
|
|
z.fadeIn = 100;
|
|
z.fadeOut = 200;
|
|
z.preserve = true;
|
|
z.pitchEnvEnabled = true;
|
|
z.pitchAttack = 0.02;
|
|
z.pitchDecay = 0.03;
|
|
z.peakSemis = 5.0;
|
|
z.attackSeconds = 0.01;
|
|
z.decaySeconds = 0.02;
|
|
z.sustainLevel = 0.8;
|
|
z.releaseSeconds = 0.15;
|
|
z.keyTrack = 0.5;
|
|
z.curve = {VelocityPoint{0.0, 0.2}, VelocityPoint{64.0, 0.6}, VelocityPoint{127.0, 1.0}};
|
|
|
|
const ComponentState out =
|
|
deserializeComponentState(legacy::envelopeWithZones("kick", {z}, 7), 48000.0);
|
|
|
|
CHECK(out.selectionId == "kick"); // same capture
|
|
const InstrumentParams& p = out.params;
|
|
CHECK(p.rootOverride && *p.rootOverride == 36); // same root
|
|
CHECK(p.loopOverride && p.loopOverride->start == 1000 && p.loopOverride->end == 5000);
|
|
CHECK(p.startPoint && *p.startPoint == 250);
|
|
CHECK(p.keyTrack == 0.5);
|
|
CHECK(p.velocityCurve.points().size() == 3);
|
|
CHECK(p.play.playMode == PlayMode::Trigger);
|
|
CHECK(p.play.adsr.attackSeconds == 0.01);
|
|
CHECK(p.play.adsr.holdSeconds == 0.05);
|
|
CHECK(p.play.adsr.decaySeconds == 0.02);
|
|
CHECK(p.play.adsr.sustainLevel == 0.8);
|
|
CHECK(p.play.adsr.releaseSeconds == 0.15);
|
|
CHECK(p.play.trigger.lengthFraction == 0.75);
|
|
// The retired fade pair lifts onto the AHD that replaced it: attack <- fade-in, decay <-
|
|
// fade-out (source frames over the project rate), hold <- the whole remainder.
|
|
CHECK(p.play.trigAhd.attackSeconds == 100.0 / 48000.0);
|
|
CHECK(p.play.trigAhd.decaySeconds == 200.0 / 48000.0);
|
|
CHECK(p.play.trigAhd.holdFraction == 1.0);
|
|
CHECK(p.play.pitchEngine == PitchEngine::Preserve);
|
|
CHECK(p.play.pitchEnv.enabled);
|
|
CHECK(p.play.pitchEnv.shape.attackSeconds == 0.02);
|
|
CHECK(p.play.pitchEnv.shape.decaySeconds == 0.03);
|
|
CHECK(p.play.pitchEnv.peakSemitones == 5.0);
|
|
// Everything the change added lifts to its own neutral, so the loaded instance plays as
|
|
// the saved one did: every exponent linear, and the pitch envelope with no hold stage.
|
|
CHECK(p.play.adsr.attackCurve == util::kCurveNeutral);
|
|
CHECK(p.play.adsr.decayCurve == util::kCurveNeutral);
|
|
CHECK(p.play.adsr.releaseCurve == util::kCurveNeutral);
|
|
CHECK(p.play.pitchEnv.shape.holdFraction == 0.0);
|
|
CHECK(p.play.filter.env.attackCurve == util::kCurveNeutral);
|
|
// The ONE exception, and the reason it is one: the fades had a prior SHAPE to reproduce,
|
|
// so they lift to the fitted exponents rather than to the neutral (see the contour test).
|
|
CHECK(p.play.trigAhd.attackCurve == kTriggerFadeLiftAttackCurve);
|
|
CHECK(p.play.trigAhd.decayCurve == kTriggerFadeLiftDecayCurve);
|
|
}
|
|
|
|
// The migrated Trigger amp shape against the retired EQUAL-POWER fade pair it replaced. The
|
|
// AHD's law is phi^p and cannot reproduce sin/cos at any exponent, so the claim is a bound —
|
|
// and the bound the fitted exponents reach is several times tighter than the linear neutral's,
|
|
// which is what makes the fit worth a constant.
|
|
static void testMigratedFadeContourTracksTheRetiredEqualPowerShape() {
|
|
const double rate = 48000.0;
|
|
const std::int64_t fadeIn = 200;
|
|
const std::int64_t fadeOut = 300;
|
|
const std::int64_t span = 1000;
|
|
|
|
legacy::Zone z;
|
|
z.sampleId = "kick";
|
|
z.trigger = true;
|
|
z.lengthFraction = 1.0;
|
|
z.fadeIn = fadeIn;
|
|
z.fadeOut = fadeOut;
|
|
const ComponentState st =
|
|
deserializeComponentState(legacy::envelopeWithZones("kick", {z}, 7), rate);
|
|
const AhdSeconds& lifted = st.params.play.trigAhd;
|
|
|
|
// Resolve the lifted seconds back to frames at the SAME rate the lift used, which is the
|
|
// matched-rate case (the mismatched one is asserted in sample_map_tests).
|
|
const auto toFrames = [rate](double sec) {
|
|
return static_cast<std::int64_t>(sec * rate + 0.5);
|
|
};
|
|
AhdParams migrated;
|
|
migrated.attackFrames = toFrames(lifted.attackSeconds);
|
|
migrated.decayFrames = toFrames(lifted.decaySeconds);
|
|
migrated.holdFraction = lifted.holdFraction;
|
|
migrated.attackCurve = lifted.attackCurve;
|
|
migrated.decayCurve = lifted.decayCurve;
|
|
// Stage LENGTHS are exact: the fades land on the same frames they always did.
|
|
AhdEnvelope ahd;
|
|
ahd.configure(span, migrated);
|
|
CHECK(ahd.stages().attack == fadeIn);
|
|
CHECK(ahd.stages().decay == fadeOut);
|
|
CHECK(ahd.stages().total == span);
|
|
|
|
// The pre-change evaluator, written out so the comparison is against a stated reference
|
|
// rather than against whatever the code now does.
|
|
const double pi = 3.14159265358979323846;
|
|
const auto retired = [&](double off) {
|
|
if (off < 0.0 || off >= static_cast<double>(span)) return 0.0;
|
|
if (off < static_cast<double>(fadeIn)) {
|
|
return std::sin(off / static_cast<double>(fadeIn) * (pi / 2.0));
|
|
}
|
|
const double foStart = static_cast<double>(span - fadeOut);
|
|
if (off >= foStart) {
|
|
return std::cos((off - foStart) / static_cast<double>(fadeOut) * (pi / 2.0));
|
|
}
|
|
return 1.0;
|
|
};
|
|
const auto worstAgainstRetired = [&](AhdEnvelope& env) {
|
|
double worst = 0.0;
|
|
for (std::int64_t i = 0; i < span; ++i) {
|
|
const double d = env.amplitudeAt(static_cast<double>(i)) -
|
|
retired(static_cast<double>(i));
|
|
worst = worst > std::fabs(d) ? worst : std::fabs(d);
|
|
}
|
|
return worst;
|
|
};
|
|
|
|
const double fitted = worstAgainstRetired(ahd);
|
|
CHECK(fitted <= 0.0876); // the measured minimax bound of phi^p against sin(pi*phi/2)
|
|
|
|
// The rejected alternative, evaluated rather than asserted about: the same lift at the
|
|
// linear neutral. If the fitted exponents were ever dropped this comparison inverts.
|
|
AhdParams neutralLift = migrated;
|
|
neutralLift.attackCurve = util::kCurveNeutral;
|
|
neutralLift.decayCurve = util::kCurveNeutral;
|
|
AhdEnvelope neutral;
|
|
neutral.configure(span, neutralLift);
|
|
const double neutralWorst = worstAgainstRetired(neutral);
|
|
CHECK(neutralWorst > 0.21);
|
|
CHECK(fitted < neutralWorst * 0.5);
|
|
|
|
// Both agree exactly where it matters structurally: the onset, the plateau, and the end.
|
|
CHECK(ahd.amplitudeAt(0.0) == retired(0.0));
|
|
CHECK(ahd.amplitudeAt(600.0) == retired(600.0));
|
|
CHECK(ahd.amplitudeAt(static_cast<double>(span)) == retired(static_cast<double>(span)));
|
|
}
|
|
|
|
// A prior ZERO fade-out lands Decay = 0: the abrupt end an old Trigger instance could express
|
|
// stays representable under the AHD, which is what makes the consolidation lossless rather
|
|
// than merely close.
|
|
static void testZeroFadeOutMigratesToZeroDecay() {
|
|
legacy::Zone z;
|
|
z.sampleId = "kick";
|
|
z.lowNote = 0;
|
|
z.highNote = 127;
|
|
z.trigger = true;
|
|
z.lengthFraction = 1.0;
|
|
z.fadeIn = 441;
|
|
z.fadeOut = 0;
|
|
const ComponentState out =
|
|
deserializeComponentState(legacy::envelopeWithZones("kick", {z}, 7), 44100.0);
|
|
const PlaySeconds& play = out.params.play;
|
|
CHECK(play.playMode == PlayMode::Trigger);
|
|
CHECK(play.trigAhd.attackSeconds == 441.0 / 44100.0);
|
|
CHECK(play.trigAhd.decaySeconds == 0.0);
|
|
CHECK(play.trigAhd.holdFraction == 1.0);
|
|
}
|
|
|
|
// A legacy OVERRIDE THAT DISABLES THE LOOP migrates as a PRESENT loopOverride with hasLoop
|
|
// false — distinct from no override at all (which leaves the sample's own intrinsic loop in
|
|
// force). The writer always emitted the override's inner hasLoop bit as true; this is the
|
|
// disabled shape it never exercised.
|
|
static void testSingleZoneMigrationLiftsLoopDisablingOverride() {
|
|
legacy::Zone z;
|
|
z.sampleId = "kick";
|
|
z.hasLoopOverride = true;
|
|
z.loopOverrideHasLoop = false;
|
|
z.loopStart = 1000;
|
|
z.loopEnd = 5000;
|
|
|
|
const ComponentState out =
|
|
deserializeComponentState(legacy::envelopeWithZones("kick", {z}, 7), 48000.0);
|
|
|
|
const InstrumentParams& p = out.params;
|
|
CHECK(p.loopOverride.has_value());
|
|
CHECK(p.loopOverride && !p.loopOverride->hasLoop);
|
|
}
|
|
|
|
// A lifted single-zone instance RE-SAVES in the current format and survives a second
|
|
// round-trip unchanged — the lift is a one-way door, not a per-open re-derivation.
|
|
static void testLiftedStateReSavesInCurrentFormat() {
|
|
legacy::Zone z;
|
|
z.sampleId = "kick";
|
|
z.rootOverride = 36;
|
|
z.keyTrack = 0.5;
|
|
z.releaseSeconds = 0.4;
|
|
const ComponentState lifted =
|
|
deserializeComponentState(legacy::envelopeWithZones("kick", {z}, 7), 48000.0);
|
|
const ComponentState again =
|
|
deserializeComponentState(serializeComponentState(lifted), 48000.0);
|
|
CHECK(again.selectionId == "kick");
|
|
CHECK(again.params.rootOverride && *again.params.rootOverride == 36);
|
|
CHECK(again.params.keyTrack == 0.5);
|
|
CHECK(again.params.play.adsr.releaseSeconds == 0.4);
|
|
}
|
|
|
|
// MULTI-ZONE LIFT ADOPTS ZONE ONE: its capture AND its parameters win; every later zone
|
|
// drops. No error, no empty state.
|
|
static void testMultiZoneMigrationAdoptsFirstZone() {
|
|
legacy::Zone first;
|
|
first.sampleId = "kick";
|
|
first.lowNote = 0;
|
|
first.highNote = 59;
|
|
first.rootOverride = 36;
|
|
first.keyTrack = 0.5;
|
|
first.releaseSeconds = 0.4;
|
|
|
|
legacy::Zone second;
|
|
second.sampleId = "snare";
|
|
second.lowNote = 60;
|
|
second.highNote = 127;
|
|
second.rootOverride = 38;
|
|
second.keyTrack = 2.0;
|
|
second.releaseSeconds = 0.9;
|
|
|
|
legacy::Zone third;
|
|
third.sampleId = "hat";
|
|
third.rootOverride = 42;
|
|
|
|
const ComponentState out = deserializeComponentState(
|
|
legacy::envelopeWithZones("", {first, second, third}, 7), 48000.0);
|
|
|
|
CHECK(out.selectionId == "kick"); // zone one's capture
|
|
CHECK(out.params.rootOverride && *out.params.rootOverride == 36);
|
|
CHECK(out.params.keyTrack == 0.5); // zone one's parameters
|
|
CHECK(out.params.play.adsr.releaseSeconds == 0.4);
|
|
// Zones two and three left no trace anywhere.
|
|
CHECK(out.selectionId != "snare" && out.selectionId != "hat");
|
|
CHECK(out.params.keyTrack != 2.0);
|
|
}
|
|
|
|
// The FIRST zone supersedes the envelope's own stored selection — that zone is what
|
|
// first-match resolve actually played, so adopting it is what keeps the sound identical.
|
|
static void testFirstZoneSupersedesStoredSelection() {
|
|
legacy::Zone z;
|
|
z.sampleId = "actually-playing";
|
|
const ComponentState out = deserializeComponentState(
|
|
legacy::envelopeWithZones("stale-selection", {z}, 7), 48000.0);
|
|
CHECK(out.selectionId == "actually-playing");
|
|
}
|
|
|
|
// An EMPTY zone list leaves the envelope's selection alone (a picked-but-never-edited
|
|
// instance) and yields default parameters.
|
|
static void testEmptyZoneListKeepsTheStoredSelection() {
|
|
const ComponentState out =
|
|
deserializeComponentState(legacy::envelopeWithZones("picked", {}, 7), 48000.0);
|
|
CHECK(out.selectionId == "picked");
|
|
CHECK(!out.params.rootOverride);
|
|
CHECK(out.params.keyTrack == 1.0);
|
|
}
|
|
|
|
// EVERY older payload version takes the migration path, and each lifts the fields its own
|
|
// shape carries while defaulting the ones it predates.
|
|
static void testEveryOlderPayloadVersionMigrates() {
|
|
for (std::uint32_t pv : {1u, 2u, 5u, 6u, 7u}) {
|
|
legacy::Zone z;
|
|
z.sampleId = "kick";
|
|
z.rootOverride = 36;
|
|
z.keyTrack = 0.5;
|
|
z.releaseSeconds = 0.4;
|
|
const ComponentState out =
|
|
deserializeComponentState(legacy::envelopeWithZones("", {z}, pv), 48000.0);
|
|
CHECK(out.selectionId == "kick"); // every version
|
|
CHECK(out.params.rootOverride && *out.params.rootOverride == 36); // v1 onward
|
|
// keyTrack arrived at v6; older payloads lift to 100% ET (bit-identical repitch).
|
|
CHECK(out.params.keyTrack == (pv >= 6 ? 0.5 : 1.0));
|
|
// The full A/D/S/R tail arrived at v5; older payloads keep the tier-0 defaults.
|
|
CHECK(out.params.play.adsr.releaseSeconds ==
|
|
(pv >= 5 ? 0.4 : AdsrSeconds{}.releaseSeconds));
|
|
}
|
|
// And the CURRENT version does NOT take the migration path: it reads its own record.
|
|
CHECK(kParamsPayloadVersion >= kParamsSingleRecordVersion);
|
|
}
|
|
|
|
// The LEGACY v3 payload's wall-clock frame counts convert to seconds at the READ boundary
|
|
// using the project rate threaded in — no baked constant.
|
|
static void testLegacyV3FramesConvertAtTheProjectRate() {
|
|
// v3's own tail shape differs from v5's, so lay it out directly here.
|
|
std::vector<std::uint8_t> out;
|
|
legacy::u32v(out, kComponentStateVersion);
|
|
legacy::u8v(out, 0);
|
|
legacy::i64v(out, 0);
|
|
legacy::u8v(out, kPreviewVelocityDefault);
|
|
legacy::u8v(out, static_cast<std::uint8_t>(kDefaultVoiceCount));
|
|
legacy::u8v(out, 0);
|
|
legacy::u8v(out, 0);
|
|
legacy::f64v(out, 1.0);
|
|
legacy::u8v(out, 0);
|
|
legacy::u32v(out, 0);
|
|
legacy::strv(out, "");
|
|
legacy::strv(out, "");
|
|
legacy::u32v(out, kParamsFormatMarker);
|
|
legacy::u32v(out, 3);
|
|
legacy::u32v(out, 1); // one zone
|
|
legacy::strv(out, "kick");
|
|
legacy::u32v(out, 0); // lowNote
|
|
legacy::u32v(out, 127); // highNote
|
|
legacy::u8v(out, 0); // no root override
|
|
legacy::u8v(out, 0); // no loop override
|
|
legacy::u8v(out, 0); // no start point
|
|
legacy::u8v(out, 0); // playMode: Gate
|
|
legacy::i64v(out, 2400); // holdFrames -> 0.05 s at 48 kHz
|
|
legacy::f64v(out, 1.0); // lengthFraction
|
|
legacy::i64v(out, 0); // fadeIn
|
|
legacy::i64v(out, 0); // fadeOut
|
|
legacy::u8v(out, 1); // pitchEngine: Preserve
|
|
legacy::u8v(out, 1); // pitchEnv enabled
|
|
legacy::i64v(out, 960); // pitchEnv attackFrames -> 0.02 s
|
|
legacy::i64v(out, 1440); // pitchEnv decayFrames -> 0.03 s
|
|
legacy::f64v(out, 5.0); // peakSemitones
|
|
|
|
const ComponentState st = deserializeComponentState(out, 48000.0);
|
|
CHECK(st.selectionId == "kick");
|
|
CHECK(st.params.play.adsr.holdSeconds == 0.05);
|
|
CHECK(st.params.play.pitchEnv.shape.attackSeconds == 0.02);
|
|
CHECK(st.params.play.pitchEnv.shape.decaySeconds == 0.03);
|
|
CHECK(st.params.play.pitchEnv.peakSemitones == 5.0);
|
|
// A/D/S/R are absent in v3 -> the tier-0 seconds defaults hold.
|
|
CHECK(st.params.play.adsr.attackSeconds == AdsrSeconds{}.attackSeconds);
|
|
CHECK(st.params.play.adsr.releaseSeconds == AdsrSeconds{}.releaseSeconds);
|
|
|
|
// The SAME bytes at a different project rate convert to different seconds — proof the
|
|
// rate is a read-time parameter, not a baked constant.
|
|
const ComponentState at96k = deserializeComponentState(out, 96000.0);
|
|
CHECK(at96k.params.play.adsr.holdSeconds == 0.025);
|
|
}
|
|
|
|
// --- The ENVELOPE ladder (v2..v11) -------------------------------------------
|
|
|
|
// EVERY envelope version restores the fields it carried and lifts the ones it predates to
|
|
// their documented defaults. One table over the whole ladder, so a new envelope field
|
|
// cannot be added without deciding what each older version lifts it to.
|
|
static void testEnvelopeLadderLiftsEachVersion() {
|
|
for (std::uint32_t v : {3u, 4u, 5u, 6u, 7u, 8u, 9u, 10u, 11u}) {
|
|
legacy::Envelope env;
|
|
env.version = v;
|
|
env.selectionId = "kick";
|
|
env.modeByte = 1; // stereo
|
|
env.assignGeneration = 4242;
|
|
env.previewVelocity = 99;
|
|
env.voiceCount = 7;
|
|
env.voiceMode = 1; // mono
|
|
env.monoTrigger = 1; // legato
|
|
env.masterGain = 0.5;
|
|
env.channelModeExplicit = 1;
|
|
env.instanceGuid = "guid-abc";
|
|
const ComponentState out =
|
|
deserializeComponentState(legacy::envelopeWithZones(env, {}, 7), 48000.0);
|
|
|
|
CHECK(out.selectionId == "kick"); // v3 onward all carry the selection
|
|
// v4 added the channel mode; older blobs lift to MONO.
|
|
CHECK(out.channelMode == (v >= 4 ? ChannelMode::Stereo : ChannelMode::Mono));
|
|
// v5 added the consumed-assignment marker; older blobs lift to 0, so a genuinely
|
|
// new first assign (generation >= 1) still applies to a pre-marker instance.
|
|
CHECK(out.lastConsumedAssignGeneration == (v >= 5 ? 4242 : 0));
|
|
// v6 added the preview velocity; older blobs lift to the mid default.
|
|
CHECK(out.previewVelocity == (v >= 6 ? 99 : kPreviewVelocityDefault));
|
|
// v7 added the voice system; older blobs lift to {16, Poly, Retrigger} — the
|
|
// pre-voice-system behavior, byte-identically.
|
|
CHECK(out.voiceCount == (v >= 7 ? 7 : kDefaultVoiceCount));
|
|
CHECK(out.voiceMode == (v >= 7 ? VoiceMode::Mono : VoiceMode::Poly));
|
|
CHECK(out.monoTrigger == (v >= 7 ? MonoTrigger::Legato : MonoTrigger::Retrigger));
|
|
// v8 added the master gain; older blobs lift to unity.
|
|
CHECK(out.masterGainLinear == (v >= 8 ? 0.5 : 1.0));
|
|
// v9 added the channel-mode EXPLICIT flag; older blobs lift to implicit, so the
|
|
// auto-default may follow the loaded capture.
|
|
CHECK(out.channelModeExplicit == (v >= 9 ? true : false));
|
|
// v10 added the refs table (always empty here), v11 the instance guid; a pre-v11
|
|
// blob lifts to an empty guid, which the processor mints on first publish.
|
|
CHECK(out.instanceGuid == (v >= 11 ? "guid-abc" : ""));
|
|
CHECK(out.sampleRefs.empty());
|
|
}
|
|
}
|
|
|
|
// A v2 blob is ZONES-ONLY — no stored selection at all — so the adopted first zone supplies
|
|
// BOTH the capture and the parameters.
|
|
static void testV2ZonesOnlyBlobAdoptsBothFromZoneOne() {
|
|
legacy::Zone z;
|
|
z.sampleId = "kick";
|
|
z.rootOverride = 36;
|
|
std::vector<std::uint8_t> out;
|
|
legacy::u32v(out, kPerformanceStateVersion); // == 2, the zones-only envelope
|
|
legacy::u32v(out, kParamsFormatMarker);
|
|
legacy::u32v(out, 7);
|
|
legacy::u32v(out, 1);
|
|
legacy::putZone(out, z, 7);
|
|
|
|
const ComponentState st = deserializeComponentState(out, 48000.0);
|
|
CHECK(st.selectionId == "kick");
|
|
CHECK(st.params.rootOverride && *st.params.rootOverride == 36);
|
|
CHECK(st.channelMode == ChannelMode::Mono); // a v2 blob predates the mode byte
|
|
}
|
|
|
|
// A CORRUPT field falls back to its own DEFAULT rather than clamping to an edge the user
|
|
// never chose (or, for the gain, silencing/blasting the instance).
|
|
static void testCorruptFieldsFallBackToDefaults() {
|
|
legacy::Envelope env;
|
|
env.selectionId = "kick";
|
|
env.previewVelocity = 0; // 0 is a note-off by convention — out of the 1..127 spec
|
|
env.voiceCount = 200; // past kMaxVoiceCount
|
|
env.masterGain = 1e9; // far past the +24 dB cap
|
|
const ComponentState out =
|
|
deserializeComponentState(legacy::envelopeWithZones(env, {}, 7), 48000.0);
|
|
CHECK(out.previewVelocity == kPreviewVelocityDefault);
|
|
CHECK(out.voiceCount == kDefaultVoiceCount);
|
|
CHECK(out.masterGainLinear == 1.0);
|
|
}
|
|
|
|
// CORRUPT-BLOB posture for the refs-table intrinsics: the refs table is the ONLY copy on the
|
|
// play path, so a bad field must degrade to its own default, never poison playback. An
|
|
// out-of-MIDI-range rootNote falls back to the middle-C default distill() uses; a negative
|
|
// channelCount falls back to 0 = unknown (the GA auto-default then skips it). The fallback is
|
|
// per-field — in-range neighbours pass through untouched.
|
|
static void testSampleRefsReaderRangeFallbacks() {
|
|
ComponentState s;
|
|
s.sampleRefs.push_back(refEntry("hi", "b/h.wav", /*root=*/999, false, 0, 0,
|
|
/*channels=*/-3));
|
|
s.sampleRefs.push_back(refEntry("lo", "b/l.wav", /*root=*/-5, false, 0, 0,
|
|
/*channels=*/1));
|
|
s.sampleRefs.push_back(refEntry("ok", "b/o.wav", /*root=*/36, false, 0, 0,
|
|
/*channels=*/2));
|
|
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
|
|
CHECK(back.sampleRefs.size() == 3);
|
|
CHECK(back.sampleRefs.size() == 3 && back.sampleRefs[0].ref.rootNote == 60);
|
|
CHECK(back.sampleRefs.size() == 3 && back.sampleRefs[0].ref.channelCount == 0);
|
|
CHECK(back.sampleRefs.size() == 3 && back.sampleRefs[1].ref.rootNote == 60);
|
|
CHECK(back.sampleRefs.size() == 3 && back.sampleRefs[1].ref.channelCount == 1);
|
|
CHECK(back.sampleRefs.size() == 3 && back.sampleRefs[2].ref.rootNote == 36);
|
|
CHECK(back.sampleRefs.size() == 3 && back.sampleRefs[2].ref.channelCount == 2);
|
|
}
|
|
|
|
// A blob cut mid-refs-entry keeps the entries that parsed cleanly and restores the rest of
|
|
// the state empty (the selection/params behind the cut are unreadable anyway) — the
|
|
// established truncation posture, never a throw across the host boundary.
|
|
static void testSampleRefsTruncatedMidEntry() {
|
|
ComponentState s;
|
|
s.selectionId = "kick";
|
|
s.sampleRefs.push_back(refEntry("kick", "b/k.wav", 36));
|
|
s.sampleRefs.push_back(refEntry("pad", "b/p.wav", 60));
|
|
std::vector<std::uint8_t> bytes = serializeComponentState(s);
|
|
// The tail after the refs table is instanceGuid(4, empty) + selectionId(4+4="kick") +
|
|
// the current params payload for DEFAULT params (marker4+version4 + overrides3 + the
|
|
// 91-byte play tail + keyTrack8 + curve(4+2*16, the flat 2-point default) + the 134-byte
|
|
// v9 filter tail + the 160-byte v10 staged-curve tail) = 452 bytes; entry two is 47 bytes
|
|
// (id 4+3, path 4+7, root4, loop 1+8+8, channels4, name 4+0). Cutting 472 keeps the first
|
|
// 27 of entry two's 47 — mid loop.start (offset 23..31).
|
|
CHECK(bytes.size() > 472);
|
|
bytes.resize(bytes.size() - 472);
|
|
const ComponentState back = deserializeComponentState(bytes, 44100.0);
|
|
CHECK(back.sampleRefs.size() == 1);
|
|
CHECK(back.sampleRefs.size() == 1 && back.sampleRefs[0].sampleId == "kick");
|
|
CHECK(back.selectionId.empty());
|
|
CHECK(!back.params.rootOverride);
|
|
}
|
|
|
|
// The WRITER never emits an out-of-range voice count or master gain, so a blob this codec
|
|
// produced always re-reads as itself.
|
|
static void testWriterClampsOutOfRangeFields() {
|
|
ComponentState in;
|
|
in.voiceCount = 999;
|
|
in.masterGainLinear = 1e9;
|
|
const ComponentState out =
|
|
deserializeComponentState(serializeComponentState(in), 48000.0);
|
|
CHECK(out.voiceCount >= kMinVoiceCount && out.voiceCount <= kMaxVoiceCount);
|
|
CHECK(out.masterGainLinear <=
|
|
reasampler::instrument::engine::masterGainMaxLinear() * (1.0 + 1e-9));
|
|
|
|
// Zero gain is TRUE silence and a legal stored value — it must not be "corrected".
|
|
ComponentState silent;
|
|
silent.masterGainLinear = 0.0;
|
|
CHECK(deserializeComponentState(serializeComponentState(silent), 48000.0)
|
|
.masterGainLinear == 0.0);
|
|
}
|
|
|
|
// An UNKNOWN envelope version yields the empty state rather than a misparse.
|
|
static void testUnknownEnvelopeVersionIsEmpty() {
|
|
legacy::Envelope env;
|
|
env.version = 99;
|
|
env.selectionId = "kick";
|
|
const ComponentState out =
|
|
deserializeComponentState(legacy::envelopeWithZones(env, {}, 7), 48000.0);
|
|
CHECK(out.selectionId.empty());
|
|
CHECK(!out.params.rootOverride);
|
|
}
|
|
|
|
// A v1 selection blob lifts to {id, default params} — the oldest live lift.
|
|
static void testV1SelectionLift() {
|
|
const std::vector<std::uint8_t> v1 = serializeSelection("old-pick");
|
|
const ComponentState out = deserializeComponentState(v1, 48000.0);
|
|
CHECK(out.selectionId == "old-pick");
|
|
CHECK(!out.params.rootOverride);
|
|
CHECK(out.params.keyTrack == 1.0);
|
|
}
|
|
|
|
// Truncation degrades to a partial/empty parse — never out-of-bounds, never throws. Run
|
|
// over BOTH the current format and a retired zone-list blob, since the migration path has
|
|
// its own bounded-read walk. Beyond mere survival, a cut read must never RETAIN more refs
|
|
// than the blob actually carried (the "keep what parsed, drop the rest" contract could not
|
|
// silently start fabricating entries) — see testSampleRefsTruncatedMidEntry for the exact
|
|
// mid-entry retention case this bounds only loosely across every cut point.
|
|
static void testTruncationDegradesCleanly() {
|
|
ComponentState in;
|
|
in.selectionId = "smp-2";
|
|
in.params.rootOverride = 61;
|
|
in.sampleRefs.push_back(refEntry("smp-2", "b/s.wav", 61));
|
|
in.sampleRefs.push_back(refEntry("smp-3", "b/t.wav", 62));
|
|
const std::vector<std::uint8_t> current = serializeComponentState(in);
|
|
|
|
legacy::Zone z;
|
|
z.sampleId = "smp-2";
|
|
const std::vector<std::uint8_t> retired = legacy::envelopeWithZones("smp-2", {z, z}, 7);
|
|
|
|
for (const std::vector<std::uint8_t>* blob : {¤t, &retired}) {
|
|
for (std::size_t cut = 0; cut < blob->size(); ++cut) {
|
|
const std::vector<std::uint8_t> part(blob->begin(),
|
|
blob->begin() + static_cast<long>(cut));
|
|
const ComponentState out = deserializeComponentState(part, 48000.0);
|
|
CHECK(out.sampleRefs.size() <= in.sampleRefs.size());
|
|
}
|
|
}
|
|
}
|
|
|
|
int main() {
|
|
testComponentStateRoundTrip();
|
|
testGoldenFullBlobFixture();
|
|
testDefaultStateRoundTripsToDefaults();
|
|
testEnvelopePrefixBytesFrozen();
|
|
testWriterEmitsCurrentPayloadVersion();
|
|
testSingleZoneMigrationIsLossless();
|
|
testMigratedFadeContourTracksTheRetiredEqualPowerShape();
|
|
testZeroFadeOutMigratesToZeroDecay();
|
|
testSingleZoneMigrationLiftsLoopDisablingOverride();
|
|
testLiftedStateReSavesInCurrentFormat();
|
|
testMultiZoneMigrationAdoptsFirstZone();
|
|
testFirstZoneSupersedesStoredSelection();
|
|
testEmptyZoneListKeepsTheStoredSelection();
|
|
testEveryOlderPayloadVersionMigrates();
|
|
testLegacyV3FramesConvertAtTheProjectRate();
|
|
testEnvelopeLadderLiftsEachVersion();
|
|
testV2ZonesOnlyBlobAdoptsBothFromZoneOne();
|
|
testCorruptFieldsFallBackToDefaults();
|
|
testSampleRefsReaderRangeFallbacks();
|
|
testSampleRefsTruncatedMidEntry();
|
|
testWriterClampsOutOfRangeFields();
|
|
testUnknownEnvelopeVersionIsEmpty();
|
|
testV1SelectionLift();
|
|
testTruncationDegradesCleanly();
|
|
testV8RecordLiftsToTheOffNeutralFilter();
|
|
testFilterTailRoundTripsLosslessly();
|
|
testNonFiniteFilterFieldsLiftToTheNeutralDefault();
|
|
if (failures == 0) {
|
|
std::printf("component_state_io_tests: all tests passed\n");
|
|
return 0;
|
|
}
|
|
std::printf("component_state_io_tests: %d FAILURE(S)\n", failures);
|
|
return 1;
|
|
}
|