322 lines
14 KiB
C++
322 lines
14 KiB
C++
// component_state_io unit tests (Q-W2v). The HISTORICAL codec suite — the full
|
|
// envelope/payload version ladder, every legacy lift, the golden byte fixtures —
|
|
// lives in test_sample_map.cpp and runs unmodified against the split module; this
|
|
// target exists as the module's OWN executable (house rule: every pure module has
|
|
// one) and as the STRUCTURAL PROOF the codec links WITHOUT the voice engine
|
|
// (T2-07): it links component_state_io + velocity_curve + master_gain only — a
|
|
// sampler_core/pitch_shift symbol reaching this link is a regression.
|
|
|
|
#include "../src/core/instrument/map/component_state_io.h"
|
|
|
|
#include <cstdio>
|
|
#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 full round-trip through the CURRENT envelope (v11): every field survives.
|
|
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);
|
|
PerformanceZone z;
|
|
z.sampleId = "smp-1";
|
|
z.lowNote = 30;
|
|
z.highNote = 90;
|
|
z.rootOverride = 61;
|
|
z.startPoint = 5;
|
|
z.keyTrack = 1.5;
|
|
z.play.playMode = PlayMode::Trigger;
|
|
z.play.trigger.lengthFraction = 0.75;
|
|
z.play.trigger.fadeInFrames = 441;
|
|
z.play.trigger.fadeOutFrames = 882;
|
|
in.map.zones.push_back(z);
|
|
|
|
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");
|
|
}
|
|
CHECK(out.map.zones.size() == 1);
|
|
if (out.map.zones.size() == 1) {
|
|
const PerformanceZone& oz = out.map.zones[0];
|
|
CHECK(oz.sampleId == "smp-1");
|
|
CHECK(oz.lowNote == 30);
|
|
CHECK(oz.highNote == 90);
|
|
CHECK(oz.rootOverride && *oz.rootOverride == 61);
|
|
CHECK(oz.startPoint && *oz.startPoint == 5);
|
|
CHECK(oz.keyTrack == 1.5);
|
|
CHECK(oz.play.playMode == PlayMode::Trigger);
|
|
CHECK(oz.play.trigger.lengthFraction == 0.75);
|
|
CHECK(oz.play.trigger.fadeInFrames == 441);
|
|
CHECK(oz.play.trigger.fadeOutFrames == 882);
|
|
}
|
|
}
|
|
|
|
// GOLDEN FULL-BLOB FIXTURE (reviewer follow-up, Q-W2v). 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/zone tail would
|
|
// still pass it). This test builds a canonical v11 ComponentState that exercises EVERY field
|
|
// family at once (two zones — one Trigger with every optional override set, one Gate with all
|
|
// optionals absent — a two-entry sample-refs table, non-default voice/gain/channel-mode
|
|
// fields, and a non-flat velocity curve) and asserts the encoded bytes equal an EXACT expected
|
|
// vector. The vector below is the current writer's PROVABLY-CORRECT output (proven by the
|
|
// round-trip test above) captured as the golden — so the byte layout itself becomes
|
|
// un-driftable, not just its first 5 bytes.
|
|
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);
|
|
|
|
// Zone A: every optional field present, Trigger mode, non-flat velocity curve.
|
|
PerformanceZone zoneA;
|
|
zoneA.sampleId = "kick";
|
|
zoneA.lowNote = 24;
|
|
zoneA.highNote = 60;
|
|
zoneA.rootOverride = 36;
|
|
SampleLoop loopA;
|
|
loopA.hasLoop = true;
|
|
loopA.start = 1000;
|
|
loopA.end = 5000;
|
|
zoneA.loopOverride = loopA;
|
|
zoneA.startPoint = 250;
|
|
zoneA.keyTrack = 0.5;
|
|
zoneA.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(
|
|
{VelocityPoint{0.0, 0.2}, VelocityPoint{64.0, 0.6}, VelocityPoint{127.0, 1.0}});
|
|
zoneA.play.playMode = PlayMode::Trigger;
|
|
zoneA.play.adsr.attackSeconds = 0.01;
|
|
zoneA.play.adsr.holdSeconds = 0.05;
|
|
zoneA.play.adsr.decaySeconds = 0.02;
|
|
zoneA.play.adsr.sustainLevel = 0.8;
|
|
zoneA.play.adsr.releaseSeconds = 0.15;
|
|
zoneA.play.trigger.lengthFraction = 0.75;
|
|
zoneA.play.trigger.fadeInFrames = 100;
|
|
zoneA.play.trigger.fadeOutFrames = 200;
|
|
zoneA.play.pitchEngine = PitchEngine::Preserve;
|
|
zoneA.play.pitchEnv.enabled = true;
|
|
zoneA.play.pitchEnv.attackSeconds = 0.02;
|
|
zoneA.play.pitchEnv.decaySeconds = 0.03;
|
|
zoneA.play.pitchEnv.peakSemitones = 5.0;
|
|
in.map.zones.push_back(zoneA);
|
|
|
|
// Zone B: every optional field absent, Gate mode, default flat velocity curve.
|
|
PerformanceZone zoneB;
|
|
zoneB.sampleId = "snare";
|
|
zoneB.lowNote = 61;
|
|
zoneB.highNote = 90;
|
|
zoneB.keyTrack = 2.0;
|
|
zoneB.play.playMode = PlayMode::Gate;
|
|
zoneB.play.adsr.attackSeconds = 0.005;
|
|
zoneB.play.adsr.holdSeconds = 0.0;
|
|
zoneB.play.adsr.decaySeconds = 0.1;
|
|
zoneB.play.adsr.sustainLevel = 0.5;
|
|
zoneB.play.adsr.releaseSeconds = 0.2;
|
|
zoneB.play.pitchEngine = PitchEngine::Varispeed;
|
|
in.map.zones.push_back(zoneB);
|
|
|
|
const std::vector<std::uint8_t> bytes = serializeComponentState(in);
|
|
// clang-format off
|
|
static const std::uint8_t kGolden[] = {
|
|
0x0b,0x00,0x00,0x00,0x01,0x39,0x30,0x00,0x00,0x00,0x00,0x00,0x00,0x64,0x18,0x01,
|
|
0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x40,0x01,0x02,0x00,0x00,0x00,0x04,0x00,
|
|
0x00,0x00,0x6b,0x69,0x63,0x6b,0x0d,0x00,0x00,0x00,0x62,0x61,0x6e,0x6b,0x2f,0x6b,
|
|
0x69,0x63,0x6b,0x2e,0x77,0x61,0x76,0x24,0x00,0x00,0x00,0x01,0xe8,0x03,0x00,0x00,
|
|
0x00,0x00,0x00,0x00,0x88,0x13,0x00,0x00,0x00,0x00,0x00,0x00,0x02,0x00,0x00,0x00,
|
|
0x09,0x00,0x00,0x00,0x4b,0x69,0x63,0x6b,0x20,0x44,0x72,0x75,0x6d,0x05,0x00,0x00,
|
|
0x00,0x73,0x6e,0x61,0x72,0x65,0x0e,0x00,0x00,0x00,0x62,0x61,0x6e,0x6b,0x2f,0x73,
|
|
0x6e,0x61,0x72,0x65,0x2e,0x77,0x61,0x76,0x26,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
|
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x00,
|
|
0x00,0x05,0x00,0x00,0x00,0x53,0x6e,0x61,0x72,0x65,0x13,0x00,0x00,0x00,0x67,0x75,
|
|
0x69,0x64,0x2d,0x31,0x32,0x33,0x34,0x2d,0x35,0x36,0x37,0x38,0x2d,0x61,0x62,0x63,
|
|
0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x07,0x00,0x00,
|
|
0x00,0x02,0x00,0x00,0x00,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x18,0x00,0x00,
|
|
0x00,0x3c,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,0x64,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xc8,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,0x05,0x00,0x00,0x00,
|
|
0x73,0x6e,0x61,0x72,0x65,0x3d,0x00,0x00,0x00,0x5a,0x00,0x00,0x00,0x00,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,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
|
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
|
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x7b,0x14,0xae,0x47,0xe1,
|
|
0x7a,0x74,0x3f,0x9a,0x99,0x99,0x99,0x99,0x99,0xb9,0x3f,0x00,0x00,0x00,0x00,0x00,
|
|
0x00,0xe0,0x3f,0x9a,0x99,0x99,0x99,0x99,0x99,0xc9,0x3f,0x00,0x00,0x00,0x00,0x00,
|
|
0x00,0x00,0x40,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,0xc0,0x5f,0x40,0x00,
|
|
0x00,0x00,0x00,0x00,0x00,0xf0,0x3f,
|
|
};
|
|
// 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]) { same = false; break; }
|
|
}
|
|
CHECK(same);
|
|
}
|
|
}
|
|
|
|
// 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). Pins the writer's absolute bytes.
|
|
static void testEnvelopePrefixBytesFrozen() {
|
|
ComponentState in; // defaults: mono, implicit, no refs, no selection, no zones
|
|
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(kZonesPayloadVersion == 7);
|
|
CHECK(kZonesFormatMarker == 0xFFFFFF00u);
|
|
}
|
|
|
|
// A v1 selection blob lifts to {id, one full-keyboard zone} — 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.map.zones.size() == 1);
|
|
if (out.map.zones.size() == 1) {
|
|
CHECK(out.map.zones[0].sampleId == "old-pick");
|
|
CHECK(out.map.zones[0].lowNote == 0);
|
|
CHECK(out.map.zones[0].highNote == 127);
|
|
}
|
|
}
|
|
|
|
// Truncation degrades to a partial/empty parse — never out-of-bounds, never throws.
|
|
static void testTruncationDegradesCleanly() {
|
|
ComponentState in;
|
|
in.selectionId = "smp-2";
|
|
PerformanceZone z;
|
|
z.sampleId = "smp-2";
|
|
in.map.zones.push_back(z);
|
|
const std::vector<std::uint8_t> bytes = serializeComponentState(in);
|
|
for (std::size_t cut = 0; cut < bytes.size(); ++cut) {
|
|
const std::vector<std::uint8_t> part(bytes.begin(),
|
|
bytes.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);
|
|
}
|
|
|
|
// serializePerformance/deserializePerformance round-trip through the v2 envelope.
|
|
static void testPerformanceRoundTrip() {
|
|
PerformanceMap in;
|
|
PerformanceZone z;
|
|
z.sampleId = "zone-a";
|
|
z.lowNote = 10;
|
|
z.highNote = 20;
|
|
in.zones.push_back(z);
|
|
const PerformanceMap out = deserializePerformance(serializePerformance(in), 48000.0);
|
|
CHECK(out.zones.size() == 1);
|
|
if (out.zones.size() == 1) {
|
|
CHECK(out.zones[0].sampleId == "zone-a");
|
|
CHECK(out.zones[0].lowNote == 10);
|
|
CHECK(out.zones[0].highNote == 20);
|
|
}
|
|
}
|
|
|
|
int main() {
|
|
testComponentStateRoundTrip();
|
|
testGoldenFullBlobFixture();
|
|
testEnvelopePrefixBytesFrozen();
|
|
testV1SelectionLift();
|
|
testTruncationDegradesCleanly();
|
|
testPerformanceRoundTrip();
|
|
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;
|
|
}
|