7151e19432
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.
733 lines
33 KiB
C++
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;
|
|
}
|