Files
reasampler/tests/test_component_state_io.cpp
T

720 lines
31 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/master_gain.h" // masterGainMaxLinear (the v8 wire cap)
#include <cstdio>
#include <cstring>
#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;
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
};
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));
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, 1);
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); }
}
}
// 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;
}
// 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 -------------------------------------------------------
// 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.trigger.fadeInFrames = 441;
in.params.play.trigger.fadeOutFrames = 882;
in.params.play.pitchEngine = PitchEngine::Preserve;
in.params.play.pitchEnv.enabled = true;
in.params.play.pitchEnv.attackSeconds = 0.02;
in.params.play.pitchEnv.decaySeconds = 0.03;
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);
CHECK(p.play.trigger.fadeInFrames == 441);
CHECK(p.play.trigger.fadeOutFrames == 882);
CHECK(p.play.pitchEngine == PitchEngine::Preserve);
CHECK(p.play.pitchEnv.enabled);
CHECK(p.play.pitchEnv.attackSeconds == 0.02);
CHECK(p.play.pitchEnv.decaySeconds == 0.03);
CHECK(p.play.pitchEnv.peakSemitones == 5.0);
}
// 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 == 8);
CHECK(kParamsFormatMarker == 0xFFFFFF00u);
}
// 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);
CHECK(p.play.trigger.fadeInFrames == 100);
CHECK(p.play.trigger.fadeOutFrames == 200);
CHECK(p.play.pitchEngine == PitchEngine::Preserve);
CHECK(p.play.pitchEnv.enabled);
CHECK(p.play.pitchEnv.attackSeconds == 0.02);
CHECK(p.play.pitchEnv.decaySeconds == 0.03);
CHECK(p.play.pitchEnv.peakSemitones == 5.0);
}
// 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 == 8);
}
// 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.attackSeconds == 0.02);
CHECK(st.params.play.pitchEnv.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);
}
// 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.
static void testTruncationDegradesCleanly() {
ComponentState in;
in.selectionId = "smp-2";
in.params.rootOverride = 61;
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 : {&current, &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);
(void)out; // reaching here without UB/throw is the contract under test
}
}
CHECK(true);
}
int main() {
testComponentStateRoundTrip();
testDefaultStateRoundTripsToDefaults();
testEnvelopePrefixBytesFrozen();
testWriterEmitsCurrentPayloadVersion();
testSingleZoneMigrationIsLossless();
testLiftedStateReSavesInCurrentFormat();
testMultiZoneMigrationAdoptsFirstZone();
testFirstZoneSupersedesStoredSelection();
testEmptyZoneListKeepsTheStoredSelection();
testEveryOlderPayloadVersionMigrates();
testLegacyV3FramesConvertAtTheProjectRate();
testEnvelopeLadderLiftsEachVersion();
testV2ZonesOnlyBlobAdoptsBothFromZoneOne();
testCorruptFieldsFallBackToDefaults();
testWriterClampsOutOfRangeFields();
testUnknownEnvelopeVersionIsEmpty();
testV1SelectionLift();
testTruncationDegradesCleanly();
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;
}