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:
@@ -1,11 +1,12 @@
|
||||
// Standalone tests for reasampler::vst::bridge_marshal — no VST3, no REAPER, no test
|
||||
// framework. Same fast assert loop as the sibling pure tests: assert the REAPER
|
||||
// bridge-read marshalling (GetProjExtState result decode + a small JSON string-field
|
||||
// reader) directly, so the DAW-facing shell only has to invoke the API.
|
||||
// bridge-read marshalling (GetProjExtState result decode) directly, so the DAW-facing
|
||||
// shell only has to invoke the API.
|
||||
//
|
||||
// Covers: decodeGetProjExtState hit/absent/zero-return/empty-buffer (the stale-buffer
|
||||
// guard); extractJsonStringField present/absent/escapes/whitespace/value-vs-key
|
||||
// disambiguation/non-string-value/malformed.
|
||||
// guard). The S1 spike's extractJsonStringField string-scan reader was retired in S4
|
||||
// (the instrument now parses the bank through the shared bank_book JSON path), so its
|
||||
// cases are gone with it.
|
||||
|
||||
#include "../src/vst/bridge_marshal.h"
|
||||
|
||||
@@ -44,72 +45,11 @@ static void testDecodeEmptyBuffer() {
|
||||
CHECK(!v.has_value());
|
||||
}
|
||||
|
||||
// --- extractJsonStringField ---------------------------------------------------
|
||||
|
||||
static void testExtractPresent() {
|
||||
const std::string json = R"({"guid":"ABC-123","name":"kick"})";
|
||||
auto g = extractJsonStringField(json, "guid");
|
||||
CHECK(g && *g == "ABC-123");
|
||||
auto n = extractJsonStringField(json, "name");
|
||||
CHECK(n && *n == "kick");
|
||||
}
|
||||
|
||||
static void testExtractAbsent() {
|
||||
const std::string json = R"({"guid":"ABC-123"})";
|
||||
CHECK(!extractJsonStringField(json, "missing").has_value());
|
||||
}
|
||||
|
||||
static void testExtractWhitespaceTolerant() {
|
||||
const std::string json = "{ \"guid\" : \"X\" , \"n\":\"y\" }";
|
||||
auto g = extractJsonStringField(json, "guid");
|
||||
CHECK(g && *g == "X");
|
||||
}
|
||||
|
||||
static void testExtractEscapes() {
|
||||
// \" \\ \/ \n \t all decode.
|
||||
const std::string json = R"({"path":"a\\b\/c\"d\ne"})";
|
||||
auto p = extractJsonStringField(json, "path");
|
||||
CHECK(p && *p == "a\\b/c\"d\ne");
|
||||
}
|
||||
|
||||
static void testExtractValueContainingKeyText() {
|
||||
// A VALUE that contains the key text must not be mistaken for the member. Here the
|
||||
// first "guid" occurrence is inside another value; the real member comes later.
|
||||
const std::string json = R"({"note":"the guid is here","guid":"REAL"})";
|
||||
auto g = extractJsonStringField(json, "guid");
|
||||
CHECK(g && *g == "REAL");
|
||||
}
|
||||
|
||||
static void testExtractNonStringValue() {
|
||||
// A numeric/object value is not a string — return nullopt rather than garbage.
|
||||
const std::string json = R"({"count":42,"name":"ok"})";
|
||||
CHECK(!extractJsonStringField(json, "count").has_value());
|
||||
// The sibling string field still reads.
|
||||
auto n = extractJsonStringField(json, "name");
|
||||
CHECK(n && *n == "ok");
|
||||
}
|
||||
|
||||
static void testExtractMalformed() {
|
||||
CHECK(!extractJsonStringField(R"({"guid":"unterminated)", "guid").has_value());
|
||||
CHECK(!extractJsonStringField(R"({"guid":)", "guid").has_value());
|
||||
CHECK(!extractJsonStringField(R"({"guid")", "guid").has_value());
|
||||
CHECK(!extractJsonStringField("", "guid").has_value());
|
||||
// Dangling escape at end of string.
|
||||
CHECK(!extractJsonStringField(R"({"guid":"abc\)", "guid").has_value());
|
||||
}
|
||||
|
||||
int main() {
|
||||
testDecodeHit();
|
||||
testDecodeAbsentKey();
|
||||
testDecodeNegativeReturn();
|
||||
testDecodeEmptyBuffer();
|
||||
testExtractPresent();
|
||||
testExtractAbsent();
|
||||
testExtractWhitespaceTolerant();
|
||||
testExtractEscapes();
|
||||
testExtractValueContainingKeyText();
|
||||
testExtractNonStringValue();
|
||||
testExtractMalformed();
|
||||
|
||||
if (g_fail == 0) std::printf("bridge_marshal: all tests passed\n");
|
||||
return g_fail != 0;
|
||||
|
||||
@@ -131,6 +131,63 @@ static void testHitTestMatchesDrawnButton() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- sample list (S4) ---------------------------------------------------------
|
||||
|
||||
static void testSampleRowRectStacks() {
|
||||
const EditorLayout L = layoutEditor(400, 260);
|
||||
const Rect r0 = sampleRowRect(L, 0);
|
||||
const Rect r1 = sampleRowRect(L, 1);
|
||||
// Row 0 starts at the canvas top and spans its full width.
|
||||
CHECK(r0.top == L.canvas.top);
|
||||
CHECK(r0.left == L.canvas.left && r0.right == L.canvas.right);
|
||||
CHECK(r0.height() == kSampleRowHeight);
|
||||
// Row 1 sits directly below row 0 (no gap, no overlap).
|
||||
CHECK(r1.top == r0.bottom);
|
||||
CHECK(r1.height() == kSampleRowHeight);
|
||||
// A negative index is an empty rect.
|
||||
CHECK(sampleRowRect(L, -1).width() == 0 && sampleRowRect(L, -1).height() == 0);
|
||||
}
|
||||
|
||||
static void testSampleRowHitTestMapsClickToRow() {
|
||||
const EditorLayout L = layoutEditor(400, 260);
|
||||
const int rows = 5;
|
||||
// A click in the vertical middle of row 2 resolves to index 2.
|
||||
const Rect r2 = sampleRowRect(L, 2);
|
||||
const int midY = (r2.top + r2.bottom) / 2;
|
||||
CHECK(sampleRowHitTest(L, rows, 200, midY) == 2);
|
||||
// Row 0's top-left corner hits row 0.
|
||||
const Rect r0 = sampleRowRect(L, 0);
|
||||
CHECK(sampleRowHitTest(L, rows, r0.left, r0.top) == 0);
|
||||
}
|
||||
|
||||
static void testSampleRowHitTestMisses() {
|
||||
const EditorLayout L = layoutEditor(400, 260);
|
||||
const int rows = 3;
|
||||
// Above the first row (in the title bar) -> no row.
|
||||
CHECK(sampleRowHitTest(L, rows, 200, L.titleBar.top) == -1);
|
||||
// Below the last row -> no row.
|
||||
const Rect last = sampleRowRect(L, rows - 1);
|
||||
CHECK(sampleRowHitTest(L, rows, 200, last.bottom + 1) == -1);
|
||||
// Left of the canvas -> no row.
|
||||
CHECK(sampleRowHitTest(L, rows, L.canvas.left - 1, last.top) == -1);
|
||||
// Zero rows -> always -1.
|
||||
CHECK(sampleRowHitTest(L, 0, 200, L.canvas.top + 1) == -1);
|
||||
}
|
||||
|
||||
// The drawn-row <-> hit-test agreement: every pixel inside a row rect must resolve to
|
||||
// that row's index (the same load-bearing invariant as the button).
|
||||
static void testSampleRowHitTestMatchesDrawnRows() {
|
||||
const EditorLayout L = layoutEditor(320, 200);
|
||||
const int rows = 4;
|
||||
for (int i = 0; i < rows; ++i) {
|
||||
const Rect r = sampleRowRect(L, i);
|
||||
if (r.top >= L.canvas.bottom) break; // clipped rows aren't clickable targets
|
||||
const int y = (r.top + r.bottom) / 2;
|
||||
if (y >= L.canvas.bottom) continue;
|
||||
CHECK(sampleRowHitTest(L, rows, r.left + 1, y) == i);
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
testContainsHalfOpen();
|
||||
testContainsDegenerate();
|
||||
@@ -141,6 +198,10 @@ int main() {
|
||||
testHitTestMissesNonButton();
|
||||
testHitTestButtonBoundary();
|
||||
testHitTestMatchesDrawnButton();
|
||||
testSampleRowRectStacks();
|
||||
testSampleRowHitTestMapsClickToRow();
|
||||
testSampleRowHitTestMisses();
|
||||
testSampleRowHitTestMatchesDrawnRows();
|
||||
|
||||
if (g_fail == 0) std::printf("editor_geometry: all tests passed\n");
|
||||
return g_fail != 0;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user