S4 Tier 0: the bank plays — VST3 marshals MIDI to the S3 core, reads the live bank + resolves WAV the M4 way, mono downmix, lock-free load handoff, LICE sample-pick

This commit is contained in:
2026-07-26 16:43:04 -04:00
parent b0fc052113
commit 0cde457224
24 changed files with 1239 additions and 302 deletions
+275
View File
@@ -0,0 +1,275 @@
// 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), first-sample fallback for
// an empty / unknown id, empty & malformed blob -> nullopt, zero-samples -> nullopt,
// rootNote/loop intrinsic threading incl. the middle-C default; listSamples ordinal
// order + 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 -> "".
#include "../src/vst/sample_map.h"
#include <cmath>
#include <cstdio>
#include <string>
#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 testSelectFirstSampleFallbackOnEmptyId() {
const std::string json = bookJson(
{makeSample("a", "Kick", "reasampler_bank/a.wav", 36)},
{makeSample("b", "Snare", "reasampler_bank/b.wav", 38)});
// No stored selection -> the FIRST sample in ordinal order (pool first).
auto sel = selectSample(json, "");
CHECK(sel.has_value());
CHECK(sel && sel->relativePath == "reasampler_bank/a.wav");
CHECK(sel && sel->rootNote == 36);
}
static void testSelectFirstSampleFallbackOnUnknownId() {
const std::string json = bookJson(
{makeSample("a", "Kick", "reasampler_bank/a.wav", 36)}, {});
// A stored id that no longer resolves falls back to the first sample, not silence.
auto sel = selectSample(json, "deleted-id");
CHECK(sel.has_value());
CHECK(sel && sel->relativePath == "reasampler_bank/a.wav");
}
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 testListSamplesEmptyAndMalformed() {
CHECK(listSamples("").empty());
CHECK(listSamples("{garbage").empty());
CHECK(listSamples(bookJson({}, {})).empty());
}
// --- 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)
}
int main() {
testSelectByIdHit();
testSelectFirstSampleFallbackOnEmptyId();
testSelectFirstSampleFallbackOnUnknownId();
testSelectRootNoteDefault();
testSelectLoopThreaded();
testSelectNoLoopIsAbsent();
testSelectEmptyBlob();
testSelectMalformedBlob();
testSelectZeroSamples();
testListSamplesOrdinalOrder();
testListSamplesEmptyAndMalformed();
testDownmixMonoPassthrough();
testDownmixStereoAverages();
testDownmixThreeChannelAverages();
testDownmixDegenerate();
testBuildKeymapSingleFullZone();
testBuildKeymapRateDefault();
testSelectionStateRoundTrip();
testSelectionStateEmptyId();
testSelectionStateWrongVersion();
testSelectionStateTruncated();
if (g_fail == 0) std::printf("sample_map: all tests passed\n");
return g_fail != 0;
}