Files
reasampler/tests/test_sample_map.cpp
daniel da14509ab5 Restore the bank fold and usage publish to the resume path, guard setActive against repeats, and make the meter fold's bound literal
The resume also hands back to a full reload when the fold moves the loaded capture's decode source, so the refs table and the audio cannot skew.
2026-08-02 14:06:37 -04:00

1081 lines
50 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 testSameDecodeSourceTracksEveryDecodeInput() {
// The predicate a resumed (already-decoded) instrument is gated on: every field that
// changes what buildSampleData produces must read as different, and the display-only
// name must not.
SelectedSample a;
a.relativePath = "b/a.wav";
a.rootNote = 36;
a.channelCount = 2;
a.loop.hasLoop = true;
a.loop.start = 100;
a.loop.end = 900;
CHECK(sameDecodeSource(a, a));
SelectedSample recaptured = a;
recaptured.relativePath = "b/a2.wav"; // the recapture case: a new file behind one id
CHECK(!sameDecodeSource(a, recaptured));
SelectedSample reRooted = a;
reRooted.rootNote = 40;
CHECK(!sameDecodeSource(a, reRooted));
SelectedSample reChanneled = a;
reChanneled.channelCount = 1; // drives the channel-mode auto-default, hence the decode
CHECK(!sameDecodeSource(a, reChanneled));
SelectedSample loopOff = a;
loopOff.loop.hasLoop = false;
CHECK(!sameDecodeSource(a, loopOff));
SelectedSample loopMoved = a;
loopMoved.loop.start = 101;
CHECK(!sameDecodeSource(a, loopMoved));
loopMoved = a;
loopMoved.loop.end = 901;
CHECK(!sameDecodeSource(a, loopMoved));
}
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());
}
// Major-1 remediation: buildSampleData (sample_map.cpp:333-334) is the ONE call site wiring
// load-time detection to SampleData; detectPeriod's own unit coverage (test_period_detect.cpp)
// never exercises this call, so a deleted wire passed the gated suite unnoticed. Goes through
// the real build, not a direct detectPeriod call.
static void testBuildSampleDataDetectsThirtyHertzSourcePeriod() {
const int rate = 44100;
const std::size_t frames = 30000;
std::vector<AudioSample> pcm(frames);
for (std::size_t i = 0; i < frames; ++i) {
pcm[i] = static_cast<float>(
std::sin(2.0 * 3.14159265358979323846 * 30.0 * static_cast<double>(i) / rate));
}
const SampleData sd = buildSampleData(resolveCapture(ref("b/a.wav", 60), InstrumentParams{}),
DecodedPcm{pcm, rate, {}});
CHECK(std::fabs(sd.sourcePeriodFrames - 1470.0) < 2.0); // 44100 / 30 Hz
}
// The span half of the same wire: buildSampleData must hand detection the LOOP region when the
// capture carries one, not the whole decoded PCM. Asserted through the real build for the same
// reason as the test above — period_detect's own coverage cannot see which span the loader picks.
static void testBuildSampleDataDetectsOverTheSustainLoopNotTheWholeSource() {
const int rate = 44100;
const std::size_t frames = 120000;
const std::int64_t loopStart = 60000;
const double kPi = 3.14159265358979323846;
const double loopPeriod = static_cast<double>(rate) / 30.0; // 1470 frames
// Head at 147 Hz, looped tail at 30 Hz: analysed whole, the probes split two-and-two and
// detection correctly refuses. Analysed over the loop, the 30 Hz sustain is unambiguous.
std::vector<AudioSample> pcm(frames);
double phase = 0.0;
for (std::size_t i = 0; i < frames; ++i) {
phase += 2.0 * kPi / (i < static_cast<std::size_t>(loopStart) ? 300.0 : loopPeriod);
pcm[i] = static_cast<float>(std::sin(phase));
}
InstrumentParams noLoop;
const SampleData bare = buildSampleData(resolveCapture(ref("b/a.wav", 60), noLoop),
DecodedPcm{pcm, rate, {}});
CHECK(bare.sourcePeriodFrames == 0.0); // no loop -> whole source -> no ONE period
InstrumentParams looped;
looped.loopOverride = SampleLoop{true, loopStart, static_cast<std::int64_t>(frames)};
const SampleData sd = buildSampleData(resolveCapture(ref("b/a.wav", 60), looped),
DecodedPcm{pcm, rate, {}});
CHECK(std::fabs(sd.sourcePeriodFrames - loopPeriod) < 2.0);
// A loop too short to host the full search band falls back to the whole source rather than
// to none — here that whole source has no one period, so the answer is none. Asserted
// against the literal, not against `bare`: the two agreeing would also hold if both
// regressed together, which is no evidence that the fallback ran.
InstrumentParams shortLoop;
shortLoop.loopOverride = SampleLoop{true, 118000, static_cast<std::int64_t>(frames)};
const SampleData shortSd = buildSampleData(resolveCapture(ref("b/a.wav", 60), shortLoop),
DecodedPcm{pcm, rate, {}});
CHECK(shortSd.sourcePeriodFrames == 0.0);
}
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();
testSameDecodeSourceTracksEveryDecodeInput();
testRetainRefsFiltersToPlayedSet();
testLegacyLiftDecision();
testResolvePlayConvertsWallClockAtTheRate();
testResolvePlayCarriesTheFilterAndResolvesOnlyItsEnvelope();
testResolvePlayCarriesThePitchVelocityCurve();
testResolvePlayRoundsAndFloorsNegatives();
testMigratedFadeStretchesWhenTheDecodeRateDiffersFromTheProjectRate();
testResolveCaptureUsesIntrinsicsWhenNoOverride();
testResolveCaptureOverridesBeatIntrinsics();
testResolveCaptureLoopOverrideCanDisableTheLoop();
testResolveFromBankAndRefsCannotDrift();
testResolveNoPickAndStaleIdAreSilence();
testResolveFromRefsNeedsNoBankAtAll();
testBuildSampleDataThreadsEverything();
testBuildSampleDataResolvesSecondsAtTheDecodeRate();
testBuildSampleDataCarriesTheSecondChannel();
testBuildSampleDataDropsMismatchedSecondChannel();
testBuildSampleDataEmptyPcmIsUnplayable();
testBuildSampleDataCarriesTheVelocityCurve();
testBuildSampleDataDetectsThirtyHertzSourcePeriod();
testBuildSampleDataDetectsOverTheSustainLoopNotTheWholeSource();
if (g_fail == 0) std::printf("sample_map: all tests passed\n");
return g_fail != 0;
}