// 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 #include #include #include #include #include using namespace reasampler; using namespace reasampler::instrument::map; namespace note = reasampler::instrument::note; // the bake Hold's ladder 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& out, std::uint8_t v) { out.push_back(v); } static void u32v(std::vector& out, std::uint32_t v) { for (int i = 0; i < 4; ++i) out.push_back(static_cast((v >> (8 * i)) & 0xFF)); } static void i64v(std::vector& out, std::int64_t v) { const auto u = static_cast(v); for (int i = 0; i < 8; ++i) out.push_back(static_cast((u >> (8 * i)) & 0xFF)); } static void f64v(std::vector& 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((bits >> (8 * i)) & 0xFF)); } static void strv(std::vector& out, const std::string& s) { u32v(out, static_cast(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 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& out, const Zone& z, std::uint32_t pv) { u8v(out, z.rootOverride >= 0 ? 1 : 0); if (z.rootOverride >= 0) u32v(out, static_cast(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 pts = z.curve.empty() ? std::vector{{0.0, 1.0}, {127.0, 1.0}} : z.curve; u32v(out, static_cast(pts.size())); for (const VelocityPoint& p : pts) { f64v(out, p.velocity); f64v(out, p.value); } } } static void putZone(std::vector& out, const Zone& z, std::uint32_t pv) { strv(out, z.sampleId); u32v(out, static_cast(z.lowNote)); u32v(out, static_cast(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(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 envelopeWithZones(const Envelope& env, const std::vector& zones, std::uint32_t pv) { std::vector 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(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 envelopeWithV8Record(const std::string& selectionId, const Zone& record) { Envelope env; env.selectionId = selectionId; std::vector 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 envelopeWithZones(const std::string& selectionId, const std::vector& 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}}, reasampler::instrument::engine::CurveDomain::Unipolar); 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 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.loopCrossfadeFrames = 256; 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}}, reasampler::instrument::engine::CurveDomain::Unipolar); 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 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,0x0e,0x00,0x00, 0x00,0x01,0x24,0x00,0x00,0x00,0x01,0x01,0xe8,0x03,0x00,0x00,0x00,0x00,0x00,0x00, 0x88,0x13,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0xfa,0x00,0x00,0x00,0x00,0x00,0x00, 0x00,0x01,0x9a,0x99,0x99,0x99,0x99,0x99,0xa9,0x3f,0x00,0x00,0x00,0x00,0x00,0x00, 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 (flat at zero) 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // velocity 0.0 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // value 0.0 0x00,0x00,0x00,0x00,0x00,0xc0,0x5f,0x40, // velocity 127.0 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // value 0.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 // --- payload v11 loop-crossfade tail --- 0x00,0x01,0x00,0x00,0x00,0x00,0x00,0x00, // loopCrossfadeFrames 256 // --- payload v12 velocity->pitch curve, at its off default (flat at zero) --- 0x02,0x00,0x00,0x00, // 2 points 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // velocity 0.0 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // value 0.0 0x00,0x00,0x00,0x00,0x00,0xc0,0x5f,0x40, // velocity 127.0 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // value 0.0 // --- payload v13 dual Staged/Spline state. Three spline EGs (amp, pitch, filter), // each Staged with the y = 1 - x default contour, then the three velocity curves' // hard-flag tails, all flags clear --- 0x00, // amp: Staged 0x02,0x00,0x00,0x00, // 2 points 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // x 0.0 0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // y 1.0 0x00, // smooth 0x00,0x00,0x00,0x00,0x00,0xc0,0x5f,0x40, // x 127.0 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // y 0.0 0x00, // smooth 0x00, // pitch: Staged 0x02,0x00,0x00,0x00, 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, 0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, 0x00, 0x00,0x00,0x00,0x00,0x00,0xc0,0x5f,0x40, 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, 0x00, 0x00, // filter: Staged 0x02,0x00,0x00,0x00, 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, 0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, 0x00, 0x00,0x00,0x00,0x00,0x00,0xc0,0x5f,0x40, 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, 0x00, 0x03,0x00,0x00,0x00, 0x00,0x00,0x00, // amp curve hard flags (3 points) 0x02,0x00,0x00,0x00, 0x00,0x00, // filter curve hard flags 0x02,0x00,0x00,0x00, 0x00,0x00, // pitch curve hard flags // --- payload v14 bake Hold, at its one-bar default --- 0x02,0x00,0x00,0x00, // quarterExponent 2 (== 1/1) 0x00, // Straight }; // clang-format on CHECK(bytes.size() == sizeof(kGolden)); 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 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 == 14); CHECK(kParamsSingleRecordVersion == 8); CHECK(kParamsFormatMarker == 0xFFFFFF00u); // The filter, staged-curve, loop, velocity, spline and bake-Hold tails rode PAYLOAD bumps, // not envelope ones — the two axes stay independent, so a future envelope field cannot // collide with any of them on one number. CHECK(kParamsFilterVersion > kParamsSingleRecordVersion); CHECK(kParamsCurveVersion > kParamsFilterVersion); CHECK(kParamsLoopVersion > kParamsCurveVersion); CHECK(kParamsVelocityVersion > kParamsLoopVersion); CHECK(kParamsSplineVersion > kParamsVelocityVersion); CHECK(kParamsBakeHoldVersion > kParamsSplineVersion); CHECK(kParamsPayloadVersion == kParamsBakeHoldVersion); } // --- 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); for (int v = 0; v <= 127; ++v) CHECK(f.velocityCurve.eval(v) == 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.875; 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; // Bipolar, and reaching into the negative half the retired unipolar shape could not // express: a codec that read this back through the old domain would clamp it to 0. f.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints( {VelocityPoint{0.0, -0.75}, VelocityPoint{100.0, 0.4}, VelocityPoint{127.0, 0.9}}, reasampler::instrument::engine::CurveDomain::Bipolar); // 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.domain() == reasampler::instrument::engine::CurveDomain::Bipolar); CHECK(g.velocityCurve.eval(0.0) == -0.75); // the negative half survives the round trip CHECK(g.velocityCurve.eval(100.0) == 0.4); CHECK(g.velocityCurve.eval(127.0) == 0.9); CHECK(out.params.velocityCurve.equals( reasampler::instrument::engine::VelocityCurve::flat())); } // The velocity->PITCH curve (payload v12) is a third, independent slot: it round-trips whole, // and neither of the other two leaks into it. static void testPitchVelocityCurveRoundTripsIndependently() { ComponentState in; in.selectionId = "pad"; in.params.play.pitchVelocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints( {VelocityPoint{0.0, -1.0}, VelocityPoint{64.0, 0.25}, VelocityPoint{127.0, 0.5}}, reasampler::instrument::engine::CurveDomain::Bipolar); in.params.play.filter.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints( {VelocityPoint{0.0, 0.2}, VelocityPoint{127.0, -0.6}}, reasampler::instrument::engine::CurveDomain::Bipolar); in.params.velocityCurve = reasampler::instrument::engine::VelocityCurve::linear(); const ComponentState out = deserializeComponentState(serializeComponentState(in), 48000.0); const reasampler::instrument::engine::VelocityCurve& p = out.params.play.pitchVelocityCurve; CHECK(p.size() == 3); CHECK(p.domain() == reasampler::instrument::engine::CurveDomain::Bipolar); CHECK(p.eval(0.0) == -1.0); CHECK(p.eval(64.0) == 0.25); CHECK(p.eval(127.0) == 0.5); // The other two slots kept their own values — no cross-talk between the three curves. CHECK(out.params.play.filter.velocityCurve.eval(127.0) == -0.6); CHECK(out.params.velocityCurve.eval(127.0) == 1.0); CHECK(out.params.velocityCurve.eval(0.0) == 0.0); } // 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 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::quiet_NaN(); f.velAmount = std::numeric_limits::infinity(); f.keyTrack = -std::numeric_limits::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); } // A non-finite attackSeconds/decaySeconds on a stored AHD (a corrupt blob) must lift to 0 // seconds rather than reach resolvePlay's static_cast (sample_map.cpp) — UB on // NaN, and on a large-enough finite value. Mirrors // testNonFiniteFilterFieldsLiftToTheNeutralDefault's per-field precedent, on the v10 AHD tail. static void testNonFiniteAhdSecondsLiftToZero() { ComponentState in; in.selectionId = "pad"; in.params.play.trigAhd.attackSeconds = std::numeric_limits::quiet_NaN(); in.params.play.trigAhd.decaySeconds = std::numeric_limits::infinity(); in.params.play.filter.trigEnv.attackSeconds = -std::numeric_limits::infinity(); in.params.play.filter.trigEnv.decaySeconds = std::numeric_limits::quiet_NaN(); const ComponentState out = deserializeComponentState(serializeComponentState(in), 48000.0); CHECK(out.params.play.trigAhd.attackSeconds == 0.0); CHECK(out.params.play.trigAhd.decaySeconds == 0.0); CHECK(out.params.play.filter.trigEnv.attackSeconds == 0.0); CHECK(out.params.play.filter.trigEnv.decaySeconds == 0.0); } // --- The v13 hard-flag tail: corruption must never widen past its own three curves ----------- // The two trailing blocks of a CURRENT blob, so the splice tests below can cut back to the // hard flags and rewrite them without hand-counting the payload twice. Every velocity curve // in those fixtures is at its default 2-point shape, which is what pins the flag block sizes. static constexpr std::size_t kHardFlagTailBytes = 4 + 2 + 4 + 2 + 4 + 2; static constexpr std::size_t kBakeHoldTailBytes = 4 + 1; // The v14 tail at its one-bar default, re-appended after a splice so the record still ends // where the reader expects it to. static void putDefaultBakeHoldTail(std::vector& out) { legacy::u32v(out, 2); // quarterExponent 2 == 1/1 legacy::u8v(out, 0); // Straight } // A hard-flag COUNT that disagrees with the curve fromPoints already built, but is still // IN-BOUNDS (the blob really does carry that many bytes) — the documented promise // (component_state_io.h) is that the tail is dropped, never misapplied, and nothing else in // the record is disturbed. Corrupts only the AMP curve's tail; FILTER/PITCH follow at their // normal, byte-precise offsets, proving a mismatch on one curve does not cascade to its // neighbours. // // Companion, not a regression guard: this in-bounds-mismatch path was already non-wiping // before the r.ok fix below — it pins the documented promise, not the fix. The two tests that // follow (OUT-OF-BOUNDS count, and a mid-count truncation) are what actually guard it — both // tripped the old "reset the whole record to defaults" behavior. static void testV13HardFlagInBoundsMismatchDropsFlagsOnly() { ComponentState in; in.selectionId = "pad"; in.params.rootOverride = 44; in.params.keyTrack = 0.6; in.params.play.adsr.attackSeconds = 0.12; in.params.play.adsr.sustainLevel = 0.55; in.params.play.filter.enabled = true; in.params.play.filter.settings.cutoffNorm = 0.4f; in.params.play.filter.modAmount = -0.3; in.params.play.pitchEnv.enabled = true; in.params.play.pitchEnv.peakSemitones = 5.0; in.params.loopCrossfadeFrames = 777; in.params.play.pitchVelocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints( {VelocityPoint{0.0, -0.4}, VelocityPoint{127.0, 0.4}}, reasampler::instrument::engine::CurveDomain::Bipolar); // Every velocity curve left at its DEFAULT 2-point shape, so the v13 hard-flag tail's byte // layout (three 4-byte-count + N-byte blocks, amp/filter/pitch order — putHardFlags' call // order in params_payload.cpp) is deterministic and this test can splice it exactly. std::vector bytes = serializeComponentState(in); CHECK(bytes.size() >= kHardFlagTailBytes + kBakeHoldTailBytes); bytes.resize(bytes.size() - kHardFlagTailBytes - kBakeHoldTailBytes); legacy::u32v(bytes, 5); // amp: bogus count... for (int i = 0; i < 5; ++i) legacy::u8v(bytes, 0); // ...with 5 REAL bytes, so nothing shifts legacy::u32v(bytes, 2); // filter: correct count, unchanged legacy::u8v(bytes, 0); legacy::u8v(bytes, 0); legacy::u32v(bytes, 2); // pitch: correct count, unchanged legacy::u8v(bytes, 0); legacy::u8v(bytes, 0); putDefaultBakeHoldTail(bytes); const ComponentState out = deserializeComponentState(bytes, 48000.0); // Every param preceding AND following the corrupted amp tail survives untouched. CHECK(out.selectionId == "pad"); CHECK(out.params.rootOverride && *out.params.rootOverride == 44); CHECK(out.params.keyTrack == 0.6); CHECK(out.params.play.adsr.attackSeconds == 0.12); CHECK(out.params.play.adsr.sustainLevel == 0.55); CHECK(out.params.play.filter.enabled); CHECK(out.params.play.filter.settings.cutoffNorm == 0.4f); CHECK(out.params.play.filter.modAmount == -0.3); CHECK(out.params.play.pitchEnv.enabled); CHECK(out.params.play.pitchEnv.peakSemitones == 5.0); CHECK(out.params.loopCrossfadeFrames == 777); CHECK(out.params.play.pitchVelocityCurve.eval(0.0) == -0.4); CHECK(out.params.play.pitchVelocityCurve.eval(127.0) == 0.4); // The mismatched (amp) curve keeps its points; the flags are dropped, never misapplied. CHECK(out.params.velocityCurve.size() == 2); CHECK(!out.params.velocityCurve.points()[0].hard); CHECK(!out.params.velocityCurve.points()[1].hard); CHECK(out.params.velocityCurve.equals(reasampler::instrument::engine::VelocityCurve::flat())); } // A hard-flag COUNT that exceeds what its OWN tail carries — a genuinely corrupt/out-of-bounds // count — must be BOUND-AND-SKIPPED without consuming any of the following bytes, so the // FILTER/PITCH tails immediately after the AMP block still parse at their correct offset. The // old behavior (r.ok = false) reset the ENTIRE params record to defaults on this path, which is // strictly worse than the documented "drops only the hard points" promise. static void testV13HardFlagOutOfBoundsCountSurvivesWithoutWipingTheRecord() { ComponentState in; in.selectionId = "pad"; in.params.rootOverride = 44; in.params.play.adsr.releaseSeconds = 0.44; in.params.play.filter.enabled = true; in.params.play.filter.settings.resonanceNorm = 0.9f; in.params.loopCrossfadeFrames = 321; std::vector bytes = serializeComponentState(in); CHECK(bytes.size() >= kHardFlagTailBytes + kBakeHoldTailBytes); bytes.resize(bytes.size() - kHardFlagTailBytes - kBakeHoldTailBytes); legacy::u32v(bytes, 1000); // amp: a count its own tail cannot possibly carry // No amp flag bytes follow — bound-and-skip must consume none, so the well-formed // filter/pitch blocks right after it land exactly where they belong. legacy::u32v(bytes, 2); // filter: correct count, unchanged legacy::u8v(bytes, 0); legacy::u8v(bytes, 0); legacy::u32v(bytes, 2); // pitch: correct count, unchanged legacy::u8v(bytes, 0); legacy::u8v(bytes, 0); putDefaultBakeHoldTail(bytes); const ComponentState out = deserializeComponentState(bytes, 48000.0); // The whole record survives — including everything the v13 section itself carries ahead of // the hard-flag tail (the three spline EGs) and the two well-formed tails after the // corrupted one — only the AMP curve's hard-flag application is lost. CHECK(out.selectionId == "pad"); CHECK(out.params.rootOverride && *out.params.rootOverride == 44); CHECK(out.params.play.adsr.releaseSeconds == 0.44); CHECK(out.params.play.filter.enabled); CHECK(out.params.play.filter.settings.resonanceNorm == 0.9f); CHECK(out.params.loopCrossfadeFrames == 321); CHECK(out.params.play.ampSpline.mode == EnvMode::Staged); CHECK(out.params.velocityCurve.size() == 2); // unaffected: not misapplied, not discarded CHECK(!out.params.velocityCurve.points()[0].hard); } // A hard-flag tail truncated mid-COUNT-FIELD (only 2 of its 4 length bytes present, and // nothing else after) is a different failure shape than a declared-huge count: the u32 read // itself fails, tripping r.ok inside readHardFlags rather than its own bound check. That must // be revived the same way — the whole record survives, only the hard-flag applications (on // all three curves, since the truncation strands every one of them) are lost. static void testV13HardFlagTailTruncatedMidCountSurvivesWithoutWipingTheRecord() { ComponentState in; in.selectionId = "pad"; in.params.rootOverride = 21; in.params.play.adsr.decaySeconds = 0.08; in.params.play.pitchEnv.enabled = true; in.params.play.pitchEnv.peakSemitones = -3.0; in.params.loopCrossfadeFrames = 5; std::vector bytes = serializeComponentState(in); CHECK(bytes.size() >= kHardFlagTailBytes + kBakeHoldTailBytes); // Drops the bake-Hold tail with the flags: the truncation strands everything after it, // which is the whole point — Hold lifts to its default alongside the flags. bytes.resize(bytes.size() - kHardFlagTailBytes - kBakeHoldTailBytes); legacy::u8v(bytes, 0x02); // half of the amp tail's 4-byte LE count, then nothing legacy::u8v(bytes, 0x00); const ComponentState out = deserializeComponentState(bytes, 48000.0); CHECK(out.selectionId == "pad"); CHECK(out.params.rootOverride && *out.params.rootOverride == 21); CHECK(out.params.play.adsr.decaySeconds == 0.08); CHECK(out.params.play.pitchEnv.enabled); CHECK(out.params.play.pitchEnv.peakSemitones == -3.0); CHECK(out.params.loopCrossfadeFrames == 5); CHECK(out.params.velocityCurve.size() == 2); CHECK(!out.params.velocityCurve.points()[0].hard); CHECK(out.params.bakeHold == InstrumentParams{}.bakeHold); } // --- The loop tail (payload v11) --------------------------------------------- // The loop span and its crossfade survive a save/reload intact, alongside the two overrides // that share the record's head — a codec that read the crossfade into a neighbouring int64 // fails here rather than at the ear. static void testLoopSpanAndCrossfadeRoundTrip() { ComponentState in; in.selectionId = "pad"; SampleLoop lp; lp.hasLoop = true; lp.start = 4096; lp.end = 65536; in.params.loopOverride = lp; in.params.loopCrossfadeFrames = 1024; in.params.startPoint = 512; in.params.rootOverride = 55; const ComponentState out = deserializeComponentState(serializeComponentState(in), 48000.0); CHECK(out.params.loopOverride && out.params.loopOverride->hasLoop); CHECK(out.params.loopOverride && out.params.loopOverride->start == 4096); CHECK(out.params.loopOverride && out.params.loopOverride->end == 65536); CHECK(out.params.loopCrossfadeFrames == 1024); CHECK(out.params.startPoint && *out.params.startPoint == 512); CHECK(out.params.rootOverride && *out.params.rootOverride == 55); } // A negative fade cannot mean anything and would only reach resolveLoop's clamp; refusing it // at the wire keeps the parameter set the editor reads back sane. static void testNegativeCrossfadeOnTheWireLiftsToZero() { ComponentState in; in.selectionId = "pad"; in.params.loopCrossfadeFrames = -4096; const ComponentState out = deserializeComponentState(serializeComponentState(in), 48000.0); CHECK(out.params.loopCrossfadeFrames == 0); } // Payload tails are strict SUFFIXES by construction, so a vN blob IS the current writer's // output with version N stamped in and the (N+1..current) tails cut. Building the older blobs // that way exercises the tolerant-reader path rather than assuming it: if a tail ever stopped // being a pure suffix, these would decode as garbage instead of as the documented lift. static const std::size_t kVelocityTailBytes = 4 + 2 * 2 * 8; // v12: the 2-pt pitch curve static const std::size_t kLoopTailBytes = 8; // v11: crossfade, one int64 static const std::size_t kCurveTailBytes = 19 * 8; // v10: nineteen doubles static const std::size_t kFilterTailBytes = 1 + 4 * 8 + 1 + 3 * 8 + 5 * 8 + (4 + 2 * 2 * 8); // v9: the filter block + its 2-pt curve static std::vector payloadDowngradedTo(const ComponentState& state, std::uint32_t pv, std::size_t cutBytes) { std::vector bytes = serializeComponentState(state); bool stamped = false; for (std::size_t i = 0; i + 8 <= bytes.size(); ++i) { const std::uint32_t m = static_cast(bytes[i]) | (static_cast(bytes[i + 1]) << 8) | (static_cast(bytes[i + 2]) << 16) | (static_cast(bytes[i + 3]) << 24); if (m != kParamsFormatMarker) continue; // Guard the naive marker scan: a false positive inside payload data would not be // sitting in front of the CURRENT version. CHECK(bytes[i + 4] == static_cast(kParamsPayloadVersion)); bytes[i + 4] = static_cast(pv); stamped = true; break; } CHECK(stamped); CHECK(bytes.size() > cutBytes); bytes.resize(bytes.size() - cutBytes); return bytes; } // Every knot of `lifted` equals `stored`'s BIT for bit — the whole claim of a domain re-tag, // which is why this compares with == rather than a tolerance. static bool knotsAreIdentical(const reasampler::instrument::engine::VelocityCurve& lifted, const reasampler::instrument::engine::VelocityCurve& stored) { if (lifted.size() != stored.size()) return false; for (std::size_t i = 0; i < lifted.size(); ++i) { if (lifted.points()[i].velocity != stored.points()[i].velocity) return false; if (lifted.points()[i].value != stored.points()[i].value) return false; } return true; } // A project saved before this change reopens sounding identical: its loop span still applies, // its seam is still hard, and velocity still modulates pitch not at all, at EVERY prior // single-record version. static void testPriorPayloadVersionsLiftToAHardSeam() { ComponentState in; in.selectionId = "pad"; SampleLoop lp; lp.hasLoop = true; lp.start = 2000; lp.end = 9000; in.params.loopOverride = lp; in.params.startPoint = 128; in.params.keyTrack = 0.5; in.params.play.adsr.releaseSeconds = 0.25; // Set on the in-state only so a v10 lift can be checked to keep it and a v9 lift to drop // it — proving the cuts land where the ladder says they do. in.params.play.adsr.attackCurve = 4.0; in.params.loopCrossfadeFrames = 777; // present in the bytes only at v11 // Present in the bytes only at v12: an off-default pitch curve, so a lift that leaked one // in from anywhere else fails rather than coincidentally matching the default. in.params.play.pitchVelocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints( {VelocityPoint{0.0, 0.5}, VelocityPoint{127.0, 1.0}}, reasampler::instrument::engine::CurveDomain::Bipolar); // The filter's velocity pair, so EVERY version that carries a filter tail (v9..v11) walks // the v12 domain re-tag, not just v11 (see testPreV12FilterVelocityLiftsAsAPureDomainReTag // for the single-version proof). The knots stay inside [0,1] — what a pre-v12 unipolar // curve could actually hold. in.params.play.filter.enabled = true; in.params.play.filter.modAmount = -0.6251953125; in.params.play.filter.velAmount = -0.75; const reasampler::instrument::engine::VelocityCurve filterShape = reasampler::instrument::engine::VelocityCurve::fromPoints( {VelocityPoint{0.0, 0.0}, VelocityPoint{64.0, 0.25}, VelocityPoint{127.0, 1.0}}, reasampler::instrument::engine::CurveDomain::Bipolar); in.params.play.filter.velocityCurve = filterShape; struct Case { std::uint32_t pv; std::size_t cut; bool keepsCurveTail; bool keepsFilterTail; }; const Case cases[] = { {11, kVelocityTailBytes, true, true}, {10, kVelocityTailBytes + kLoopTailBytes, true, true}, {9, kVelocityTailBytes + kLoopTailBytes + kCurveTailBytes, false, true}, {8, kVelocityTailBytes + kLoopTailBytes + kCurveTailBytes + kFilterTailBytes, false, false}, }; for (const Case& c : cases) { const std::vector bytes = payloadDowngradedTo(in, c.pv, c.cut); const ComponentState out = deserializeComponentState(bytes, 48000.0); // The span itself has been in the format since v2 and must survive untouched. CHECK(out.params.loopOverride && out.params.loopOverride->hasLoop); CHECK(out.params.loopOverride && out.params.loopOverride->start == 2000); CHECK(out.params.loopOverride && out.params.loopOverride->end == 9000); CHECK(out.params.startPoint && *out.params.startPoint == 128); CHECK(out.params.keyTrack == 0.5); CHECK(out.params.play.adsr.releaseSeconds == 0.25); // The documented pre-change behaviour: a hard seam and no velocity->pitch at all. CHECK(out.params.loopCrossfadeFrames == (c.pv >= 11 ? 777 : 0)); for (int v = 0; v <= 127; ++v) { CHECK(out.params.play.pitchVelocityCurve.eval(v) == 0.0); } // And the cut landed on the tail boundary the ladder claims, not somewhere inside it. CHECK(out.params.play.adsr.attackCurve == (c.keepsCurveTail ? 4.0 : reasampler::util::kCurveNeutral)); // The filter's velocity pair where the tail survives (v9..v11): the depth carries // forward untouched and the curve is re-tagged, not rescaled — so the cutoff // contribution, depth * curve(v), is exactly what the pre-change reader computed. // Where the tail was cut away entirely (v8) it is the off/neutral default. if (c.keepsFilterTail) { CHECK(out.params.play.filter.enabled); CHECK(out.params.play.filter.velAmount == in.params.play.filter.velAmount); CHECK(knotsAreIdentical(out.params.play.filter.velocityCurve, filterShape)); for (int v = 0; v <= 127; ++v) { CHECK(out.params.play.filter.velAmount * out.params.play.filter.velocityCurve.eval(v) == in.params.play.filter.velAmount * filterShape.eval(v)); } } else { CHECK(!out.params.play.filter.enabled); CHECK(out.params.play.filter.velAmount == 0.0); for (int v = 0; v <= 127; ++v) { CHECK(out.params.play.filter.velocityCurve.eval(v) == 0.0); } } } } // The sharp edge of the bipolar change: v12 widened the filter curve's y domain, and the lift // is a pure DOMAIN RE-TAG — no rescaling, no rounding. A pre-v12 curve's y values all lie in // [0,1], which is inside [-1,+1], so every knot must come back bit-identical, the depth beside // it untouched, and the cutoff contribution equal to the pre-change product at every velocity. static void testPreV12FilterVelocityLiftsAsAPureDomainReTag() { // A shape confined to [0,1] — what a pre-v12 unipolar curve could actually store. The // reference reads the SAME knots through the old domain, so the comparison is against what // the pre-change reader built, not against another read of the new one. const std::vector knots = {VelocityPoint{0.0, 0.0}, VelocityPoint{40.0, 0.125}, VelocityPoint{64.0, 0.75}, VelocityPoint{127.0, 1.0}}; const reasampler::instrument::engine::VelocityCurve asStored = reasampler::instrument::engine::VelocityCurve::fromPoints( knots, reasampler::instrument::engine::CurveDomain::Unipolar); for (const double depth : {-0.75, 0.5, 0.0}) { ComponentState in; in.selectionId = "pad"; FilterSeconds& f = in.params.play.filter; f.enabled = true; f.modAmount = -0.6251953125; f.velAmount = depth; f.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints( knots, reasampler::instrument::engine::CurveDomain::Bipolar); const std::vector bytes = payloadDowngradedTo(in, kParamsLoopVersion, kVelocityTailBytes); const ComponentState out = deserializeComponentState(bytes, 48000.0); const reasampler::instrument::engine::VelocityCurve& lifted = out.params.play.filter.velocityCurve; CHECK(lifted.domain() == reasampler::instrument::engine::CurveDomain::Bipolar); CHECK(knotsAreIdentical(lifted, asStored)); // bit-identical, not within a tolerance CHECK(out.params.play.filter.velAmount == depth); // the depth carries forward untouched CHECK(out.params.play.filter.modAmount == f.modAmount); // Sound-identical, stated as the product the voice actually computes. for (int v = 0; v <= 127; ++v) { CHECK(out.params.play.filter.velAmount * lifted.eval(v) == depth * asStored.eval(v)); } } // The v12 blob of the same state reads back the same way — the re-tag is what the reader // does at EVERY version, so the pre-v12 and current paths cannot diverge. ComponentState now; now.selectionId = "pad"; now.params.play.filter.enabled = true; now.params.play.filter.velAmount = -0.75; now.params.play.filter.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints( knots, reasampler::instrument::engine::CurveDomain::Bipolar); const ComponentState back = deserializeComponentState(serializeComponentState(now), 48000.0); CHECK(knotsAreIdentical(back.params.play.filter.velocityCurve, asStored)); CHECK(back.params.play.filter.velAmount == -0.75); } // --- The bake Hold tail (payload v14) ----------------------------------------- // Hold survives a save/reload as its {rung, modifier} pair, and it is the ONLY field the v14 // bump touches — everything either side of it in the record comes back untouched. static void testBakeHoldRoundTripsAndDisturbsNothingElse() { ComponentState in; in.selectionId = "pad"; in.params.rootOverride = 33; in.params.keyTrack = 0.25; in.params.loopCrossfadeFrames = 64; in.params.play.adsr.releaseSeconds = 0.31; in.params.play.ampSpline.mode = EnvMode::Spline; // A triplet on a rung well away from the default, so neither field can be read off the // other's default and still pass. in.params.bakeHold = note::makeDivision(-2, note::DivisionModifier::Triplet); const ComponentState out = deserializeComponentState(serializeComponentState(in), 48000.0); CHECK(out.params.bakeHold == note::makeDivision(-2, note::DivisionModifier::Triplet)); CHECK(out.params.bakeHold != InstrumentParams{}.bakeHold); CHECK(out.selectionId == "pad"); CHECK(out.params.rootOverride && *out.params.rootOverride == 33); CHECK(out.params.keyTrack == 0.25); CHECK(out.params.loopCrossfadeFrames == 64); CHECK(out.params.play.adsr.releaseSeconds == 0.31); CHECK(out.params.play.ampSpline.mode == EnvMode::Spline); } // A v13 blob is a strict PREFIX of v14, so it must lift to the one-bar Hold default with // every other field intact — the reason a project saved before Hold existed reopens the same. static void testV13BlobLiftsToTheDefaultHold() { ComponentState in; in.selectionId = "pad"; in.params.rootOverride = 55; in.params.play.adsr.decaySeconds = 0.09; in.params.play.filter.enabled = true; in.params.loopCrossfadeFrames = 128; in.params.bakeHold = note::makeDivision(5, note::DivisionModifier::Dotted); // Stamp the payload back to v13 and drop exactly the v14 tail: byte-for-byte what the // previous binary would have written. const std::vector v13 = payloadDowngradedTo(in, kParamsSplineVersion, kBakeHoldTailBytes); const ComponentState out = deserializeComponentState(v13, 48000.0); CHECK(out.params.bakeHold == InstrumentParams{}.bakeHold); CHECK(out.selectionId == "pad"); CHECK(out.params.rootOverride && *out.params.rootOverride == 55); CHECK(out.params.play.adsr.decaySeconds == 0.09); CHECK(out.params.play.filter.enabled); CHECK(out.params.loopCrossfadeFrames == 128); } // A corrupt rung/modifier pair clamps to the nearest legal division rather than being held as // an unrepresentable one — makeDivision is the only door, and the codec goes through it. static void testBakeHoldCorruptPairClampsToTheLadder() { ComponentState in; in.selectionId = "pad"; std::vector bytes = serializeComponentState(in); CHECK(bytes.size() >= kBakeHoldTailBytes); bytes.resize(bytes.size() - kBakeHoldTailBytes); legacy::u32v(bytes, static_cast(static_cast(9999))); legacy::u8v(bytes, 200); // an unnamed modifier byte const ComponentState out = deserializeComponentState(bytes, 48000.0); CHECK(out.params.bakeHold == note::makeDivision(note::kMaxQuarterExponent, note::DivisionModifier::Straight)); } // A blob truncated INSIDE the v14 tail costs the Hold alone. It sits last, so without the // revive a stray missing byte would reset every parameter ahead of it to defaults. static void testBakeHoldTruncatedTailSurvivesWithoutWipingTheRecord() { ComponentState in; in.selectionId = "pad"; in.params.rootOverride = 71; in.params.play.adsr.attackSeconds = 0.017; in.params.loopCrossfadeFrames = 96; in.params.bakeHold = note::makeDivision(4, note::DivisionModifier::Triplet); std::vector bytes = serializeComponentState(in); CHECK(bytes.size() >= kBakeHoldTailBytes); bytes.resize(bytes.size() - kBakeHoldTailBytes); legacy::u8v(bytes, 0x02); // two of the exponent's four bytes, then nothing legacy::u8v(bytes, 0x00); const ComponentState out = deserializeComponentState(bytes, 48000.0); CHECK(out.params.bakeHold == InstrumentParams{}.bakeHold); CHECK(out.selectionId == "pad"); CHECK(out.params.rootOverride && *out.params.rootOverride == 71); CHECK(out.params.play.adsr.attackSeconds == 0.017); CHECK(out.params.loopCrossfadeFrames == 96); } // The WRITER emits the CURRENT payload version, and the marker + version sit at the head of // the payload — the self-describing property every legacy branch depends on. Asserted // against the semantic constants, not literals. static void testWriterEmitsCurrentPayloadVersion() { ComponentState in; in.selectionId = "id"; const std::vector 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(bytes[i]) | (static_cast(bytes[i + 1]) << 8) | (static_cast(bytes[i + 2]) << 16) | (static_cast(bytes[i + 3]) << 24); if (m != kParamsFormatMarker) continue; const std::uint32_t v = static_cast(bytes[i + 4]) | (static_cast(bytes[i + 5]) << 8) | (static_cast(bytes[i + 6]) << 16) | (static_cast(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(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(span)) return 0.0; if (off < static_cast(fadeIn)) { return std::sin(off / static_cast(fadeIn) * (pi / 2.0)); } const double foStart = static_cast(span - fadeOut); if (off >= foStart) { return std::cos((off - foStart) / static_cast(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(i)) - retired(static_cast(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(span)) == retired(static_cast(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 out; legacy::u32v(out, kComponentStateVersion); legacy::u8v(out, 0); legacy::i64v(out, 0); legacy::u8v(out, kPreviewVelocityDefault); legacy::u8v(out, static_cast(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 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 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 152-byte v10 staged-curve tail + the 8-byte v11 crossfade + the // 36-byte v12 pitch curve + the 135-byte v13 dual-state tail, three 39-byte spline EGs and // three 6-byte hard-flag tails + the 5-byte v14 bake-Hold tail) = 628 bytes; entry two is // 47 bytes (id 4+3, path 4+7, root4, loop 1+8+8, channels4, name 4+0). Cutting 648 keeps // the first 27 of entry two's 47 — mid loop.start (offset 23..31). CHECK(bytes.size() > 648); bytes.resize(bytes.size() - 648); const ComponentState back = deserializeComponentState(bytes, 44100.0); CHECK(back.sampleRefs.size() == 1); CHECK(back.sampleRefs.size() == 1 && back.sampleRefs[0].sampleId == "kick"); 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 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 current = serializeComponentState(in); legacy::Zone z; z.sampleId = "smp-2"; const std::vector retired = legacy::envelopeWithZones("smp-2", {z, z}, 7); for (const std::vector* blob : {¤t, &retired}) { for (std::size_t cut = 0; cut < blob->size(); ++cut) { const std::vector part(blob->begin(), blob->begin() + static_cast(cut)); const ComponentState out = deserializeComponentState(part, 48000.0); CHECK(out.sampleRefs.size() <= in.sampleRefs.size()); } } } int main() { testComponentStateRoundTrip(); testGoldenFullBlobFixture(); testDefaultStateRoundTripsToDefaults(); testEnvelopePrefixBytesFrozen(); testLoopSpanAndCrossfadeRoundTrip(); testNegativeCrossfadeOnTheWireLiftsToZero(); testPriorPayloadVersionsLiftToAHardSeam(); testPreV12FilterVelocityLiftsAsAPureDomainReTag(); testWriterEmitsCurrentPayloadVersion(); testSingleZoneMigrationIsLossless(); testMigratedFadeContourTracksTheRetiredEqualPowerShape(); testZeroFadeOutMigratesToZeroDecay(); testSingleZoneMigrationLiftsLoopDisablingOverride(); testLiftedStateReSavesInCurrentFormat(); testMultiZoneMigrationAdoptsFirstZone(); testFirstZoneSupersedesStoredSelection(); testEmptyZoneListKeepsTheStoredSelection(); testEveryOlderPayloadVersionMigrates(); testLegacyV3FramesConvertAtTheProjectRate(); testEnvelopeLadderLiftsEachVersion(); testV2ZonesOnlyBlobAdoptsBothFromZoneOne(); testCorruptFieldsFallBackToDefaults(); testSampleRefsReaderRangeFallbacks(); testSampleRefsTruncatedMidEntry(); testWriterClampsOutOfRangeFields(); testUnknownEnvelopeVersionIsEmpty(); testV1SelectionLift(); testTruncationDegradesCleanly(); testV8RecordLiftsToTheOffNeutralFilter(); testFilterTailRoundTripsLosslessly(); testPitchVelocityCurveRoundTripsIndependently(); testNonFiniteFilterFieldsLiftToTheNeutralDefault(); testNonFiniteAhdSecondsLiftToZero(); testV13HardFlagInBoundsMismatchDropsFlagsOnly(); testV13HardFlagOutOfBoundsCountSurvivesWithoutWipingTheRecord(); testV13HardFlagTailTruncatedMidCountSurvivesWithoutWipingTheRecord(); testBakeHoldRoundTripsAndDisturbsNothingElse(); testV13BlobLiftsToTheDefaultHold(); testBakeHoldCorruptPairClampsToTheLadder(); testBakeHoldTruncatedTailSurvivesWithoutWipingTheRecord(); 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; }