983 lines
45 KiB
C++
983 lines
45 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 mapping heart: bank
|
|
// blob -> selected capture (through the SHARED bank_book JSON parse), the channel policy,
|
|
// the one parameter set's override-beats-intrinsic fold, and the SampleData build.
|
|
//
|
|
// The ComponentState wire ladder lives in test_component_state_io.cpp — its own module, its
|
|
// own suite, since the Q-W2v split.
|
|
//
|
|
// 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 / resolve / build values are checked against independently
|
|
// computed expectations.
|
|
//
|
|
// Covers: selectSample by-id hit (across pool + named banks), the policy reversal (empty /
|
|
// stale id -> SILENCE nullopt, not the first sample), empty & malformed blob -> nullopt,
|
|
// zero-samples -> nullopt, rootNote/loop/channel-count intrinsic threading incl. the
|
|
// middle-C default; channelModeFor's auto-default rule; listSamples ordinal order + the
|
|
// card metadata + empty/malformed; listBanks ordinal order (pool first); downmixToMono /
|
|
// extractChannel / decodeChannels across both channel modes; the instance-owned refs
|
|
// helpers (findRef / referencedSampleIds / refreshRefsFromBank / retainRefs) and the
|
|
// legacy-lift decision; resolvePlay's seconds->frames conversion at the live rate;
|
|
// resolveCapture's override-beats-intrinsic fold and the bank/refs paths' agreement;
|
|
// buildSampleData's threading, per-decode rate resolution, and channel handling; the
|
|
// selection-state round-trip; and the wav_trim -> extractFloatFrames -> downmixToMono
|
|
// integration, which locks the interleave-stride contract across that seam.
|
|
|
|
#include "../src/core/instrument/map/sample_map.h"
|
|
#include "../src/core/instrument/map/component_state_io.h" // serializeSelection (the v1 blob)
|
|
|
|
#include <cmath>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
#include "../src/core/model/bank_book.h"
|
|
#include "../src/core/model/bank_model.h"
|
|
|
|
using namespace reasampler;
|
|
using namespace reasampler::instrument::engine;
|
|
using namespace reasampler::instrument::map;
|
|
using namespace reasampler::capture; // wav_trim (WavLayout)
|
|
using namespace reasampler::model;
|
|
|
|
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
|
|
// BankModel::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");
|
|
BankModel* di = book.index("drums-id");
|
|
for (const Sample& s : drumSamples) di->add(s);
|
|
}
|
|
return book.serialize();
|
|
}
|
|
|
|
// --- selectSample -------------------------------------------------------------
|
|
|
|
static void testSelectByIdHit() {
|
|
const std::string json = bookJson(
|
|
{makeSample("a", "Kick", "reasampler_bank/a.wav", 36)},
|
|
{makeSample("b", "Snare", "reasampler_bank/b.wav", 38)});
|
|
// A sample in the NAMED bank resolves by id (search spans every bank).
|
|
auto sel = selectSample(json, "b");
|
|
CHECK(sel.has_value());
|
|
CHECK(sel && sel->relativePath == "reasampler_bank/b.wav");
|
|
CHECK(sel && sel->rootNote == 38);
|
|
}
|
|
|
|
static void testSelectEmptyIdIsSilence() {
|
|
const std::string json = bookJson(
|
|
{makeSample("a", "Kick", "reasampler_bank/a.wav", 36)},
|
|
{makeSample("b", "Snare", "reasampler_bank/b.wav", 38)});
|
|
// POLICY REVERSAL (S10): no stored selection resolves to SILENCE (nullopt), NOT the
|
|
// bank's first sample. A fresh instance plays nothing and shows the "pick a capture"
|
|
// empty state — the deliberate reversal of the S4 first-sample auto-play.
|
|
auto sel = selectSample(json, "");
|
|
CHECK(!sel.has_value());
|
|
}
|
|
|
|
static void testSelectUnknownIdIsSilence() {
|
|
const std::string json = bookJson(
|
|
{makeSample("a", "Kick", "reasampler_bank/a.wav", 36)}, {});
|
|
// A stale stored id (deleted/moved-out sample) resolves to SILENCE, not a substituted
|
|
// first sample — the editor reflects the missing pick with its empty state rather than
|
|
// masking it with a mystery sample.
|
|
auto sel = selectSample(json, "deleted-id");
|
|
CHECK(!sel.has_value());
|
|
}
|
|
|
|
static void testSelectRootNoteDefault() {
|
|
const std::string json = bookJson(
|
|
{makeSample("a", "Loop", "reasampler_bank/a.wav", std::nullopt)}, {});
|
|
// A sample with no root-note intrinsic defaults to middle C (60).
|
|
auto sel = selectSample(json, "a");
|
|
CHECK(sel.has_value());
|
|
CHECK(sel && sel->rootNote == 60);
|
|
}
|
|
|
|
static void testSelectLoopThreaded() {
|
|
Sample s = makeSample("a", "Pad", "reasampler_bank/a.wav", 60);
|
|
s.loop = LoopPoints{100, 500};
|
|
const std::string json = bookJson({s}, {});
|
|
auto sel = selectSample(json, "a");
|
|
CHECK(sel.has_value());
|
|
CHECK(sel && sel->loop.hasLoop);
|
|
CHECK(sel && sel->loop.start == 100 && sel->loop.end == 500);
|
|
}
|
|
|
|
static void testSelectNoLoopIsAbsent() {
|
|
const std::string json = bookJson(
|
|
{makeSample("a", "OneShot", "reasampler_bank/a.wav", 60)}, {});
|
|
auto sel = selectSample(json, "a");
|
|
CHECK(sel.has_value());
|
|
CHECK(sel && !sel->loop.hasLoop); // absent loop -> hasLoop false (not a zero loop)
|
|
}
|
|
|
|
static void testSelectChannelCountThreaded() {
|
|
// GA: the bank's capture channel-count intrinsic rides SelectedSample so the shell can
|
|
// auto-default the channel mode (stereo capture -> Stereo). An entry without the
|
|
// intrinsic yields 0 (unknown — the auto-default skips it).
|
|
Sample st = makeSample("st", "Wide", "reasampler_bank/st.wav", 60);
|
|
st.channelCount = 2;
|
|
Sample mo = makeSample("mo", "Narrow", "reasampler_bank/mo.wav", 60);
|
|
mo.channelCount = 1;
|
|
const std::string json = bookJson({st, mo, makeSample("un", "Old", "reasampler_bank/un.wav", 60)}, {});
|
|
auto selSt = selectSample(json, "st");
|
|
CHECK(selSt && selSt->channelCount == 2);
|
|
auto selMo = selectSample(json, "mo");
|
|
CHECK(selMo && selMo->channelCount == 1);
|
|
auto selUn = selectSample(json, "un");
|
|
CHECK(selUn && selUn->channelCount == 0); // unstamped -> unknown, never a guess
|
|
}
|
|
|
|
// --- channelModeFor (GA auto-default rule) -------------------------------------------
|
|
|
|
static void testChannelModeForExplicitIsNeverFought() {
|
|
// An explicit user choice is ALWAYS returned unchanged, regardless of channelCount.
|
|
CHECK(channelModeFor(2, ChannelMode::Mono, true) == ChannelMode::Mono);
|
|
CHECK(channelModeFor(1, ChannelMode::Stereo, true) == ChannelMode::Stereo);
|
|
CHECK(channelModeFor(0, ChannelMode::Stereo, true) == ChannelMode::Stereo);
|
|
}
|
|
|
|
static void testChannelModeForUnknownCountIsNoOp() {
|
|
// An unknown channel count (0 — an older bank entry) leaves the current mode unchanged.
|
|
CHECK(channelModeFor(0, ChannelMode::Mono, false) == ChannelMode::Mono);
|
|
CHECK(channelModeFor(0, ChannelMode::Stereo, false) == ChannelMode::Stereo);
|
|
}
|
|
|
|
static void testChannelModeForStereoCapture() {
|
|
// A capture with channelCount >= 2 selects Stereo (regardless of current mode).
|
|
CHECK(channelModeFor(2, ChannelMode::Mono, false) == ChannelMode::Stereo);
|
|
CHECK(channelModeFor(2, ChannelMode::Stereo, false) == ChannelMode::Stereo);
|
|
CHECK(channelModeFor(6, ChannelMode::Mono, false) == ChannelMode::Stereo);
|
|
}
|
|
|
|
static void testChannelModeForMonoCapture() {
|
|
// A capture with channelCount == 1 selects Mono (ingest-imported mono files only).
|
|
CHECK(channelModeFor(1, ChannelMode::Stereo, false) == ChannelMode::Mono);
|
|
CHECK(channelModeFor(1, ChannelMode::Mono, false) == ChannelMode::Mono);
|
|
}
|
|
|
|
static void testSelectEmptyBlob() {
|
|
CHECK(!selectSample("", "a").has_value());
|
|
}
|
|
|
|
static void testSelectMalformedBlob() {
|
|
CHECK(!selectSample("{not valid json", "a").has_value());
|
|
}
|
|
|
|
static void testSelectZeroSamples() {
|
|
// A valid book with NO samples anywhere -> nothing to play.
|
|
const std::string json = bookJson({}, {});
|
|
CHECK(!selectSample(json, "").has_value());
|
|
CHECK(!selectSample(json, "anything").has_value());
|
|
}
|
|
|
|
// --- listSamples --------------------------------------------------------------
|
|
|
|
static void testListSamplesOrdinalOrder() {
|
|
const std::string json = bookJson(
|
|
{makeSample("a", "Kick", "reasampler_bank/a.wav", 36),
|
|
makeSample("c", "Hat", "reasampler_bank/c.wav", 42)},
|
|
{makeSample("b", "Snare", "reasampler_bank/b.wav", 38)});
|
|
const std::vector<SampleChoice> list = listSamples(json);
|
|
// Pool samples (insertion order) come before the named bank's.
|
|
CHECK(list.size() == 3);
|
|
CHECK(list.size() == 3 && list[0].id == "a" && list[0].displayName == "Kick");
|
|
CHECK(list.size() == 3 && list[1].id == "c");
|
|
CHECK(list.size() == 3 && list[2].id == "b" && list[2].displayName == "Snare");
|
|
}
|
|
|
|
static void testListSamplesCarriesCardMetadata() {
|
|
// The browser card needs rootNote/key badge + the bank id (for the filter). A pool sample
|
|
// reports the pool bank id; a named-bank sample reports "drums-id"; an un-rooted sample
|
|
// reports no rootNote (the badge shows "root —", never a guessed value).
|
|
Sample rooted = makeSample("a", "Kick", "reasampler_bank/a.wav", 36);
|
|
rooted.key = "Cm";
|
|
Sample unrooted = makeSample("u", "Loop", "reasampler_bank/u.wav", std::nullopt);
|
|
const std::string json = bookJson({rooted, unrooted},
|
|
{makeSample("b", "Snare", "reasampler_bank/b.wav", 38)});
|
|
const std::vector<SampleChoice> list = listSamples(json);
|
|
CHECK(list.size() == 3);
|
|
// Pool sample "a": rooted + keyed, pool bank id.
|
|
CHECK(list[0].id == "a" && list[0].rootNote.has_value() && *list[0].rootNote == 36);
|
|
CHECK(list[0].key.has_value() && *list[0].key == "Cm");
|
|
CHECK(!list[0].bankId.empty()); // the pool has an id; the filter matches on it
|
|
// Pool sample "u": no root intrinsic -> no rootNote (badge shows "root —").
|
|
CHECK(list[1].id == "u" && !list[1].rootNote.has_value());
|
|
// Named-bank sample "b": its bank id distinguishes it from the pool for the filter.
|
|
CHECK(list[2].id == "b" && list[2].bankId == "drums-id");
|
|
CHECK(list[2].bankId != list[0].bankId); // pool vs. named bank differ (filterable apart)
|
|
}
|
|
|
|
static void testListSamplesEmptyAndMalformed() {
|
|
CHECK(listSamples("").empty());
|
|
CHECK(listSamples("{garbage").empty());
|
|
CHECK(listSamples(bookJson({}, {})).empty());
|
|
}
|
|
|
|
static void testListBanksOrdinalOrder() {
|
|
const std::string json = bookJson(
|
|
{makeSample("a", "Kick", "reasampler_bank/a.wav", 36)},
|
|
{makeSample("b", "Snare", "reasampler_bank/b.wav", 38)});
|
|
const std::vector<BankChoice> banks = listBanks(json);
|
|
// Pool first (bank-zero), then the named bank "Drums". Both ids are present so the filter
|
|
// tab strip can key on them.
|
|
CHECK(banks.size() == 2);
|
|
CHECK(banks.size() == 2 && banks[1].id == "drums-id" && banks[1].displayName == "Drums");
|
|
CHECK(banks.size() == 2 && !banks[0].id.empty()); // the pool bank has an id too
|
|
}
|
|
|
|
static void testListBanksEmptyAndMalformed() {
|
|
CHECK(listBanks("").empty());
|
|
CHECK(listBanks("{garbage").empty());
|
|
// A valid book with no samples still has the pool bank -> one entry.
|
|
CHECK(listBanks(bookJson({}, {})).size() == 1);
|
|
}
|
|
|
|
// --- downmixToMono ------------------------------------------------------------
|
|
|
|
static bool approx(double a, double b) { return std::fabs(a - b) < 1e-6; }
|
|
|
|
static void testDownmixMonoPassthrough() {
|
|
const std::vector<AudioSample> in{0.1f, -0.2f, 0.3f};
|
|
const std::vector<AudioSample> out = downmixToMono(in, 1);
|
|
CHECK(out.size() == 3);
|
|
CHECK(out.size() == 3 && approx(out[0], 0.1) && approx(out[1], -0.2) &&
|
|
approx(out[2], 0.3));
|
|
}
|
|
|
|
static void testDownmixStereoAverages() {
|
|
// Two frames, stereo interleaved: frame0 = (1.0, 0.0) -> 0.5; frame1 = (0.4, 0.6) -> 0.5.
|
|
const std::vector<AudioSample> in{1.0f, 0.0f, 0.4f, 0.6f};
|
|
const std::vector<AudioSample> out = downmixToMono(in, 2);
|
|
CHECK(out.size() == 2);
|
|
CHECK(out.size() == 2 && approx(out[0], 0.5) && approx(out[1], 0.5));
|
|
}
|
|
|
|
static void testDownmixThreeChannelAverages() {
|
|
// One 3-channel frame (0.3, 0.3, 0.6) -> 0.4.
|
|
const std::vector<AudioSample> in{0.3f, 0.3f, 0.6f};
|
|
const std::vector<AudioSample> out = downmixToMono(in, 3);
|
|
CHECK(out.size() == 1);
|
|
CHECK(out.size() == 1 && approx(out[0], 0.4));
|
|
}
|
|
|
|
static void testDownmixDegenerate() {
|
|
CHECK(downmixToMono({}, 2).empty()); // empty input
|
|
CHECK(downmixToMono({0.1f, 0.2f}, 0).empty()); // zero stride
|
|
CHECK(downmixToMono({0.1f, 0.2f}, -1).empty()); // negative stride
|
|
}
|
|
|
|
// --- 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));
|
|
}
|
|
|
|
// --- S7: extractChannel / decodeChannels (cross-mode channel policy) ----------
|
|
|
|
static void testExtractChannelStereo() {
|
|
// Interleaved stereo [L0,R0,L1,R1,...]; extract channel 0 -> L's, channel 1 -> R's.
|
|
const std::vector<AudioSample> in{0.1f, 0.9f, 0.2f, 0.8f, 0.3f, 0.7f};
|
|
const std::vector<AudioSample> l = extractChannel(in, 2, 0);
|
|
const std::vector<AudioSample> r = extractChannel(in, 2, 1);
|
|
CHECK(l.size() == 3 && approx(l[0], 0.1) && approx(l[1], 0.2) && approx(l[2], 0.3));
|
|
CHECK(r.size() == 3 && approx(r[0], 0.9) && approx(r[1], 0.8) && approx(r[2], 0.7));
|
|
}
|
|
|
|
static void testExtractChannelClampsToLast() {
|
|
// A mono source asked for channel 1 yields channel 0 (clamp to last) — the dual-mono block.
|
|
const std::vector<AudioSample> mono{0.1f, 0.2f, 0.3f};
|
|
const std::vector<AudioSample> ch1 = extractChannel(mono, 1, 1);
|
|
CHECK(ch1.size() == 3 && approx(ch1[0], 0.1) && approx(ch1[2], 0.3)); // == channel 0
|
|
CHECK(extractChannel({}, 2, 0).empty()); // empty in
|
|
CHECK(extractChannel({0.1f}, 0, 0).empty()); // zero stride
|
|
}
|
|
|
|
static void testDecodeChannelsMonoModeDownmixes() {
|
|
// MONO mode: a stereo source averages to one channel (the existing policy), framesR empty.
|
|
const std::vector<AudioSample> stereo{1.0f, 0.0f, 0.4f, 0.6f}; // frames (1,0) and (0.4,0.6)
|
|
const DecodedPcm d = decodeChannels(stereo, 2, ChannelMode::Mono, 48000);
|
|
CHECK(d.monoFrames.size() == 2 && approx(d.monoFrames[0], 0.5) && approx(d.monoFrames[1], 0.5));
|
|
CHECK(d.framesR.empty()); // mono mode -> single channel
|
|
CHECK(d.sampleRate == 48000);
|
|
}
|
|
|
|
static void testDecodeChannelsStereoModeStereoSource() {
|
|
// STEREO mode + stereo source: channels taken as-is (L/R), both present + distinct.
|
|
const std::vector<AudioSample> stereo{0.1f, 0.9f, 0.2f, 0.8f};
|
|
const DecodedPcm d = decodeChannels(stereo, 2, ChannelMode::Stereo, 44100);
|
|
CHECK(d.monoFrames.size() == 2 && approx(d.monoFrames[0], 0.1) && approx(d.monoFrames[1], 0.2));
|
|
CHECK(d.framesR.size() == 2 && approx(d.framesR[0], 0.9) && approx(d.framesR[1], 0.8));
|
|
}
|
|
|
|
static void testDecodeChannelsStereoModeMonoSourceDualMono() {
|
|
// STEREO mode + mono source: dual-mono — framesR duplicates channel 0 (centered, not silent).
|
|
const std::vector<AudioSample> mono{0.3f, 0.6f, 0.9f};
|
|
const DecodedPcm d = decodeChannels(mono, 1, ChannelMode::Stereo, 44100);
|
|
CHECK(d.monoFrames.size() == 3);
|
|
CHECK(d.framesR.size() == 3);
|
|
for (std::size_t i = 0; i < 3; ++i) CHECK(approx(d.monoFrames[i], d.framesR[i])); // R == L
|
|
}
|
|
|
|
// --- pS self-contained playback: the instance-owned sample refs (envelope v10) ---------------
|
|
|
|
static SampleRefEntry refEntry(const std::string& id, const std::string& rel, int root,
|
|
bool hasLoop = false, std::int64_t loopStart = 0,
|
|
std::int64_t loopEnd = 0, int channels = 0,
|
|
const std::string& name = "") {
|
|
SampleRefEntry e;
|
|
e.sampleId = id;
|
|
e.ref.relativePath = rel;
|
|
e.ref.rootNote = root;
|
|
e.ref.loop.hasLoop = hasLoop;
|
|
e.ref.loop.start = loopStart;
|
|
e.ref.loop.end = loopEnd;
|
|
e.ref.channelCount = channels;
|
|
e.displayName = name;
|
|
return e;
|
|
}
|
|
|
|
static void testReferencedSampleIdsIsTheLoadedCapture() {
|
|
// One capture = at most one referenced id. An empty selection contributes nothing (no
|
|
// phantom "" id can reach the refs table).
|
|
const std::vector<std::string> ids = referencedSampleIds("b");
|
|
CHECK(ids.size() == 1);
|
|
CHECK(ids.size() == 1 && ids[0] == "b");
|
|
CHECK(referencedSampleIds("").empty());
|
|
}
|
|
|
|
static void testFindRefLooksUpTheOwnedCopy() {
|
|
SampleRefs refs;
|
|
refs.push_back(refEntry("a", "b/a.wav", 36));
|
|
refs.push_back(refEntry("b", "b/b.wav", 48));
|
|
const SelectedSample* a = findRef(refs, "a");
|
|
CHECK(a != nullptr && a->rootNote == 36 && a->relativePath == "b/a.wav");
|
|
CHECK(findRef(refs, "ghost") == nullptr);
|
|
CHECK(findRef(refs, "") == nullptr); // an empty id never matches an entry
|
|
CHECK(findRef(SampleRefs{}, "a") == nullptr);
|
|
}
|
|
|
|
static void testRefreshRefsFromBankUpsertAndOwnership() {
|
|
// Upsert: a resolvable id copies in (the selectSample distillation); a re-refresh after
|
|
// a bank edit UPDATES the owned copy (S9 recapture sync); a bank MISS never strips the
|
|
// owned entry (a bank deletion cannot silence a self-contained instance); an empty or
|
|
// malformed blob is a no-op.
|
|
Sample s1 = makeSample("a", "Kick", "b/a.wav", 36);
|
|
s1.channelCount = 2;
|
|
const std::string json1 = bookJson({s1}, {});
|
|
SampleRefs refs;
|
|
refreshRefsFromBank(refs, json1, {"a", "ghost"});
|
|
CHECK(refs.size() == 1); // "ghost" does not resolve -> no entry minted
|
|
CHECK(refs.size() == 1 && refs[0].sampleId == "a" && refs[0].ref.rootNote == 36);
|
|
CHECK(refs.size() == 1 && refs[0].ref.relativePath == "b/a.wav");
|
|
CHECK(refs.size() == 1 && refs[0].ref.channelCount == 2);
|
|
CHECK(refs.size() == 1 && refs[0].displayName == "Kick"); // name copied with the ref
|
|
// Recapture-style bank edit: path + root + name changed -> the owned copy refreshes.
|
|
refreshRefsFromBank(refs, bookJson({makeSample("a", "Kick 2", "b/a2.wav", 40)}, {}), {"a"});
|
|
CHECK(refs.size() == 1 && refs[0].ref.rootNote == 40);
|
|
CHECK(refs.size() == 1 && refs[0].ref.relativePath == "b/a2.wav");
|
|
CHECK(refs.size() == 1 && refs[0].displayName == "Kick 2"); // rename sync
|
|
// Bank deletion: the id no longer resolves -> the OWNED copy survives untouched.
|
|
refreshRefsFromBank(refs, bookJson({makeSample("x", "Other", "b/x.wav", 60)}, {}), {"a"});
|
|
CHECK(refs.size() == 1 && refs[0].ref.rootNote == 40);
|
|
CHECK(refs.size() == 1 && refs[0].displayName == "Kick 2");
|
|
// Malformed / empty blobs: no-op.
|
|
refreshRefsFromBank(refs, "{garbage", {"a"});
|
|
refreshRefsFromBank(refs, "", {"a"});
|
|
CHECK(refs.size() == 1 && refs[0].ref.rootNote == 40);
|
|
}
|
|
|
|
static void testRetainRefsFiltersToPlayedSet() {
|
|
// getState hygiene: only the entries the instance currently plays persist — the table
|
|
// cannot grow with browsing history. Order of survivors is preserved.
|
|
SampleRefs refs;
|
|
refs.push_back(refEntry("a", "b/a.wav", 36));
|
|
refs.push_back(refEntry("b", "b/b.wav", 48));
|
|
refs.push_back(refEntry("c", "b/c.wav", 60));
|
|
retainRefs(refs, {"c", "a"});
|
|
CHECK(refs.size() == 2);
|
|
CHECK(refs.size() == 2 && refs[0].sampleId == "a" && refs[1].sampleId == "c");
|
|
retainRefs(refs, {});
|
|
CHECK(refs.empty());
|
|
}
|
|
|
|
static void testLegacyLiftDecision() {
|
|
// The #A terminating guard, pure: Retry while the blob is not readable YET (absent,
|
|
// empty, malformed — the project's ext-state may simply not have parsed); Lift when a
|
|
// referenced id resolves (a lift attempt makes progress); Stale — the shell latches
|
|
// permanently — when the blob PARSES and knows none of the referenced ids (an empty
|
|
// id list included), so a stale-id pre-v10 lift STOPS instead of churning every tick.
|
|
const std::string json = bookJson({makeSample("a", "Kick", "b/a.wav", 36)}, {});
|
|
const std::vector<std::string> ids{"a"};
|
|
CHECK(legacyLiftDecision(std::nullopt, ids) == LegacyLiftDecision::Retry);
|
|
CHECK(legacyLiftDecision(std::string(), ids) == LegacyLiftDecision::Retry);
|
|
CHECK(legacyLiftDecision(std::string("{garbage"), ids) == LegacyLiftDecision::Retry);
|
|
CHECK(legacyLiftDecision(json, ids) == LegacyLiftDecision::Lift);
|
|
// One resolvable id among stale ones is still progress (the lift copies what it can).
|
|
CHECK(legacyLiftDecision(json, {"ghost", "a"}) == LegacyLiftDecision::Lift);
|
|
CHECK(legacyLiftDecision(json, {"ghost"}) == LegacyLiftDecision::Stale);
|
|
CHECK(legacyLiftDecision(json, {}) == LegacyLiftDecision::Stale);
|
|
}
|
|
|
|
// --- resolvePlay: stored SECONDS -> engine FRAMES at the live rate --------------
|
|
|
|
static void testResolvePlayConvertsWallClockAtTheRate() {
|
|
// Wall-clock times convert at the LIVE rate; the Trigger %-length, every hold FRACTION and
|
|
// every curve exponent are rate-free and carry through untouched, as do levels and depths.
|
|
PlaySeconds st;
|
|
st.playMode = PlayMode::Trigger;
|
|
st.adsr.attackSeconds = 0.01;
|
|
st.adsr.holdSeconds = 0.05;
|
|
st.adsr.decaySeconds = 0.02;
|
|
st.adsr.sustainLevel = 0.8;
|
|
st.adsr.releaseSeconds = 0.15;
|
|
st.adsr.attackCurve = 2.5;
|
|
st.adsr.decayCurve = 0.4;
|
|
st.adsr.releaseCurve = 3.5;
|
|
st.trigger.lengthFraction = 0.75;
|
|
st.trigAhd.attackSeconds = 0.01;
|
|
st.trigAhd.decaySeconds = 0.02;
|
|
st.trigAhd.holdFraction = 0.6;
|
|
st.trigAhd.attackCurve = 1.75;
|
|
st.trigAhd.decayCurve = 0.8;
|
|
st.pitchEngine = PitchEngine::Preserve;
|
|
st.pitchEnv.enabled = true;
|
|
st.pitchEnv.shape.attackSeconds = 0.02;
|
|
st.pitchEnv.shape.decaySeconds = 0.03;
|
|
st.pitchEnv.shape.holdFraction = 0.25;
|
|
st.pitchEnv.peakSemitones = 5.0;
|
|
|
|
const PlayParams at48 = resolvePlay(st, 48000);
|
|
CHECK(at48.playMode == PlayMode::Trigger);
|
|
CHECK(at48.adsr.attackFrames == 480);
|
|
CHECK(at48.adsr.holdFrames == 2400);
|
|
CHECK(at48.adsr.decayFrames == 960);
|
|
CHECK(at48.adsr.sustainLevel == 0.8); // a level, not a time
|
|
CHECK(at48.adsr.releaseFrames == 7200);
|
|
CHECK(at48.adsr.attackCurve == 2.5); // dimensionless
|
|
CHECK(at48.adsr.decayCurve == 0.4);
|
|
CHECK(at48.adsr.releaseCurve == 3.5);
|
|
CHECK(at48.trigger.lengthFraction == 0.75); // source-timeline, unconverted
|
|
CHECK(at48.trigAhd.attackFrames == 480);
|
|
CHECK(at48.trigAhd.decayFrames == 960);
|
|
CHECK(at48.trigAhd.holdFraction == 0.6); // a fraction, not a time
|
|
CHECK(at48.trigAhd.attackCurve == 1.75);
|
|
CHECK(at48.trigAhd.decayCurve == 0.8);
|
|
CHECK(at48.pitchEngine == PitchEngine::Preserve);
|
|
CHECK(at48.pitchEnv.enabled);
|
|
CHECK(at48.pitchEnv.shape.attackFrames == 960);
|
|
CHECK(at48.pitchEnv.shape.decayFrames == 1440);
|
|
CHECK(at48.pitchEnv.shape.holdFraction == 0.25);
|
|
CHECK(at48.pitchEnv.peakSemitones == 5.0); // a depth, not a time
|
|
|
|
// THE no-hardcoded-rate contract: the SAME stored seconds yield different frame counts
|
|
// at a different rate. A baked-in rate would make these equal.
|
|
const PlayParams at96 = resolvePlay(st, 96000);
|
|
CHECK(at96.adsr.attackFrames == 960);
|
|
CHECK(at96.adsr.holdFrames == 4800);
|
|
CHECK(at96.adsr.releaseFrames == 14400);
|
|
CHECK(at96.pitchEnv.shape.attackFrames == 1920);
|
|
CHECK(at96.trigAhd.attackFrames == 960);
|
|
CHECK(at96.trigAhd.holdFraction == 0.6); // still unconverted
|
|
CHECK(at96.adsr.attackCurve == 2.5);
|
|
}
|
|
|
|
static void testResolvePlayCarriesTheFilterAndResolvesOnlyItsEnvelope() {
|
|
// The filter's control positions are already rate-free, so only its envelope crosses the
|
|
// seconds->frames boundary. A converted norm would be a bug in the other direction: the
|
|
// same preset must sound identical at 48k and 96k.
|
|
PlaySeconds st;
|
|
st.filter.enabled = true;
|
|
st.filter.settings.cutoffNorm = 0.25f;
|
|
st.filter.settings.resonanceNorm = 0.75f;
|
|
st.filter.settings.morphNorm = 0.5f;
|
|
st.filter.settings.driveNorm = 0.125f;
|
|
st.filter.settings.morphLaw = reasampler::instrument::engine::filter::MorphLaw::HighNotchLow;
|
|
st.filter.modAmount = -0.5;
|
|
st.filter.velAmount = -0.375; // distinct from every neighbouring field, so a mis-wire shows
|
|
st.filter.velocityCurve = VelocityCurve::fromPoints(
|
|
{{0.0, 0.0}, {127.0, 0.25}}, reasampler::instrument::engine::CurveDomain::Bipolar);
|
|
st.filter.keyTrack = 1.25;
|
|
st.filter.env.attackSeconds = 0.01;
|
|
st.filter.env.holdSeconds = 0.02;
|
|
st.filter.env.decaySeconds = 0.03;
|
|
st.filter.env.sustainLevel = 0.4;
|
|
st.filter.env.releaseSeconds = 0.05;
|
|
|
|
const PlayParams at48 = resolvePlay(st, 48000);
|
|
CHECK(at48.filter.enabled);
|
|
CHECK(at48.filter.settings.cutoffNorm == 0.25f);
|
|
CHECK(at48.filter.settings.resonanceNorm == 0.75f);
|
|
CHECK(at48.filter.settings.morphNorm == 0.5f);
|
|
CHECK(at48.filter.settings.driveNorm == 0.125f);
|
|
CHECK(at48.filter.settings.morphLaw == reasampler::instrument::engine::filter::MorphLaw::HighNotchLow);
|
|
CHECK(at48.filter.modAmount == -0.5);
|
|
CHECK(at48.filter.velAmount == -0.375);
|
|
// The transfer curve is dimensionless, so it crosses unchanged — asserted against the
|
|
// straight line the two stored knots describe, not against the stored object.
|
|
CHECK(at48.filter.velocityCurve.eval(0.0) == 0.0);
|
|
CHECK(approx(at48.filter.velocityCurve.eval(127.0), 0.25));
|
|
CHECK(approx(at48.filter.velocityCurve.eval(63.5), 0.125));
|
|
CHECK(at48.filter.keyTrack == 1.25);
|
|
CHECK(at48.filter.env.attackFrames == 480);
|
|
CHECK(at48.filter.env.holdFrames == 960);
|
|
CHECK(at48.filter.env.decayFrames == 1440);
|
|
CHECK(at48.filter.env.sustainLevel == 0.4); // a level, not a time
|
|
CHECK(at48.filter.env.releaseFrames == 2400);
|
|
|
|
const PlayParams at96 = resolvePlay(st, 96000);
|
|
CHECK(at96.filter.env.attackFrames == 960);
|
|
CHECK(at96.filter.env.releaseFrames == 4800);
|
|
CHECK(at96.filter.settings.cutoffNorm == 0.25f); // rate-free: unchanged
|
|
|
|
// Off by default, and the default envelope is a flat unity so a disengaged filter has
|
|
// nothing to modulate with either.
|
|
const PlayParams bare = resolvePlay(PlaySeconds{}, 48000);
|
|
CHECK(!bare.filter.enabled);
|
|
CHECK(bare.filter.modAmount == 0.0);
|
|
CHECK(bare.filter.velAmount == 0.0);
|
|
for (int v = 0; v <= 127; ++v) CHECK(bare.filter.velocityCurve.eval(v) == 0.0);
|
|
CHECK(bare.filter.keyTrack == 0.0);
|
|
CHECK(bare.filter.env.sustainLevel == 1.0);
|
|
// The velocity->pitch curve rides the same boundary and is off by the same default.
|
|
for (int v = 0; v <= 127; ++v) CHECK(bare.pitchVelocityCurve.eval(v) == 0.0);
|
|
}
|
|
|
|
// The velocity->pitch curve is dimensionless like the filter's, so resolvePlay carries it
|
|
// across the seconds->frames boundary untouched at any rate.
|
|
static void testResolvePlayCarriesThePitchVelocityCurve() {
|
|
PlaySeconds st;
|
|
st.pitchVelocityCurve = VelocityCurve::fromPoints(
|
|
{{0.0, -1.0}, {127.0, 1.0}}, reasampler::instrument::engine::CurveDomain::Bipolar);
|
|
for (const int rate : {44100, 96000}) {
|
|
const PlayParams p = resolvePlay(st, rate);
|
|
CHECK(p.pitchVelocityCurve.eval(0.0) == -1.0);
|
|
CHECK(p.pitchVelocityCurve.eval(127.0) == 1.0);
|
|
CHECK(approx(p.pitchVelocityCurve.eval(63.5), 0.0));
|
|
}
|
|
}
|
|
|
|
static void testResolvePlayRoundsAndFloorsNegatives() {
|
|
PlaySeconds st;
|
|
st.adsr.attackSeconds = 0.0001; // 4.41 frames at 44.1k -> rounds to 4
|
|
st.adsr.decaySeconds = 0.00012; // 5.292 -> rounds to 5
|
|
st.adsr.releaseSeconds = -1.0; // negative is floored to 0, never a negative count
|
|
const PlayParams p = resolvePlay(st, 44100);
|
|
CHECK(p.adsr.attackFrames == 4);
|
|
CHECK(p.adsr.decayFrames == 5);
|
|
CHECK(p.adsr.releaseFrames == 0);
|
|
}
|
|
|
|
// The retired Trigger fade pair was SOURCE frames; the AHD that replaced it stores wall-clock
|
|
// seconds, and the codec's lift can only divide by the PROJECT rate. This is the far end of
|
|
// that seam: the build multiplies by the DECODE rate, so a migrated fade comes back scaled by
|
|
// decodeRate/projectRate whenever a file's own rate differs from the project's. The bound is
|
|
// documented at the lift in component_state_io.h; this is its measured size.
|
|
static void testMigratedFadeStretchesWhenTheDecodeRateDiffersFromTheProjectRate() {
|
|
PlaySeconds st;
|
|
st.trigAhd.attackSeconds = 441.0 / 44100.0; // a 441-SOURCE-frame fade lifted at 44.1k
|
|
st.trigAhd.decaySeconds = 882.0 / 44100.0;
|
|
|
|
// Matched rates are EXACT: the round trip through seconds loses nothing.
|
|
const PlayParams matched = resolvePlay(st, 44100);
|
|
CHECK(matched.trigAhd.attackFrames == 441);
|
|
CHECK(matched.trigAhd.decayFrames == 882);
|
|
|
|
// resolvePlay's second argument is the DECODE rate; the seconds above were lifted (divided)
|
|
// at the PROJECT rate 44100 — so this is a 48 kHz file opened in a 44.1 kHz project (the
|
|
// mirror of component_state_io.h's worked example): 441 * 48000/44100 = 480 source frames,
|
|
// ~8.8% longer than the fade the saved instance actually had.
|
|
const PlayParams stretched = resolvePlay(st, 48000);
|
|
CHECK(stretched.trigAhd.attackFrames == 480);
|
|
CHECK(stretched.trigAhd.decayFrames == 960);
|
|
}
|
|
|
|
// --- resolveCapture: the ONE override-beats-intrinsic fold ---------------------
|
|
|
|
static SelectedSample ref(const std::string& rel, int root, bool hasLoop = false,
|
|
std::int64_t loopStart = 0, std::int64_t loopEnd = 0) {
|
|
SelectedSample s;
|
|
s.relativePath = rel;
|
|
s.rootNote = root;
|
|
s.loop.hasLoop = hasLoop;
|
|
s.loop.start = loopStart;
|
|
s.loop.end = loopEnd;
|
|
return s;
|
|
}
|
|
|
|
static void testResolveCaptureUsesIntrinsicsWhenNoOverride() {
|
|
const ResolvedCapture r = resolveCapture(ref("b/a.wav", 40, true, 200, 800),
|
|
InstrumentParams{});
|
|
CHECK(r.relativePath == "b/a.wav");
|
|
CHECK(r.rootNote == 40); // the capture's own root
|
|
CHECK(r.loop.hasLoop && r.loop.start == 200 && r.loop.end == 800);
|
|
CHECK(r.startFrame == 0); // absent start point -> frame 0
|
|
CHECK(r.keyTrack == 1.0);
|
|
}
|
|
|
|
static void testResolveCaptureOverridesBeatIntrinsics() {
|
|
InstrumentParams p;
|
|
p.rootOverride = 72;
|
|
SampleLoop lp;
|
|
lp.hasLoop = true;
|
|
lp.start = 10;
|
|
lp.end = 90;
|
|
p.loopOverride = lp;
|
|
p.startPoint = 512;
|
|
p.keyTrack = 0.5;
|
|
p.play.playMode = PlayMode::Trigger;
|
|
const ResolvedCapture r = resolveCapture(ref("b/a.wav", 40, true, 200, 800), p);
|
|
CHECK(r.rootNote == 72); // override beats the intrinsic
|
|
CHECK(r.loop.hasLoop && r.loop.start == 10 && r.loop.end == 90);
|
|
CHECK(r.startFrame == 512);
|
|
CHECK(r.keyTrack == 0.5);
|
|
CHECK(r.play.playMode == PlayMode::Trigger);
|
|
CHECK(r.relativePath == "b/a.wav"); // the path is always the capture's
|
|
}
|
|
|
|
static void testResolveCaptureLoopOverrideCanDisableTheLoop() {
|
|
// A loop override with hasLoop=false is how the user turns a looping capture into a
|
|
// one-shot — it must beat the intrinsic rather than falling back to it.
|
|
InstrumentParams p;
|
|
p.loopOverride = SampleLoop{}; // hasLoop == false
|
|
const ResolvedCapture r = resolveCapture(ref("b/a.wav", 40, true, 200, 800), p);
|
|
CHECK(!r.loop.hasLoop);
|
|
}
|
|
|
|
static void testResolveFromBankAndRefsCannotDrift() {
|
|
// Both resolution paths share ONE fold, so the same parameter set resolved via the bank
|
|
// blob and via a refs table refreshed FROM that bank yields identical results.
|
|
Sample s1 = makeSample("a", "Pad", "b/a.wav", 40);
|
|
s1.loop = LoopPoints{200, 800};
|
|
const std::string json = bookJson({s1}, {});
|
|
InstrumentParams p;
|
|
p.rootOverride = 72;
|
|
p.startPoint = 512;
|
|
SampleRefs refs;
|
|
refreshRefsFromBank(refs, json, referencedSampleIds("a"));
|
|
|
|
const std::optional<ResolvedCapture> viaBank = resolveFromBank(json, "a", p);
|
|
const std::optional<ResolvedCapture> viaRefs = resolveFromRefs(refs, "a", p);
|
|
CHECK(viaBank.has_value() && viaRefs.has_value());
|
|
if (viaBank && viaRefs) {
|
|
CHECK(viaRefs->relativePath == viaBank->relativePath);
|
|
CHECK(viaRefs->rootNote == viaBank->rootNote); // 72 (override)
|
|
CHECK(viaRefs->loop.hasLoop == viaBank->loop.hasLoop);
|
|
CHECK(viaRefs->loop.start == viaBank->loop.start); // 200 (intrinsic)
|
|
CHECK(viaRefs->loop.end == viaBank->loop.end);
|
|
CHECK(viaRefs->startFrame == viaBank->startFrame); // 512
|
|
}
|
|
}
|
|
|
|
static void testResolveNoPickAndStaleIdAreSilence() {
|
|
const std::string json = bookJson({makeSample("a", "Kick", "b/a.wav", 36)}, {});
|
|
SampleRefs refs;
|
|
refs.push_back(refEntry("a", "b/a.wav", 36));
|
|
// No pick -> nothing to resolve; a stale id -> the SAME defined no-play, never a
|
|
// substituted first sample.
|
|
CHECK(!resolveFromBank(json, "", InstrumentParams{}).has_value());
|
|
CHECK(!resolveFromBank(json, "ghost", InstrumentParams{}).has_value());
|
|
CHECK(!resolveFromRefs(refs, "", InstrumentParams{}).has_value());
|
|
CHECK(!resolveFromRefs(refs, "ghost", InstrumentParams{}).has_value());
|
|
// And an unreadable bank blob resolves to nothing rather than throwing.
|
|
CHECK(!resolveFromBank("", "a", InstrumentParams{}).has_value());
|
|
CHECK(!resolveFromBank("{garbage", "a", InstrumentParams{}).has_value());
|
|
}
|
|
|
|
static void testResolveFromRefsNeedsNoBankAtAll() {
|
|
// The self-contained play path: an instance with owned refs resolves with NO bank blob
|
|
// anywhere in the call — this is what an instance does when the extension is absent.
|
|
SampleRefs refs;
|
|
refs.push_back(refEntry("a", "b/a.wav", 36, /*hasLoop=*/true, 100, 500));
|
|
const std::optional<ResolvedCapture> r = resolveFromRefs(refs, "a", InstrumentParams{});
|
|
CHECK(r.has_value());
|
|
CHECK(r && r->relativePath == "b/a.wav");
|
|
CHECK(r && r->rootNote == 36);
|
|
CHECK(r && r->loop.hasLoop && r->loop.start == 100 && r->loop.end == 500);
|
|
}
|
|
|
|
// --- buildSampleData -----------------------------------------------------------
|
|
|
|
static void testBuildSampleDataThreadsEverything() {
|
|
InstrumentParams p;
|
|
p.rootOverride = 40;
|
|
SampleLoop lp;
|
|
lp.hasLoop = true;
|
|
lp.start = 10;
|
|
lp.end = 90;
|
|
p.loopOverride = lp;
|
|
p.startPoint = 7;
|
|
p.keyTrack = 0.25;
|
|
p.play.adsr.attackSeconds = 0.01;
|
|
const SampleData sd = buildSampleData(resolveCapture(ref("b/a.wav", 60), p),
|
|
DecodedPcm{{0.1f, 0.2f, 0.3f}, 48000, {}});
|
|
CHECK(sd.playable());
|
|
CHECK(sd.frames.size() == 3);
|
|
CHECK(sd.sampleRate == 48000);
|
|
CHECK(sd.rootNote == 40); // the override, not the capture's 60
|
|
CHECK(sd.loop.hasLoop && sd.loop.start == 10 && sd.loop.end == 90);
|
|
CHECK(sd.startFrame == 7);
|
|
CHECK(sd.keyTrack == 0.25);
|
|
CHECK(sd.channelCount() == 1);
|
|
// Wall-clock seconds resolve to frames at THIS decode's rate.
|
|
CHECK(sd.play.adsr.attackFrames == 480);
|
|
}
|
|
|
|
static void testBuildSampleDataResolvesSecondsAtTheDecodeRate() {
|
|
// The rate that governs the conversion is the DECODE's, not a baked constant: the same
|
|
// parameter set built against two decodes yields two different frame counts.
|
|
InstrumentParams p;
|
|
p.play.adsr.attackSeconds = 0.1;
|
|
p.play.adsr.releaseSeconds = 0.25;
|
|
const ResolvedCapture rc = resolveCapture(ref("b/a.wav", 60), p);
|
|
const SampleData at44 = buildSampleData(rc, DecodedPcm{{0.1f}, 44100, {}});
|
|
const SampleData at96 = buildSampleData(rc, DecodedPcm{{0.1f}, 96000, {}});
|
|
CHECK(at44.play.adsr.attackFrames == 4410);
|
|
CHECK(at44.play.adsr.releaseFrames == 11025);
|
|
CHECK(at96.play.adsr.attackFrames == 9600);
|
|
CHECK(at96.play.adsr.releaseFrames == 24000);
|
|
}
|
|
|
|
static void testBuildSampleDataCarriesTheSecondChannel() {
|
|
const SampleData sd = buildSampleData(
|
|
resolveCapture(ref("b/a.wav", 60), InstrumentParams{}),
|
|
DecodedPcm{{0.1f, 0.2f}, 44100, {0.9f, 0.8f}});
|
|
CHECK(sd.channelCount() == 2);
|
|
CHECK(sd.framesR.size() == 2 && approx(sd.framesR[0], 0.9) && approx(sd.framesR[1], 0.8));
|
|
}
|
|
|
|
static void testBuildSampleDataDropsMismatchedSecondChannel() {
|
|
// A malformed pair must fall back to MONO rather than half-playing.
|
|
const SampleData sd = buildSampleData(
|
|
resolveCapture(ref("b/a.wav", 60), InstrumentParams{}),
|
|
DecodedPcm{{0.1f, 0.2f, 0.3f}, 44100, {0.9f}});
|
|
CHECK(sd.channelCount() == 1);
|
|
CHECK(sd.framesR.empty());
|
|
}
|
|
|
|
static void testBuildSampleDataEmptyPcmIsUnplayable() {
|
|
// An unreadable/missing WAV decodes to empty PCM: the build yields an UNPLAYABLE
|
|
// SampleData (silence), never a voice started on an empty read span. (A non-positive
|
|
// rate is a programming error the build asserts on, so it is not exercised here.)
|
|
const ResolvedCapture rc = resolveCapture(ref("b/a.wav", 60), InstrumentParams{});
|
|
const SampleData sd = buildSampleData(rc, DecodedPcm{{}, 44100, {}});
|
|
CHECK(!sd.playable());
|
|
CHECK(sd.frames.empty());
|
|
}
|
|
|
|
static void testBuildSampleDataCarriesTheVelocityCurve() {
|
|
InstrumentParams p;
|
|
p.velocityCurve = VelocityCurve::linear();
|
|
const SampleData sd = buildSampleData(resolveCapture(ref("b/a.wav", 60), p),
|
|
DecodedPcm{{0.1f}, 44100, {}});
|
|
// The curve reaches the engine's own copy: a mid velocity maps to ~half gain, which the
|
|
// flat default would not do.
|
|
CHECK(std::fabs(sd.velocityCurve.eval(64.0) - 64.0 / 127.0) < 1e-6);
|
|
}
|
|
|
|
int main() {
|
|
testSelectByIdHit();
|
|
testSelectEmptyIdIsSilence();
|
|
testSelectUnknownIdIsSilence();
|
|
testSelectRootNoteDefault();
|
|
testSelectLoopThreaded();
|
|
testSelectNoLoopIsAbsent();
|
|
testSelectChannelCountThreaded();
|
|
testChannelModeForExplicitIsNeverFought();
|
|
testChannelModeForUnknownCountIsNoOp();
|
|
testChannelModeForStereoCapture();
|
|
testChannelModeForMonoCapture();
|
|
testSelectEmptyBlob();
|
|
testSelectMalformedBlob();
|
|
testSelectZeroSamples();
|
|
testListSamplesOrdinalOrder();
|
|
testListSamplesCarriesCardMetadata();
|
|
testListSamplesEmptyAndMalformed();
|
|
testListBanksOrdinalOrder();
|
|
testListBanksEmptyAndMalformed();
|
|
testDownmixMonoPassthrough();
|
|
testDownmixStereoAverages();
|
|
testDownmixThreeChannelAverages();
|
|
testDownmixDegenerate();
|
|
testSelectionStateRoundTrip();
|
|
testSelectionStateEmptyId();
|
|
testSelectionStateWrongVersion();
|
|
testSelectionStateTruncated();
|
|
testWavTrimToDownmixPipelineStereo();
|
|
testWavTrimToDownmixPipelineMono();
|
|
testExtractChannelStereo();
|
|
testExtractChannelClampsToLast();
|
|
testDecodeChannelsMonoModeDownmixes();
|
|
testDecodeChannelsStereoModeStereoSource();
|
|
testDecodeChannelsStereoModeMonoSourceDualMono();
|
|
testReferencedSampleIdsIsTheLoadedCapture();
|
|
testFindRefLooksUpTheOwnedCopy();
|
|
testRefreshRefsFromBankUpsertAndOwnership();
|
|
testRetainRefsFiltersToPlayedSet();
|
|
testLegacyLiftDecision();
|
|
testResolvePlayConvertsWallClockAtTheRate();
|
|
testResolvePlayCarriesTheFilterAndResolvesOnlyItsEnvelope();
|
|
testResolvePlayCarriesThePitchVelocityCurve();
|
|
testResolvePlayRoundsAndFloorsNegatives();
|
|
testMigratedFadeStretchesWhenTheDecodeRateDiffersFromTheProjectRate();
|
|
testResolveCaptureUsesIntrinsicsWhenNoOverride();
|
|
testResolveCaptureOverridesBeatIntrinsics();
|
|
testResolveCaptureLoopOverrideCanDisableTheLoop();
|
|
testResolveFromBankAndRefsCannotDrift();
|
|
testResolveNoPickAndStaleIdAreSilence();
|
|
testResolveFromRefsNeedsNoBankAtAll();
|
|
testBuildSampleDataThreadsEverything();
|
|
testBuildSampleDataResolvesSecondsAtTheDecodeRate();
|
|
testBuildSampleDataCarriesTheSecondChannel();
|
|
testBuildSampleDataDropsMismatchedSecondChannel();
|
|
testBuildSampleDataEmptyPcmIsUnplayable();
|
|
testBuildSampleDataCarriesTheVelocityCurve();
|
|
|
|
if (g_fail == 0) std::printf("sample_map: all tests passed\n");
|
|
return g_fail != 0;
|
|
}
|