Files
reasampler/tests/test_sample_map.cpp
T

2537 lines
130 KiB
C++

// Standalone tests for reasampler::sample_map — no VST3, no REAPER, no test framework.
// Same fast assert loop as the sibling pure tests. This module is the S4 mapping heart:
// bank blob -> selected sample (through the SHARED bank_book JSON parse), interleaved ->
// mono downmix (the Tier-0 channel policy), the Tier-0 chromatic keymap build, and the
// selected-sample instance-state (de)serialization.
//
// Every assertion is written to FAIL if the mapping were wrong: the bank blobs are built
// by serializing a real BankBook (so we exercise the shared parse, not a fixture string),
// and the selection / downmix / keymap / state values are checked against independently
// computed expectations.
//
// Covers: selectSample by-id hit (across pool + named banks), the S10 policy reversal
// (empty / stale id -> SILENCE nullopt, not the first sample), empty & malformed blob ->
// nullopt, zero-samples -> nullopt, rootNote/loop intrinsic threading incl. the middle-C
// default; listSamples ordinal order + the card metadata (rootNote/key/bankId) + empty/
// malformed; listBanks ordinal order (pool first) + empty/malformed; downmixToMono mono
// passthrough / stereo average / 3-ch average / zero-stride / empty; buildTier0Keymap single
// full-keyboard zone with the root + loop + rate threaded and rate defaulting; selection
// state round-trip + empty id + wrong-version / truncated -> ""; component state (v3)
// round-trip + v1/v2 back-compat lift + empty/unknown -> empty.
// wav_trim -> extractFloatFrames -> downmixToMono integration: locks the interleave-
// stride contract across the seam (that the byte stride wav_trim reports matches the
// channel-count stride downmixToMono divides by).
#include "../src/vst/sample_map.h"
#include <cmath>
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
#include "../src/bank_book.h"
#include "../src/bank_model.h"
#include "../src/vst/master_gain.h" // masterGainMaxLinear (the v8 master-gain wire cap)
using namespace reasampler;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
// Build a Sample with the fields sample_map reads. Relative path is required by
// BankIndex::add (relative-only invariant); a content hash is set so dedup does not
// collapse distinct entries.
static Sample makeSample(const std::string& id, const std::string& name,
const std::string& rel, std::optional<int> root) {
Sample s;
s.id = id;
s.displayName = name;
s.relativePath = rel;
s.contentHash = "hash-" + id;
s.rootNote = root;
return s;
}
// A serialized BankBook: the pool carries `poolSamples`, and one named bank "Drums"
// carries `drumSamples`. Returns the JSON the instrument would read from ext-state.
static std::string bookJson(const std::vector<Sample>& poolSamples,
const std::vector<Sample>& drumSamples) {
BankBook book;
for (const Sample& s : poolSamples) book.pool().index.add(s);
if (!drumSamples.empty()) {
book.createBank("drums-id", "Drums");
BankIndex* di = book.index("drums-id");
for (const Sample& s : drumSamples) di->add(s);
}
return book.serialize();
}
// --- selectSample -------------------------------------------------------------
static void testSelectByIdHit() {
const std::string json = bookJson(
{makeSample("a", "Kick", "reasampler_bank/a.wav", 36)},
{makeSample("b", "Snare", "reasampler_bank/b.wav", 38)});
// A sample in the NAMED bank resolves by id (search spans every bank).
auto sel = selectSample(json, "b");
CHECK(sel.has_value());
CHECK(sel && sel->relativePath == "reasampler_bank/b.wav");
CHECK(sel && sel->rootNote == 38);
}
static void testSelectEmptyIdIsSilence() {
const std::string json = bookJson(
{makeSample("a", "Kick", "reasampler_bank/a.wav", 36)},
{makeSample("b", "Snare", "reasampler_bank/b.wav", 38)});
// POLICY REVERSAL (S10): no stored selection resolves to SILENCE (nullopt), NOT the
// bank's first sample. A fresh instance plays nothing and shows the "pick a capture"
// empty state — the deliberate reversal of the S4 first-sample auto-play.
auto sel = selectSample(json, "");
CHECK(!sel.has_value());
}
static void testSelectUnknownIdIsSilence() {
const std::string json = bookJson(
{makeSample("a", "Kick", "reasampler_bank/a.wav", 36)}, {});
// A stale stored id (deleted/moved-out sample) resolves to SILENCE, not a substituted
// first sample — the editor reflects the missing pick with its empty state rather than
// masking it with a mystery sample.
auto sel = selectSample(json, "deleted-id");
CHECK(!sel.has_value());
}
static void testSelectRootNoteDefault() {
const std::string json = bookJson(
{makeSample("a", "Loop", "reasampler_bank/a.wav", std::nullopt)}, {});
// A sample with no root-note intrinsic defaults to middle C (60).
auto sel = selectSample(json, "a");
CHECK(sel.has_value());
CHECK(sel && sel->rootNote == 60);
}
static void testSelectLoopThreaded() {
Sample s = makeSample("a", "Pad", "reasampler_bank/a.wav", 60);
s.loop = LoopPoints{100, 500};
const std::string json = bookJson({s}, {});
auto sel = selectSample(json, "a");
CHECK(sel.has_value());
CHECK(sel && sel->loop.hasLoop);
CHECK(sel && sel->loop.start == 100 && sel->loop.end == 500);
}
static void testSelectNoLoopIsAbsent() {
const std::string json = bookJson(
{makeSample("a", "OneShot", "reasampler_bank/a.wav", 60)}, {});
auto sel = selectSample(json, "a");
CHECK(sel.has_value());
CHECK(sel && !sel->loop.hasLoop); // absent loop -> hasLoop false (not a zero loop)
}
static void testSelectChannelCountThreaded() {
// GA: the bank's capture channel-count intrinsic rides SelectedSample so the shell can
// auto-default the channel mode (stereo capture -> Stereo). An entry without the
// intrinsic yields 0 (unknown — the auto-default skips it).
Sample st = makeSample("st", "Wide", "reasampler_bank/st.wav", 60);
st.channelCount = 2;
Sample mo = makeSample("mo", "Narrow", "reasampler_bank/mo.wav", 60);
mo.channelCount = 1;
const std::string json = bookJson({st, mo, makeSample("un", "Old", "reasampler_bank/un.wav", 60)}, {});
auto selSt = selectSample(json, "st");
CHECK(selSt && selSt->channelCount == 2);
auto selMo = selectSample(json, "mo");
CHECK(selMo && selMo->channelCount == 1);
auto selUn = selectSample(json, "un");
CHECK(selUn && selUn->channelCount == 0); // unstamped -> unknown, never a guess
}
// --- channelModeFor (GA auto-default rule) -------------------------------------------
static void testChannelModeForExplicitIsNeverFought() {
// An explicit user choice is ALWAYS returned unchanged, regardless of channelCount.
CHECK(channelModeFor(2, ChannelMode::Mono, true) == ChannelMode::Mono);
CHECK(channelModeFor(1, ChannelMode::Stereo, true) == ChannelMode::Stereo);
CHECK(channelModeFor(0, ChannelMode::Stereo, true) == ChannelMode::Stereo);
}
static void testChannelModeForUnknownCountIsNoOp() {
// An unknown channel count (0 — an older bank entry) leaves the current mode unchanged.
CHECK(channelModeFor(0, ChannelMode::Mono, false) == ChannelMode::Mono);
CHECK(channelModeFor(0, ChannelMode::Stereo, false) == ChannelMode::Stereo);
}
static void testChannelModeForStereoCapture() {
// A capture with channelCount >= 2 selects Stereo (regardless of current mode).
CHECK(channelModeFor(2, ChannelMode::Mono, false) == ChannelMode::Stereo);
CHECK(channelModeFor(2, ChannelMode::Stereo, false) == ChannelMode::Stereo);
CHECK(channelModeFor(6, ChannelMode::Mono, false) == ChannelMode::Stereo);
}
static void testChannelModeForMonoCapture() {
// A capture with channelCount == 1 selects Mono (ingest-imported mono files only).
CHECK(channelModeFor(1, ChannelMode::Stereo, false) == ChannelMode::Mono);
CHECK(channelModeFor(1, ChannelMode::Mono, false) == ChannelMode::Mono);
}
static void testSelectEmptyBlob() {
CHECK(!selectSample("", "a").has_value());
}
static void testSelectMalformedBlob() {
CHECK(!selectSample("{not valid json", "a").has_value());
}
static void testSelectZeroSamples() {
// A valid book with NO samples anywhere -> nothing to play.
const std::string json = bookJson({}, {});
CHECK(!selectSample(json, "").has_value());
CHECK(!selectSample(json, "anything").has_value());
}
// --- listSamples --------------------------------------------------------------
static void testListSamplesOrdinalOrder() {
const std::string json = bookJson(
{makeSample("a", "Kick", "reasampler_bank/a.wav", 36),
makeSample("c", "Hat", "reasampler_bank/c.wav", 42)},
{makeSample("b", "Snare", "reasampler_bank/b.wav", 38)});
const std::vector<SampleChoice> list = listSamples(json);
// Pool samples (insertion order) come before the named bank's.
CHECK(list.size() == 3);
CHECK(list.size() == 3 && list[0].id == "a" && list[0].displayName == "Kick");
CHECK(list.size() == 3 && list[1].id == "c");
CHECK(list.size() == 3 && list[2].id == "b" && list[2].displayName == "Snare");
}
static void testListSamplesCarriesCardMetadata() {
// The browser card needs rootNote/key badge + the bank id (for the filter). A pool sample
// reports the pool bank id; a named-bank sample reports "drums-id"; an un-rooted sample
// reports no rootNote (the badge shows "root —", never a guessed value).
Sample rooted = makeSample("a", "Kick", "reasampler_bank/a.wav", 36);
rooted.key = "Cm";
Sample unrooted = makeSample("u", "Loop", "reasampler_bank/u.wav", std::nullopt);
const std::string json = bookJson({rooted, unrooted},
{makeSample("b", "Snare", "reasampler_bank/b.wav", 38)});
const std::vector<SampleChoice> list = listSamples(json);
CHECK(list.size() == 3);
// Pool sample "a": rooted + keyed, pool bank id.
CHECK(list[0].id == "a" && list[0].rootNote.has_value() && *list[0].rootNote == 36);
CHECK(list[0].key.has_value() && *list[0].key == "Cm");
CHECK(!list[0].bankId.empty()); // the pool has an id; the filter matches on it
// Pool sample "u": no root intrinsic -> no rootNote (badge shows "root —").
CHECK(list[1].id == "u" && !list[1].rootNote.has_value());
// Named-bank sample "b": its bank id distinguishes it from the pool for the filter.
CHECK(list[2].id == "b" && list[2].bankId == "drums-id");
CHECK(list[2].bankId != list[0].bankId); // pool vs. named bank differ (filterable apart)
}
static void testListSamplesEmptyAndMalformed() {
CHECK(listSamples("").empty());
CHECK(listSamples("{garbage").empty());
CHECK(listSamples(bookJson({}, {})).empty());
}
static void testListBanksOrdinalOrder() {
const std::string json = bookJson(
{makeSample("a", "Kick", "reasampler_bank/a.wav", 36)},
{makeSample("b", "Snare", "reasampler_bank/b.wav", 38)});
const std::vector<BankChoice> banks = listBanks(json);
// Pool first (bank-zero), then the named bank "Drums". Both ids are present so the filter
// tab strip can key on them.
CHECK(banks.size() == 2);
CHECK(banks.size() == 2 && banks[1].id == "drums-id" && banks[1].displayName == "Drums");
CHECK(banks.size() == 2 && !banks[0].id.empty()); // the pool bank has an id too
}
static void testListBanksEmptyAndMalformed() {
CHECK(listBanks("").empty());
CHECK(listBanks("{garbage").empty());
// A valid book with no samples still has the pool bank -> one entry.
CHECK(listBanks(bookJson({}, {})).size() == 1);
}
// --- downmixToMono ------------------------------------------------------------
static bool approx(double a, double b) { return std::fabs(a - b) < 1e-6; }
static void testDownmixMonoPassthrough() {
const std::vector<AudioSample> in{0.1f, -0.2f, 0.3f};
const std::vector<AudioSample> out = downmixToMono(in, 1);
CHECK(out.size() == 3);
CHECK(out.size() == 3 && approx(out[0], 0.1) && approx(out[1], -0.2) &&
approx(out[2], 0.3));
}
static void testDownmixStereoAverages() {
// Two frames, stereo interleaved: frame0 = (1.0, 0.0) -> 0.5; frame1 = (0.4, 0.6) -> 0.5.
const std::vector<AudioSample> in{1.0f, 0.0f, 0.4f, 0.6f};
const std::vector<AudioSample> out = downmixToMono(in, 2);
CHECK(out.size() == 2);
CHECK(out.size() == 2 && approx(out[0], 0.5) && approx(out[1], 0.5));
}
static void testDownmixThreeChannelAverages() {
// One 3-channel frame (0.3, 0.3, 0.6) -> 0.4.
const std::vector<AudioSample> in{0.3f, 0.3f, 0.6f};
const std::vector<AudioSample> out = downmixToMono(in, 3);
CHECK(out.size() == 1);
CHECK(out.size() == 1 && approx(out[0], 0.4));
}
static void testDownmixDegenerate() {
CHECK(downmixToMono({}, 2).empty()); // empty input
CHECK(downmixToMono({0.1f, 0.2f}, 0).empty()); // zero stride
CHECK(downmixToMono({0.1f, 0.2f}, -1).empty()); // negative stride
}
// --- buildTier0Keymap ---------------------------------------------------------
static void testBuildKeymapSingleFullZone() {
SampleLoop loop;
loop.hasLoop = true;
loop.start = 10;
loop.end = 90;
const Keymap km = buildTier0Keymap({0.1f, 0.2f, 0.3f}, 48000, 40, loop);
// One sample, one zone spanning the whole keyboard, rooted at 40.
CHECK(km.samples.size() == 1);
CHECK(km.zones.size() == 1);
CHECK(km.zones.size() == 1 && km.zones[0].lowNote == 0 && km.zones[0].highNote == 127);
CHECK(km.zones.size() == 1 && km.zones[0].rootNote == 40);
CHECK(km.samples.size() == 1 && km.samples[0].sampleRate == 48000);
CHECK(km.samples.size() == 1 && km.samples[0].rootNote == 40);
CHECK(km.samples.size() == 1 && km.samples[0].frames.size() == 3);
CHECK(km.samples.size() == 1 && km.samples[0].loop.hasLoop &&
km.samples[0].loop.start == 10 && km.samples[0].loop.end == 90);
// Resolution: any note lands in the single zone.
CHECK(km.resolve(0, 100).matched);
CHECK(km.resolve(127, 100).matched);
}
// --- selection state (setState/getState) --------------------------------------
static void testSelectionStateRoundTrip() {
const std::string id = "sample-guid-123";
const std::vector<std::uint8_t> bytes = serializeSelection(id);
// Versioned: 4-byte tag + the id bytes.
CHECK(bytes.size() == 4 + id.size());
CHECK(deserializeSelection(bytes) == id);
}
static void testSelectionStateEmptyId() {
const std::vector<std::uint8_t> bytes = serializeSelection("");
CHECK(bytes.size() == 4); // just the version tag
CHECK(deserializeSelection(bytes) == "");
}
static void testSelectionStateWrongVersion() {
std::vector<std::uint8_t> bytes = serializeSelection("id");
bytes[0] = 0xEE; // corrupt the version tag
CHECK(deserializeSelection(bytes) == ""); // unknown version -> no selection
}
static void testSelectionStateTruncated() {
CHECK(deserializeSelection({}) == ""); // empty
CHECK(deserializeSelection({1, 0, 0}) == ""); // fewer than 4 bytes (no tag)
}
// --- wav_trim -> extractFloatFrames -> downmixToMono integration ---------------
//
// Locks the interleave-stride contract at the seam between wav_trim and sample_map:
// wav_trim reports channelCount, extractFloatFrames yields interleaved samples with
// that stride, and downmixToMono divides by that same stride. If either module
// changed its understanding of the layout (e.g. extractFloatFrames started packing
// differently, or downmixToMono changed its stride divisor), this test catches it.
static void putU16sm(std::vector<std::uint8_t>& b, std::uint16_t v) {
b.push_back(static_cast<std::uint8_t>(v & 0xFF));
b.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
}
static void putU32sm(std::vector<std::uint8_t>& b, std::uint32_t v) {
b.push_back(static_cast<std::uint8_t>(v & 0xFF));
b.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
b.push_back(static_cast<std::uint8_t>((v >> 16) & 0xFF));
b.push_back(static_cast<std::uint8_t>((v >> 24) & 0xFF));
}
static void putTagsm(std::vector<std::uint8_t>& b, const char* t) {
for (int i = 0; i < 4; ++i) b.push_back(static_cast<std::uint8_t>(t[i]));
}
static void putFloatsm(std::vector<std::uint8_t>& b, float f) {
std::uint8_t tmp[4];
std::memcpy(tmp, &f, 4);
for (int i = 0; i < 4; ++i) b.push_back(tmp[i]);
}
// Build a 32-bit-float WAV byte buffer. Samples: frame f, channel c = value(f, c).
template <typename Fn>
static std::vector<std::uint8_t> buildWav(std::uint16_t channels,
std::uint32_t sampleRate,
std::size_t frames,
Fn value) {
const std::uint32_t dataBytes =
static_cast<std::uint32_t>(frames * channels * 4u);
std::vector<std::uint8_t> chunks;
putTagsm(chunks, "fmt ");
putU32sm(chunks, 16);
putU16sm(chunks, 3); // IEEE float
putU16sm(chunks, channels);
putU32sm(chunks, sampleRate);
putU32sm(chunks, sampleRate * channels * 4u);
putU16sm(chunks, static_cast<std::uint16_t>(channels * 4));
putU16sm(chunks, 32);
putTagsm(chunks, "data");
putU32sm(chunks, dataBytes);
for (std::size_t f = 0; f < frames; ++f)
for (std::uint16_t c = 0; c < channels; ++c)
putFloatsm(chunks, value(f, c));
std::vector<std::uint8_t> wav;
putTagsm(wav, "RIFF");
putU32sm(wav, static_cast<std::uint32_t>(4 + chunks.size()));
putTagsm(wav, "WAVE");
wav.insert(wav.end(), chunks.begin(), chunks.end());
return wav;
}
static void testWavTrimToDownmixPipelineStereo() {
// Stereo WAV: frame f, L = f * 0.1f, R = f * 0.1f + 0.5f. Expected mono average:
// (f * 0.1f + f * 0.1f + 0.5f) / 2 = f * 0.1f + 0.25f.
const std::size_t kFrames = 4;
auto wav = buildWav(2, 48000, kFrames,
[](std::size_t f, std::uint16_t c) {
return static_cast<float>(f) * 0.1f + (c == 1 ? 0.5f : 0.0f);
});
WavLayout layout = parseWavLayout(wav);
CHECK(layout.valid);
CHECK(layout.channelCount == 2);
CHECK(layout.frameCount() == kFrames);
const std::vector<AudioSample> interleaved =
extractFloatFrames(wav, layout, 0, layout.frameCount());
CHECK(interleaved.size() == kFrames * 2);
const std::vector<AudioSample> mono = downmixToMono(interleaved, layout.channelCount);
CHECK(mono.size() == kFrames);
for (std::size_t f = 0; f < kFrames; ++f) {
const float expected = static_cast<float>(f) * 0.1f + 0.25f;
CHECK(approx(mono[f], expected));
}
}
static void testWavTrimToDownmixPipelineMono() {
// Mono WAV: extractFloatFrames -> downmixToMono with channelCount==1 is a passthrough.
const std::size_t kFrames = 3;
auto wav = buildWav(1, 44100, kFrames,
[](std::size_t f, std::uint16_t) {
return static_cast<float>(f) * 0.5f;
});
WavLayout layout = parseWavLayout(wav);
CHECK(layout.valid);
CHECK(layout.channelCount == 1);
const std::vector<AudioSample> interleaved =
extractFloatFrames(wav, layout, 0, layout.frameCount());
CHECK(interleaved.size() == kFrames);
const std::vector<AudioSample> mono = downmixToMono(interleaved, layout.channelCount);
CHECK(mono.size() == kFrames);
CHECK(approx(mono[0], 0.0) && approx(mono[1], 0.5) && approx(mono[2], 1.0));
}
// --- performance map: resolvePerformance --------------------------------------
static PerformanceZone zone(const std::string& id, int lo, int hi,
std::optional<int> rootOverride = std::nullopt) {
PerformanceZone z;
z.sampleId = id;
z.lowNote = lo;
z.highNote = hi;
z.rootOverride = rootOverride;
return z;
}
static void testResolveEmptyMap() {
// An empty performance map resolves to nothing (the shell falls back to Tier 0).
const std::string json = bookJson({makeSample("a", "Kick", "b/a.wav", 36)}, {});
const ResolvedPerformance r = resolvePerformance(json, PerformanceMap{});
CHECK(r.zones.empty());
CHECK(r.droppedSampleIds.empty());
}
static void testResolveEmptyBlob() {
PerformanceMap m;
m.zones.push_back(zone("a", 0, 127));
CHECK(resolvePerformance("", m).zones.empty()); // no bank
CHECK(resolvePerformance("{garbage", m).zones.empty()); // malformed
}
static void testResolveMultiZoneAcrossBanks() {
const std::string json = bookJson(
{makeSample("a", "Kick", "b/a.wav", 36)},
{makeSample("b", "Snare", "b/b.wav", 38)});
PerformanceMap m;
m.zones.push_back(zone("a", 36, 47));
m.zones.push_back(zone("b", 48, 59));
const ResolvedPerformance r = resolvePerformance(json, m);
CHECK(r.zones.size() == 2);
CHECK(r.droppedSampleIds.empty());
// Order preserved; paths + ranges threaded.
CHECK(r.zones.size() == 2 && r.zones[0].relativePath == "b/a.wav");
CHECK(r.zones.size() == 2 && r.zones[0].lowNote == 36 && r.zones[0].highNote == 47);
CHECK(r.zones.size() == 2 && r.zones[1].relativePath == "b/b.wav");
CHECK(r.zones.size() == 2 && r.zones[1].lowNote == 48 && r.zones[1].highNote == 59);
}
static void testResolveStaleIdDropsZone() {
// STALE-ID POLICY: a zone naming a deleted sample is dropped, its id reported; the
// surviving zone still resolves (the whole map is NOT abandoned).
const std::string json = bookJson({makeSample("a", "Kick", "b/a.wav", 36)}, {});
PerformanceMap m;
m.zones.push_back(zone("a", 0, 59));
m.zones.push_back(zone("ghost", 60, 127)); // no such sample
const ResolvedPerformance r = resolvePerformance(json, m);
CHECK(r.zones.size() == 1);
CHECK(r.zones.size() == 1 && r.zones[0].relativePath == "b/a.wav");
CHECK(r.droppedSampleIds.size() == 1);
CHECK(r.droppedSampleIds.size() == 1 && r.droppedSampleIds[0] == "ghost");
}
static void testResolveRootPrecedence() {
// Override beats bank intrinsic beats middle-C default.
const std::string json = bookJson(
{makeSample("rooted", "R", "b/r.wav", 40), // bank intrinsic 40
makeSample("unrooted", "U", "b/u.wav", std::nullopt)}, // no intrinsic
{});
PerformanceMap m;
m.zones.push_back(zone("rooted", 0, 42)); // no override -> 40
m.zones.push_back(zone("rooted", 43, 84, /*override=*/72)); // override -> 72
m.zones.push_back(zone("unrooted", 85, 127)); // no intrinsic -> 60
const ResolvedPerformance r = resolvePerformance(json, m);
CHECK(r.zones.size() == 3);
CHECK(r.zones.size() == 3 && r.zones[0].rootNote == 40); // bank intrinsic
CHECK(r.zones.size() == 3 && r.zones[1].rootNote == 72); // override wins
CHECK(r.zones.size() == 3 && r.zones[2].rootNote == 60); // middle-C default
}
static void testResolveLoopThreaded() {
Sample s = makeSample("a", "Pad", "b/a.wav", 60);
s.loop = LoopPoints{200, 800};
const std::string json = bookJson({s}, {});
PerformanceMap m;
m.zones.push_back(zone("a", 0, 127));
const ResolvedPerformance r = resolvePerformance(json, m);
CHECK(r.zones.size() == 1);
CHECK(r.zones.size() == 1 && r.zones[0].loop.hasLoop);
CHECK(r.zones.size() == 1 && r.zones[0].loop.start == 200 && r.zones[0].loop.end == 800);
// No loop override + no startPoint -> effective start is 0 (S11 default).
CHECK(r.zones.size() == 1 && r.zones[0].startFrame == 0);
}
static void testResolveLoopOverrideWins() {
// S11: the instrument's per-zone loopOverride beats the bank's S2 loop intrinsic, and the
// startPoint feeds the effective startFrame — without mutating the bank.
Sample s = makeSample("a", "Pad", "b/a.wav", 60);
s.loop = LoopPoints{200, 800}; // bank intrinsic
const std::string json = bookJson({s}, {});
PerformanceMap m;
PerformanceZone z = zone("a", 0, 127);
SampleLoop over; over.hasLoop = true; over.start = 1000; over.end = 4000;
z.loopOverride = over; // instrument override
z.startPoint = 512; // start offset
m.zones.push_back(z);
const ResolvedPerformance r = resolvePerformance(json, m);
CHECK(r.zones.size() == 1 && r.zones[0].loop.hasLoop);
CHECK(r.zones.size() == 1 && r.zones[0].loop.start == 1000 && r.zones[0].loop.end == 4000);
CHECK(r.zones.size() == 1 && r.zones[0].startFrame == 512);
}
static void testResolveLoopOverrideDisablesLoop() {
// A loopOverride with hasLoop=false explicitly REMOVES the bank's loop for this instance
// (override present-but-empty wins over the intrinsic — a deliberate "no loop here").
Sample s = makeSample("a", "Pad", "b/a.wav", 60);
s.loop = LoopPoints{200, 800};
const std::string json = bookJson({s}, {});
PerformanceMap m;
PerformanceZone z = zone("a", 0, 127);
z.loopOverride = SampleLoop{}; // hasLoop=false, start=end=0
m.zones.push_back(z);
const ResolvedPerformance r = resolvePerformance(json, m);
CHECK(r.zones.size() == 1 && !r.zones[0].loop.hasLoop);
}
// --- performance map: buildZonedKeymap ----------------------------------------
static void testBuildZonedKeymapMultiZone() {
std::vector<ResolvedZone> zones;
ResolvedZone z0; z0.lowNote = 36; z0.highNote = 47; z0.rootNote = 36; zones.push_back(z0);
ResolvedZone z1; z1.lowNote = 48; z1.highNote = 59; z1.rootNote = 48; zones.push_back(z1);
std::vector<DecodedZonePcm> decoded;
decoded.push_back(DecodedZonePcm{{0.1f, 0.2f}, 44100});
decoded.push_back(DecodedZonePcm{{0.3f, 0.4f, 0.5f}, 48000});
const Keymap km = buildZonedKeymap(zones, decoded);
CHECK(km.samples.size() == 2);
CHECK(km.zones.size() == 2);
// Zone 0 -> sample 0, rooted 36, range 36..47; zone 1 -> sample 1, rooted 48.
CHECK(km.zones.size() == 2 && km.zones[0].sampleIndex == 0 && km.zones[0].rootNote == 36);
CHECK(km.zones.size() == 2 && km.zones[0].lowNote == 36 && km.zones[0].highNote == 47);
CHECK(km.zones.size() == 2 && km.zones[1].sampleIndex == 1 && km.zones[1].rootNote == 48);
CHECK(km.samples.size() == 2 && km.samples[1].sampleRate == 48000);
CHECK(km.samples.size() == 2 && km.samples[1].frames.size() == 3);
// Resolution: a note in each range lands in the right zone.
CHECK(km.resolve(40, 100).matched && km.resolve(40, 100).zoneIndex == 0);
CHECK(km.resolve(52, 100).matched && km.resolve(52, 100).zoneIndex == 1);
// A note outside every zone does not match (no-play, not zone 0).
CHECK(!km.resolve(24, 100).matched);
}
static void testBuildZonedKeymapThreadsLoopAndStart() {
// S11: the effective loop + start on a ResolvedZone reach the core's SampleData so the
// voice honors them at note-on.
std::vector<ResolvedZone> zones;
ResolvedZone z0;
z0.lowNote = 0; z0.highNote = 127; z0.rootNote = 60;
z0.loop.hasLoop = true; z0.loop.start = 3; z0.loop.end = 7;
z0.startFrame = 2;
zones.push_back(z0);
std::vector<DecodedZonePcm> decoded;
decoded.push_back(DecodedZonePcm{{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}, 44100});
const Keymap km = buildZonedKeymap(zones, decoded);
CHECK(km.samples.size() == 1);
CHECK(km.samples.size() == 1 && km.samples[0].loop.hasLoop &&
km.samples[0].loop.start == 3 && km.samples[0].loop.end == 7);
CHECK(km.samples.size() == 1 && km.samples[0].startFrame == 2);
}
static void testBuildZonedKeymapDropsEmptyPcm() {
// A zone whose decoded WAV is empty is dropped; the other zone survives, and the
// survivor's sampleIndex points at ITS sample (not the dropped one's slot).
std::vector<ResolvedZone> zones;
ResolvedZone z0; z0.lowNote = 0; z0.highNote = 63; z0.rootNote = 60; zones.push_back(z0);
ResolvedZone z1; z1.lowNote = 64; z1.highNote = 127; z1.rootNote = 72; zones.push_back(z1);
std::vector<DecodedZonePcm> decoded;
decoded.push_back(DecodedZonePcm{{}, 44100}); // empty -> dropped
decoded.push_back(DecodedZonePcm{{0.9f}, 44100}); // survives
const Keymap km = buildZonedKeymap(zones, decoded);
CHECK(km.samples.size() == 1);
CHECK(km.zones.size() == 1);
CHECK(km.zones.size() == 1 && km.zones[0].sampleIndex == 0); // remapped to slot 0
CHECK(km.zones.size() == 1 && km.zones[0].lowNote == 64 && km.zones[0].rootNote == 72);
}
static void testBuildZonedKeymapOverlapFirstWins() {
// OVERLAP POLICY: two zones share keys; the FIRST in order wins the contested note
// (mirrors the S3 core's first-match resolve).
std::vector<ResolvedZone> zones;
ResolvedZone z0; z0.lowNote = 0; z0.highNote = 127; z0.rootNote = 60; zones.push_back(z0);
ResolvedZone z1; z1.lowNote = 60; z1.highNote = 72; z1.rootNote = 48; zones.push_back(z1);
std::vector<DecodedZonePcm> decoded;
decoded.push_back(DecodedZonePcm{{0.1f}, 44100});
decoded.push_back(DecodedZonePcm{{0.2f}, 44100});
const Keymap km = buildZonedKeymap(zones, decoded);
CHECK(km.zones.size() == 2);
// Note 64 is in both zones; first-match resolves to zone 0.
CHECK(km.resolve(64, 100).matched && km.resolve(64, 100).zoneIndex == 0);
}
static void testBuildZonedKeymapEmpty() {
// No zones -> empty keymap (silence).
const Keymap km = buildZonedKeymap({}, {});
CHECK(km.samples.empty() && km.zones.empty());
CHECK(!km.resolve(60, 100).matched);
}
// --- performance-map state: serialize / deserialize ---------------------------
static void testPerformanceStateRoundTrip() {
PerformanceMap m;
m.zones.push_back(zone("kick", 36, 47)); // no override
m.zones.push_back(zone("snare", 48, 59, /*override=*/50)); // with override
const std::vector<std::uint8_t> bytes = serializePerformance(m);
const PerformanceMap back = deserializePerformance(bytes, 44100.0);
CHECK(back.zones.size() == 2);
CHECK(back.zones.size() == 2 && back.zones[0].sampleId == "kick");
CHECK(back.zones.size() == 2 && back.zones[0].lowNote == 36 && back.zones[0].highNote == 47);
CHECK(back.zones.size() == 2 && !back.zones[0].rootOverride.has_value());
CHECK(back.zones.size() == 2 && back.zones[1].sampleId == "snare");
CHECK(back.zones.size() == 2 && back.zones[1].rootOverride.has_value() &&
*back.zones[1].rootOverride == 50);
}
static void testPerformanceStateEmpty() {
const std::vector<std::uint8_t> bytes = serializePerformance(PerformanceMap{});
// Envelope version (4) + zones-payload marker (4) + payload version (4) + zero count (4).
CHECK(bytes.size() == 16);
CHECK(deserializePerformance(bytes, 44100.0).zones.empty());
}
static void testPerformanceStateLoopStartRoundTrip() {
// S11: the per-zone loopOverride + startPoint survive the payload-v2 round trip.
PerformanceMap m;
PerformanceZone z = zone("pad", 24, 96, /*override=*/64);
SampleLoop lp; lp.hasLoop = true; lp.start = 12345; lp.end = 67890;
z.loopOverride = lp;
z.startPoint = 4096;
m.zones.push_back(z);
// A second zone with NO overrides proves the optional tail is per-record.
m.zones.push_back(zone("kick", 0, 23));
const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0);
CHECK(back.zones.size() == 2);
CHECK(back.zones.size() == 2 && back.zones[0].rootOverride.has_value() &&
*back.zones[0].rootOverride == 64);
CHECK(back.zones.size() == 2 && back.zones[0].loopOverride.has_value() &&
back.zones[0].loopOverride->hasLoop &&
back.zones[0].loopOverride->start == 12345 &&
back.zones[0].loopOverride->end == 67890);
CHECK(back.zones.size() == 2 && back.zones[0].startPoint.has_value() &&
*back.zones[0].startPoint == 4096);
// Zone 1: no overrides -> all optionals absent after round trip.
CHECK(back.zones.size() == 2 && !back.zones[1].loopOverride.has_value());
CHECK(back.zones.size() == 2 && !back.zones[1].startPoint.has_value());
}
static void testPerformanceStateV1PayloadBackCompat() {
// A pre-S11 PAYLOAD v1 blob (no format marker: envelope v2 + bare count + short records)
// parses cleanly with the loop/start overrides defaulting absent. Hand-build the exact
// shipped shape to prove the reader still accepts the marker-less payload.
std::vector<std::uint8_t> b;
auto u32 = [&](std::uint32_t v) {
b.push_back(v & 0xFF); b.push_back((v >> 8) & 0xFF);
b.push_back((v >> 16) & 0xFF); b.push_back((v >> 24) & 0xFF);
};
u32(2); // envelope version 2
u32(1); // zone count 1 (NOT the marker -> payload v1)
const std::string id = "legacy";
u32(static_cast<std::uint32_t>(id.size()));
b.insert(b.end(), id.begin(), id.end());
u32(10); // lowNote
u32(40); // highNote
b.push_back(0); // hasRootOverride = 0 (record ends here in v1)
const PerformanceMap back = deserializePerformance(b, 44100.0);
CHECK(back.zones.size() == 1 && back.zones[0].sampleId == "legacy");
CHECK(back.zones.size() == 1 && back.zones[0].lowNote == 10 && back.zones[0].highNote == 40);
CHECK(back.zones.size() == 1 && !back.zones[0].loopOverride.has_value());
CHECK(back.zones.size() == 1 && !back.zones[0].startPoint.has_value());
}
static void testComponentStateLoopStartRoundTrip() {
// The overrides also round-trip through the v3 ComponentState envelope (zones nest inside
// it), so the processor's live getState/setState preserves them — the composition property.
ComponentState s;
s.selectionId = "pick";
PerformanceZone z = zone("pick", 0, 127);
SampleLoop lp; lp.hasLoop = true; lp.start = 500; lp.end = 9000;
z.loopOverride = lp;
z.startPoint = 128;
s.map.zones.push_back(z);
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
CHECK(back.selectionId == "pick");
CHECK(back.map.zones.size() == 1 && back.map.zones[0].loopOverride.has_value() &&
back.map.zones[0].loopOverride->start == 500 &&
back.map.zones[0].loopOverride->end == 9000);
CHECK(back.map.zones.size() == 1 && back.map.zones[0].startPoint.has_value() &&
*back.map.zones[0].startPoint == 128);
}
static void testPerformanceStateV1BackCompat() {
// A v1 blob (the S4 single-selection format) lifts to a single full-keyboard zone.
const std::vector<std::uint8_t> v1 = serializeSelection("legacy-sample-id");
const PerformanceMap back = deserializePerformance(v1, 44100.0);
CHECK(back.zones.size() == 1);
CHECK(back.zones.size() == 1 && back.zones[0].sampleId == "legacy-sample-id");
CHECK(back.zones.size() == 1 && back.zones[0].lowNote == 0 && back.zones[0].highNote == 127);
CHECK(back.zones.size() == 1 && !back.zones[0].rootOverride.has_value());
// A v1 blob with an EMPTY id lifts to an empty map (no zone for "no selection").
CHECK(deserializePerformance(serializeSelection(""), 44100.0).zones.empty());
}
static void testPerformanceStateGarbage() {
// Unknown version / truncated / empty -> empty map (never throws).
CHECK(deserializePerformance({}, 44100.0).zones.empty());
CHECK(deserializePerformance({0xAA, 0xBB, 0xCC, 0xDD}, 44100.0).zones.empty()); // unknown version
// Truncated mid-zone: valid v2 header claiming 1 zone but no zone bytes -> empty.
std::vector<std::uint8_t> t;
t.push_back(2); t.push_back(0); t.push_back(0); t.push_back(0); // version 2
t.push_back(1); t.push_back(0); t.push_back(0); t.push_back(0); // count 1
// (no zone payload)
CHECK(deserializePerformance(t, 44100.0).zones.empty());
}
static void testPerformanceStateNegativeNotesRoundTrip() {
// Notes are clamped in the UI, but the wire format must survive the full int range so
// a hand-set/legacy value round-trips without corruption (two's-complement on the wire).
PerformanceMap m;
m.zones.push_back(zone("s", 0, 127, /*override=*/0));
const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0);
CHECK(back.zones.size() == 1 && back.zones[0].rootOverride.has_value() &&
*back.zones[0].rootOverride == 0);
}
// --- Combined component state (v3, S10) --------------------------------------
static void testComponentStateRoundTrip() {
// The v3 state carries the single-capture selection AND the opt-in zones, distinctly.
ComponentState s;
s.selectionId = "picked-capture";
s.map.zones.push_back(zone("z0", 0, 59, /*override=*/std::nullopt));
s.map.zones.push_back(zone("z1", 60, 127, /*override=*/48));
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
CHECK(back.selectionId == "picked-capture");
CHECK(back.map.zones.size() == 2);
CHECK(back.map.zones.size() == 2 && back.map.zones[0].sampleId == "z0" &&
back.map.zones[0].highNote == 59 && !back.map.zones[0].rootOverride.has_value());
CHECK(back.map.zones.size() == 2 && back.map.zones[1].sampleId == "z1" &&
back.map.zones[1].rootOverride.has_value() && *back.map.zones[1].rootOverride == 48);
}
static void testComponentStateSelectionOnlyNoZones() {
// A single-capture instance: a pick, no zones. Must restore the pick with an empty map
// (NOT synthesize a zone) — the default face is one capture, zones are opt-in.
ComponentState s;
s.selectionId = "just-a-pick";
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
CHECK(back.selectionId == "just-a-pick");
CHECK(back.map.zones.empty());
}
static void testComponentStateEmptyIsEmpty() {
// No pick, no zones -> restores EMPTY (the S10 silent empty state), never a first sample.
const ComponentState s; // selectionId "", empty map
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
CHECK(back.selectionId.empty());
CHECK(back.map.zones.empty());
}
static void testComponentStateV1BackCompat() {
// A v1 S4 blob (single-selection) lifts to {id, one full-keyboard zone} so an old pick
// survives as BOTH the selection and a one-zone map.
const std::vector<std::uint8_t> v1 = serializeSelection("legacy-id");
const ComponentState back = deserializeComponentState(v1, 44100.0);
CHECK(back.selectionId == "legacy-id");
CHECK(back.map.zones.size() == 1);
CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "legacy-id" &&
back.map.zones[0].lowNote == 0 && back.map.zones[0].highNote == 127);
// A v1 blob with an EMPTY id -> empty state (no selection, no zone).
const ComponentState empty = deserializeComponentState(serializeSelection(""), 44100.0);
CHECK(empty.selectionId.empty() && empty.map.zones.empty());
}
static void testComponentStateV2BackCompat() {
// A v2 S5 blob (zones-only) lifts to {"", zones}: that instance had zones but no separate
// single-capture selection.
PerformanceMap m;
m.zones.push_back(zone("s", 12, 24, /*override=*/std::nullopt));
const std::vector<std::uint8_t> v2 = serializePerformance(m);
const ComponentState back = deserializeComponentState(v2, 44100.0);
CHECK(back.selectionId.empty());
CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "s" &&
back.map.zones[0].lowNote == 12 && back.map.zones[0].highNote == 24);
}
static void testComponentStateGarbage() {
// Empty / unknown version -> empty (never throws across the host).
CHECK(deserializeComponentState({}, 44100.0).selectionId.empty());
CHECK(deserializeComponentState({}, 44100.0).map.zones.empty());
const std::vector<std::uint8_t> unknown{0xAA, 0xBB, 0xCC, 0xDD};
CHECK(deserializeComponentState(unknown, 44100.0).map.zones.empty());
CHECK(deserializeComponentState(unknown, 44100.0).selectionId.empty());
// A v3 header claiming a longer id than the blob holds -> empty (bounded read).
std::vector<std::uint8_t> t;
t.push_back(3); t.push_back(0); t.push_back(0); t.push_back(0); // version 3
t.push_back(200); t.push_back(0); t.push_back(0); t.push_back(0); // id length 200 (absent)
CHECK(deserializeComponentState(t, 44100.0).selectionId.empty());
CHECK(deserializeComponentState(t, 44100.0).map.zones.empty());
}
// --- S7: extractChannel / decodeChannels (cross-mode channel policy) ----------
static void testExtractChannelStereo() {
// Interleaved stereo [L0,R0,L1,R1,...]; extract channel 0 -> L's, channel 1 -> R's.
const std::vector<AudioSample> in{0.1f, 0.9f, 0.2f, 0.8f, 0.3f, 0.7f};
const std::vector<AudioSample> l = extractChannel(in, 2, 0);
const std::vector<AudioSample> r = extractChannel(in, 2, 1);
CHECK(l.size() == 3 && approx(l[0], 0.1) && approx(l[1], 0.2) && approx(l[2], 0.3));
CHECK(r.size() == 3 && approx(r[0], 0.9) && approx(r[1], 0.8) && approx(r[2], 0.7));
}
static void testExtractChannelClampsToLast() {
// A mono source asked for channel 1 yields channel 0 (clamp to last) — the dual-mono block.
const std::vector<AudioSample> mono{0.1f, 0.2f, 0.3f};
const std::vector<AudioSample> ch1 = extractChannel(mono, 1, 1);
CHECK(ch1.size() == 3 && approx(ch1[0], 0.1) && approx(ch1[2], 0.3)); // == channel 0
CHECK(extractChannel({}, 2, 0).empty()); // empty in
CHECK(extractChannel({0.1f}, 0, 0).empty()); // zero stride
}
static void testDecodeChannelsMonoModeDownmixes() {
// MONO mode: a stereo source averages to one channel (the existing policy), framesR empty.
const std::vector<AudioSample> stereo{1.0f, 0.0f, 0.4f, 0.6f}; // frames (1,0) and (0.4,0.6)
const DecodedZonePcm d = decodeChannels(stereo, 2, ChannelMode::Mono, 48000);
CHECK(d.monoFrames.size() == 2 && approx(d.monoFrames[0], 0.5) && approx(d.monoFrames[1], 0.5));
CHECK(d.framesR.empty()); // mono mode -> single channel
CHECK(d.sampleRate == 48000);
}
static void testDecodeChannelsStereoModeStereoSource() {
// STEREO mode + stereo source: channels taken as-is (L/R), both present + distinct.
const std::vector<AudioSample> stereo{0.1f, 0.9f, 0.2f, 0.8f};
const DecodedZonePcm d = decodeChannels(stereo, 2, ChannelMode::Stereo, 44100);
CHECK(d.monoFrames.size() == 2 && approx(d.monoFrames[0], 0.1) && approx(d.monoFrames[1], 0.2));
CHECK(d.framesR.size() == 2 && approx(d.framesR[0], 0.9) && approx(d.framesR[1], 0.8));
}
static void testDecodeChannelsStereoModeMonoSourceDualMono() {
// STEREO mode + mono source: dual-mono — framesR duplicates channel 0 (centered, not silent).
const std::vector<AudioSample> mono{0.3f, 0.6f, 0.9f};
const DecodedZonePcm d = decodeChannels(mono, 1, ChannelMode::Stereo, 44100);
CHECK(d.monoFrames.size() == 3);
CHECK(d.framesR.size() == 3);
for (std::size_t i = 0; i < 3; ++i) CHECK(approx(d.monoFrames[i], d.framesR[i])); // R == L
}
// --- S7: buildTier0Keymap stereo threading ------------------------------------
static void testBuildKeymapStereoCarriesSecondChannel() {
const Keymap km = buildTier0Keymap({0.1f, 0.2f}, 48000, 60, SampleLoop{}, {0.9f, 0.8f});
CHECK(km.samples.size() == 1);
CHECK(km.samples.size() == 1 && km.samples[0].channelCount() == 2);
CHECK(km.samples.size() == 1 && km.samples[0].framesR.size() == 2 &&
approx(km.samples[0].framesR[0], 0.9) && approx(km.samples[0].framesR[1], 0.8));
}
static void testBuildKeymapMonoWhenNoSecondChannel() {
// No framesR passed -> mono SampleData (byte-identical to the pre-S7 build).
const Keymap km = buildTier0Keymap({0.1f, 0.2f}, 48000, 60, SampleLoop{});
CHECK(km.samples.size() == 1 && km.samples[0].channelCount() == 1);
CHECK(km.samples.size() == 1 && km.samples[0].framesR.empty());
}
static void testBuildKeymapDropsMismatchedSecondChannel() {
// A framesR whose length mismatches frames is dropped -> mono (a bad pair never half-plays).
const Keymap km = buildTier0Keymap({0.1f, 0.2f, 0.3f}, 48000, 60, SampleLoop{}, {0.9f});
CHECK(km.samples.size() == 1 && km.samples[0].channelCount() == 1);
}
static void testBuildZonedKeymapCarriesSecondChannel() {
// The zoned build threads each zone's framesR when it length-matches channel 0.
std::vector<ResolvedZone> zones;
ResolvedZone z; z.lowNote = 0; z.highNote = 127; z.rootNote = 60; zones.push_back(z);
std::vector<DecodedZonePcm> decoded;
DecodedZonePcm d; d.monoFrames = {0.1f, 0.2f}; d.sampleRate = 44100; d.framesR = {0.9f, 0.8f};
decoded.push_back(d);
const Keymap km = buildZonedKeymap(zones, decoded);
CHECK(km.samples.size() == 1 && km.samples[0].channelCount() == 2);
CHECK(km.samples.size() == 1 && km.samples[0].framesR.size() == 2 &&
approx(km.samples[0].framesR[1], 0.8));
}
// --- S7: component state v4 (channel mode) ------------------------------------
static void testComponentStateV4RoundTripStereo() {
ComponentState s;
s.selectionId = "pick";
s.channelMode = ChannelMode::Stereo;
s.map.zones.push_back(zone("z0", 0, 127, /*override=*/std::nullopt));
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
CHECK(back.selectionId == "pick");
CHECK(back.channelMode == ChannelMode::Stereo); // mode round-trips
CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "z0");
}
static void testComponentStateV4RoundTripMono() {
ComponentState s;
s.selectionId = "pick";
s.channelMode = ChannelMode::Mono;
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
CHECK(back.selectionId == "pick");
CHECK(back.channelMode == ChannelMode::Mono);
}
static void testComponentStateV4DefaultIsMono() {
// A default-constructed state serializes with mono and restores mono (preserves behavior).
const ComponentState back = deserializeComponentState(serializeComponentState(ComponentState{}), 44100.0);
CHECK(back.channelMode == ChannelMode::Mono);
CHECK(back.selectionId.empty() && back.map.zones.empty());
}
static void testComponentStateV3LiftsToMono() {
// A pre-S7 v3 blob (selection + zones, no mode byte) lifts to channelMode = mono, with the
// selection and zones intact. Build a v3 blob by hand: tag 3, id length + id, zones payload.
std::vector<std::uint8_t> v3;
v3.push_back(3); v3.push_back(0); v3.push_back(0); v3.push_back(0); // version 3
const std::string id = "legacy";
v3.push_back(static_cast<std::uint8_t>(id.size())); v3.push_back(0); v3.push_back(0); v3.push_back(0);
v3.insert(v3.end(), id.begin(), id.end());
v3.push_back(0); v3.push_back(0); v3.push_back(0); v3.push_back(0); // zone count 0
const ComponentState back = deserializeComponentState(v3, 44100.0);
CHECK(back.selectionId == "legacy");
CHECK(back.channelMode == ChannelMode::Mono); // pre-S7 default
CHECK(back.map.zones.empty());
}
static void testComponentStateV1V2LiftToMono() {
// The older lifts (v1 single-selection, v2 zones-only) also default to mono under v4 read.
const ComponentState v1 = deserializeComponentState(serializeSelection("old"), 44100.0);
CHECK(v1.channelMode == ChannelMode::Mono && v1.selectionId == "old");
PerformanceMap m; m.zones.push_back(zone("s", 12, 24));
const ComponentState v2 = deserializeComponentState(serializePerformance(m), 44100.0);
CHECK(v2.channelMode == ChannelMode::Mono && v2.map.zones.size() == 1);
}
static void testComponentStateV4TruncatedModeByte() {
// A v4 blob truncated right after the version tag (no mode byte) -> empty, mono default holds.
std::vector<std::uint8_t> t{4, 0, 0, 0}; // version 4, nothing after
const ComponentState back = deserializeComponentState(t, 44100.0);
CHECK(back.channelMode == ChannelMode::Mono);
CHECK(back.selectionId.empty() && back.map.zones.empty());
}
static void testComponentStateV4StereoWithZoneOverridesRoundTrip() {
// The MERGE composition property (S7 v4 envelope x S11 v2 zones payload): a v4 blob carrying
// channelMode = STEREO AND zones with loopOverride + startPoint must round-trip ALL of it
// losslessly. The channel-mode byte lives on the envelope; the loop/start overrides live in
// the self-versioned zones payload — the two tracks are orthogonal, so both survive one
// serialize/deserialize. (V4RoundTripStereo covers mode with a bare zone; LoopStartRoundTrip
// covers overrides at the default mono mode; this asserts them TOGETHER.)
ComponentState s;
s.selectionId = "pick";
s.channelMode = ChannelMode::Stereo;
PerformanceZone z0 = zone("z0", 12, 48, /*override=*/36);
SampleLoop lp0; lp0.hasLoop = true; lp0.start = 500; lp0.end = 9000;
z0.loopOverride = lp0;
z0.startPoint = 128;
PerformanceZone z1 = zone("z1", 49, 127); // second zone: no overrides (mixed payload)
s.map.zones.push_back(z0);
s.map.zones.push_back(z1);
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
CHECK(back.channelMode == ChannelMode::Stereo); // envelope field survives
CHECK(back.selectionId == "pick");
CHECK(back.map.zones.size() == 2);
CHECK(back.map.zones.size() == 2 && back.map.zones[0].sampleId == "z0" &&
back.map.zones[0].lowNote == 12 && back.map.zones[0].highNote == 48);
CHECK(back.map.zones.size() == 2 && back.map.zones[0].rootOverride.has_value() &&
*back.map.zones[0].rootOverride == 36);
CHECK(back.map.zones.size() == 2 && back.map.zones[0].loopOverride.has_value() &&
back.map.zones[0].loopOverride->hasLoop &&
back.map.zones[0].loopOverride->start == 500 &&
back.map.zones[0].loopOverride->end == 9000);
CHECK(back.map.zones.size() == 2 && back.map.zones[0].startPoint.has_value() &&
*back.map.zones[0].startPoint == 128);
// The override-free second zone stays override-free (the payload framing per zone is intact).
CHECK(back.map.zones.size() == 2 && back.map.zones[1].sampleId == "z1" &&
!back.map.zones[1].loopOverride.has_value() &&
!back.map.zones[1].startPoint.has_value());
}
// --- v5 component state: the S8/S9 last-consumed-assignment marker -------------
static void testComponentStateV5MarkerRoundTrip() {
// The consumed-assignment generation (S8 reader marker) round-trips through the v5 envelope
// alongside selection + mode + zones. A non-zero, > 32-bit value proves the 8-byte LE field.
ComponentState s;
s.selectionId = "pick";
s.channelMode = ChannelMode::Stereo;
s.lastConsumedAssignGeneration = 1700000123456LL; // > INT32_MAX
s.map.zones.push_back(zone("z0", 0, 127, /*override=*/std::nullopt));
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
CHECK(back.lastConsumedAssignGeneration == 1700000123456LL); // marker survives
CHECK(back.channelMode == ChannelMode::Stereo);
CHECK(back.selectionId == "pick");
CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "z0");
}
static void testComponentStateDefaultMarkerIsZero() {
// A default-constructed state has marker 0 and round-trips 0 — a fresh instance's first
// assign (generation >= 1) must not be swallowed by a non-zero default.
const ComponentState back = deserializeComponentState(serializeComponentState(ComponentState{}), 44100.0);
CHECK(back.lastConsumedAssignGeneration == 0);
}
static void testComponentStateV4LiftsMarkerToZero() {
// A GENUINE v4 blob (version tag 4: mode byte, then id + zones — NO 8-byte marker) must lift
// with lastConsumedAssignGeneration = 0 and its mode/selection/zones intact. Build it by hand
// (serializeComponentState now emits v5, so we cannot use it to make a v4 blob). This proves
// an already-saved pre-S8/S9 instance restores cleanly and its first assign still applies.
std::vector<std::uint8_t> v4;
v4.push_back(4); v4.push_back(0); v4.push_back(0); v4.push_back(0); // version 4
v4.push_back(1); // channel mode = stereo
const std::string id = "saved";
v4.push_back(static_cast<std::uint8_t>(id.size())); v4.push_back(0); v4.push_back(0); v4.push_back(0);
v4.insert(v4.end(), id.begin(), id.end());
v4.push_back(0); v4.push_back(0); v4.push_back(0); v4.push_back(0); // zone count 0
const ComponentState back = deserializeComponentState(v4, 44100.0);
CHECK(back.lastConsumedAssignGeneration == 0); // no marker in v4 -> default 0
CHECK(back.channelMode == ChannelMode::Stereo); // v4 mode byte still honored
CHECK(back.selectionId == "saved");
CHECK(back.map.zones.empty());
}
static void testComponentStateV5TruncatedMarker() {
// A v5 blob truncated inside the 8-byte marker (mode byte present, marker cut short) -> empty,
// mono + marker 0 default holds (bounded read, never throws across the host).
std::vector<std::uint8_t> t{5, 0, 0, 0, 1, 0xAA, 0xBB}; // version 5, mode byte, 2 marker bytes
const ComponentState back = deserializeComponentState(t, 44100.0);
CHECK(back.lastConsumedAssignGeneration == 0);
CHECK(back.selectionId.empty() && back.map.zones.empty());
}
// --- v6 component state: the S-VIEW-4 preview-trigger velocity ------------------
static void testComponentStatePreviewVelocityRoundTrip() {
// The preview velocity round-trips through the v6 envelope alongside selection + mode + marker
// + zones. A non-default value (not 64) proves the byte is actually read back, not defaulted.
ComponentState s;
s.selectionId = "pick";
s.channelMode = ChannelMode::Stereo;
s.lastConsumedAssignGeneration = 1700000123456LL;
s.previewVelocity = 111; // non-default
s.map.zones.push_back(zone("z0", 0, 127, /*override=*/std::nullopt));
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
CHECK(back.previewVelocity == 111); // velocity survives
CHECK(back.channelMode == ChannelMode::Stereo); // envelope neighbours intact
CHECK(back.lastConsumedAssignGeneration == 1700000123456LL);
CHECK(back.selectionId == "pick");
CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "z0");
}
static void testComponentStateDefaultPreviewVelocityIsMid() {
// A default-constructed state carries the mid velocity default and round-trips it.
const ComponentState back = deserializeComponentState(serializeComponentState(ComponentState{}), 44100.0);
CHECK(back.previewVelocity == kPreviewVelocityDefault);
CHECK(kPreviewVelocityDefault == 64);
}
static void testComponentStatePreviewVelocityExtremes() {
// The full MIDI velocity range round-trips: 1 (softest audible) and 127 (max) both survive the
// single-byte field without clamping or overflow.
for (std::uint8_t v : {std::uint8_t{1}, std::uint8_t{127}}) {
ComponentState s;
s.previewVelocity = v;
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
CHECK(back.previewVelocity == v);
}
}
static void testComponentStateV5LiftsVelocityToMid() {
// A GENUINE v5 blob (version tag 5: mode byte, 8-byte marker, then id + zones — NO velocity
// byte) must lift previewVelocity to kPreviewVelocityDefault, its mode/marker/selection/zones
// intact. Build it by hand (serializeComponentState now emits v6, so it cannot make a v5 blob).
// This proves an already-saved pre-S-VIEW-4 instance restores at the mid default.
std::vector<std::uint8_t> v5;
v5.push_back(5); v5.push_back(0); v5.push_back(0); v5.push_back(0); // version 5
v5.push_back(1); // channel mode = stereo
for (int i = 0; i < 8; ++i) v5.push_back(0); // marker = 0
const std::string id = "saved";
v5.push_back(static_cast<std::uint8_t>(id.size())); v5.push_back(0); v5.push_back(0); v5.push_back(0);
v5.insert(v5.end(), id.begin(), id.end());
v5.push_back(0); v5.push_back(0); v5.push_back(0); v5.push_back(0); // zone count 0
const ComponentState back = deserializeComponentState(v5, 44100.0);
CHECK(back.previewVelocity == kPreviewVelocityDefault); // no velocity byte in v5 -> mid default
CHECK(back.channelMode == ChannelMode::Stereo); // v5 mode byte honored
CHECK(back.selectionId == "saved");
CHECK(back.map.zones.empty());
}
static void testComponentStateV4LiftsVelocityToMid() {
// A pre-S8/S9 v4 blob (mode byte, then id + zones — no marker, no velocity) also lifts
// previewVelocity to the mid default. Proves the older-than-v5 lift path defaults the field too.
std::vector<std::uint8_t> v4;
v4.push_back(4); v4.push_back(0); v4.push_back(0); v4.push_back(0); // version 4
v4.push_back(0); // channel mode = mono
v4.push_back(0); v4.push_back(0); v4.push_back(0); v4.push_back(0); // id length 0
v4.push_back(0); v4.push_back(0); v4.push_back(0); v4.push_back(0); // zone count 0
const ComponentState back = deserializeComponentState(v4, 44100.0);
CHECK(back.previewVelocity == kPreviewVelocityDefault);
}
static void testComponentStateV6TruncatedVelocity() {
// A v6 blob truncated inside the header before the velocity byte (mode + full marker present,
// velocity byte cut) -> empty, with the mid velocity default holding (bounded read, never throws).
std::vector<std::uint8_t> t{6, 0, 0, 0, 1}; // version 6, mode byte
for (int i = 0; i < 8; ++i) t.push_back(0); // full marker, no velocity byte
const ComponentState back = deserializeComponentState(t, 44100.0);
CHECK(back.previewVelocity == kPreviewVelocityDefault);
CHECK(back.selectionId.empty() && back.map.zones.empty());
}
// --- v7 component state: the Phase S voice-system fields (count / mode / trigger) -------------
static void testComponentStateVoiceSystemRoundTrip() {
// Non-default values on all three fields prove the bytes are read back, not defaulted; the
// envelope neighbours (mode, marker, velocity, selection, zones) ride alongside intact.
ComponentState s;
s.selectionId = "pick";
s.channelMode = ChannelMode::Stereo;
s.lastConsumedAssignGeneration = 42;
s.previewVelocity = 99;
s.voiceCount = 5;
s.voiceMode = VoiceMode::Mono;
s.monoTrigger = MonoTrigger::Legato;
s.map.zones.push_back(zone("z0", 0, 127, /*override=*/std::nullopt));
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
CHECK(back.voiceCount == 5);
CHECK(back.voiceMode == VoiceMode::Mono);
CHECK(back.monoTrigger == MonoTrigger::Legato);
CHECK(back.channelMode == ChannelMode::Stereo);
CHECK(back.lastConsumedAssignGeneration == 42);
CHECK(back.previewVelocity == 99);
CHECK(back.selectionId == "pick");
CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "z0");
}
static void testComponentStateVoiceDefaultsRoundTrip() {
// A default-constructed state carries {16, Poly, Retrigger} — the pre-Phase-S behavior —
// and round-trips it. Locks the constants the engine + editor share.
const ComponentState back =
deserializeComponentState(serializeComponentState(ComponentState{}), 44100.0);
CHECK(back.voiceCount == kDefaultVoiceCount);
CHECK(kDefaultVoiceCount == 16 && kMinVoiceCount == 1 && kMaxVoiceCount == 32);
CHECK(back.voiceMode == VoiceMode::Poly);
CHECK(back.monoTrigger == MonoTrigger::Retrigger);
}
static void testComponentStateVoiceCountExtremesRoundTrip() {
// Both range edges survive the single-byte field exactly.
for (int vc : {kMinVoiceCount, kMaxVoiceCount}) {
ComponentState s;
s.voiceCount = vc;
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
CHECK(back.voiceCount == vc);
}
}
static void testComponentStateVoiceCountWriterClamps() {
// The WRITER never emits an out-of-range byte: above-max clamps to max; a nonsensical
// below-min value (a programming error upstream) falls back to the default.
ComponentState hi;
hi.voiceCount = 99;
CHECK(deserializeComponentState(serializeComponentState(hi), 44100.0).voiceCount ==
kMaxVoiceCount);
ComponentState lo;
lo.voiceCount = 0;
CHECK(deserializeComponentState(serializeComponentState(lo), 44100.0).voiceCount ==
kDefaultVoiceCount);
}
static void testComponentStateV6LiftsVoiceDefaults() {
// A GENUINE v6 blob (version tag 6: mode, marker, velocity, id, zones — NO voice bytes)
// lifts to the Phase S voice defaults {16, Poly, Retrigger}, its other fields intact.
// Hand-built (serializeComponentState now emits v7, so it cannot make a v6 blob). This
// proves an already-saved pre-Phase-S instance restores playing exactly as it did.
std::vector<std::uint8_t> v6;
v6.push_back(6); v6.push_back(0); v6.push_back(0); v6.push_back(0); // version 6
v6.push_back(1); // channel mode = stereo
for (int i = 0; i < 8; ++i) v6.push_back(0); // marker = 0
v6.push_back(111); // preview velocity
const std::string id = "saved";
v6.push_back(static_cast<std::uint8_t>(id.size())); v6.push_back(0); v6.push_back(0); v6.push_back(0);
v6.insert(v6.end(), id.begin(), id.end());
v6.push_back(0); v6.push_back(0); v6.push_back(0); v6.push_back(0); // zone count 0
const ComponentState back = deserializeComponentState(v6, 44100.0);
CHECK(back.voiceCount == kDefaultVoiceCount);
CHECK(back.voiceMode == VoiceMode::Poly);
CHECK(back.monoTrigger == MonoTrigger::Retrigger);
CHECK(back.previewVelocity == 111); // the v6 byte still honored
CHECK(back.channelMode == ChannelMode::Stereo);
CHECK(back.selectionId == "saved");
CHECK(back.map.zones.empty());
}
static void testComponentStateV7CorruptVoiceBytesFallBack() {
// Out-of-range voice bytes in a v7 blob fall back to each field's DEFAULT (the
// previewVelocity corrupt-byte precedent) — a corrupt blob never silences or distorts the
// instance to an edge the user never chose. Build v7 by serializing, then vandalize the
// three voice bytes in place (offsets: 4 version + 1 mode + 8 marker + 1 velocity = 14).
ComponentState s;
s.voiceCount = 7;
s.voiceMode = VoiceMode::Mono;
s.monoTrigger = MonoTrigger::Legato;
std::vector<std::uint8_t> bytes = serializeComponentState(s);
bytes[14] = 0; // voice count 0: below kMinVoiceCount
bytes[15] = 7; // voice mode: not a legal {0,1} value
bytes[16] = 9; // mono trigger: not a legal {0,1} value
const ComponentState back = deserializeComponentState(bytes, 44100.0);
CHECK(back.voiceCount == kDefaultVoiceCount);
CHECK(back.voiceMode == VoiceMode::Poly); // non-1 mode byte -> Poly default
CHECK(back.monoTrigger == MonoTrigger::Retrigger);
}
static void testComponentStateV7TruncatedVoiceBytes() {
// A v7 blob cut INSIDE the three voice bytes -> empty, defaults holding (bounded read).
std::vector<std::uint8_t> t{7, 0, 0, 0, 1}; // version 7, mode byte
for (int i = 0; i < 8; ++i) t.push_back(0); // full marker
t.push_back(64); // velocity byte
t.push_back(16); // voice count only —
const ComponentState back = deserializeComponentState(t, 44100.0); // mode/trigger cut
CHECK(back.voiceCount == kDefaultVoiceCount);
CHECK(back.voiceMode == VoiceMode::Poly);
CHECK(back.monoTrigger == MonoTrigger::Retrigger);
CHECK(back.selectionId.empty() && back.map.zones.empty());
}
// --- v8 component state: the FB1 post-mixer master gain (linear double) -----------------------
static void testComponentStateMasterGainRoundTrip() {
// A non-default gain proves the bytes are read back, not defaulted; the envelope
// neighbours (voice bytes, velocity, selection, zones) ride alongside intact.
ComponentState s;
s.selectionId = "pick";
s.previewVelocity = 99;
s.voiceCount = 5;
s.masterGainLinear = 0.25; // -12.04 dB
s.map.zones.push_back(zone("z0", 0, 127, /*override=*/std::nullopt));
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
CHECK(std::fabs(back.masterGainLinear - 0.25) < 1e-12); // an exact double round-trip
CHECK(back.voiceCount == 5);
CHECK(back.previewVelocity == 99);
CHECK(back.selectionId == "pick");
CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "z0");
}
static void testComponentStateMasterGainDefaultAndZeroRoundTrip() {
// Default unity round-trips (pre-FB1 output); the -inf bottom (TRUE zero) round-trips
// exactly — a user who pulled the gain to silence gets silence back after a save/load.
const ComponentState defBack =
deserializeComponentState(serializeComponentState(ComponentState{}), 44100.0);
CHECK(defBack.masterGainLinear == 1.0);
ComponentState zero;
zero.masterGainLinear = 0.0;
const ComponentState zeroBack =
deserializeComponentState(serializeComponentState(zero), 44100.0);
CHECK(zeroBack.masterGainLinear == 0.0);
}
static void testComponentStateMasterGainWriterClamps() {
// The WRITER never emits an out-of-range value: above the +24 dB cap clamps to the cap;
// a negative/non-finite value (a programming error upstream) falls back to unity.
ComponentState hi;
hi.masterGainLinear = 1000.0;
CHECK(std::fabs(deserializeComponentState(serializeComponentState(hi), 44100.0)
.masterGainLinear -
vst::masterGainMaxLinear()) < 1e-9);
ComponentState lo;
lo.masterGainLinear = -5.0;
CHECK(deserializeComponentState(serializeComponentState(lo), 44100.0).masterGainLinear ==
1.0);
}
static void testComponentStateV7LiftsUnityMasterGain() {
// A GENUINE v7 blob (version tag 7: mode, marker, velocity, voice bytes, id, zones — NO
// master-gain double) lifts to unity, its other fields intact. Hand-built
// (serializeComponentState now emits v8, so it cannot make a v7 blob). Proves an
// already-saved pre-FB1 instance restores playing at exactly its old output level.
std::vector<std::uint8_t> v7;
v7.push_back(7); v7.push_back(0); v7.push_back(0); v7.push_back(0); // version 7
v7.push_back(1); // channel mode = stereo
for (int i = 0; i < 8; ++i) v7.push_back(0); // marker = 0
v7.push_back(111); // preview velocity
v7.push_back(5); // voice count
v7.push_back(1); // voice mode = mono
v7.push_back(1); // trigger = legato
const std::string id = "saved";
v7.push_back(static_cast<std::uint8_t>(id.size())); v7.push_back(0); v7.push_back(0); v7.push_back(0);
v7.insert(v7.end(), id.begin(), id.end());
v7.push_back(0); v7.push_back(0); v7.push_back(0); v7.push_back(0); // zone count 0
const ComponentState back = deserializeComponentState(v7, 44100.0);
CHECK(back.masterGainLinear == 1.0);
CHECK(back.voiceCount == 5);
CHECK(back.voiceMode == VoiceMode::Mono);
CHECK(back.monoTrigger == MonoTrigger::Legato);
CHECK(back.previewVelocity == 111);
CHECK(back.channelMode == ChannelMode::Stereo);
CHECK(back.selectionId == "saved");
CHECK(back.map.zones.empty());
}
static void testComponentStateV8CorruptMasterGainFallsBack() {
// A corrupt gain double (NaN) in a v8 blob falls back to unity (the previewVelocity
// corrupt-byte precedent) — never silences or blasts the instance. Build v8 by
// serializing, then vandalize the 8 gain bytes in place (offsets: 4 version + 1 mode +
// 8 marker + 1 velocity + 3 voice bytes = 17..24).
ComponentState s;
s.masterGainLinear = 0.5;
std::vector<std::uint8_t> bytes = serializeComponentState(s);
for (int i = 0; i < 8; ++i) bytes[17 + i] = 0xFF; // 0xFFFF... = a negative NaN pattern
const ComponentState back = deserializeComponentState(bytes, 44100.0);
CHECK(back.masterGainLinear == 1.0);
}
static void testComponentStateV8TruncatedMasterGain() {
// A v8 blob cut INSIDE the gain double -> empty, defaults holding (bounded read).
std::vector<std::uint8_t> t{8, 0, 0, 0, 0}; // version 8, mode byte
for (int i = 0; i < 8; ++i) t.push_back(0); // full marker
t.push_back(64); // velocity byte
t.push_back(16); t.push_back(0); t.push_back(0); // the three voice bytes
t.push_back(0); t.push_back(0); t.push_back(0); // gain cut mid-double
const ComponentState back = deserializeComponentState(t, 44100.0);
CHECK(back.masterGainLinear == 1.0);
CHECK(back.selectionId.empty() && back.map.zones.empty());
}
// --- v9 component state: the GA channel-mode explicit flag ------------------------------------
static void testComponentStateChannelModeExplicitRoundTrip() {
// The explicit flag survives a round-trip in BOTH states, its envelope neighbours intact.
ComponentState s;
s.selectionId = "pick";
s.channelMode = ChannelMode::Stereo;
s.channelModeExplicit = true;
s.masterGainLinear = 0.5;
ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
CHECK(back.channelModeExplicit);
CHECK(back.channelMode == ChannelMode::Stereo);
CHECK(std::fabs(back.masterGainLinear - 0.5) < 1e-12);
CHECK(back.selectionId == "pick");
s.channelModeExplicit = false;
back = deserializeComponentState(serializeComponentState(s), 44100.0);
CHECK(!back.channelModeExplicit); // implicit round-trips too (not defaulted-true)
CHECK(back.channelMode == ChannelMode::Stereo);
}
static void testComponentStateV8LiftsImplicitChannelMode() {
// A GENUINE v8 blob (version tag 8: mode, marker, velocity, voice bytes, gain, id, zones —
// NO explicit flag) lifts to channelModeExplicit = FALSE: a pre-GA mode byte is treated as
// the un-touched default so the shell's auto-default may follow the loaded capture. Hand-
// built (serializeComponentState now emits v9, so it cannot make a v8 blob).
std::vector<std::uint8_t> v8;
v8.push_back(8); v8.push_back(0); v8.push_back(0); v8.push_back(0); // version 8
v8.push_back(0); // channel mode = mono
for (int i = 0; i < 8; ++i) v8.push_back(0); // marker = 0
v8.push_back(88); // preview velocity
v8.push_back(7); // voice count
v8.push_back(0); // voice mode = poly
v8.push_back(0); // trigger = retrigger
for (int i = 0; i < 8; ++i) v8.push_back(0); // gain double bytes...
v8[17 + 6] = 0xF0; v8[17 + 7] = 0x3F; // ...= 1.0 (LE IEEE-754)
const std::string id = "saved";
v8.push_back(static_cast<std::uint8_t>(id.size())); v8.push_back(0); v8.push_back(0); v8.push_back(0);
v8.insert(v8.end(), id.begin(), id.end());
v8.push_back(0); v8.push_back(0); v8.push_back(0); v8.push_back(0); // zone count 0
const ComponentState back = deserializeComponentState(v8, 44100.0);
CHECK(!back.channelModeExplicit); // pre-GA blob -> implicit (auto-default allowed)
CHECK(back.channelMode == ChannelMode::Mono);
CHECK(back.masterGainLinear == 1.0);
CHECK(back.previewVelocity == 88);
CHECK(back.voiceCount == 7);
CHECK(back.selectionId == "saved");
CHECK(back.map.zones.empty());
}
// --- MERGE COMPOSITION (S9 v5 marker envelope x S15/S16 v3 play-param payload) ----------------
//
// The merge of ps-w9-t1-sync (envelope v5, adds the consumed-assignment marker) and
// ps-w9-t2-modes (payload v3, adds the per-zone play params) makes THREE combinations first
// reachable. Each pre-existing suite covers one axis in isolation; these lock the axes together.
static void testV5EnvelopeWithMarkerAndPlayParamsRoundTrip() {
// (a) The full v5 face: channelMode + the S8/S9 consumed marker (envelope) AND zones carrying
// S15/S16 play params (payload) must ALL survive one serialize/deserialize. The two extensions
// sit on orthogonal tracks (envelope vs self-versioned payload); this proves they compose with
// no field cross-talk — neither the marker read nor the play-param read consumes the other's bytes.
ComponentState s;
s.selectionId = "pick";
s.channelMode = ChannelMode::Stereo;
s.lastConsumedAssignGeneration = 1700000123456LL; // > INT32_MAX -> exercises the full 8-byte field
PerformanceZone z = zone("z0", 0, 127, /*override=*/48);
z.play.playMode = PlayMode::Trigger;
z.play.adsr.holdSeconds = 0.093;
z.play.trigger.lengthFraction = 0.625;
z.play.trigger.fadeInFrames = 32;
z.play.trigger.fadeOutFrames = 96;
z.play.pitchEngine = PitchEngine::Varispeed;
z.play.pitchEnv.enabled = true;
z.play.pitchEnv.attackSeconds = 0.00018;
z.play.pitchEnv.decaySeconds = 0.0145;
z.play.pitchEnv.peakSemitones = 12.5;
s.map.zones.push_back(z);
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
CHECK(back.channelMode == ChannelMode::Stereo); // envelope: mode
CHECK(back.lastConsumedAssignGeneration == 1700000123456LL); // envelope: marker
CHECK(back.selectionId == "pick");
CHECK(back.map.zones.size() == 1);
if (back.map.zones.size() != 1) return;
const ZonePlaySeconds& p = back.map.zones[0].play; // payload: play params
CHECK(p.playMode == PlayMode::Trigger);
CHECK(p.adsr.holdSeconds == 0.093);
CHECK(p.trigger.lengthFraction == 0.625);
CHECK(p.trigger.fadeInFrames == 32 && p.trigger.fadeOutFrames == 96);
CHECK(p.pitchEngine == PitchEngine::Varispeed);
CHECK(p.pitchEnv.enabled && p.pitchEnv.attackSeconds == 0.00018 &&
p.pitchEnv.decaySeconds == 0.0145 && p.pitchEnv.peakSemitones == 12.5);
}
// Hand-build ONE v3 zone record (marker-versioned payload body) for a single-zone map. Emits the
// exact on-wire order the header's PAYLOAD v3 spec + putZonesPayload write: id, lo/hi, no root/loop/
// start overrides, then the always-present S15/S16 play tail. Used to synthesize the two v4 blobs
// below WITHOUT serializeComponentState (which now emits v5) — so the reader's widened accept-chain
// is exercised against a genuine, older-envelope byte layout rather than a self-produced buffer.
static std::vector<std::uint8_t> handBuildV3PayloadOneZone(const std::string& id) {
std::vector<std::uint8_t> b;
auto u32 = [&](std::uint32_t v) {
b.push_back(v & 0xFF); b.push_back((v >> 8) & 0xFF);
b.push_back((v >> 16) & 0xFF); b.push_back((v >> 24) & 0xFF);
};
auto u64 = [&](std::uint64_t v) {
for (int i = 0; i < 8; ++i) b.push_back(static_cast<std::uint8_t>((v >> (i * 8)) & 0xFF));
};
auto dbl = [&](double d) { std::uint64_t bits; std::memcpy(&bits, &d, 8); u64(bits); };
u32(kZonesFormatMarker);
u32(3); // PAYLOAD VERSION 3 (S15/S16 play tail present)
u32(1); // zone count 1
u32(static_cast<std::uint32_t>(id.size()));
b.insert(b.end(), id.begin(), id.end());
u32(10); // lowNote
u32(70); // highNote
b.push_back(0); // hasRootOverride = 0
b.push_back(0); // hasLoopOverride = 0
b.push_back(0); // hasStartPoint = 0
// Always-present v3 play tail: Trigger, hold, lengthFraction, fades, Varispeed, env off.
b.push_back(1); // playMode = Trigger
u64(static_cast<std::uint64_t>(2048)); // adsr.holdFrames
dbl(0.5); // trigger.lengthFraction
u64(static_cast<std::uint64_t>(16)); // trigger.fadeInFrames
u64(static_cast<std::uint64_t>(48)); // trigger.fadeOutFrames
b.push_back(0); // pitchEngine = Varispeed
b.push_back(0); // pitchEnv.enabled = 0
u64(0); // pitchEnv.attackFrames
u64(0); // pitchEnv.decayFrames
dbl(0.0); // pitchEnv.peakSemitones
return b;
}
static void testV4BlobWithPlayParamsLiftsMarkerZeroKeepsPlay() {
// (b) + (c) unified: a GENUINE v4 ENVELOPE blob (version tag 4: mode byte, id, then the zones
// payload — NO 8-byte marker) whose zones payload is PAYLOAD v3 (the exact shape an S15-test-build
// save produced). Under the widened accept-chain it must (b) lift lastConsumedAssignGeneration to
// 0 AND (c) deserialize its payload-v3 play params intact. This is the precise blob a user who
// saved on the S15 test build (envelope v4 + payload v3) would hold; the v4 lift branch delegates
// zones to readZonesPayload, which self-selects the v3 record shape from the payload marker — so
// the two v4 layouts (S7-era payload-v2, S15-era payload-v3) are UNAMBIGUOUS, distinguished
// inside the payload, not on the envelope.
std::vector<std::uint8_t> v4;
v4.push_back(4); v4.push_back(0); v4.push_back(0); v4.push_back(0); // ENVELOPE version 4
v4.push_back(1); // channel mode = stereo
const std::string id = "s15saved";
v4.push_back(static_cast<std::uint8_t>(id.size()));
v4.push_back(0); v4.push_back(0); v4.push_back(0); // idLen (LE)
v4.insert(v4.end(), id.begin(), id.end());
const std::vector<std::uint8_t> payload = handBuildV3PayloadOneZone("zv3");
v4.insert(v4.end(), payload.begin(), payload.end());
const ComponentState back = deserializeComponentState(v4, 44100.0);
CHECK(back.lastConsumedAssignGeneration == 0); // (b) no marker in v4 -> default 0
CHECK(back.channelMode == ChannelMode::Stereo); // v4 envelope mode honored
CHECK(back.selectionId == "s15saved");
CHECK(back.map.zones.size() == 1); // (c) payload-v3 zone parsed under widened check
if (back.map.zones.size() != 1) return;
CHECK(back.map.zones[0].sampleId == "zv3");
CHECK(back.map.zones[0].lowNote == 10 && back.map.zones[0].highNote == 70);
const ZonePlaySeconds& p = back.map.zones[0].play;
CHECK(p.playMode == PlayMode::Trigger); // (c) play params survive the v4 envelope
// Legacy v3 wall-clock frames convert to seconds at the passed project rate (44100.0 here).
CHECK(approx(p.adsr.holdSeconds, 2048.0 / 44100.0));
CHECK(p.trigger.lengthFraction == 0.5);
CHECK(p.trigger.fadeInFrames == 16 && p.trigger.fadeOutFrames == 48); // source frames, as-is
CHECK(p.pitchEngine == PitchEngine::Varispeed);
CHECK(p.pitchEnv.enabled == false);
}
// --- S15/S16 zone-payload v3: per-zone play params round-trip + back-compat lift -------------
static void testPlayParamsRoundTrip() {
// A zone carrying explicit S15/S16 play params (Trigger mode, hold seconds, source-frame fades,
// Varispeed engine, pitch env on) must round-trip ALL fields losslessly through the v5 tail.
PerformanceMap m;
PerformanceZone z = zone("lead", 20, 100, /*override=*/55);
z.play.playMode = PlayMode::Trigger;
z.play.adsr.holdSeconds = 0.028; // wall-clock seconds
z.play.trigger.lengthFraction = 0.375;
z.play.trigger.fadeInFrames = 64; // source frames
z.play.trigger.fadeOutFrames = 128;
z.play.pitchEngine = PitchEngine::Varispeed;
z.play.pitchEnv.enabled = true;
z.play.pitchEnv.attackSeconds = 0.0002; // wall-clock seconds
z.play.pitchEnv.decaySeconds = 0.011;
z.play.pitchEnv.peakSemitones = -7.5;
m.zones.push_back(z);
const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0);
CHECK(back.zones.size() == 1);
if (back.zones.size() != 1) return;
const ZonePlaySeconds& p = back.zones[0].play;
CHECK(p.playMode == PlayMode::Trigger);
CHECK(p.adsr.holdSeconds == 0.028); // exact double round-trip
CHECK(p.trigger.lengthFraction == 0.375); // exact double round-trip
CHECK(p.trigger.fadeInFrames == 64);
CHECK(p.trigger.fadeOutFrames == 128);
CHECK(p.pitchEngine == PitchEngine::Varispeed);
CHECK(p.pitchEnv.enabled == true);
CHECK(p.pitchEnv.attackSeconds == 0.0002);
CHECK(p.pitchEnv.decaySeconds == 0.011);
CHECK(p.pitchEnv.peakSemitones == -7.5); // exact double round-trip
}
static void testPlayParamsComposeWithLoopStart() {
// S11 (loop/start) x S15/S16 (play params) tails co-exist per zone: both round-trip together.
PerformanceMap m;
PerformanceZone z = zone("pad", 0, 60);
SampleLoop lp; lp.hasLoop = true; lp.start = 111; lp.end = 222;
z.loopOverride = lp;
z.startPoint = 333;
z.play.playMode = PlayMode::Gate;
z.play.adsr.holdSeconds = 0.0225;
z.play.pitchEngine = PitchEngine::Preserve;
m.zones.push_back(z);
const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0);
CHECK(back.zones.size() == 1);
if (back.zones.size() != 1) return;
CHECK(back.zones[0].loopOverride.has_value() &&
back.zones[0].loopOverride->start == 111 && back.zones[0].loopOverride->end == 222);
CHECK(back.zones[0].startPoint.has_value() && *back.zones[0].startPoint == 333);
CHECK(back.zones[0].play.adsr.holdSeconds == 0.0225);
CHECK(back.zones[0].play.pitchEngine == PitchEngine::Preserve);
}
// --- S-VIEW-6 key-tracking scalar: v6 round-trip + resolve-through + back-compat lift ----------
static void testKeyTrackRoundTrip() {
// A per-zone keyTrack survives the payload-v6 round trip losslessly (exact double). A second
// zone left at the default proves the field is per-record and the default is 1.0.
PerformanceMap m;
PerformanceZone z = zone("lead", 20, 100, /*override=*/55);
z.keyTrack = 0.5;
m.zones.push_back(z);
m.zones.push_back(zone("pad", 0, 19)); // default keyTrack (1.0)
const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0);
CHECK(back.zones.size() == 2);
if (back.zones.size() != 2) return;
CHECK(back.zones[0].keyTrack == 0.5); // exact double round-trip
CHECK(back.zones[1].keyTrack == 1.0); // untouched zone keeps the 100% default
}
static void testKeyTrackThroughComponentEnvelope() {
// keyTrack round-trips through the ComponentState envelope too (the composition property:
// the zones payload is envelope-independent, so it carries the v6 tail unchanged).
ComponentState s;
s.selectionId = "pick";
PerformanceZone z = zone("pick", 0, 127);
z.keyTrack = 2.0;
s.map.zones.push_back(z);
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
CHECK(back.map.zones.size() == 1);
if (back.map.zones.size() != 1) return;
CHECK(back.map.zones[0].keyTrack == 2.0);
}
static void testKeyTrackResolvesToZone() {
// resolvePerformance carries keyTrack from the PerformanceZone through to the ResolvedZone,
// so the keymap build (and thus the repitch engine) sees the authored scalar.
const std::string json = bookJson({makeSample("a", "Kick", "b/a.wav", 36)}, {});
PerformanceMap m;
PerformanceZone z = zone("a", 0, 127);
z.keyTrack = 0.0; // no tracking
m.zones.push_back(z);
const ResolvedPerformance r = resolvePerformance(json, m);
CHECK(r.zones.size() == 1);
if (r.zones.size() != 1) return;
CHECK(r.zones[0].keyTrack == 0.0);
}
static void testKeyTrackV5BackCompatLiftsToUnity() {
// A v5 PAYLOAD blob (marker + version 5 + full play tail but NO keyTrack field) lifts every
// zone to keyTrack == 1.0 (the PerformanceZone default) — so an instance saved BEFORE S-VIEW-6
// repitches BIT-IDENTICALLY (100% ET). Hand-build the exact v5 record shape.
std::vector<std::uint8_t> b;
auto u32 = [&](std::uint32_t v) {
b.push_back(v & 0xFF); b.push_back((v >> 8) & 0xFF);
b.push_back((v >> 16) & 0xFF); b.push_back((v >> 24) & 0xFF);
};
auto f64 = [&](double d) {
std::uint64_t bits; std::memcpy(&bits, &d, sizeof(bits));
for (int i = 0; i < 8; ++i) b.push_back(static_cast<std::uint8_t>((bits >> (i * 8)) & 0xFF));
};
auto i64 = [&](std::int64_t v) {
std::uint64_t bits = static_cast<std::uint64_t>(v);
for (int i = 0; i < 8; ++i) b.push_back(static_cast<std::uint8_t>((bits >> (i * 8)) & 0xFF));
};
u32(kPerformanceStateVersion); // envelope version (2)
u32(kZonesFormatMarker); // marker -> a versioned payload
u32(5); // PAYLOAD VERSION 5 (pre-S-VIEW-6, no keyTrack tail)
u32(1); // zone count 1
const std::string id = "v5saved";
u32(static_cast<std::uint32_t>(id.size()));
b.insert(b.end(), id.begin(), id.end());
u32(10); u32(70); // low/high
b.push_back(0); // hasRootOverride = 0
b.push_back(0); // hasLoopOverride = 0
b.push_back(0); // hasStartPoint = 0
// v5 play tail (order matches putZonesPayload): playMode, hold, len, fadeIn, fadeOut, engine,
// envEnabled, envAttack, envDecay, peak, attack, decay, sustain, release.
b.push_back(0); // playMode = Gate
f64(0.0); // adsr.holdSeconds
f64(1.0); // trigger.lengthFraction
i64(0); i64(0); // trigger fades (source frames)
b.push_back(1); // pitchEngine = Preserve
b.push_back(0); // pitchEnv.enabled = false
f64(0.0); f64(0.0); f64(0.0); // pitchEnv attack/decay/peak
f64(0.003); f64(0.0); f64(1.0); f64(0.060); // adsr A/D/S/R (tier-0 seconds)
const PerformanceMap back = deserializePerformance(b, 44100.0);
CHECK(back.zones.size() == 1);
if (back.zones.size() != 1) return;
CHECK(back.zones[0].sampleId == "v5saved");
CHECK(back.zones[0].keyTrack == 1.0); // no keyTrack tail -> default 1.0 (bit-identical repitch)
}
// --- S-VIEW-9 velocity->amp curve: v7 round-trip + resolve-through + v6 back-compat lift ---------
static void testVelocityCurveRoundTrip() {
// A per-zone velocity curve survives the payload-v7 round trip losslessly (exact point coords).
// A second zone left at the flat default proves the field is per-record and defaults to flat y=1.
PerformanceMap m;
PerformanceZone z = zone("lead", 20, 100);
z.velocityCurve = vst::VelocityCurve::linear();
z.velocityCurve.addPoint(60.0, 0.3); // an interior knot to exercise multi-point round-trip
m.zones.push_back(z);
m.zones.push_back(zone("pad", 0, 19)); // default flat curve
const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0);
CHECK(back.zones.size() == 2);
if (back.zones.size() != 2) return;
CHECK(back.zones[0].velocityCurve.equals(z.velocityCurve)); // exact point round-trip
CHECK(back.zones[1].velocityCurve.equals(vst::VelocityCurve::flat())); // default preserved
// And the flat default really is unity everywhere (R10-F1 Option A), not the old linear ramp.
CHECK(back.zones[1].velocityCurve.eval(1.0) == 1.0);
CHECK(back.zones[1].velocityCurve.eval(64.0) == 1.0);
}
static void testVelocityCurveThroughComponentEnvelope() {
// The curve round-trips through the ComponentState envelope too (zones-payload is envelope-
// independent, so it carries the v7 tail unchanged).
ComponentState s;
s.selectionId = "pick";
PerformanceZone z = zone("pick", 0, 127);
z.velocityCurve = vst::VelocityCurve::linear();
s.map.zones.push_back(z);
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
CHECK(back.map.zones.size() == 1);
if (back.map.zones.size() != 1) return;
CHECK(back.map.zones[0].velocityCurve.equals(vst::VelocityCurve::linear()));
}
static void testVelocityCurveResolvesToZone() {
// resolvePerformance carries the curve from PerformanceZone through to ResolvedZone, so the
// keymap build (and thus the voice engine at start()) sees the authored curve.
const std::string json = bookJson({makeSample("a", "Kick", "b/a.wav", 36)}, {});
PerformanceMap m;
PerformanceZone z = zone("a", 0, 127);
z.velocityCurve = vst::VelocityCurve::linear();
m.zones.push_back(z);
const ResolvedPerformance r = resolvePerformance(json, m);
CHECK(r.zones.size() == 1);
if (r.zones.size() != 1) return;
CHECK(r.zones[0].velocityCurve.equals(vst::VelocityCurve::linear()));
}
// FA1 bug 3a — the COMPOSED end-to-end regression, mirroring the processor's reload composition
// exactly: an authored curve survives the component-state round-trip (the save/load seam), then
// resolvePerformance -> buildZonedKeymap -> VoiceEngine (constructed with a Preserve window, the
// DAW configuration) -> render, and the rendered level tracks velocity through the curve. This
// is the full pure slice of the click-to-sound path; only the bridge read + WAV decode (shell
// I/O) are outside it. A y=x curve at velocity 1 must be near-silent — NOT max volume.
static void testVelocityCurveEndToEndThroughReloadComposition() {
// 1. The instrument's own state: one full-keyboard zone with a LINEAR curve (the exact edit
// Daniel made), round-tripped through the v7 component-state wire (save -> load).
ComponentState s;
s.selectionId = "a";
PerformanceZone z = zone("a", 0, 127);
z.velocityCurve = vst::VelocityCurve::linear();
s.map.zones.push_back(z);
const ComponentState back = deserializeComponentState(serializeComponentState(s), 48000.0);
CHECK(back.map.zones.size() == 1);
if (back.map.zones.size() != 1) return;
// 2. Resolve against a live bank blob (the shared bank_book parse, root 60 intrinsic).
const std::string json = bookJson({makeSample("a", "Kick", "reasampler_bank/a.wav", 60)}, {});
const ResolvedPerformance rp = resolvePerformance(json, back.map);
CHECK(rp.zones.size() == 1);
if (rp.zones.size() != 1) return;
// The round-tripped zone still runs the PRESERVE product default (the DAW engine config).
CHECK(rp.zones[0].play.pitchEngine == PitchEngine::Preserve);
// 3. Build the zoned keymap from decoded DC-1 PCM and play it through an engine constructed
// the way reloadInstrument constructs it (Preserve voices pre-sized to a real window).
auto steadyLevelAt = [&](int vel) -> double {
DecodedZonePcm pcm;
pcm.monoFrames.assign(4000, 1.0f);
pcm.sampleRate = 48000;
const Keymap km = buildZonedKeymap(rp.zones, {pcm});
VoiceEngine eng(16, km, /*preserveCap=*/8, /*window=*/256);
eng.noteOn(62, vel); // transposed: the genuine OLA shifter path
std::vector<AudioSample> out;
eng.render(out, 1000);
return static_cast<double>(out[900]); // steady state (ring fully DC past the window)
};
CHECK(approx(steadyLevelAt(127), 1.0));
CHECK(approx(steadyLevelAt(64), 64.0 / 127.0));
CHECK(steadyLevelAt(1) < 0.02); // velocity 1 through y=x: near-silent, never max volume
}
static void testVelocityCurveV6BackCompatLiftsToFlat() {
// A v6 PAYLOAD blob (marker + version 6 + full play tail + keyTrack, but NO velocity-curve field)
// lifts every zone to VelocityCurve::flat() (R10-F1 Option A — flat y=1). This is the DELIBERATE
// non-back-compat behavior change: an instance saved BEFORE S-VIEW-9 now plays every velocity at
// unity, NOT the old linear velocity/127. Hand-build the exact v6 record shape.
std::vector<std::uint8_t> b;
auto u32 = [&](std::uint32_t v) {
b.push_back(v & 0xFF); b.push_back((v >> 8) & 0xFF);
b.push_back((v >> 16) & 0xFF); b.push_back((v >> 24) & 0xFF);
};
auto f64 = [&](double d) {
std::uint64_t bits; std::memcpy(&bits, &d, sizeof(bits));
for (int i = 0; i < 8; ++i) b.push_back(static_cast<std::uint8_t>((bits >> (i * 8)) & 0xFF));
};
auto i64 = [&](std::int64_t v) {
std::uint64_t bits = static_cast<std::uint64_t>(v);
for (int i = 0; i < 8; ++i) b.push_back(static_cast<std::uint8_t>((bits >> (i * 8)) & 0xFF));
};
u32(kPerformanceStateVersion); // envelope version (2)
u32(kZonesFormatMarker); // marker -> a versioned payload
u32(6); // PAYLOAD VERSION 6 (pre-S-VIEW-9, keyTrack but no curve)
u32(1); // zone count 1
const std::string id = "v6saved";
u32(static_cast<std::uint32_t>(id.size()));
b.insert(b.end(), id.begin(), id.end());
u32(10); u32(70); // low/high
b.push_back(0); // hasRootOverride = 0
b.push_back(0); // hasLoopOverride = 0
b.push_back(0); // hasStartPoint = 0
// v5 play tail.
b.push_back(0); // playMode = Gate
f64(0.0); // adsr.holdSeconds
f64(1.0); // trigger.lengthFraction
i64(0); i64(0); // trigger fades
b.push_back(1); // pitchEngine = Preserve
b.push_back(0); // pitchEnv.enabled = false
f64(0.0); f64(0.0); f64(0.0); // pitchEnv attack/decay/peak
f64(0.003); f64(0.0); f64(1.0); f64(0.060); // adsr A/D/S/R
f64(0.5); // v6 keyTrack (0.5) — present, but no curve tail follows
const PerformanceMap back = deserializePerformance(b, 44100.0);
CHECK(back.zones.size() == 1);
if (back.zones.size() != 1) return;
CHECK(back.zones[0].sampleId == "v6saved");
CHECK(back.zones[0].keyTrack == 0.5); // the v6 field still read correctly
// No curve tail -> flat y=1 default (the deliberate behavior change).
CHECK(back.zones[0].velocityCurve.equals(vst::VelocityCurve::flat()));
CHECK(back.zones[0].velocityCurve.eval(20.0) == 1.0); // a soft hit now plays at unity
}
static void testPlayParamsV2BackCompatLiftsToDefaults() {
// A pre-S15 PAYLOAD v2 blob (marker + version 2 + record with the S11 tail but NO play tail)
// lifts each zone to the PRODUCT defaults: Gate + Preserve (S16-F1) + no fades + env off — the
// deliberate behavior change for already-saved instruments. Hand-build a v2 record exactly.
std::vector<std::uint8_t> b;
auto u32 = [&](std::uint32_t v) {
b.push_back(v & 0xFF); b.push_back((v >> 8) & 0xFF);
b.push_back((v >> 16) & 0xFF); b.push_back((v >> 24) & 0xFF);
};
u32(kPerformanceStateVersion); // envelope version (2)
u32(kZonesFormatMarker); // marker -> a versioned payload
u32(2); // PAYLOAD VERSION 2 (S11, no play tail)
u32(1); // zone count 1
const std::string id = "old";
u32(static_cast<std::uint32_t>(id.size()));
b.insert(b.end(), id.begin(), id.end());
u32(5); // lowNote
u32(80); // highNote
b.push_back(0); // hasRootOverride = 0
b.push_back(0); // hasLoopOverride = 0
b.push_back(0); // hasStartPoint = 0 (record ends here in v2)
const PerformanceMap back = deserializePerformance(b, 44100.0);
CHECK(back.zones.size() == 1);
if (back.zones.size() != 1) return;
CHECK(back.zones[0].sampleId == "old");
// Lifted to product defaults: Gate play mode, PRESERVE engine (the S16-F1 default), env off.
CHECK(back.zones[0].play.playMode == PlayMode::Gate);
CHECK(back.zones[0].play.pitchEngine == kDefaultPitchEngine); // == Preserve
CHECK(back.zones[0].play.pitchEnv.enabled == false);
CHECK(back.zones[0].play.adsr.holdSeconds == 0.0);
}
static void testPlayParamsThroughComponentEnvelope() {
// The play params round-trip through the v4 COMPONENT envelope too (the composition property:
// the zones payload is envelope-independent, so v4 {channelMode, selection, zones} carries them).
ComponentState s;
s.selectionId = "pick";
s.channelMode = ChannelMode::Stereo;
PerformanceZone z = zone("z", 0, 127);
z.play.playMode = PlayMode::Trigger;
z.play.trigger.lengthFraction = 0.9;
z.play.pitchEngine = PitchEngine::Varispeed;
s.map.zones.push_back(z);
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
CHECK(back.channelMode == ChannelMode::Stereo);
CHECK(back.map.zones.size() == 1);
if (back.map.zones.size() != 1) return;
CHECK(back.map.zones[0].play.playMode == PlayMode::Trigger);
CHECK(back.map.zones[0].play.trigger.lengthFraction == 0.9);
CHECK(back.map.zones[0].play.pitchEngine == PitchEngine::Varispeed);
}
// --- S12 domain fix: wall-clock ADSR stored as SECONDS, resolved to frames at the live rate. ---
//
// These tests replace the R1/R2 flag/nominal-frame tests. The stored domain is seconds (rate-free);
// the keymap build resolves seconds -> frames against whatever WAV rate is live. The lift ->
// commit -> reload sequence must stay rate-correct at every rate (the R2 blocker).
// All five AHDSR fields round-trip through the v5 payload as SECONDS (exact double round-trip).
static void testFullAdsrSecondsRoundTrip() {
PerformanceMap m;
PerformanceZone z = zone("pad", 0, 127);
z.play.playMode = PlayMode::Gate;
z.play.adsr.attackSeconds = 0.01;
z.play.adsr.holdSeconds = 0.02;
z.play.adsr.decaySeconds = 0.1;
z.play.adsr.sustainLevel = 0.7;
z.play.adsr.releaseSeconds = 0.2;
z.play.pitchEngine = PitchEngine::Preserve;
m.zones.push_back(z);
const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0);
CHECK(back.zones.size() == 1);
if (back.zones.size() != 1) return;
const AdsrSeconds& a = back.zones[0].play.adsr;
CHECK(a.attackSeconds == 0.01);
CHECK(a.holdSeconds == 0.02);
CHECK(a.decaySeconds == 0.1);
CHECK(a.sustainLevel == 0.7); // exact double round-trip via bit-cast
CHECK(a.releaseSeconds == 0.2);
CHECK(back.zones[0].play.pitchEngine == PitchEngine::Preserve);
}
// A legacy PAYLOAD v3 blob (Daniel's beta projects — has holdFrames but no A/D/S/R) lifts the
// absent A/D/S/R to the tier-0 SECONDS defaults (0.003 / 0 / 1.0 / 0.060), NO rate involved: they
// were always the seconds constants. holdSeconds converts from the v3 44.1k-nominal frame count.
static void testV3BlobLiftsAdsrToSecondsDefaults() {
std::vector<std::uint8_t> blob;
auto u32 = [&](std::uint32_t v) {
blob.push_back(v & 0xFF); blob.push_back((v >> 8) & 0xFF);
blob.push_back((v >> 16) & 0xFF); blob.push_back((v >> 24) & 0xFF);
};
u32(kPerformanceStateVersion); // envelope version 2 header
const std::vector<std::uint8_t> payload = handBuildV3PayloadOneZone("old");
blob.insert(blob.end(), payload.begin(), payload.end());
const PerformanceMap back = deserializePerformance(blob, 44100.0);
CHECK(back.zones.size() == 1);
if (back.zones.size() != 1) return;
const AdsrSeconds& a = back.zones[0].play.adsr;
// hold converts from the v3 record's frames at the passed project rate (44100.0 here).
CHECK(approx(a.holdSeconds, 2048.0 / 44100.0)); // from the hand-built v3 record
CHECK(a.attackSeconds == AdsrSeconds{}.attackSeconds); // 0.003 (tier-0 default seconds)
CHECK(a.decaySeconds == AdsrSeconds{}.decaySeconds); // 0.0
CHECK(a.sustainLevel == AdsrSeconds{}.sustainLevel); // 1.0
CHECK(a.releaseSeconds == AdsrSeconds{}.releaseSeconds); // 0.060
}
// A legacy PAYLOAD v3 blob decoded at 96k: the hold frame count (2048) converts using the
// PASSED project rate, not a baked 44100 constant. At 96000 the seconds value is 2048/96000.
static void testV3BlobLiftsAdsrAt96k() {
std::vector<std::uint8_t> blob;
auto u32 = [&](std::uint32_t v) {
blob.push_back(v & 0xFF); blob.push_back((v >> 8) & 0xFF);
blob.push_back((v >> 16) & 0xFF); blob.push_back((v >> 24) & 0xFF);
};
u32(kPerformanceStateVersion); // envelope version 2 header
const std::vector<std::uint8_t> payload = handBuildV3PayloadOneZone("old96k");
blob.insert(blob.end(), payload.begin(), payload.end());
const PerformanceMap back = deserializePerformance(blob, 96000.0);
CHECK(back.zones.size() == 1);
if (back.zones.size() != 1) return;
const AdsrSeconds& a = back.zones[0].play.adsr;
// 2048 frames at 96000 Hz -> 2048/96000 seconds (not 2048/44100).
CHECK(approx(a.holdSeconds, 2048.0 / 96000.0));
CHECK(a.attackSeconds == AdsrSeconds{}.attackSeconds);
CHECK(a.decaySeconds == AdsrSeconds{}.decaySeconds);
CHECK(a.sustainLevel == AdsrSeconds{}.sustainLevel);
CHECK(a.releaseSeconds == AdsrSeconds{}.releaseSeconds);
}
// The lift -> commit -> reload sequence must stay rate-correct at 44.1k / 48k / 96k. A DEFAULT zone
// resolves to the tier-0 wall-clock durations at each rate (round(0.003*rate), round(0.060*rate));
// an AUTHORED zone resolves to round(seconds*rate). This is the R2 blocker, pinned across rates.
static void testKeymapBuildResolvesSecondsToFramesAtEachRate() {
const auto rnd = [](double s, int rate) {
return static_cast<std::int64_t>(s * static_cast<double>(rate) + 0.5);
};
for (int rate : {44100, 48000, 96000}) {
// (a) DEFAULT zone (round-tripped through serialize/deserialize) -> tier-0 seconds.
{
PerformanceMap m;
m.zones.push_back(zone("def", 0, 127)); // product-default play (tier-0 AHDSR seconds)
const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0);
CHECK(back.zones.size() == 1);
if (back.zones.size() != 1) continue;
ResolvedZone rz; rz.lowNote = 0; rz.highNote = 127; rz.rootNote = 60;
rz.play = back.zones[0].play;
const DecodedZonePcm pcm{{0.5f}, rate};
const Keymap km = buildZonedKeymap({rz}, {pcm});
CHECK(km.samples.size() == 1);
if (km.samples.empty()) continue;
const AdsrParams& a = km.samples[0].play.adsr;
CHECK(a.attackFrames == rnd(0.003, rate)); // tier-0 attack at this rate
CHECK(a.decayFrames == 0);
CHECK(a.sustainLevel == 1.0); // level, never rate-scaled
CHECK(a.releaseFrames == rnd(0.060, rate)); // tier-0 release at this rate
}
// (b) AUTHORED zone -> round(seconds * rate) at this rate.
{
PerformanceMap m;
PerformanceZone z = zone("auth", 0, 127);
z.play.adsr.attackSeconds = 0.01;
z.play.adsr.decaySeconds = 0.1;
z.play.adsr.sustainLevel = 0.7;
z.play.adsr.releaseSeconds = 0.2;
m.zones.push_back(z);
const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0);
CHECK(back.zones.size() == 1);
if (back.zones.size() != 1) continue;
ResolvedZone rz; rz.lowNote = 0; rz.highNote = 127; rz.rootNote = 60;
rz.play = back.zones[0].play;
const DecodedZonePcm pcm{{0.5f}, rate};
const Keymap km = buildZonedKeymap({rz}, {pcm});
CHECK(km.samples.size() == 1);
if (km.samples.empty()) continue;
const AdsrParams& a = km.samples[0].play.adsr;
CHECK(a.attackFrames == rnd(0.01, rate));
CHECK(a.decayFrames == rnd(0.1, rate));
CHECK(a.sustainLevel == 0.7); // level, never rate-scaled
CHECK(a.releaseFrames == rnd(0.2, rate));
}
}
}
// buildTier0Keymap resolves the default (seconds) play arg to frames at the WAV's rate — the
// single-capture fast path. At 48k the tier-0 attack is round(0.003*48000)=144, release
// round(0.060*48000)=2880 — identical wall-clock to any rate, no baked constant.
static void testBuildTier0KeymapResolvesSecondsAt48k() {
const Keymap km = buildTier0Keymap({0.5f}, 48000, 60, SampleLoop{});
CHECK(km.samples.size() == 1);
if (km.samples.empty()) return;
const AdsrParams& a = km.samples[0].play.adsr;
CHECK(a.attackFrames == 144); // round(0.003 * 48000)
CHECK(a.decayFrames == 0);
CHECK(a.sustainLevel == 1.0); // level, not a time
CHECK(a.releaseFrames == 2880); // round(0.060 * 48000)
}
// --- single-capture zone lifecycle: reconcileSingleCaptureZones (zone-bleed fix, 3a) ---
// The bled state: two full-range Sample-face zones. Reconcile on load keeps only the
// selected sample's zone, params intact (a return to that sample restores its edits).
static void testReconcileKeepsOnlySelectedFullRangeZone() {
PerformanceMap m;
m.zones.push_back(zone("a", 0, 127));
PerformanceZone b = zone("b", 0, 127);
b.keyTrack = 0.5; // distinctive param — must survive the reconcile
m.zones.push_back(b);
CHECK(reconcileSingleCaptureZones(m, "b"));
CHECK(m.zones.size() == 1);
CHECK(m.zones.size() == 1 && m.zones[0].sampleId == "b");
CHECK(m.zones.size() == 1 && m.zones[0].keyTrack == 0.5);
}
// Loading a sample with no zone yet empties a Sample-face-shaped map — the shell then
// plays the selection via the Tier-0 fast path (product defaults), never the stale zone.
static void testReconcileClearsWhenSelectionUnzoned() {
PerformanceMap m;
m.zones.push_back(zone("a", 0, 127));
CHECK(reconcileSingleCaptureZones(m, "b"));
CHECK(m.zones.empty());
}
// Any narrow key range marks Zone-view authorship: the map (including a legitimate
// full-range fallback zone) is untouched — first-match order is load-bearing there.
static void testReconcileLeavesAuthoredMapUntouched() {
PerformanceMap m;
m.zones.push_back(zone("a", 60, 72)); // authored narrow range
m.zones.push_back(zone("b", 0, 127)); // authored full-range fallback layer
CHECK(!reconcileSingleCaptureZones(m, "c"));
CHECK(m.zones.size() == 2);
CHECK(m.zones.size() == 2 && m.zones[0].sampleId == "a" && m.zones[1].sampleId == "b");
}
// A map already holding exactly the selection's one zone is coherent — no change reported,
// so callers do not republish/reload needlessly.
static void testReconcileNoOpWhenAlreadyCoherent() {
PerformanceMap m;
m.zones.push_back(zone("a", 0, 127));
CHECK(!reconcileSingleCaptureZones(m, "a"));
CHECK(m.zones.size() == 1 && m.zones[0].sampleId == "a");
}
// Guards: an empty map and an empty selection both leave the map untouched.
static void testReconcileGuards() {
PerformanceMap empty;
CHECK(!reconcileSingleCaptureZones(empty, "a"));
PerformanceMap m;
m.zones.push_back(zone("a", 0, 127));
m.zones.push_back(zone("b", 0, 127));
CHECK(!reconcileSingleCaptureZones(m, "")); // no selection -> never mutate
CHECK(m.zones.size() == 2);
}
// The reported browse sequence, end to end at the pure layer: edit sample A (full-range
// zone materialized), browse-load B, edit B (zone appended AFTER A's). First proves the
// bug — first-match resolve plays A's zone while B is loaded — then proves the reconcile
// at the load step makes the loaded sample's zone the one resolve() returns.
static void testReconcileBrowseSequenceNoShadowing() {
const std::string json = bookJson(
{makeSample("a", "A", "reasampler_bank/a.wav", 60),
makeSample("b", "B", "reasampler_bank/b.wav", 60)}, {});
PerformanceMap m;
m.zones.push_back(zone("a", 0, 127)); // edit on A materializes A's zone
m.zones.push_back(zone("b", 0, 127)); // browse to B (pre-fix: no reconcile) + edit B
// PCM markers: A decodes to 0.75, B to 0.25 — which zone resolve() picked is audible
// in frames[0] of the resolved sample.
const auto keymapFor = [&](const PerformanceMap& map) {
const ResolvedPerformance r = resolvePerformance(json, map);
std::vector<DecodedZonePcm> decoded;
for (const ResolvedZone& rz : r.zones) {
decoded.push_back(DecodedZonePcm{
{rz.relativePath == "reasampler_bank/a.wav" ? 0.75f : 0.25f}, 44100});
}
return buildZonedKeymap(r.zones, decoded);
};
// The bled map: the engine resolves A's zone (index 0) — the shadowing bug.
const Keymap bled = keymapFor(m);
CHECK(bled.zones.size() == 2);
const ZoneResolution shadow = bled.resolve(60, 100);
CHECK(shadow.matched);
CHECK(shadow.matched &&
bled.samples[bled.zones[shadow.zoneIndex].sampleIndex].frames[0] == 0.75f);
// The fix at the load step: reconcile on the selection change keeps only B's zone —
// the loaded sample's zone IS the zone resolve() returns, at every note.
CHECK(reconcileSingleCaptureZones(m, "b"));
const Keymap fixed = keymapFor(m);
CHECK(fixed.zones.size() == 1);
for (int note : {0, 60, 127}) {
const ZoneResolution r = fixed.resolve(note, 100);
CHECK(r.matched);
CHECK(r.matched &&
fixed.samples[fixed.zones[r.zoneIndex].sampleIndex].frames[0] == 0.25f);
}
}
// --- pS self-contained playback: the instance-owned sample refs (envelope v10) ---------------
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;
}
static void testSampleRefsRoundTrip() {
// v10: the owned refs table round-trips — path + every decode intrinsic per entry —
// with the envelope neighbours (selection, zones, explicit flag, gain) intact.
ComponentState s;
s.selectionId = "kick";
s.channelMode = ChannelMode::Stereo;
s.channelModeExplicit = true;
s.masterGainLinear = 0.5;
s.sampleRefs.push_back(refEntry("kick", "reasampler_bank/kick.wav", 36,
/*hasLoop=*/true, 100, 500, /*channels=*/2,
/*name=*/"Kick Drum"));
s.sampleRefs.push_back(refEntry("pad", "reasampler_bank/pad.wav", 60,
/*hasLoop=*/false, 0, 0, /*channels=*/1));
s.map.zones.push_back(zone("pad", 48, 72));
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
CHECK(back.sampleRefs.size() == 2);
CHECK(back.sampleRefs.size() == 2 && back.sampleRefs[0].sampleId == "kick");
CHECK(back.sampleRefs.size() == 2 &&
back.sampleRefs[0].ref.relativePath == "reasampler_bank/kick.wav");
CHECK(back.sampleRefs.size() == 2 && back.sampleRefs[0].ref.rootNote == 36);
CHECK(back.sampleRefs.size() == 2 && back.sampleRefs[0].ref.loop.hasLoop &&
back.sampleRefs[0].ref.loop.start == 100 && back.sampleRefs[0].ref.loop.end == 500);
CHECK(back.sampleRefs.size() == 2 && back.sampleRefs[0].ref.channelCount == 2);
CHECK(back.sampleRefs.size() == 2 && back.sampleRefs[0].displayName == "Kick Drum");
CHECK(back.sampleRefs.size() == 2 && back.sampleRefs[1].sampleId == "pad");
CHECK(back.sampleRefs.size() == 2 && !back.sampleRefs[1].ref.loop.hasLoop);
CHECK(back.sampleRefs.size() == 2 && back.sampleRefs[1].ref.channelCount == 1);
CHECK(back.sampleRefs.size() == 2 && back.sampleRefs[1].displayName.empty());
// Envelope neighbours undisturbed (the refs read consumed exactly its own bytes).
CHECK(back.selectionId == "kick");
CHECK(back.map.zones.size() == 1);
CHECK(back.channelMode == ChannelMode::Stereo && back.channelModeExplicit);
CHECK(std::fabs(back.masterGainLinear - 0.5) < 1e-12);
}
static void testSampleRefsResolvePlayableKeymapWithoutBank() {
// THE pS architecture correction, end to end in the pure domain: a restored blob
// carrying refs resolves to a PLAYABLE keymap with NO bank blob anywhere in the path —
// deserialize -> resolvePerformanceFromRefs -> buildZonedKeymap. This is the load path
// an instance takes when the extension has not loaded (or does not exist).
ComponentState s;
s.sampleRefs.push_back(refEntry("a", "b/a.wav", 36));
s.sampleRefs.push_back(refEntry("b", "b/b.wav", 48));
s.map.zones.push_back(zone("a", 36, 47));
s.map.zones.push_back(zone("b", 48, 59, /*rootOverride=*/50));
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
const ResolvedPerformance r = resolvePerformanceFromRefs(back.sampleRefs, back.map);
CHECK(r.zones.size() == 2);
CHECK(r.droppedSampleIds.empty());
CHECK(r.zones.size() == 2 && r.zones[0].relativePath == "b/a.wav");
CHECK(r.zones.size() == 2 && r.zones[0].rootNote == 36); // ref intrinsic
CHECK(r.zones.size() == 2 && r.zones[1].rootNote == 50); // zone override beats intrinsic
std::vector<DecodedZonePcm> decoded;
decoded.push_back(DecodedZonePcm{{0.1f, 0.2f}, 44100});
decoded.push_back(DecodedZonePcm{{0.3f}, 44100});
const Keymap km = buildZonedKeymap(r.zones, decoded);
CHECK(km.resolve(40, 100).matched && km.resolve(40, 100).zoneIndex == 0);
CHECK(km.resolve(52, 100).matched && km.resolve(52, 100).zoneIndex == 1);
}
static void testResolveFromRefsMissingRefDrops() {
// MISSING-REF = defined no-play: a zone whose id has no ref (never copied, or a pre-v10
// blob not yet lifted) drops cleanly + reports; the survivor still plays — the same
// shape as the bank path's stale-id policy. (The shell's missing-FILE no-play is the
// decode seam: an unreadable WAV yields empty PCM and the zone drops in
// buildZonedKeymap — see testBuildZonedKeymapDropsEmptyPcm.)
SampleRefs refs;
refs.push_back(refEntry("a", "b/a.wav", 36));
PerformanceMap m;
m.zones.push_back(zone("a", 0, 59));
m.zones.push_back(zone("ghost", 60, 127));
const ResolvedPerformance r = resolvePerformanceFromRefs(refs, m);
CHECK(r.zones.size() == 1);
CHECK(r.zones.size() == 1 && r.zones[0].relativePath == "b/a.wav");
CHECK(r.droppedSampleIds.size() == 1);
CHECK(r.droppedSampleIds.size() == 1 && r.droppedSampleIds[0] == "ghost");
}
static void testResolveFromRefsMatchesBankResolve() {
// The two resolution paths share ONE fold (foldZone): the same map resolved via the
// bank blob and via a refs table refreshed FROM that bank yields identical effective
// zones — the paths cannot drift.
Sample s1 = makeSample("a", "Pad", "b/a.wav", 40);
s1.loop = LoopPoints{200, 800};
const std::string json = bookJson({s1}, {});
PerformanceMap m;
PerformanceZone z = zone("a", 10, 90, /*rootOverride=*/72);
z.startPoint = 512;
m.zones.push_back(z);
SampleRefs refs;
refreshRefsFromBank(refs, json, referencedSampleIds("", m));
const ResolvedPerformance viaBank = resolvePerformance(json, m);
const ResolvedPerformance viaRefs = resolvePerformanceFromRefs(refs, m);
CHECK(viaBank.zones.size() == 1 && viaRefs.zones.size() == 1);
if (viaBank.zones.size() == 1 && viaRefs.zones.size() == 1) {
CHECK(viaRefs.zones[0].relativePath == viaBank.zones[0].relativePath);
CHECK(viaRefs.zones[0].rootNote == viaBank.zones[0].rootNote); // 72 (override)
CHECK(viaRefs.zones[0].loop.hasLoop == viaBank.zones[0].loop.hasLoop);
CHECK(viaRefs.zones[0].loop.start == viaBank.zones[0].loop.start); // 200 (intrinsic)
CHECK(viaRefs.zones[0].loop.end == viaBank.zones[0].loop.end);
CHECK(viaRefs.zones[0].startFrame == viaBank.zones[0].startFrame); // 512
}
}
static void testComponentStateV9LiftsToEmptyRefs() {
// OLD-BLOB FALLBACK: a genuine v9 blob (no refs table) restores with an EMPTY table and
// every other field intact — the shell then lifts via the bridge-resolve path once the
// bank is readable and re-saves self-contained. Hand-built (serializeComponentState now
// emits v10, so it cannot make a v9 blob).
std::vector<std::uint8_t> v9;
v9.push_back(9); v9.push_back(0); v9.push_back(0); v9.push_back(0); // version 9
v9.push_back(0); // channel mode = mono
for (int i = 0; i < 8; ++i) v9.push_back(0); // marker = 0
v9.push_back(88); // preview velocity
v9.push_back(7); // voice count
v9.push_back(0); // voice mode = poly
v9.push_back(0); // trigger = retrigger
for (int i = 0; i < 8; ++i) v9.push_back(0); // gain double bytes...
v9[17 + 6] = 0xF0; v9[17 + 7] = 0x3F; // ...= 1.0 (LE IEEE-754)
v9.push_back(1); // explicit flag = true
const std::string id = "saved";
v9.push_back(static_cast<std::uint8_t>(id.size())); v9.push_back(0); v9.push_back(0); v9.push_back(0);
v9.insert(v9.end(), id.begin(), id.end());
v9.push_back(0); v9.push_back(0); v9.push_back(0); v9.push_back(0); // zone count 0
const ComponentState back = deserializeComponentState(v9, 44100.0);
CHECK(back.sampleRefs.empty()); // pre-pS blob -> empty table (bridge-resolve lift)
CHECK(back.selectionId == "saved");
CHECK(back.channelModeExplicit);
CHECK(back.previewVelocity == 88);
CHECK(back.voiceCount == 7);
CHECK(back.masterGainLinear == 1.0);
CHECK(back.map.zones.empty());
}
static void testReferencedSampleIdsDedup() {
// Selection first, then map order, duplicates collapsed; an empty selection contributes
// nothing (no phantom "" id in the refs table).
PerformanceMap m;
m.zones.push_back(zone("a", 0, 59));
m.zones.push_back(zone("b", 60, 99));
m.zones.push_back(zone("a", 100, 127)); // duplicate id across zones
const std::vector<std::string> ids = referencedSampleIds("b", m); // selection dups a zone
CHECK(ids.size() == 2);
CHECK(ids.size() == 2 && ids[0] == "b" && ids[1] == "a");
const std::vector<std::string> noSel = referencedSampleIds("", m);
CHECK(noSel.size() == 2);
CHECK(noSel.size() == 2 && noSel[0] == "a" && noSel[1] == "b");
}
static void testRefreshRefsFromBankUpsertAndOwnership() {
// Upsert: a resolvable id copies in (the selectSample distillation); a re-refresh after
// a bank edit UPDATES the owned copy (S9 recapture sync); a bank MISS never strips the
// owned entry (a bank deletion cannot silence a self-contained instance); an empty or
// malformed blob is a no-op.
Sample s1 = makeSample("a", "Kick", "b/a.wav", 36);
s1.channelCount = 2;
const std::string json1 = bookJson({s1}, {});
SampleRefs refs;
refreshRefsFromBank(refs, json1, {"a", "ghost"});
CHECK(refs.size() == 1); // "ghost" does not resolve -> no entry minted
CHECK(refs.size() == 1 && refs[0].sampleId == "a" && refs[0].ref.rootNote == 36);
CHECK(refs.size() == 1 && refs[0].ref.relativePath == "b/a.wav");
CHECK(refs.size() == 1 && refs[0].ref.channelCount == 2);
CHECK(refs.size() == 1 && refs[0].displayName == "Kick"); // name copied with the ref
// Recapture-style bank edit: path + root + name changed -> the owned copy refreshes.
refreshRefsFromBank(refs, bookJson({makeSample("a", "Kick 2", "b/a2.wav", 40)}, {}), {"a"});
CHECK(refs.size() == 1 && refs[0].ref.rootNote == 40);
CHECK(refs.size() == 1 && refs[0].ref.relativePath == "b/a2.wav");
CHECK(refs.size() == 1 && refs[0].displayName == "Kick 2"); // rename sync
// Bank deletion: the id no longer resolves -> the OWNED copy survives untouched.
refreshRefsFromBank(refs, bookJson({makeSample("x", "Other", "b/x.wav", 60)}, {}), {"a"});
CHECK(refs.size() == 1 && refs[0].ref.rootNote == 40);
CHECK(refs.size() == 1 && refs[0].displayName == "Kick 2");
// Malformed / empty blobs: no-op.
refreshRefsFromBank(refs, "{garbage", {"a"});
refreshRefsFromBank(refs, "", {"a"});
CHECK(refs.size() == 1 && refs[0].ref.rootNote == 40);
}
static void testRetainRefsFiltersToPlayedSet() {
// getState hygiene: only the entries the instance currently plays persist — the table
// cannot grow with browsing history. Order of survivors is preserved.
SampleRefs refs;
refs.push_back(refEntry("a", "b/a.wav", 36));
refs.push_back(refEntry("b", "b/b.wav", 48));
refs.push_back(refEntry("c", "b/c.wav", 60));
retainRefs(refs, {"c", "a"});
CHECK(refs.size() == 2);
CHECK(refs.size() == 2 && refs[0].sampleId == "a" && refs[1].sampleId == "c");
retainRefs(refs, {});
CHECK(refs.empty());
}
static void testSampleRefsTruncatedMidEntry() {
// A blob cut mid-refs-entry keeps the entries that parsed cleanly and restores the rest
// of the state empty (the selection/zones behind the cut are unreadable anyway) — the
// established truncation posture, never a throw across the host boundary.
ComponentState s;
s.selectionId = "kick";
s.sampleRefs.push_back(refEntry("kick", "b/k.wav", 36));
s.sampleRefs.push_back(refEntry("pad", "b/p.wav", 60));
std::vector<std::uint8_t> bytes = serializeComponentState(s);
// The tail after the refs table is idLen(4) + "kick"(4) + the empty-map zones payload
// (marker 4 + version 4 + count 4) = 20 bytes; entry 2 is 47 bytes (4+3 id, 4+7 path,
// 4 root, 1+8+8 loop, 4 channels, 4+0 name). Cutting 40 bytes lands 27 bytes into
// entry 2 (inside loop.start).
CHECK(bytes.size() > 40);
bytes.resize(bytes.size() - 40);
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.map.zones.empty());
}
static void testSampleRefsReaderRangeFallbacks() {
// Corrupt-blob posture for the refs intrinsics (the refs table is the ONLY copy on the
// play path, so a bad field must degrade to its 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.
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);
}
static void testLegacyLiftDecision() {
// The #A terminating guard, pure: Retry while the blob is not readable YET (absent,
// empty, malformed — the project's ext-state may simply not have parsed); Lift when a
// referenced id resolves (a lift attempt makes progress); Stale — the shell latches
// permanently — when the blob PARSES and knows none of the referenced ids (an empty
// id list included), so a stale-id pre-v10 lift STOPS instead of churning every tick.
const std::string json = bookJson({makeSample("a", "Kick", "b/a.wav", 36)}, {});
const std::vector<std::string> ids{"a"};
CHECK(legacyLiftDecision(std::nullopt, ids) == LegacyLiftDecision::Retry);
CHECK(legacyLiftDecision(std::string(), ids) == LegacyLiftDecision::Retry);
CHECK(legacyLiftDecision(std::string("{garbage"), ids) == LegacyLiftDecision::Retry);
CHECK(legacyLiftDecision(json, ids) == LegacyLiftDecision::Lift);
// One resolvable id among stale ones is still progress (the lift copies what it can).
CHECK(legacyLiftDecision(json, {"ghost", "a"}) == LegacyLiftDecision::Lift);
CHECK(legacyLiftDecision(json, {"ghost"}) == LegacyLiftDecision::Stale);
CHECK(legacyLiftDecision(json, {}) == LegacyLiftDecision::Stale);
}
int main() {
testSelectByIdHit();
testSelectEmptyIdIsSilence();
testSelectUnknownIdIsSilence();
testSelectRootNoteDefault();
testSelectLoopThreaded();
testSelectNoLoopIsAbsent();
testSelectChannelCountThreaded();
testChannelModeForExplicitIsNeverFought();
testChannelModeForUnknownCountIsNoOp();
testChannelModeForStereoCapture();
testChannelModeForMonoCapture();
testSelectEmptyBlob();
testSelectMalformedBlob();
testSelectZeroSamples();
testListSamplesOrdinalOrder();
testListSamplesCarriesCardMetadata();
testListSamplesEmptyAndMalformed();
testListBanksOrdinalOrder();
testListBanksEmptyAndMalformed();
testDownmixMonoPassthrough();
testDownmixStereoAverages();
testDownmixThreeChannelAverages();
testDownmixDegenerate();
testBuildKeymapSingleFullZone();
testSelectionStateRoundTrip();
testSelectionStateEmptyId();
testSelectionStateWrongVersion();
testSelectionStateTruncated();
testWavTrimToDownmixPipelineStereo();
testWavTrimToDownmixPipelineMono();
testResolveEmptyMap();
testResolveEmptyBlob();
testResolveMultiZoneAcrossBanks();
testResolveStaleIdDropsZone();
testResolveRootPrecedence();
testResolveLoopThreaded();
testResolveLoopOverrideWins();
testResolveLoopOverrideDisablesLoop();
testBuildZonedKeymapMultiZone();
testBuildZonedKeymapThreadsLoopAndStart();
testBuildZonedKeymapDropsEmptyPcm();
testBuildZonedKeymapOverlapFirstWins();
testBuildZonedKeymapEmpty();
testPerformanceStateRoundTrip();
testPerformanceStateEmpty();
testPerformanceStateLoopStartRoundTrip();
testPerformanceStateV1PayloadBackCompat();
testPerformanceStateV1BackCompat();
testPerformanceStateGarbage();
testPerformanceStateNegativeNotesRoundTrip();
testPlayParamsRoundTrip();
testPlayParamsComposeWithLoopStart();
testKeyTrackRoundTrip();
testKeyTrackThroughComponentEnvelope();
testKeyTrackResolvesToZone();
testKeyTrackV5BackCompatLiftsToUnity();
testVelocityCurveRoundTrip();
testVelocityCurveThroughComponentEnvelope();
testVelocityCurveResolvesToZone();
testVelocityCurveEndToEndThroughReloadComposition();
testVelocityCurveV6BackCompatLiftsToFlat();
testPlayParamsV2BackCompatLiftsToDefaults();
testPlayParamsThroughComponentEnvelope();
testFullAdsrSecondsRoundTrip();
testV3BlobLiftsAdsrToSecondsDefaults();
testV3BlobLiftsAdsrAt96k();
testKeymapBuildResolvesSecondsToFramesAtEachRate();
testBuildTier0KeymapResolvesSecondsAt48k();
testComponentStateRoundTrip();
testComponentStateLoopStartRoundTrip();
testComponentStateSelectionOnlyNoZones();
testComponentStateEmptyIsEmpty();
testComponentStateV1BackCompat();
testComponentStateV2BackCompat();
testComponentStateGarbage();
testExtractChannelStereo();
testExtractChannelClampsToLast();
testDecodeChannelsMonoModeDownmixes();
testDecodeChannelsStereoModeStereoSource();
testDecodeChannelsStereoModeMonoSourceDualMono();
testBuildKeymapStereoCarriesSecondChannel();
testBuildKeymapMonoWhenNoSecondChannel();
testBuildKeymapDropsMismatchedSecondChannel();
testBuildZonedKeymapCarriesSecondChannel();
testComponentStateV4RoundTripStereo();
testComponentStateV4RoundTripMono();
testComponentStateV4DefaultIsMono();
testComponentStateV3LiftsToMono();
testComponentStateV1V2LiftToMono();
testComponentStateV4TruncatedModeByte();
testComponentStateV4StereoWithZoneOverridesRoundTrip();
testComponentStateV5MarkerRoundTrip();
testComponentStateDefaultMarkerIsZero();
testComponentStateV4LiftsMarkerToZero();
testComponentStateV5TruncatedMarker();
testComponentStatePreviewVelocityRoundTrip();
testComponentStateDefaultPreviewVelocityIsMid();
testComponentStatePreviewVelocityExtremes();
testComponentStateV5LiftsVelocityToMid();
testComponentStateV4LiftsVelocityToMid();
testComponentStateV6TruncatedVelocity();
testComponentStateVoiceSystemRoundTrip();
testComponentStateVoiceDefaultsRoundTrip();
testComponentStateVoiceCountExtremesRoundTrip();
testComponentStateVoiceCountWriterClamps();
testComponentStateV6LiftsVoiceDefaults();
testComponentStateV7CorruptVoiceBytesFallBack();
testComponentStateV7TruncatedVoiceBytes();
testComponentStateMasterGainRoundTrip();
testComponentStateMasterGainDefaultAndZeroRoundTrip();
testComponentStateMasterGainWriterClamps();
testComponentStateV7LiftsUnityMasterGain();
testComponentStateV8CorruptMasterGainFallsBack();
testComponentStateV8TruncatedMasterGain();
testComponentStateChannelModeExplicitRoundTrip();
testComponentStateV8LiftsImplicitChannelMode();
testV5EnvelopeWithMarkerAndPlayParamsRoundTrip();
testV4BlobWithPlayParamsLiftsMarkerZeroKeepsPlay();
testReconcileKeepsOnlySelectedFullRangeZone();
testReconcileClearsWhenSelectionUnzoned();
testReconcileLeavesAuthoredMapUntouched();
testReconcileNoOpWhenAlreadyCoherent();
testReconcileGuards();
testReconcileBrowseSequenceNoShadowing();
testSampleRefsRoundTrip();
testSampleRefsResolvePlayableKeymapWithoutBank();
testResolveFromRefsMissingRefDrops();
testResolveFromRefsMatchesBankResolve();
testComponentStateV9LiftsToEmptyRefs();
testReferencedSampleIdsDedup();
testRefreshRefsFromBankUpsertAndOwnership();
testRetainRefsFiltersToPlayedSet();
testSampleRefsTruncatedMidEntry();
testSampleRefsReaderRangeFallbacks();
testLegacyLiftDecision();
if (g_fail == 0) std::printf("sample_map: all tests passed\n");
return g_fail != 0;
}