Files
reasampler/tests/test_sample_map.cpp
T
daniel 7151e19432 S10: capture-first ReaSampler 9000 editor — browser, single-capture setup, zones toggle
Reverse S4 auto-select (empty=silence+empty state), add peak-thumbnail capture
browser + bank filter + keyboard-strip drag machine, v3 component state (selection
+ zones), and the S-NAME-1 filename/display rename (UID locked). MSVC min/max
macro collisions fixed post-implementation; 26/26 tests green.
2026-07-27 04:09:50 -04:00

733 lines
33 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"
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 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);
}
static void testBuildKeymapRateDefault() {
// A zero/invalid rate defaults to 44100 rather than producing a divide-by-zero-shaped
// sample rate downstream.
const Keymap km = buildTier0Keymap({0.1f}, 0, 60, SampleLoop{});
CHECK(km.samples.size() == 1 && km.samples[0].sampleRate == 44100);
}
// --- 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);
}
// --- 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 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);
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{});
// Just the version + zero-count header.
CHECK(bytes.size() == 8);
CHECK(deserializePerformance(bytes).zones.empty());
}
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);
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("")).zones.empty());
}
static void testPerformanceStateGarbage() {
// Unknown version / truncated / empty -> empty map (never throws).
CHECK(deserializePerformance({}).zones.empty());
CHECK(deserializePerformance({0xAA, 0xBB, 0xCC, 0xDD}).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).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));
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));
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));
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));
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);
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(""));
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);
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({}).selectionId.empty());
CHECK(deserializeComponentState({}).map.zones.empty());
const std::vector<std::uint8_t> unknown{0xAA, 0xBB, 0xCC, 0xDD};
CHECK(deserializeComponentState(unknown).map.zones.empty());
CHECK(deserializeComponentState(unknown).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).selectionId.empty());
CHECK(deserializeComponentState(t).map.zones.empty());
}
int main() {
testSelectByIdHit();
testSelectEmptyIdIsSilence();
testSelectUnknownIdIsSilence();
testSelectRootNoteDefault();
testSelectLoopThreaded();
testSelectNoLoopIsAbsent();
testSelectEmptyBlob();
testSelectMalformedBlob();
testSelectZeroSamples();
testListSamplesOrdinalOrder();
testListSamplesCarriesCardMetadata();
testListSamplesEmptyAndMalformed();
testListBanksOrdinalOrder();
testListBanksEmptyAndMalformed();
testDownmixMonoPassthrough();
testDownmixStereoAverages();
testDownmixThreeChannelAverages();
testDownmixDegenerate();
testBuildKeymapSingleFullZone();
testBuildKeymapRateDefault();
testSelectionStateRoundTrip();
testSelectionStateEmptyId();
testSelectionStateWrongVersion();
testSelectionStateTruncated();
testWavTrimToDownmixPipelineStereo();
testWavTrimToDownmixPipelineMono();
testResolveEmptyMap();
testResolveEmptyBlob();
testResolveMultiZoneAcrossBanks();
testResolveStaleIdDropsZone();
testResolveRootPrecedence();
testResolveLoopThreaded();
testBuildZonedKeymapMultiZone();
testBuildZonedKeymapDropsEmptyPcm();
testBuildZonedKeymapOverlapFirstWins();
testBuildZonedKeymapEmpty();
testPerformanceStateRoundTrip();
testPerformanceStateEmpty();
testPerformanceStateV1BackCompat();
testPerformanceStateGarbage();
testPerformanceStateNegativeNotesRoundTrip();
testComponentStateRoundTrip();
testComponentStateSelectionOnlyNoZones();
testComponentStateEmptyIsEmpty();
testComponentStateV1BackCompat();
testComponentStateV2BackCompat();
testComponentStateGarbage();
if (g_fail == 0) std::printf("sample_map: all tests passed\n");
return g_fail != 0;
}