Retire the zone system: one capture = one parameter set, and re-seam the engine and Sample face into bands

This commit is contained in:
2026-07-30 07:15:54 -04:00
parent a689fb75eb
commit 8d4ccbf841
61 changed files with 5416 additions and 8008 deletions
+3
View File
@@ -12,6 +12,9 @@
// and returning every index for an empty query.
#include "../src/core/instrument/ui/browser_scroll.h"
// The modal reuses the Sample face's chrome metrics (kPad / kTitleHeight); assert against
// those same constants so a metric change can never desync the sheet from what it covers.
#include "../src/core/instrument/ui/sample_bands.h"
#include <cstdio>
#include <string>
+612 -214
View File
@@ -1,14 +1,16 @@
// component_state_io unit tests (Q-W2v). The HISTORICAL codec suite the full
// envelope/payload version ladder, every legacy lift, the golden byte fixtures —
// lives in test_sample_map.cpp and runs unmodified against the split module; this
// target exists as the module's OWN executable (house rule: every pure module has
// one) and as the STRUCTURAL PROOF the codec links WITHOUT the voice engine
// (T2-07): it links component_state_io + velocity_curve + master_gain only — a
// sampler_core/pitch_shift symbol reaching this link is a regression.
// component_state_io unit tests — the codec's whole suite: the current envelope + params
// payload round-trip, the frozen prefix bytes, the ENVELOPE ladder (v1..v11) with each
// version's documented lift, and the RETIRED-ZONE-PAYLOAD migration ladder (payload v1..v7
// -> the one parameter set, adopting zone one). The codec's own executable is also the
// STRUCTURAL PROOF it links WITHOUT the voice engine: it links component_state_io +
// velocity_curve + master_gain only, so a sampler_core/pitch_shift symbol reaching this
// link is a regression.
#include "../src/core/instrument/map/component_state_io.h"
#include "../src/core/instrument/engine/master_gain.h" // masterGainMaxLinear (the v8 wire cap)
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
@@ -25,7 +27,166 @@ static int failures = 0;
} \
} while (0)
// A full round-trip through the CURRENT envelope (v11): every field survives.
// --- A writer for the RETIRED zone-list payloads -----------------------------
//
// The shipping codec no longer EMITS a zone list, so the migration ladder can only be
// tested against bytes this suite lays out itself. These helpers mirror the frozen v1..v7
// record shapes documented in component_state_io.h; if they and the reader ever disagree,
// the migration tests below fail — which is the point.
namespace legacy {
static void u8v(std::vector<std::uint8_t>& out, std::uint8_t v) { out.push_back(v); }
static void u32v(std::vector<std::uint8_t>& out, std::uint32_t v) {
for (int i = 0; i < 4; ++i) out.push_back(static_cast<std::uint8_t>((v >> (8 * i)) & 0xFF));
}
static void i64v(std::vector<std::uint8_t>& out, std::int64_t v) {
const auto u = static_cast<std::uint64_t>(v);
for (int i = 0; i < 8; ++i) out.push_back(static_cast<std::uint8_t>((u >> (8 * i)) & 0xFF));
}
static void f64v(std::vector<std::uint8_t>& out, double v) {
std::uint64_t bits = 0;
std::memcpy(&bits, &v, sizeof(bits));
for (int i = 0; i < 8; ++i) out.push_back(static_cast<std::uint8_t>((bits >> (8 * i)) & 0xFF));
}
static void strv(std::vector<std::uint8_t>& out, const std::string& s) {
u32v(out, static_cast<std::uint32_t>(s.size()));
out.insert(out.end(), s.begin(), s.end());
}
// One zone's worth of the retired per-zone record, in the v7 (fullest) shape.
struct Zone {
std::string sampleId;
int lowNote = 0;
int highNote = 127;
int rootOverride = -1; // < 0 = absent
bool hasLoopOverride = false;
std::int64_t loopStart = 0;
std::int64_t loopEnd = 0;
std::int64_t startPoint = -1; // < 0 = absent
bool trigger = false;
double holdSeconds = 0.0;
double lengthFraction = 1.0;
std::int64_t fadeIn = 0;
std::int64_t fadeOut = 0;
bool preserve = false;
bool pitchEnvEnabled = false;
double pitchAttack = 0.0;
double pitchDecay = 0.0;
double peakSemis = 0.0;
double attackSeconds = 0.003;
double decaySeconds = 0.0;
double sustainLevel = 1.0;
double releaseSeconds = 0.060;
double keyTrack = 1.0;
std::vector<VelocityPoint> curve; // empty -> the flat endpoints
};
static void putZone(std::vector<std::uint8_t>& out, const Zone& z, std::uint32_t pv) {
strv(out, z.sampleId);
u32v(out, static_cast<std::uint32_t>(z.lowNote));
u32v(out, static_cast<std::uint32_t>(z.highNote));
u8v(out, z.rootOverride >= 0 ? 1 : 0);
if (z.rootOverride >= 0) u32v(out, static_cast<std::uint32_t>(z.rootOverride));
if (pv >= 2) {
u8v(out, z.hasLoopOverride ? 1 : 0);
if (z.hasLoopOverride) {
u8v(out, 1);
i64v(out, z.loopStart);
i64v(out, z.loopEnd);
}
u8v(out, z.startPoint >= 0 ? 1 : 0);
if (z.startPoint >= 0) i64v(out, z.startPoint);
}
if (pv >= 5) {
u8v(out, z.trigger ? 1 : 0);
f64v(out, z.holdSeconds);
f64v(out, z.lengthFraction);
i64v(out, z.fadeIn);
i64v(out, z.fadeOut);
u8v(out, z.preserve ? 1 : 0);
u8v(out, z.pitchEnvEnabled ? 1 : 0);
f64v(out, z.pitchAttack);
f64v(out, z.pitchDecay);
f64v(out, z.peakSemis);
f64v(out, z.attackSeconds);
f64v(out, z.decaySeconds);
f64v(out, z.sustainLevel);
f64v(out, z.releaseSeconds);
}
if (pv >= 6) f64v(out, z.keyTrack);
if (pv >= 7) {
const std::vector<VelocityPoint> pts =
z.curve.empty() ? std::vector<VelocityPoint>{{0.0, 1.0}, {127.0, 1.0}} : z.curve;
u32v(out, static_cast<std::uint32_t>(pts.size()));
for (const VelocityPoint& p : pts) { f64v(out, p.velocity); f64v(out, p.amp); }
}
}
// The envelope fields, in wire order. A builder at version N emits only the prefix fields
// version N carried, so each lift can be asserted against a blob shaped exactly as that
// version's writer produced.
struct Envelope {
std::uint32_t version = kComponentStateVersion;
std::uint8_t modeByte = 0; // v4+ 0 mono / 1 stereo
std::int64_t assignGeneration = 0; // v5+
std::uint8_t previewVelocity = kPreviewVelocityDefault; // v6+
std::uint8_t voiceCount = static_cast<std::uint8_t>(kDefaultVoiceCount); // v7+
std::uint8_t voiceMode = 0; // v7+ 0 poly / 1 mono
std::uint8_t monoTrigger = 0; // v7+ 0 retrigger / 1 legato
double masterGain = 1.0; // v8+
std::uint8_t channelModeExplicit = 0; // v9+
std::string instanceGuid; // v11+
std::string selectionId; // v3+
};
// A complete envelope at `env.version` whose tail is a RETIRED zone-list payload at version
// `pv`. `env.selectionId` is the envelope's own stored pick — which the adoption rule
// overrides when the payload carries a zone. The sample-refs table (v10+) is always empty:
// its own shape is covered by the round-trip test.
static std::vector<std::uint8_t> envelopeWithZones(const Envelope& env,
const std::vector<Zone>& zones,
std::uint32_t pv) {
std::vector<std::uint8_t> out;
const std::uint32_t v = env.version;
u32v(out, v);
if (v >= 4) u8v(out, env.modeByte);
if (v >= 5) i64v(out, env.assignGeneration);
if (v >= 6) u8v(out, env.previewVelocity);
if (v >= 7) { u8v(out, env.voiceCount); u8v(out, env.voiceMode); u8v(out, env.monoTrigger); }
if (v >= 8) f64v(out, env.masterGain);
if (v >= 9) u8v(out, env.channelModeExplicit);
if (v >= 10) u32v(out, 0); // sample-refs: empty table
if (v >= 11) strv(out, env.instanceGuid);
if (v >= 3) strv(out, env.selectionId); // v2 was zones-only, no selection
if (pv >= 2) {
u32v(out, kParamsFormatMarker);
u32v(out, pv);
}
u32v(out, static_cast<std::uint32_t>(zones.size()));
for (const Zone& z : zones) putZone(out, z, pv);
return out;
}
// Shorthand for the common case: the CURRENT envelope version carrying a zone payload.
static std::vector<std::uint8_t> envelopeWithZones(const std::string& selectionId,
const std::vector<Zone>& zones,
std::uint32_t pv) {
Envelope env;
env.selectionId = selectionId;
return envelopeWithZones(env, zones, pv);
}
} // namespace legacy
// --- The current format -------------------------------------------------------
// A full round-trip through the CURRENT envelope (v11) + params payload (v8): every field
// survives. This is the "one parameter set round-trips save/reload intact" contract.
static void testComponentStateRoundTrip() {
ComponentState in;
in.selectionId = "smp-1";
@@ -48,18 +209,30 @@ static void testComponentStateRoundTrip() {
e.ref.channelCount = 2;
e.displayName = "My Capture";
in.sampleRefs.push_back(e);
PerformanceZone z;
z.sampleId = "smp-1";
z.lowNote = 30;
z.highNote = 90;
z.rootOverride = 61;
z.startPoint = 5;
z.keyTrack = 1.5;
z.play.playMode = PlayMode::Trigger;
z.play.trigger.lengthFraction = 0.75;
z.play.trigger.fadeInFrames = 441;
z.play.trigger.fadeOutFrames = 882;
in.map.zones.push_back(z);
in.params.rootOverride = 61;
SampleLoop lp;
lp.hasLoop = true;
lp.start = 7;
lp.end = 900;
in.params.loopOverride = lp;
in.params.startPoint = 5;
in.params.keyTrack = 1.5;
in.params.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(
{VelocityPoint{0.0, 0.2}, VelocityPoint{64.0, 0.6}, VelocityPoint{127.0, 1.0}});
in.params.play.playMode = PlayMode::Trigger;
in.params.play.adsr.attackSeconds = 0.01;
in.params.play.adsr.holdSeconds = 0.05;
in.params.play.adsr.decaySeconds = 0.02;
in.params.play.adsr.sustainLevel = 0.8;
in.params.play.adsr.releaseSeconds = 0.15;
in.params.play.trigger.lengthFraction = 0.75;
in.params.play.trigger.fadeInFrames = 441;
in.params.play.trigger.fadeOutFrames = 882;
in.params.play.pitchEngine = PitchEngine::Preserve;
in.params.play.pitchEnv.enabled = true;
in.params.play.pitchEnv.attackSeconds = 0.02;
in.params.play.pitchEnv.decaySeconds = 0.03;
in.params.play.pitchEnv.peakSemitones = 5.0;
const std::vector<std::uint8_t> bytes = serializeComponentState(in);
const ComponentState out = deserializeComponentState(bytes, 48000.0);
@@ -85,168 +258,52 @@ static void testComponentStateRoundTrip() {
CHECK(out.sampleRefs[0].ref.channelCount == 2);
CHECK(out.sampleRefs[0].displayName == "My Capture");
}
CHECK(out.map.zones.size() == 1);
if (out.map.zones.size() == 1) {
const PerformanceZone& oz = out.map.zones[0];
CHECK(oz.sampleId == "smp-1");
CHECK(oz.lowNote == 30);
CHECK(oz.highNote == 90);
CHECK(oz.rootOverride && *oz.rootOverride == 61);
CHECK(oz.startPoint && *oz.startPoint == 5);
CHECK(oz.keyTrack == 1.5);
CHECK(oz.play.playMode == PlayMode::Trigger);
CHECK(oz.play.trigger.lengthFraction == 0.75);
CHECK(oz.play.trigger.fadeInFrames == 441);
CHECK(oz.play.trigger.fadeOutFrames == 882);
}
const InstrumentParams& p = out.params;
CHECK(p.rootOverride && *p.rootOverride == 61);
CHECK(p.loopOverride && p.loopOverride->hasLoop);
CHECK(p.loopOverride && p.loopOverride->start == 7 && p.loopOverride->end == 900);
CHECK(p.startPoint && *p.startPoint == 5);
CHECK(p.keyTrack == 1.5);
CHECK(p.velocityCurve.points().size() == 3);
CHECK(p.play.playMode == PlayMode::Trigger);
CHECK(p.play.adsr.attackSeconds == 0.01);
CHECK(p.play.adsr.holdSeconds == 0.05);
CHECK(p.play.adsr.decaySeconds == 0.02);
CHECK(p.play.adsr.sustainLevel == 0.8);
CHECK(p.play.adsr.releaseSeconds == 0.15);
CHECK(p.play.trigger.lengthFraction == 0.75);
CHECK(p.play.trigger.fadeInFrames == 441);
CHECK(p.play.trigger.fadeOutFrames == 882);
CHECK(p.play.pitchEngine == PitchEngine::Preserve);
CHECK(p.play.pitchEnv.enabled);
CHECK(p.play.pitchEnv.attackSeconds == 0.02);
CHECK(p.play.pitchEnv.decaySeconds == 0.03);
CHECK(p.play.pitchEnv.peakSemitones == 5.0);
}
// GOLDEN FULL-BLOB FIXTURE (reviewer follow-up, Q-W2v). testEnvelopePrefixBytesFrozen below
// only pins the first 5 bytes of a near-EMPTY blob; it cannot catch a drift anywhere past the
// mode byte (a field re-ordered or dropped inside the voice/gain/refs/guid/zone tail would
// still pass it). This test builds a canonical v11 ComponentState that exercises EVERY field
// family at once (two zones — one Trigger with every optional override set, one Gate with all
// optionals absent — a two-entry sample-refs table, non-default voice/gain/channel-mode
// fields, and a non-flat velocity curve) and asserts the encoded bytes equal an EXACT expected
// vector. The vector below is the current writer's PROVABLY-CORRECT output (proven by the
// round-trip test above) captured as the golden — so the byte layout itself becomes
// un-driftable, not just its first 5 bytes.
static void testGoldenFullBlobFixture() {
ComponentState in;
in.selectionId = "kick";
in.channelMode = ChannelMode::Stereo;
in.channelModeExplicit = true;
in.lastConsumedAssignGeneration = 12345;
in.previewVelocity = 100;
in.voiceCount = 24;
in.voiceMode = VoiceMode::Mono;
in.monoTrigger = MonoTrigger::Legato;
in.masterGainLinear = 2.0;
in.instanceGuid = "guid-1234-5678-abcd";
SampleRefEntry kickRef;
kickRef.sampleId = "kick";
kickRef.ref.relativePath = "bank/kick.wav";
kickRef.ref.rootNote = 36;
kickRef.ref.loop.hasLoop = true;
kickRef.ref.loop.start = 1000;
kickRef.ref.loop.end = 5000;
kickRef.ref.channelCount = 2;
kickRef.displayName = "Kick Drum";
in.sampleRefs.push_back(kickRef);
SampleRefEntry snareRef;
snareRef.sampleId = "snare";
snareRef.ref.relativePath = "bank/snare.wav";
snareRef.ref.rootNote = 38;
snareRef.ref.loop.hasLoop = false;
snareRef.ref.loop.start = 0;
snareRef.ref.loop.end = 0;
snareRef.ref.channelCount = 1;
snareRef.displayName = "Snare";
in.sampleRefs.push_back(snareRef);
// Zone A: every optional field present, Trigger mode, non-flat velocity curve.
PerformanceZone zoneA;
zoneA.sampleId = "kick";
zoneA.lowNote = 24;
zoneA.highNote = 60;
zoneA.rootOverride = 36;
SampleLoop loopA;
loopA.hasLoop = true;
loopA.start = 1000;
loopA.end = 5000;
zoneA.loopOverride = loopA;
zoneA.startPoint = 250;
zoneA.keyTrack = 0.5;
zoneA.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(
{VelocityPoint{0.0, 0.2}, VelocityPoint{64.0, 0.6}, VelocityPoint{127.0, 1.0}});
zoneA.play.playMode = PlayMode::Trigger;
zoneA.play.adsr.attackSeconds = 0.01;
zoneA.play.adsr.holdSeconds = 0.05;
zoneA.play.adsr.decaySeconds = 0.02;
zoneA.play.adsr.sustainLevel = 0.8;
zoneA.play.adsr.releaseSeconds = 0.15;
zoneA.play.trigger.lengthFraction = 0.75;
zoneA.play.trigger.fadeInFrames = 100;
zoneA.play.trigger.fadeOutFrames = 200;
zoneA.play.pitchEngine = PitchEngine::Preserve;
zoneA.play.pitchEnv.enabled = true;
zoneA.play.pitchEnv.attackSeconds = 0.02;
zoneA.play.pitchEnv.decaySeconds = 0.03;
zoneA.play.pitchEnv.peakSemitones = 5.0;
in.map.zones.push_back(zoneA);
// Zone B: every optional field absent, Gate mode, default flat velocity curve.
PerformanceZone zoneB;
zoneB.sampleId = "snare";
zoneB.lowNote = 61;
zoneB.highNote = 90;
zoneB.keyTrack = 2.0;
zoneB.play.playMode = PlayMode::Gate;
zoneB.play.adsr.attackSeconds = 0.005;
zoneB.play.adsr.holdSeconds = 0.0;
zoneB.play.adsr.decaySeconds = 0.1;
zoneB.play.adsr.sustainLevel = 0.5;
zoneB.play.adsr.releaseSeconds = 0.2;
zoneB.play.pitchEngine = PitchEngine::Varispeed;
in.map.zones.push_back(zoneB);
const std::vector<std::uint8_t> bytes = serializeComponentState(in);
// clang-format off
static const std::uint8_t kGolden[] = {
0x0b,0x00,0x00,0x00,0x01,0x39,0x30,0x00,0x00,0x00,0x00,0x00,0x00,0x64,0x18,0x01,
0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x40,0x01,0x02,0x00,0x00,0x00,0x04,0x00,
0x00,0x00,0x6b,0x69,0x63,0x6b,0x0d,0x00,0x00,0x00,0x62,0x61,0x6e,0x6b,0x2f,0x6b,
0x69,0x63,0x6b,0x2e,0x77,0x61,0x76,0x24,0x00,0x00,0x00,0x01,0xe8,0x03,0x00,0x00,
0x00,0x00,0x00,0x00,0x88,0x13,0x00,0x00,0x00,0x00,0x00,0x00,0x02,0x00,0x00,0x00,
0x09,0x00,0x00,0x00,0x4b,0x69,0x63,0x6b,0x20,0x44,0x72,0x75,0x6d,0x05,0x00,0x00,
0x00,0x73,0x6e,0x61,0x72,0x65,0x0e,0x00,0x00,0x00,0x62,0x61,0x6e,0x6b,0x2f,0x73,
0x6e,0x61,0x72,0x65,0x2e,0x77,0x61,0x76,0x26,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x00,
0x00,0x05,0x00,0x00,0x00,0x53,0x6e,0x61,0x72,0x65,0x13,0x00,0x00,0x00,0x67,0x75,
0x69,0x64,0x2d,0x31,0x32,0x33,0x34,0x2d,0x35,0x36,0x37,0x38,0x2d,0x61,0x62,0x63,
0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x07,0x00,0x00,
0x00,0x02,0x00,0x00,0x00,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x18,0x00,0x00,
0x00,0x3c,0x00,0x00,0x00,0x01,0x24,0x00,0x00,0x00,0x01,0x01,0xe8,0x03,0x00,0x00,
0x00,0x00,0x00,0x00,0x88,0x13,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0xfa,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,0x01,0x9a,0x99,0x99,0x99,0x99,0x99,0xa9,0x3f,0x00,0x00,
0x00,0x00,0x00,0x00,0xe8,0x3f,0x64,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xc8,0x00,
0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x01,0x7b,0x14,0xae,0x47,0xe1,0x7a,0x94,0x3f,
0xb8,0x1e,0x85,0xeb,0x51,0xb8,0x9e,0x3f,0x00,0x00,0x00,0x00,0x00,0x00,0x14,0x40,
0x7b,0x14,0xae,0x47,0xe1,0x7a,0x84,0x3f,0x7b,0x14,0xae,0x47,0xe1,0x7a,0x94,0x3f,
0x9a,0x99,0x99,0x99,0x99,0x99,0xe9,0x3f,0x33,0x33,0x33,0x33,0x33,0x33,0xc3,0x3f,
0x00,0x00,0x00,0x00,0x00,0x00,0xe0,0x3f,0x03,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x9a,0x99,0x99,0x99,0x99,0x99,0xc9,0x3f,0x00,0x00,0x00,0x00,
0x00,0x00,0x50,0x40,0x33,0x33,0x33,0x33,0x33,0x33,0xe3,0x3f,0x00,0x00,0x00,0x00,
0x00,0xc0,0x5f,0x40,0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f,0x05,0x00,0x00,0x00,
0x73,0x6e,0x61,0x72,0x65,0x3d,0x00,0x00,0x00,0x5a,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xf0,
0x3f,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x7b,0x14,0xae,0x47,0xe1,
0x7a,0x74,0x3f,0x9a,0x99,0x99,0x99,0x99,0x99,0xb9,0x3f,0x00,0x00,0x00,0x00,0x00,
0x00,0xe0,0x3f,0x9a,0x99,0x99,0x99,0x99,0x99,0xc9,0x3f,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x40,0x02,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,0xf0,0x3f,0x00,0x00,0x00,0x00,0x00,0xc0,0x5f,0x40,0x00,
0x00,0x00,0x00,0x00,0x00,0xf0,0x3f,
};
// clang-format on
CHECK(bytes.size() == sizeof(kGolden));
if (bytes.size() == sizeof(kGolden)) {
bool same = true;
for (std::size_t i = 0; i < bytes.size(); ++i) {
if (bytes[i] != kGolden[i]) { same = false; break; }
}
CHECK(same);
}
// A DEFAULT parameter set must round-trip to defaults — the "no pick, nothing configured"
// blob restores as the silent empty state, not as a set of accidental values.
static void testDefaultStateRoundTripsToDefaults() {
const ComponentState out =
deserializeComponentState(serializeComponentState(ComponentState{}), 48000.0);
CHECK(out.selectionId.empty());
CHECK(!out.params.rootOverride);
CHECK(!out.params.loopOverride);
CHECK(!out.params.startPoint);
CHECK(out.params.keyTrack == 1.0);
CHECK(out.params.play.playMode == PlayMode::Gate);
CHECK(out.params.play.pitchEngine == kDefaultPitchEngine);
CHECK(!out.params.play.pitchEnv.enabled);
CHECK(out.params.play.adsr.attackSeconds == AdsrSeconds{}.attackSeconds);
CHECK(out.params.play.adsr.releaseSeconds == AdsrSeconds{}.releaseSeconds);
}
// The FROZEN envelope prefix: version tag v11 LE, then the mode byte — a drift in
// either is a byte-format break the round-trip alone can't prove (both sides could
// drift together). Pins the writer's absolute bytes.
// The FROZEN envelope prefix: version tag v11 LE, then the mode byte — a drift in either is
// a byte-format break the round-trip alone can't prove (both sides could drift together).
// Also pins the payload version + marker as SEMANTIC constants, so a bump has to be
// deliberate rather than incidental.
static void testEnvelopePrefixBytesFrozen() {
ComponentState in; // defaults: mono, implicit, no refs, no selection, no zones
ComponentState in; // defaults: mono, implicit, no refs, no selection, default params
const std::vector<std::uint8_t> bytes = serializeComponentState(in);
CHECK(bytes.size() > 5);
if (bytes.size() > 5) {
@@ -254,64 +311,405 @@ static void testEnvelopePrefixBytesFrozen() {
CHECK(bytes[4] == 0); // ChannelMode::Mono
}
CHECK(kComponentStateVersion == 11);
CHECK(kZonesPayloadVersion == 7);
CHECK(kZonesFormatMarker == 0xFFFFFF00u);
CHECK(kParamsPayloadVersion == 8);
CHECK(kParamsFormatMarker == 0xFFFFFF00u);
}
// A v1 selection blob lifts to {id, one full-keyboard zone} — the oldest live lift.
// The WRITER emits the CURRENT payload version, and the marker + version sit at the head of
// the payload — the self-describing property every legacy branch depends on. Asserted
// against the semantic constants, not literals.
static void testWriterEmitsCurrentPayloadVersion() {
ComponentState in;
in.selectionId = "id";
const std::vector<std::uint8_t> bytes = serializeComponentState(in);
// Scan for the marker; the four bytes after it are the payload version.
bool found = false;
for (std::size_t i = 0; i + 8 <= bytes.size(); ++i) {
const std::uint32_t m = static_cast<std::uint32_t>(bytes[i]) |
(static_cast<std::uint32_t>(bytes[i + 1]) << 8) |
(static_cast<std::uint32_t>(bytes[i + 2]) << 16) |
(static_cast<std::uint32_t>(bytes[i + 3]) << 24);
if (m != kParamsFormatMarker) continue;
const std::uint32_t v = static_cast<std::uint32_t>(bytes[i + 4]) |
(static_cast<std::uint32_t>(bytes[i + 5]) << 8) |
(static_cast<std::uint32_t>(bytes[i + 6]) << 16) |
(static_cast<std::uint32_t>(bytes[i + 7]) << 24);
CHECK(v == kParamsPayloadVersion);
found = true;
break;
}
CHECK(found);
}
// --- The retired-zone-payload migration ladder --------------------------------
// SINGLE-ZONE LIFT IS LOSSLESS: a single-capture instance saved under the zone model
// restores with the same capture, the same root, and the same parameters.
static void testSingleZoneMigrationIsLossless() {
legacy::Zone z;
z.sampleId = "kick";
z.lowNote = 0;
z.highNote = 127;
z.rootOverride = 36;
z.hasLoopOverride = true;
z.loopStart = 1000;
z.loopEnd = 5000;
z.startPoint = 250;
z.trigger = true;
z.holdSeconds = 0.05;
z.lengthFraction = 0.75;
z.fadeIn = 100;
z.fadeOut = 200;
z.preserve = true;
z.pitchEnvEnabled = true;
z.pitchAttack = 0.02;
z.pitchDecay = 0.03;
z.peakSemis = 5.0;
z.attackSeconds = 0.01;
z.decaySeconds = 0.02;
z.sustainLevel = 0.8;
z.releaseSeconds = 0.15;
z.keyTrack = 0.5;
z.curve = {VelocityPoint{0.0, 0.2}, VelocityPoint{64.0, 0.6}, VelocityPoint{127.0, 1.0}};
const ComponentState out =
deserializeComponentState(legacy::envelopeWithZones("kick", {z}, 7), 48000.0);
CHECK(out.selectionId == "kick"); // same capture
const InstrumentParams& p = out.params;
CHECK(p.rootOverride && *p.rootOverride == 36); // same root
CHECK(p.loopOverride && p.loopOverride->start == 1000 && p.loopOverride->end == 5000);
CHECK(p.startPoint && *p.startPoint == 250);
CHECK(p.keyTrack == 0.5);
CHECK(p.velocityCurve.points().size() == 3);
CHECK(p.play.playMode == PlayMode::Trigger);
CHECK(p.play.adsr.attackSeconds == 0.01);
CHECK(p.play.adsr.holdSeconds == 0.05);
CHECK(p.play.adsr.decaySeconds == 0.02);
CHECK(p.play.adsr.sustainLevel == 0.8);
CHECK(p.play.adsr.releaseSeconds == 0.15);
CHECK(p.play.trigger.lengthFraction == 0.75);
CHECK(p.play.trigger.fadeInFrames == 100);
CHECK(p.play.trigger.fadeOutFrames == 200);
CHECK(p.play.pitchEngine == PitchEngine::Preserve);
CHECK(p.play.pitchEnv.enabled);
CHECK(p.play.pitchEnv.attackSeconds == 0.02);
CHECK(p.play.pitchEnv.decaySeconds == 0.03);
CHECK(p.play.pitchEnv.peakSemitones == 5.0);
}
// A lifted single-zone instance RE-SAVES in the current format and survives a second
// round-trip unchanged — the lift is a one-way door, not a per-open re-derivation.
static void testLiftedStateReSavesInCurrentFormat() {
legacy::Zone z;
z.sampleId = "kick";
z.rootOverride = 36;
z.keyTrack = 0.5;
z.releaseSeconds = 0.4;
const ComponentState lifted =
deserializeComponentState(legacy::envelopeWithZones("kick", {z}, 7), 48000.0);
const ComponentState again =
deserializeComponentState(serializeComponentState(lifted), 48000.0);
CHECK(again.selectionId == "kick");
CHECK(again.params.rootOverride && *again.params.rootOverride == 36);
CHECK(again.params.keyTrack == 0.5);
CHECK(again.params.play.adsr.releaseSeconds == 0.4);
}
// MULTI-ZONE LIFT ADOPTS ZONE ONE: its capture AND its parameters win; every later zone
// drops. No error, no empty state.
static void testMultiZoneMigrationAdoptsFirstZone() {
legacy::Zone first;
first.sampleId = "kick";
first.lowNote = 0;
first.highNote = 59;
first.rootOverride = 36;
first.keyTrack = 0.5;
first.releaseSeconds = 0.4;
legacy::Zone second;
second.sampleId = "snare";
second.lowNote = 60;
second.highNote = 127;
second.rootOverride = 38;
second.keyTrack = 2.0;
second.releaseSeconds = 0.9;
legacy::Zone third;
third.sampleId = "hat";
third.rootOverride = 42;
const ComponentState out = deserializeComponentState(
legacy::envelopeWithZones("", {first, second, third}, 7), 48000.0);
CHECK(out.selectionId == "kick"); // zone one's capture
CHECK(out.params.rootOverride && *out.params.rootOverride == 36);
CHECK(out.params.keyTrack == 0.5); // zone one's parameters
CHECK(out.params.play.adsr.releaseSeconds == 0.4);
// Zones two and three left no trace anywhere.
CHECK(out.selectionId != "snare" && out.selectionId != "hat");
CHECK(out.params.keyTrack != 2.0);
}
// The FIRST zone supersedes the envelope's own stored selection — that zone is what
// first-match resolve actually played, so adopting it is what keeps the sound identical.
static void testFirstZoneSupersedesStoredSelection() {
legacy::Zone z;
z.sampleId = "actually-playing";
const ComponentState out = deserializeComponentState(
legacy::envelopeWithZones("stale-selection", {z}, 7), 48000.0);
CHECK(out.selectionId == "actually-playing");
}
// An EMPTY zone list leaves the envelope's selection alone (a picked-but-never-edited
// instance) and yields default parameters.
static void testEmptyZoneListKeepsTheStoredSelection() {
const ComponentState out =
deserializeComponentState(legacy::envelopeWithZones("picked", {}, 7), 48000.0);
CHECK(out.selectionId == "picked");
CHECK(!out.params.rootOverride);
CHECK(out.params.keyTrack == 1.0);
}
// EVERY older payload version takes the migration path, and each lifts the fields its own
// shape carries while defaulting the ones it predates.
static void testEveryOlderPayloadVersionMigrates() {
for (std::uint32_t pv : {1u, 2u, 5u, 6u, 7u}) {
legacy::Zone z;
z.sampleId = "kick";
z.rootOverride = 36;
z.keyTrack = 0.5;
z.releaseSeconds = 0.4;
const ComponentState out =
deserializeComponentState(legacy::envelopeWithZones("", {z}, pv), 48000.0);
CHECK(out.selectionId == "kick"); // every version
CHECK(out.params.rootOverride && *out.params.rootOverride == 36); // v1 onward
// keyTrack arrived at v6; older payloads lift to 100% ET (bit-identical repitch).
CHECK(out.params.keyTrack == (pv >= 6 ? 0.5 : 1.0));
// The full A/D/S/R tail arrived at v5; older payloads keep the tier-0 defaults.
CHECK(out.params.play.adsr.releaseSeconds ==
(pv >= 5 ? 0.4 : AdsrSeconds{}.releaseSeconds));
}
// And the CURRENT version does NOT take the migration path: it reads its own record.
CHECK(kParamsPayloadVersion == 8);
}
// The LEGACY v3 payload's wall-clock frame counts convert to seconds at the READ boundary
// using the project rate threaded in — no baked constant.
static void testLegacyV3FramesConvertAtTheProjectRate() {
// v3's own tail shape differs from v5's, so lay it out directly here.
std::vector<std::uint8_t> out;
legacy::u32v(out, kComponentStateVersion);
legacy::u8v(out, 0);
legacy::i64v(out, 0);
legacy::u8v(out, kPreviewVelocityDefault);
legacy::u8v(out, static_cast<std::uint8_t>(kDefaultVoiceCount));
legacy::u8v(out, 0);
legacy::u8v(out, 0);
legacy::f64v(out, 1.0);
legacy::u8v(out, 0);
legacy::u32v(out, 0);
legacy::strv(out, "");
legacy::strv(out, "");
legacy::u32v(out, kParamsFormatMarker);
legacy::u32v(out, 3);
legacy::u32v(out, 1); // one zone
legacy::strv(out, "kick");
legacy::u32v(out, 0); // lowNote
legacy::u32v(out, 127); // highNote
legacy::u8v(out, 0); // no root override
legacy::u8v(out, 0); // no loop override
legacy::u8v(out, 0); // no start point
legacy::u8v(out, 0); // playMode: Gate
legacy::i64v(out, 2400); // holdFrames -> 0.05 s at 48 kHz
legacy::f64v(out, 1.0); // lengthFraction
legacy::i64v(out, 0); // fadeIn
legacy::i64v(out, 0); // fadeOut
legacy::u8v(out, 1); // pitchEngine: Preserve
legacy::u8v(out, 1); // pitchEnv enabled
legacy::i64v(out, 960); // pitchEnv attackFrames -> 0.02 s
legacy::i64v(out, 1440); // pitchEnv decayFrames -> 0.03 s
legacy::f64v(out, 5.0); // peakSemitones
const ComponentState st = deserializeComponentState(out, 48000.0);
CHECK(st.selectionId == "kick");
CHECK(st.params.play.adsr.holdSeconds == 0.05);
CHECK(st.params.play.pitchEnv.attackSeconds == 0.02);
CHECK(st.params.play.pitchEnv.decaySeconds == 0.03);
CHECK(st.params.play.pitchEnv.peakSemitones == 5.0);
// A/D/S/R are absent in v3 -> the tier-0 seconds defaults hold.
CHECK(st.params.play.adsr.attackSeconds == AdsrSeconds{}.attackSeconds);
CHECK(st.params.play.adsr.releaseSeconds == AdsrSeconds{}.releaseSeconds);
// The SAME bytes at a different project rate convert to different seconds — proof the
// rate is a read-time parameter, not a baked constant.
const ComponentState at96k = deserializeComponentState(out, 96000.0);
CHECK(at96k.params.play.adsr.holdSeconds == 0.025);
}
// --- The ENVELOPE ladder (v2..v11) -------------------------------------------
// EVERY envelope version restores the fields it carried and lifts the ones it predates to
// their documented defaults. One table over the whole ladder, so a new envelope field
// cannot be added without deciding what each older version lifts it to.
static void testEnvelopeLadderLiftsEachVersion() {
for (std::uint32_t v : {3u, 4u, 5u, 6u, 7u, 8u, 9u, 10u, 11u}) {
legacy::Envelope env;
env.version = v;
env.selectionId = "kick";
env.modeByte = 1; // stereo
env.assignGeneration = 4242;
env.previewVelocity = 99;
env.voiceCount = 7;
env.voiceMode = 1; // mono
env.monoTrigger = 1; // legato
env.masterGain = 0.5;
env.channelModeExplicit = 1;
env.instanceGuid = "guid-abc";
const ComponentState out =
deserializeComponentState(legacy::envelopeWithZones(env, {}, 7), 48000.0);
CHECK(out.selectionId == "kick"); // v3 onward all carry the selection
// v4 added the channel mode; older blobs lift to MONO.
CHECK(out.channelMode == (v >= 4 ? ChannelMode::Stereo : ChannelMode::Mono));
// v5 added the consumed-assignment marker; older blobs lift to 0, so a genuinely
// new first assign (generation >= 1) still applies to a pre-marker instance.
CHECK(out.lastConsumedAssignGeneration == (v >= 5 ? 4242 : 0));
// v6 added the preview velocity; older blobs lift to the mid default.
CHECK(out.previewVelocity == (v >= 6 ? 99 : kPreviewVelocityDefault));
// v7 added the voice system; older blobs lift to {16, Poly, Retrigger} — the
// pre-voice-system behavior, byte-identically.
CHECK(out.voiceCount == (v >= 7 ? 7 : kDefaultVoiceCount));
CHECK(out.voiceMode == (v >= 7 ? VoiceMode::Mono : VoiceMode::Poly));
CHECK(out.monoTrigger == (v >= 7 ? MonoTrigger::Legato : MonoTrigger::Retrigger));
// v8 added the master gain; older blobs lift to unity.
CHECK(out.masterGainLinear == (v >= 8 ? 0.5 : 1.0));
// v9 added the channel-mode EXPLICIT flag; older blobs lift to implicit, so the
// auto-default may follow the loaded capture.
CHECK(out.channelModeExplicit == (v >= 9 ? true : false));
// v10 added the refs table (always empty here), v11 the instance guid; a pre-v11
// blob lifts to an empty guid, which the processor mints on first publish.
CHECK(out.instanceGuid == (v >= 11 ? "guid-abc" : ""));
CHECK(out.sampleRefs.empty());
}
}
// A v2 blob is ZONES-ONLY — no stored selection at all — so the adopted first zone supplies
// BOTH the capture and the parameters.
static void testV2ZonesOnlyBlobAdoptsBothFromZoneOne() {
legacy::Zone z;
z.sampleId = "kick";
z.rootOverride = 36;
std::vector<std::uint8_t> out;
legacy::u32v(out, kPerformanceStateVersion); // == 2, the zones-only envelope
legacy::u32v(out, kParamsFormatMarker);
legacy::u32v(out, 7);
legacy::u32v(out, 1);
legacy::putZone(out, z, 7);
const ComponentState st = deserializeComponentState(out, 48000.0);
CHECK(st.selectionId == "kick");
CHECK(st.params.rootOverride && *st.params.rootOverride == 36);
CHECK(st.channelMode == ChannelMode::Mono); // a v2 blob predates the mode byte
}
// A CORRUPT field falls back to its own DEFAULT rather than clamping to an edge the user
// never chose (or, for the gain, silencing/blasting the instance).
static void testCorruptFieldsFallBackToDefaults() {
legacy::Envelope env;
env.selectionId = "kick";
env.previewVelocity = 0; // 0 is a note-off by convention — out of the 1..127 spec
env.voiceCount = 200; // past kMaxVoiceCount
env.masterGain = 1e9; // far past the +24 dB cap
const ComponentState out =
deserializeComponentState(legacy::envelopeWithZones(env, {}, 7), 48000.0);
CHECK(out.previewVelocity == kPreviewVelocityDefault);
CHECK(out.voiceCount == kDefaultVoiceCount);
CHECK(out.masterGainLinear == 1.0);
}
// The WRITER never emits an out-of-range voice count or master gain, so a blob this codec
// produced always re-reads as itself.
static void testWriterClampsOutOfRangeFields() {
ComponentState in;
in.voiceCount = 999;
in.masterGainLinear = 1e9;
const ComponentState out =
deserializeComponentState(serializeComponentState(in), 48000.0);
CHECK(out.voiceCount >= kMinVoiceCount && out.voiceCount <= kMaxVoiceCount);
CHECK(out.masterGainLinear <=
reasampler::instrument::engine::masterGainMaxLinear() * (1.0 + 1e-9));
// Zero gain is TRUE silence and a legal stored value — it must not be "corrected".
ComponentState silent;
silent.masterGainLinear = 0.0;
CHECK(deserializeComponentState(serializeComponentState(silent), 48000.0)
.masterGainLinear == 0.0);
}
// An UNKNOWN envelope version yields the empty state rather than a misparse.
static void testUnknownEnvelopeVersionIsEmpty() {
legacy::Envelope env;
env.version = 99;
env.selectionId = "kick";
const ComponentState out =
deserializeComponentState(legacy::envelopeWithZones(env, {}, 7), 48000.0);
CHECK(out.selectionId.empty());
CHECK(!out.params.rootOverride);
}
// A v1 selection blob lifts to {id, default params} — the oldest live lift.
static void testV1SelectionLift() {
const std::vector<std::uint8_t> v1 = serializeSelection("old-pick");
const ComponentState out = deserializeComponentState(v1, 48000.0);
CHECK(out.selectionId == "old-pick");
CHECK(out.map.zones.size() == 1);
if (out.map.zones.size() == 1) {
CHECK(out.map.zones[0].sampleId == "old-pick");
CHECK(out.map.zones[0].lowNote == 0);
CHECK(out.map.zones[0].highNote == 127);
}
CHECK(!out.params.rootOverride);
CHECK(out.params.keyTrack == 1.0);
}
// Truncation degrades to a partial/empty parse — never out-of-bounds, never throws.
// Truncation degrades to a partial/empty parse — never out-of-bounds, never throws. Run
// over BOTH the current format and a retired zone-list blob, since the migration path has
// its own bounded-read walk.
static void testTruncationDegradesCleanly() {
ComponentState in;
in.selectionId = "smp-2";
PerformanceZone z;
in.params.rootOverride = 61;
const std::vector<std::uint8_t> current = serializeComponentState(in);
legacy::Zone z;
z.sampleId = "smp-2";
in.map.zones.push_back(z);
const std::vector<std::uint8_t> bytes = serializeComponentState(in);
for (std::size_t cut = 0; cut < bytes.size(); ++cut) {
const std::vector<std::uint8_t> part(bytes.begin(),
bytes.begin() + static_cast<long>(cut));
const ComponentState out = deserializeComponentState(part, 48000.0);
(void)out; // reaching here without UB/throw is the contract under test
const std::vector<std::uint8_t> retired = legacy::envelopeWithZones("smp-2", {z, z}, 7);
for (const std::vector<std::uint8_t>* blob : {&current, &retired}) {
for (std::size_t cut = 0; cut < blob->size(); ++cut) {
const std::vector<std::uint8_t> part(blob->begin(),
blob->begin() + static_cast<long>(cut));
const ComponentState out = deserializeComponentState(part, 48000.0);
(void)out; // reaching here without UB/throw is the contract under test
}
}
CHECK(true);
}
// serializePerformance/deserializePerformance round-trip through the v2 envelope.
static void testPerformanceRoundTrip() {
PerformanceMap in;
PerformanceZone z;
z.sampleId = "zone-a";
z.lowNote = 10;
z.highNote = 20;
in.zones.push_back(z);
const PerformanceMap out = deserializePerformance(serializePerformance(in), 48000.0);
CHECK(out.zones.size() == 1);
if (out.zones.size() == 1) {
CHECK(out.zones[0].sampleId == "zone-a");
CHECK(out.zones[0].lowNote == 10);
CHECK(out.zones[0].highNote == 20);
}
}
int main() {
testComponentStateRoundTrip();
testGoldenFullBlobFixture();
testDefaultStateRoundTripsToDefaults();
testEnvelopePrefixBytesFrozen();
testWriterEmitsCurrentPayloadVersion();
testSingleZoneMigrationIsLossless();
testLiftedStateReSavesInCurrentFormat();
testMultiZoneMigrationAdoptsFirstZone();
testFirstZoneSupersedesStoredSelection();
testEmptyZoneListKeepsTheStoredSelection();
testEveryOlderPayloadVersionMigrates();
testLegacyV3FramesConvertAtTheProjectRate();
testEnvelopeLadderLiftsEachVersion();
testV2ZonesOnlyBlobAdoptsBothFromZoneOne();
testCorruptFieldsFallBackToDefaults();
testWriterClampsOutOfRangeFields();
testUnknownEnvelopeVersionIsEmpty();
testV1SelectionLift();
testTruncationDegradesCleanly();
testPerformanceRoundTrip();
if (failures == 0) {
std::printf("component_state_io_tests: all tests passed\n");
return 0;
-413
View File
@@ -1,413 +0,0 @@
// Standalone tests for reasampler::instrument::ui::editor_geometry — no VST3, no REAPER, no test
// framework. Same fast assert loop as the sibling pure tests (mode_switch et al.):
// assert the IPlugView LICE editor's layout math + hit-testing directly.
//
// Covers: contains() half-open convention + degenerate rects; layoutEditor regions on a
// normal view (title band + button + canvas), a tiny view (button clamped to canvas,
// never overhanging), and a zero view (all rects empty, no inversion); hitTest hitting
// the button, missing on the title/canvas, missing outside the surface, and boundary
// pixels; layout<->hit-test agreement (a click on the drawn button rect hits it).
#include "../src/core/instrument/ui/editor_geometry.h"
#include <cstdio>
using namespace reasampler;
using namespace reasampler::instrument::ui;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
// --- contains() ---------------------------------------------------------------
static void testContainsHalfOpen() {
Rect r = Rect::ltrb(10, 20, 50, 40); // [10,50) x [20,40)
CHECK(contains(r, 10, 20)); // top-left inclusive
CHECK(contains(r, 49, 39)); // bottom-right exclusive edge, inside
CHECK(!contains(r, 50, 30)); // right edge excluded
CHECK(!contains(r, 30, 40)); // bottom edge excluded
CHECK(!contains(r, 9, 30)); // left of rect
CHECK(!contains(r, 30, 19)); // above rect
}
static void testContainsDegenerate() {
CHECK(!contains(Rect::ltrb(10, 10, 10, 20), 10, 15)); // zero width
CHECK(!contains(Rect::ltrb(10, 10, 20, 10), 15, 10)); // zero height
CHECK(!contains(Rect::ltrb(20, 10, 10, 20), 15, 15)); // inverted (right < left)
}
// --- layoutEditor: normal view ------------------------------------------------
static void testLayoutNormalView() {
// A comfortable 400x260 view: title band spans the top full width; canvas is the
// rest; button sits inside the canvas, inset by the margin.
const EditorLayout L = layoutEditor(400, 260);
CHECK(L.titleBar.x == 0 && L.titleBar.y == 0);
CHECK(L.titleBar.right() == 400);
CHECK(L.titleBar.height > 0 && L.titleBar.height <= 260);
// Canvas begins right below the title bar and reaches the bottom-right.
CHECK(L.canvas.y == L.titleBar.bottom());
CHECK(L.canvas.right() == 400 && L.canvas.bottom() == 260);
// Button is inside the canvas (does not overhang any edge).
CHECK(L.button.x >= L.canvas.x);
CHECK(L.button.y >= L.canvas.y);
CHECK(L.button.right() <= L.canvas.right());
CHECK(L.button.bottom() <= L.canvas.bottom());
CHECK(L.button.width > 0 && L.button.height > 0);
}
// --- layoutEditor: tiny view (clamping) ---------------------------------------
static void testLayoutTinyViewClampsButton() {
// A view narrower/shorter than the button's natural size: the button must clamp to
// the canvas and never produce an inverted or overhanging rect.
const EditorLayout L = layoutEditor(40, 40);
CHECK(L.button.right() <= L.canvas.right());
CHECK(L.button.bottom() <= L.canvas.bottom());
CHECK(L.button.right() >= L.button.x); // never inverted
CHECK(L.button.bottom() >= L.button.y);
// Title bar clamps to the client height when the view is shorter than its height.
CHECK(L.titleBar.bottom() <= 40);
}
// --- layoutEditor: zero view (all empty, no inversion) ------------------------
static void testLayoutZeroView() {
const EditorLayout L = layoutEditor(0, 0);
CHECK(L.titleBar.width <= 0 || L.titleBar.height <= 0);
CHECK(L.canvas.width <= 0 || L.canvas.height <= 0);
// No rect is inverted.
CHECK(L.button.right() >= L.button.x);
CHECK(L.button.bottom() >= L.button.y);
CHECK(L.canvas.right() >= L.canvas.x);
CHECK(L.canvas.bottom() >= L.canvas.y);
// A click anywhere on an empty layout hits nothing.
CHECK(hitTest(L, 0, 0) == HitTarget::kNone);
CHECK(hitTest(L, 5, 5) == HitTarget::kNone);
}
// --- hitTest ------------------------------------------------------------------
static void testHitTestButton() {
const EditorLayout L = layoutEditor(400, 260);
// Center of the button hits it.
const int cx = (L.button.x + L.button.right()) / 2;
const int cy = (L.button.y + L.button.bottom()) / 2;
CHECK(hitTest(L, cx, cy) == HitTarget::kButton);
}
static void testHitTestMissesNonButton() {
const EditorLayout L = layoutEditor(400, 260);
// Title bar is inert in the spike.
CHECK(hitTest(L, 200, L.titleBar.y + 1) == HitTarget::kNone);
// Empty canvas away from the button.
CHECK(hitTest(L, 380, 240) == HitTarget::kNone);
// Outside the surface entirely.
CHECK(hitTest(L, -5, -5) == HitTarget::kNone);
CHECK(hitTest(L, 500, 500) == HitTarget::kNone);
}
static void testHitTestButtonBoundary() {
const EditorLayout L = layoutEditor(400, 260);
// Top-left corner of the button is inclusive; the right/bottom edges are excluded.
CHECK(hitTest(L, L.button.x, L.button.y) == HitTarget::kButton);
CHECK(hitTest(L, L.button.right(), L.button.y) == HitTarget::kNone);
CHECK(hitTest(L, L.button.x, L.button.bottom()) == HitTarget::kNone);
}
// --- layout<->hit-test agreement ----------------------------------------------
// Every pixel inside the drawn button rect must hit the button; this is the
// load-bearing consistency invariant between what the shell draws and what it routes.
static void testHitTestMatchesDrawnButton() {
const EditorLayout L = layoutEditor(320, 200);
for (int y = L.button.y; y < L.button.bottom(); ++y) {
for (int x = L.button.x; x < L.button.right(); ++x) {
CHECK(hitTest(L, x, y) == HitTarget::kButton);
}
}
}
// --- sample list (S4) ---------------------------------------------------------
static void testSampleRowRectStacks() {
const EditorLayout L = layoutEditor(400, 260);
const Rect r0 = sampleRowRect(L, 0);
const Rect r1 = sampleRowRect(L, 1);
// Row 0 starts at the canvas top and spans its full width.
CHECK(r0.y == L.canvas.y);
CHECK(r0.x == L.canvas.x && r0.right() == L.canvas.right());
CHECK(r0.height == kSampleRowHeight);
// Row 1 sits directly below row 0 (no gap, no overlap).
CHECK(r1.y == r0.bottom());
CHECK(r1.height == kSampleRowHeight);
// A negative index is an empty rect.
CHECK(sampleRowRect(L, -1).width == 0 && sampleRowRect(L, -1).height == 0);
}
static void testSampleRowHitTestMapsClickToRow() {
const EditorLayout L = layoutEditor(400, 260);
const int rows = 5;
// A click in the vertical middle of row 2 resolves to index 2.
const Rect r2 = sampleRowRect(L, 2);
const int midY = (r2.y + r2.bottom()) / 2;
CHECK(sampleRowHitTest(L, rows, 200, midY) == 2);
// Row 0's top-left corner hits row 0.
const Rect r0 = sampleRowRect(L, 0);
CHECK(sampleRowHitTest(L, rows, r0.x, r0.y) == 0);
}
static void testSampleRowHitTestMisses() {
const EditorLayout L = layoutEditor(400, 260);
const int rows = 3;
// Above the first row (in the title bar) -> no row.
CHECK(sampleRowHitTest(L, rows, 200, L.titleBar.y) == -1);
// Below the last row -> no row.
const Rect last = sampleRowRect(L, rows - 1);
CHECK(sampleRowHitTest(L, rows, 200, last.bottom() + 1) == -1);
// Left of the canvas -> no row.
CHECK(sampleRowHitTest(L, rows, L.canvas.x - 1, last.y) == -1);
// Zero rows -> always -1.
CHECK(sampleRowHitTest(L, 0, 200, L.canvas.y + 1) == -1);
// At or below canvas.bottom() -> always -1, even if rowCount would cover that y.
// This guards paint<->hit-test agreement: sampleRowRect does not clamp to canvas,
// so without this clip a row that extends past canvas.bottom() would hit-test but
// never be drawn (or vice versa).
CHECK(sampleRowHitTest(L, rows, 200, L.canvas.bottom()) == -1);
// Use a large rowCount so index arithmetic would return a valid row without the
// canvas.bottom() guard — proving the guard fires independently of rowCount.
const int bigRows = 1000;
CHECK(sampleRowHitTest(L, bigRows, 200, L.canvas.bottom()) == -1);
CHECK(sampleRowHitTest(L, bigRows, 200, L.canvas.bottom() + 5) == -1);
}
// The drawn-row <-> hit-test agreement: every pixel inside a row rect must resolve to
// that row's index (the same load-bearing invariant as the button).
static void testSampleRowHitTestMatchesDrawnRows() {
const EditorLayout L = layoutEditor(320, 200);
const int rows = 4;
for (int i = 0; i < rows; ++i) {
const Rect r = sampleRowRect(L, i);
if (r.y >= L.canvas.bottom()) break; // clipped rows aren't clickable targets
const int y = (r.y + r.bottom()) / 2;
if (y >= L.canvas.bottom()) continue;
CHECK(sampleRowHitTest(L, rows, r.x + 1, y) == i);
}
}
// --- keymap editor (S5 Tier-1 UI) --------------------------------------------
static void testKeymapLayoutSplitsCanvas() {
const KeymapEditorLayout L = layoutKeymapEditor(600, 300);
// The left sample list and right zone panel partition the canvas with no overlap and
// no gap: the list's right edge is the panel's left edge.
CHECK(L.sampleList.x == L.base.canvas.x);
CHECK(L.sampleList.right() == L.zonePanel.x);
CHECK(L.zonePanel.right() == L.base.canvas.right());
CHECK(L.sampleList.y == L.base.canvas.y);
CHECK(L.zonePanel.y == L.base.canvas.y);
CHECK(L.sampleList.bottom() == L.base.canvas.bottom());
CHECK(L.zonePanel.bottom() == L.base.canvas.bottom());
CHECK(L.sampleList.width > 0 && L.zonePanel.width > 0);
// Add-Zone button caps the panel; zone rows stack below it.
CHECK(L.addZoneButton.y == L.zonePanel.y);
CHECK(L.addZoneButton.x == L.zonePanel.x && L.addZoneButton.right() == L.zonePanel.right());
CHECK(L.zoneRowArea.y == L.addZoneButton.bottom());
CHECK(L.zoneRowArea.bottom() == L.zonePanel.bottom());
}
static void checkNoInversion(const KeymapEditorLayout& L) {
CHECK(L.sampleList.right() >= L.sampleList.x);
CHECK(L.zonePanel.right() >= L.zonePanel.x);
CHECK(L.addZoneButton.right() >= L.addZoneButton.x);
CHECK(L.addZoneButton.bottom() >= L.addZoneButton.y);
CHECK(L.zoneRowArea.right() >= L.zoneRowArea.x);
CHECK(L.zoneRowArea.bottom() >= L.zoneRowArea.y);
// Regions stay within the client area.
CHECK(L.zonePanel.right() <= L.base.canvas.right());
}
static void testKeymapLayoutTinyAndZeroNoInversion() {
checkNoInversion(layoutKeymapEditor(30, 30));
checkNoInversion(layoutKeymapEditor(0, 0));
// A click anywhere on a zero layout hits no zone and no Add button.
const KeymapEditorLayout Z = layoutKeymapEditor(0, 0);
CHECK(zoneHitTest(Z, 3, 0, 0).zoneIndex == -1);
CHECK(!addZoneHitTest(Z, 0, 0));
}
static void testKeymapSampleRowInLeftColumn() {
const KeymapEditorLayout L = layoutKeymapEditor(600, 300);
const Rect r0 = keymapSampleRowRect(L, 0);
// Rows live in the LEFT column (not the full canvas width).
CHECK(r0.x == L.sampleList.x && r0.right() == L.sampleList.right());
CHECK(r0.right() < L.base.canvas.right()); // strictly left of the zone panel
CHECK(r0.y == L.sampleList.y && r0.height == kSampleRowHeight);
// Hit-test maps a left-column click to the row and rejects a click in the zone panel.
const int midY = (r0.y + r0.bottom()) / 2;
CHECK(keymapSampleRowHitTest(L, 3, r0.x + 2, midY) == 0);
CHECK(keymapSampleRowHitTest(L, 3, L.zonePanel.x + 2, midY) == -1);
}
static void testAddZoneHitTest() {
const KeymapEditorLayout L = layoutKeymapEditor(600, 300);
const int cx = (L.addZoneButton.x + L.addZoneButton.right()) / 2;
const int cy = (L.addZoneButton.y + L.addZoneButton.bottom()) / 2;
CHECK(addZoneHitTest(L, cx, cy));
// A click in the zone-row area below the button is NOT the Add button.
CHECK(!addZoneHitTest(L, cx, L.zoneRowArea.y + 2));
// A click in the left list is NOT the Add button.
CHECK(!addZoneHitTest(L, L.sampleList.x + 2, L.sampleList.y + 2));
}
static void testZoneRowStacksAndSelects() {
const KeymapEditorLayout L = layoutKeymapEditor(600, 300);
const Rect z0 = zoneRowRect(L, 0);
const Rect z1 = zoneRowRect(L, 1);
CHECK(z0.y == L.zoneRowArea.y && z0.height == kZoneRowHeight);
CHECK(z1.y == z0.bottom()); // stacked, no gap
CHECK(z0.x == L.zoneRowArea.x && z0.right() == L.zoneRowArea.right());
// A click on the LABEL area (left part of a zone row) selects the zone with no field.
const int labelX = z0.x + 2; // far left = label, not a control
const int midY = (z0.y + z0.bottom()) / 2;
const ZoneHit h = zoneHitTest(L, 2, labelX, midY);
CHECK(h.zoneIndex == 0 && h.field == ZoneField::kZoneNone);
}
static void testZoneRowControlsMapToFields() {
const KeymapEditorLayout L = layoutKeymapEditor(600, 300);
const Rect row = zoneRowRect(L, 0);
const int midY = (row.y + row.bottom()) / 2;
// The seven controls occupy the rightmost 7*kZoneCtrlWidth px, left-to-right:
// low-, low+, high-, high+, root-, root+, delete.
const int block = row.right() - 7 * kZoneCtrlWidth;
const ZoneField expected[7] = {
ZoneField::kLowDown, ZoneField::kLowUp, ZoneField::kHighDown,
ZoneField::kHighUp, ZoneField::kRootDown, ZoneField::kRootUp,
ZoneField::kDelete,
};
for (int s = 0; s < 7; ++s) {
const int x = block + s * kZoneCtrlWidth + kZoneCtrlWidth / 2; // center of slot s
const ZoneHit h = zoneHitTest(L, 1, x, midY);
CHECK(h.zoneIndex == 0);
CHECK(h.zoneIndex == 0 && h.field == expected[s]);
}
}
static void testZoneHitTestMisses() {
const KeymapEditorLayout L = layoutKeymapEditor(600, 300);
const Rect row = zoneRowRect(L, 0);
const int midY = (row.y + row.bottom()) / 2;
// Zero zones -> always miss.
CHECK(zoneHitTest(L, 0, row.x + 2, midY).zoneIndex == -1);
// Below the last zone row -> miss.
const Rect last = zoneRowRect(L, 2);
CHECK(zoneHitTest(L, 3, row.x + 2, last.bottom() + 1).zoneIndex == -1);
// Left of the zone panel (in the sample list) -> miss.
CHECK(zoneHitTest(L, 3, L.sampleList.x + 2, midY).zoneIndex == -1);
}
// --- r11 Sample / Zone face layout (Q-W2v hoist, T2-06) ----------------------
// The band stack at the default 840x620 with a 120px deck: title / hero / cluster /
// deck in order, hero elastic (absorbs the slack), deck bottom-anchored at kPad.
static void testSampleBandsStackAndElasticHero() {
const SampleBands b = computeSampleBands(840, 620, 120);
CHECK(b.title.y == 0 && b.title.height == kTitleHeight && b.title.width == 840);
CHECK(b.hero.y == b.title.bottom());
CHECK(b.hero.height >= 150); // above the hero floor
CHECK(b.cluster.y > b.hero.bottom()); // cluster below the hero (+gap)
CHECK(b.deck.bottom() == 620 - kPad); // deck bottom-anchored
CHECK(b.deck.height == 120);
// Nav buttons right-anchored inside the title band, Browse left of Zone.
CHECK(b.navZone.right() == 840 - kPad);
CHECK(b.navBrowse.right() < b.navZone.x);
CHECK(b.navZone.bottom() <= b.title.bottom());
// A too-short window: the hero keeps its floor; the lower bands clip below.
const SampleBands s = computeSampleBands(840, 200, 120);
CHECK(s.hero.height == 150);
CHECK(s.deck.bottom() > 200); // clips past the window bottom (defensive case)
}
// The cluster's right-anchored run tiles left of the channel toggle without overlap:
// rootStrip | preview | velCell(velKnob+velLabel) | curveBtn | (toggle).
static void testClusterRectsRunAndKnobCentering() {
const Rect cluster = Rect::ltrb(0, 500, 840, 552);
const ChannelToggleRects chan = channelToggleRects(cluster);
CHECK(chan.stereo.right() == 840 - kPad);
CHECK(chan.mono.right() == chan.stereo.x);
const ClusterRects cr = clusterRects(cluster, chan.mono, 28);
CHECK(cr.curveBtn.right() == chan.mono.x - kPad);
CHECK(cr.velCell.right() == cr.curveBtn.x - kPad);
CHECK(cr.preview.right() == cr.velCell.x - kPad);
CHECK(cr.rootStrip.x == cluster.x + kPad);
CHECK(cr.rootStrip.right() == cr.preview.x - kPad);
// The knob square centers in the cell and the label band sits beneath it.
CHECK(cr.velKnob.width == 28);
CHECK(cr.velKnob.x - cr.velCell.x == cr.velCell.right() - cr.velKnob.right());
CHECK(cr.velLabel.y == cr.velKnob.bottom());
CHECK(cr.velLabel.bottom() == cr.velCell.bottom());
}
// The Zone surface: content below the title; strip below the add/delete row; the note
// entry fields tile in three ordered segments; deck + curve button split the panel.
static void testZoneSurfaceLayoutAnchors() {
const Rect content = zoneContentArea(840, 620);
CHECK(content.y == kTitleHeight && content.bottom() == 620);
const Rect back = zoneBackRect(840, 620);
CHECK(back.right() == 840 - kPad && back.bottom() <= kTitleHeight);
const Rect addR = zoneAddRect(content);
const Rect delR = zoneDeleteRect(addR);
CHECK(addR.y == content.y + 4);
CHECK(delR.x == addR.right() + 8 && delR.y == addR.y);
const Rect strip = zonesStripArea(content);
CHECK(strip.y == addR.bottom() + 12);
CHECK(strip.x == content.x + kPad && strip.right() == content.right() - kPad);
const Rect fields = noteEntryFieldsArea(content);
CHECK(fields.y == strip.bottom() + 8);
const Rect f0 = noteEntryFieldRect(fields, 0);
const Rect f1 = noteEntryFieldRect(fields, 1);
const Rect f2 = noteEntryFieldRect(fields, 2);
CHECK(f0.x < f1.x && f1.x < f2.x);
CHECK(f2.right() == fields.right());
CHECK(noteEntryFieldRect(fields, 3).width == 0); // out-of-range -> empty
const Rect panel = zonesControlPanel(content);
const Rect deck = zonesDeckArea(content);
const Rect curve = zonesCurveButton(content);
CHECK(panel.y == strip.bottom() + 8 + 18 + 8);
CHECK(deck.y == panel.y && deck.right() < curve.x); // curve column reserved
CHECK(curve.right() == panel.right() && curve.y == panel.y);
}
int main() {
testContainsHalfOpen();
testContainsDegenerate();
testLayoutNormalView();
testLayoutTinyViewClampsButton();
testLayoutZeroView();
testHitTestButton();
testHitTestMissesNonButton();
testHitTestButtonBoundary();
testHitTestMatchesDrawnButton();
testSampleRowRectStacks();
testSampleRowHitTestMapsClickToRow();
testSampleRowHitTestMisses();
testSampleRowHitTestMatchesDrawnRows();
testKeymapLayoutSplitsCanvas();
testKeymapLayoutTinyAndZeroNoInversion();
testKeymapSampleRowInLeftColumn();
testAddZoneHitTest();
testZoneRowStacksAndSelects();
testZoneRowControlsMapToFields();
testZoneHitTestMisses();
testSampleBandsStackAndElasticHero();
testClusterRectsRunAndKnobCentering();
testZoneSurfaceLayoutAnchors();
if (g_fail == 0) std::printf("editor_geometry: all tests passed\n");
return g_fail != 0;
}
+30 -59
View File
@@ -1,12 +1,11 @@
// Standalone tests for reasampler::instrument::ui::embed_strip — no VST3, no REAPER, no framework.
// Same fast assert loop as the sibling pure tests (editor_geometry et al.): assert the
// embedded TCP/MCP strip's layout math + zone hit-testing + level fill directly.
// Same fast assert loop as the sibling pure tests: assert the embedded TCP/MCP strip's
// layout math + key-span mapping + level fill directly.
//
// Covers: layoutEmbed splitting a normal area into keymap + level band, a tiny area
// (band yields to the keymap minimum, no inversion), and a zero area (all empty);
// zoneSegmentRect mapping the 128-key span linearly, tiling adjacent zones seamlessly,
// clamping out-of-range/inverted notes; zoneAtPoint hitting the covering zone, first-match
// on overlap, missing on uncovered keys and off-band, and rejecting a null/empty list;
// keySpanRect mapping the 128-key span linearly, tiling adjacent spans seamlessly,
// resolving a single-key span (the root marker), and clamping out-of-range/inverted notes;
// levelFillRect clamping 0..1 and its endpoints.
#include "../src/core/instrument/ui/embed_strip.h"
@@ -55,74 +54,48 @@ static void testLayoutZeroArea() {
CHECK(N.keymap.right() >= N.keymap.x && N.keymap.bottom() >= N.keymap.y);
}
// --- zoneSegmentRect ----------------------------------------------------------
// --- keySpanRect --------------------------------------------------------------
static void testZoneSegmentFullSpan() {
// A zone covering the whole keyboard spans the entire keymap band width.
static void testKeySpanFullKeyboard() {
// The loaded capture responds across the whole keyboard, so its span is the whole band.
const EmbedLayout L = layoutEmbed(256, 40);
const Rect r = zoneSegmentRect(L, 0, 127);
const Rect r = keySpanRect(L, 0, 127);
CHECK(r.x == L.keymap.x);
CHECK(r.right() == L.keymap.right());
CHECK(r.y == L.keymap.y && r.bottom() == L.keymap.bottom());
}
static void testAdjacentZonesTileSeamlessly() {
// 256px band, 128 keys -> 2px/key. Zones 0..59 and 60..127 must abut with no gap or
// overlap: the low zone's right == the high zone's left.
static void testAdjacentSpansTileSeamlessly() {
// 256px band, 128 keys -> 2px/key. Spans 0..59 and 60..127 must abut with no gap or
// overlap: the low span's right == the high span's left.
const EmbedLayout L = layoutEmbed(256, 40);
const Rect lo = zoneSegmentRect(L, 0, 59);
const Rect hi = zoneSegmentRect(L, 60, 127);
const Rect lo = keySpanRect(L, 0, 59);
const Rect hi = keySpanRect(L, 60, 127);
CHECK(lo.x == L.keymap.x);
CHECK(hi.right() == L.keymap.right());
CHECK(lo.right() == hi.x); // seamless tile — the load-bearing assertion
CHECK(lo.right() == L.keymap.x + 60 * 2); // 60 keys * 2px
}
static void testZoneSegmentClampsBadNotes() {
static void testSingleKeySpanIsTheRootMarker() {
// low == high is the root marker: exactly one key wide, inside the band.
const EmbedLayout L = layoutEmbed(256, 40);
// Out-of-range notes clamp into the band; an inverted zone (low > high) collapses to a
const Rect root = keySpanRect(L, 60, 60);
CHECK(root.x == L.keymap.x + 60 * 2);
CHECK(root.width == 2);
CHECK(root.y == L.keymap.y && root.bottom() == L.keymap.bottom());
}
static void testKeySpanClampsBadNotes() {
const EmbedLayout L = layoutEmbed(256, 40);
// Out-of-range notes clamp into the band; an inverted span (low > high) collapses to a
// zero-or-positive-width rect, never inverts.
const Rect over = zoneSegmentRect(L, -10, 200);
const Rect over = keySpanRect(L, -10, 200);
CHECK(over.x == L.keymap.x && over.right() == L.keymap.right());
const Rect inv = zoneSegmentRect(L, 100, 20);
const Rect inv = keySpanRect(L, 100, 20);
CHECK(inv.right() >= inv.x);
}
// --- zoneAtPoint --------------------------------------------------------------
static void testZoneAtPointHits() {
const EmbedLayout L = layoutEmbed(256, 40);
const EmbedZone zones[2] = {{0, 59}, {60, 127}};
// A point inside the low zone's segment resolves to zone 0; inside the high zone, 1.
const Rect lo = zoneSegmentRect(L, 0, 59);
const Rect hi = zoneSegmentRect(L, 60, 127);
const int yMid = (L.keymap.y + L.keymap.bottom()) / 2;
CHECK(zoneAtPoint(L, zones, 2, lo.x + 1, yMid) == 0);
CHECK(zoneAtPoint(L, zones, 2, hi.right() - 1, yMid) == 1);
}
static void testZoneAtPointFirstMatchOnOverlap() {
const EmbedLayout L = layoutEmbed(256, 40);
// Two overlapping zones; the FIRST in order must win the contested keys.
const EmbedZone zones[2] = {{0, 127}, {40, 80}};
const int yMid = (L.keymap.y + L.keymap.bottom()) / 2;
const Rect contested = zoneSegmentRect(L, 40, 80);
CHECK(zoneAtPoint(L, zones, 2, contested.x + 1, yMid) == 0); // zone 0 wins
}
static void testZoneAtPointMisses() {
const EmbedLayout L = layoutEmbed(256, 40);
const EmbedZone zones[1] = {{60, 72}}; // a narrow zone; most keys uncovered
const int yMid = (L.keymap.y + L.keymap.bottom()) / 2;
// A key left of the zone is uncovered -> -1.
CHECK(zoneAtPoint(L, zones, 1, L.keymap.x + 1, yMid) == -1);
// A point in the level band (below the keymap) is off the keymap -> -1.
CHECK(zoneAtPoint(L, zones, 1, L.levelBand.x + 4, L.levelBand.y) == -1);
// Empty / null list -> -1.
CHECK(zoneAtPoint(L, zones, 0, L.keymap.x + 1, yMid) == -1);
CHECK(zoneAtPoint(L, nullptr, 3, L.keymap.x + 1, yMid) == -1);
}
// --- levelFillRect ------------------------------------------------------------
static void testLevelFillClamps() {
@@ -144,12 +117,10 @@ int main() {
testLayoutNormalArea();
testLayoutTinyAreaKeepsKeymap();
testLayoutZeroArea();
testZoneSegmentFullSpan();
testAdjacentZonesTileSeamlessly();
testZoneSegmentClampsBadNotes();
testZoneAtPointHits();
testZoneAtPointFirstMatchOnOverlap();
testZoneAtPointMisses();
testKeySpanFullKeyboard();
testAdjacentSpansTileSeamlessly();
testSingleKeySpanIsTheRootMarker();
testKeySpanClampsBadNotes();
testLevelFillClamps();
if (g_fail == 0) std::printf("embed_strip: all tests passed\n");
+3 -2
View File
@@ -116,7 +116,8 @@ static void testPresetRoundTripsThroughInstrumentReader() {
const ComponentState cs = deserializeComponentState(p.compChunk, kRate);
CHECK(cs.selectionId == id); // the capture IS selected — the whole point
CHECK(cs.map.zones.empty()); // a drop selects one capture, authors no zones
// A drop selects one capture and leaves the parameter set at its defaults.
CHECK(!cs.params.rootOverride && !cs.params.loopOverride && !cs.params.startPoint);
CHECK(cs.channelMode == ChannelMode::Mono); // fresh-instance default
CHECK(cs.lastConsumedAssignGeneration == 0); // fresh instance, no consumed assign
}
@@ -158,7 +159,7 @@ static void testEmptyIdYieldsEmptyState() {
CHECK(!p.compChunk.empty()); // still a versioned envelope, just an empty selection
const ComponentState cs = deserializeComponentState(p.compChunk, kRate);
CHECK(cs.selectionId.empty());
CHECK(cs.map.zones.empty());
CHECK(!cs.params.rootOverride && !cs.params.loopOverride && !cs.params.startPoint);
}
// Deterministic: the same id always produces the same bytes (no time/random in the path).
+4 -77
View File
@@ -1,15 +1,11 @@
// Standalone tests for reasampler::instrument::ui::keyboard_strip — no VST3, no REAPER, no framework.
// Same fast assert loop as the sibling pure tests. Assert the capture-first editor's
// keyboard-strip layout, root marker, key mapping, zone-bar hit regions, and the drag-delta
// note resolver directly — the geometry that backs the single-capture root-set and the opt-in
// Zones panel.
// Same fast assert loop as the sibling pure tests. Assert the editor's keyboard-strip
// layout, root marker, key mapping, and drag-delta note resolver directly — the geometry
// that backs the root display and root-set.
//
// Covers: layoutStrip (normal + zero); keyLeftX monotonic across the 128-key span with the
// boundary at 128 == band right; keyRect / rootMarkerRect (rootMarkerRect == keyRect);
// keyAtPoint inverting the mapping and clamping/ missing off-band; zoneBarRect spanning
// [low,high] inclusive and collapsing (not inverting) a malformed low>high; zoneGrabAt
// classifying low-edge / high-edge / body and the narrow-bar midpoint split (low wins the
// tie); zoneBarAtPoint first-match on overlap + null-list rejection; resolveDragNote rounding
// keyAtPoint inverting the mapping and clamping/ missing off-band; resolveDragNote rounding
// to the nearest key at the key centre, clamping to [0,127], and the zero-delta / zero-width
// no-ops; isNaturalKey across a full octave (C4..B4), at boundary notes 0 and 127, and with
// out-of-range inputs that clamp to [0,127].
@@ -95,69 +91,6 @@ static void testKeyAtPointOffBand() {
CHECK(keyAtPoint(L, 100, L.keys.bottom() + 5) == -1); // below band
}
// --- zoneBarRect --------------------------------------------------------------
static void testZoneBarSpansInclusive() {
const StripLayout L = wideStrip();
const Rect bar = zoneBarRect(L, 12, 23); // C1..B1 inclusive
CHECK(bar.x == keyLeftX(L, 12));
CHECK(bar.right() == keyLeftX(L, 24)); // high+1 -> the bar covers key 23 fully
CHECK(bar.width == 120); // 12 keys * 10px
}
static void testZoneBarMalformedCollapses() {
const StripLayout L = wideStrip();
// low > high must collapse, never invert.
const Rect bar = zoneBarRect(L, 80, 40);
CHECK(bar.width >= 0);
CHECK(bar.right() >= bar.x);
}
// --- zoneGrabAt ---------------------------------------------------------------
static void testZoneGrabEdgesAndBody() {
const StripLayout L = wideStrip();
const Rect bar = zoneBarRect(L, 20, 60); // wide bar with a clear body
const int y = L.keys.y + 2;
// Near the left edge -> low; near the right edge -> high; the middle -> body.
CHECK(zoneGrabAt(L, 20, 60, bar.x + 1, y) == ZoneGrab::kLowEdge);
CHECK(zoneGrabAt(L, 20, 60, bar.right() - 1, y) == ZoneGrab::kHighEdge);
CHECK(zoneGrabAt(L, 20, 60, bar.x + bar.width / 2, y) == ZoneGrab::kBody);
// Off the bar entirely -> none.
CHECK(zoneGrabAt(L, 20, 60, bar.right() + 20, y) == ZoneGrab::kNone);
}
static void testZoneGrabNarrowBarSplitsAtMidpointLowWins() {
const StripLayout L = wideStrip();
// A 1-key bar is narrower than 2*edge: no body; the low edge wins the exact midpoint.
const Rect bar = zoneBarRect(L, 50, 50);
const int y = L.keys.y + 2;
const int mid = bar.x + bar.width / 2;
CHECK(zoneGrabAt(L, 50, 50, mid, y) == ZoneGrab::kLowEdge); // tie -> low
CHECK(zoneGrabAt(L, 50, 50, bar.right() - 1, y) == ZoneGrab::kHighEdge);
}
// --- zoneBarAtPoint -----------------------------------------------------------
static void testZoneBarAtPointFirstMatch() {
const StripLayout L = wideStrip();
const int lows[2] = {20, 30}; // zone 0 and zone 1 overlap on [30,50]
const int highs[2] = {50, 70};
const Rect overlap = zoneBarRect(L, 30, 50);
const int y = L.keys.y + 2;
const int cx = overlap.x + overlap.width / 2;
// A point in the overlap resolves to the FIRST covering zone (draw order).
const ZoneBarHit hit = zoneBarAtPoint(L, lows, highs, 2, cx, y);
CHECK(hit.zoneIndex == 0);
CHECK(hit.grab != ZoneGrab::kNone);
}
static void testZoneBarAtPointNullList() {
const StripLayout L = wideStrip();
const ZoneBarHit hit = zoneBarAtPoint(L, nullptr, nullptr, 0, 100, 2);
CHECK(hit.zoneIndex == -1 && hit.grab == ZoneGrab::kNone);
}
// --- resolveDragNote ----------------------------------------------------------
static void testResolveDragRoundsToNearestKey() {
@@ -246,12 +179,6 @@ int main() {
testRootMarkerEqualsKeyRect();
testKeyAtPointInverts();
testKeyAtPointOffBand();
testZoneBarSpansInclusive();
testZoneBarMalformedCollapses();
testZoneGrabEdgesAndBody();
testZoneGrabNarrowBarSplitsAtMidpointLowWins();
testZoneBarAtPointFirstMatch();
testZoneBarAtPointNullList();
testResolveDragRoundsToNearestKey();
testResolveDragClampsAndNoOps();
testResolveDragProportionalNonDivisibleWidth();
+163
View File
@@ -0,0 +1,163 @@
// Standalone tests for reasampler::instrument::ui::sample_bands — no VST3, no REAPER, no
// test framework. Same fast assert loop as the sibling pure tests.
//
// Covers: the shared Rect vocabulary (contains() half-open + degenerate rects); the
// three-band vertical inventory (chrome over waveform over decks, no overlap, no
// inversion) asserted as pure geometry with no paint call; the waveform band's two-lane
// floor and the bands-clip-rather-than-squeeze rule on a short window; the deck band's
// bottom anchor and its exact requested height; and the lane split (mono = one full-band
// lane, stereo = two lanes with the seam gap between them).
#include "../src/core/instrument/ui/sample_bands.h"
#include <cstdio>
using namespace reasampler;
using namespace reasampler::instrument::ui;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
// --- the shared Rect vocabulary ----------------------------------------------
static void testContainsHalfOpen() {
Rect r = Rect::ltrb(10, 20, 50, 40); // [10,50) x [20,40)
CHECK(contains(r, 10, 20)); // top-left inclusive
CHECK(contains(r, 49, 39)); // bottom-right exclusive edge, inside
CHECK(!contains(r, 50, 30)); // right edge excluded
CHECK(!contains(r, 30, 40)); // bottom edge excluded
CHECK(!contains(r, 9, 30)); // left of rect
CHECK(!contains(r, 30, 19)); // above rect
}
static void testContainsDegenerate() {
CHECK(!contains(Rect::ltrb(10, 10, 10, 20), 10, 15)); // zero width
CHECK(!contains(Rect::ltrb(10, 10, 20, 10), 15, 10)); // zero height
CHECK(!contains(Rect::ltrb(20, 10, 10, 20), 15, 15)); // inverted (right < left)
}
// --- the vertical inventory ---------------------------------------------------
static void testBandsStackTopToBottomWithoutOverlap() {
const SampleBands b = computeSampleBands(840, 620, 120);
CHECK(b.chrome.y == 0);
CHECK(b.chrome.height == kTitleHeight + kChromeRowHeight);
// Strictly ordered, no overlap: each band starts at or after the previous one's bottom.
CHECK(b.waveform.y >= b.chrome.bottom());
CHECK(b.decks.y >= b.waveform.bottom());
// No inversion anywhere.
CHECK(b.chrome.height > 0 && b.waveform.height > 0 && b.decks.height > 0);
CHECK(b.chrome.width > 0 && b.waveform.width > 0 && b.decks.width > 0);
}
static void testChromeSpansFullWidthAndLowerBandsAreInset() {
const SampleBands b = computeSampleBands(840, 620, 120);
CHECK(b.chrome.x == 0 && b.chrome.right() == 840);
CHECK(b.waveform.x == kPad && b.waveform.right() == 840 - kPad);
CHECK(b.decks.x == kPad && b.decks.right() == 840 - kPad);
}
static void testDeckBandIsBottomAnchoredAtItsRequestedHeight() {
const int deckH = 96;
const SampleBands b = computeSampleBands(840, 620, deckH);
CHECK(b.decks.height == deckH);
CHECK(b.decks.bottom() == 620 - kPad); // bottom-anchored inside the pad
}
static void testWaveformAbsorbsSlackAsTheWindowGrows() {
const SampleBands small = computeSampleBands(840, 620, 120);
const SampleBands big = computeSampleBands(840, 900, 120);
CHECK(big.waveform.height == small.waveform.height + 280);
// The fixed bands do not grow with the window.
CHECK(big.chrome.height == small.chrome.height);
CHECK(big.decks.height == small.decks.height);
}
static void testWaveformNeverShrinksBelowTheTwoLaneFloor() {
// A window far too short for chrome + two lanes + deck: the floor wins and the deck band
// is pushed past the bottom (clipped) rather than squeezing the waveform.
const SampleBands b = computeSampleBands(840, 160, 120);
CHECK(b.waveform.height == kWaveformMinHeight);
CHECK(b.decks.y >= b.waveform.bottom());
CHECK(b.decks.bottom() > 160); // deliberately clipped below the window
}
static void testTwoLaneFloorHoldsTwoUsableLanes() {
// The floor is exactly what two minimum lanes plus their seam need — not an arbitrary
// number, so a lane can never be allocated below its own minimum.
CHECK(kWaveformMinHeight == 2 * kLaneMinHeight + kLaneGap);
const SampleBands b = computeSampleBands(840, 160, 120);
const WaveformLanes lanes = waveformLanes(b.waveform, /*stereo=*/true);
CHECK(lanes.upper.height >= kLaneMinHeight);
CHECK(lanes.lower.height >= kLaneMinHeight);
}
static void testDegenerateWindowYieldsNoInvertedRects() {
const SampleBands z = computeSampleBands(0, 0, 0);
CHECK(z.chrome.width == 0 && z.chrome.height == 0);
CHECK(z.waveform.width <= 0 || z.waveform.height >= 0);
CHECK(z.waveform.right() >= z.waveform.x);
CHECK(z.decks.right() >= z.decks.x);
const SampleBands tiny = computeSampleBands(20, 20, 4);
CHECK(tiny.waveform.right() >= tiny.waveform.x);
CHECK(tiny.decks.right() >= tiny.decks.x);
}
// --- the waveform band's lanes ------------------------------------------------
static void testMonoUsesOneFullBandLane() {
const Rect band = Rect::ltrb(8, 100, 832, 300);
const WaveformLanes lanes = waveformLanes(band, /*stereo=*/false);
CHECK(lanes.upper == band);
CHECK(lanes.lower.empty()); // no redundant duplicate lane in mono
}
static void testStereoSplitsIntoTwoLanesWithTheSeamGap() {
const Rect band = Rect::ltrb(8, 100, 832, 300); // height 200
const WaveformLanes lanes = waveformLanes(band, /*stereo=*/true);
CHECK(lanes.upper.y == band.y);
CHECK(lanes.lower.bottom() == band.bottom());
// Full width each, seam exactly kLaneGap, no overlap.
CHECK(lanes.upper.x == band.x && lanes.upper.right() == band.right());
CHECK(lanes.lower.x == band.x && lanes.lower.right() == band.right());
CHECK(lanes.lower.y - lanes.upper.bottom() == kLaneGap);
CHECK(lanes.upper.height + lanes.lower.height + kLaneGap == band.height);
}
static void testStereoOddRemainderGoesToTheUpperLane() {
const Rect band = Rect::ltrb(0, 0, 100, 201); // usable 199 -> 100 / 99
const WaveformLanes lanes = waveformLanes(band, /*stereo=*/true);
CHECK(lanes.upper.height == 100);
CHECK(lanes.lower.height == 99);
CHECK(lanes.lower.bottom() == band.bottom());
}
static void testEmptyBandYieldsEmptyLanes() {
const WaveformLanes lanes = waveformLanes(Rect{}, /*stereo=*/true);
CHECK(lanes.upper.empty());
CHECK(lanes.lower.empty());
}
int main() {
testContainsHalfOpen();
testContainsDegenerate();
testBandsStackTopToBottomWithoutOverlap();
testChromeSpansFullWidthAndLowerBandsAreInset();
testDeckBandIsBottomAnchoredAtItsRequestedHeight();
testWaveformAbsorbsSlackAsTheWindowGrows();
testWaveformNeverShrinksBelowTheTwoLaneFloor();
testTwoLaneFloorHoldsTwoUsableLanes();
testDegenerateWindowYieldsNoInvertedRects();
testMonoUsesOneFullBandLane();
testStereoSplitsIntoTwoLanesWithTheSeamGap();
testStereoOddRemainderGoesToTheUpperLane();
testEmptyBandYieldsEmptyLanes();
if (g_fail == 0) {
std::printf("sample_bands: all tests passed\n");
return 0;
}
std::printf("sample_bands: %d failure(s)\n", g_fail);
return 1;
}
+120
View File
@@ -0,0 +1,120 @@
// Standalone tests for reasampler::instrument::ui::sample_chrome — no VST3, no REAPER, no
// test framework.
//
// Covers: the chrome band's two rows (toolbar over control row, tiling the band exactly);
// the Browse button right-anchored inside the toolbar; the control row's fixed
// right-anchored run in order (preview, velocity cell, curve button, Mono|Stereo) with the
// root strip taking the remainder; the velocity knob centred in its cell above its label;
// and degenerate bands yielding no inverted rects.
#include "../src/core/instrument/ui/sample_bands.h"
#include "../src/core/instrument/ui/sample_chrome.h"
#include <cstdio>
using namespace reasampler;
using namespace reasampler::instrument::ui;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
static constexpr int kKnob = 26; // stands in for knob_deck's kDeckKnobSize
static Rect chromeBand(int w = 840, int h = 620) {
return computeSampleBands(w, h, 120).chrome;
}
static void testRowsTileTheBandExactly() {
const Rect band = chromeBand();
const ChromeRects r = chromeRects(band, kKnob);
CHECK(r.toolbar.y == band.y);
CHECK(r.toolbar.height == kTitleHeight);
CHECK(r.controls.y == r.toolbar.bottom());
CHECK(r.controls.bottom() == band.bottom());
CHECK(r.toolbar.x == band.x && r.toolbar.right() == band.right());
CHECK(r.controls.x == band.x && r.controls.right() == band.right());
}
static void testBrowseIsRightAnchoredInsideTheToolbar() {
const Rect band = chromeBand();
const ChromeRects r = chromeRects(band, kKnob);
CHECK(r.navBrowse.right() == band.right() - kPad);
CHECK(r.navBrowse.width == kNavButtonWidth);
CHECK(r.navBrowse.y >= r.toolbar.y);
CHECK(r.navBrowse.bottom() <= r.toolbar.bottom());
}
static void testControlRunIsOrderedRightToLeftWithoutOverlap() {
const Rect band = chromeBand();
const ChromeRects r = chromeRects(band, kKnob);
// Rightmost first: stereo, mono, curve button, velocity cell, preview, then the strip.
CHECK(r.chanStereo.right() == band.right() - kPad);
CHECK(r.chanMono.right() == r.chanStereo.x);
CHECK(r.curveBtn.right() <= r.chanMono.x);
CHECK(r.velCell.right() <= r.curveBtn.x);
CHECK(r.preview.right() <= r.velCell.x);
CHECK(r.rootStrip.right() <= r.preview.x);
CHECK(r.rootStrip.x == band.x + kPad);
CHECK(r.rootStrip.width > 0);
}
static void testRootStripTakesTheRemainderWidth() {
const ChromeRects narrow = chromeRects(chromeBand(600, 620), kKnob);
const ChromeRects wide = chromeRects(chromeBand(1000, 620), kKnob);
// The fixed run keeps its size; every extra pixel goes to the strip.
CHECK(wide.preview.width == narrow.preview.width);
CHECK(wide.velCell.width == narrow.velCell.width);
CHECK(wide.rootStrip.width == narrow.rootStrip.width + 400);
}
static void testVelocityKnobIsCentredInItsCellAboveTheLabel() {
const ChromeRects r = chromeRects(chromeBand(), kKnob);
CHECK(r.velKnob.width == kKnob && r.velKnob.height == kKnob);
CHECK(r.velKnob.y == r.velCell.y);
const int leftGap = r.velKnob.x - r.velCell.x;
const int rightGap = r.velCell.right() - r.velKnob.right();
CHECK(leftGap == rightGap); // horizontally centred in the cell
CHECK(r.velLabel.y == r.velKnob.bottom());
CHECK(r.velLabel.bottom() == r.velCell.bottom());
CHECK(r.velLabel.x == r.velCell.x && r.velLabel.right() == r.velCell.right());
}
static void testDegenerateBandYieldsNoInvertedRects() {
const ChromeRects empty = chromeRects(Rect{}, kKnob);
CHECK(empty.toolbar.empty() && empty.controls.empty());
CHECK(empty.rootStrip.empty() && empty.preview.empty());
// A band far too narrow for the fixed run: the strip collapses, nothing inverts.
const ChromeRects tiny = chromeRects(Rect::ltrb(0, 0, 40, kTitleHeight + kChromeRowHeight),
kKnob);
CHECK(tiny.rootStrip.right() >= tiny.rootStrip.x);
CHECK(tiny.navBrowse.right() >= tiny.navBrowse.x);
CHECK(tiny.preview.right() >= tiny.preview.x || tiny.preview.width < 0);
}
static void testToolbarOnlyBandStillPlacesTheNav() {
// A band clipped to just the toolbar row: the control row is empty but Browse still
// resolves, so the empty state's call-to-action is never unreachable.
const ChromeRects r = chromeRects(Rect::ltrb(0, 0, 400, kTitleHeight), kKnob);
CHECK(r.toolbar.height == kTitleHeight);
CHECK(r.controls.empty());
CHECK(r.navBrowse.width == kNavButtonWidth);
}
int main() {
testRowsTileTheBandExactly();
testBrowseIsRightAnchoredInsideTheToolbar();
testControlRunIsOrderedRightToLeftWithoutOverlap();
testRootStripTakesTheRemainderWidth();
testVelocityKnobIsCentredInItsCellAboveTheLabel();
testDegenerateBandYieldsNoInvertedRects();
testToolbarOnlyBandStillPlacesTheNav();
if (g_fail == 0) {
std::printf("sample_chrome: all tests passed\n");
return 0;
}
std::printf("sample_chrome: %d failure(s)\n", g_fail);
return 1;
}
+295 -2047
View File
File diff suppressed because it is too large Load Diff
+177 -187
View File
@@ -17,7 +17,7 @@
// by the CMake target linking neither SDK — this file includes only sampler_core.h +
// the standard library, which is itself the compile-time proof.
#include "../src/core/instrument/engine/sampler_core.h"
#include "../src/core/instrument/engine/voice_engine.h"
#include <algorithm>
#include <cmath>
@@ -69,49 +69,42 @@ static AdsrParams flatAdsr() {
}
// ---------------------------------------------------------------------------
// 6. Keymap resolution.
// 6. Full-keyboard response over the one loaded capture.
// ---------------------------------------------------------------------------
static void testChromaticSingleRoot() {
Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60));
CHECK(km.zones.size() == 1);
// Every note in 0..127 resolves to the single zone.
static void testEveryKeyPlaysTheLoadedCapture() {
// No key range survives: the loaded capture answers every note in 0..127, repitched
// from its root. Each note-on must take a real voice.
SampleData km = dcSample(100, 60);
VoiceEngine eng(128, km);
for (int n = 0; n <= 127; ++n) {
ZoneResolution r = km.resolve(n, 100);
CHECK(r.matched);
CHECK(r.zoneIndex == 0);
CHECK(eng.noteOn(n, 100) != VoiceEngine::kNoVoice);
}
CHECK(eng.activeVoiceCount() == 128);
}
static void testZonedRangesBoundaries() {
Keymap km;
km.samples.push_back(dcSample(100, 48)); // low sample
km.samples.push_back(dcSample(100, 72)); // high sample
// Two adjacent zones: [36,59] and [60,83]. Boundary notes 59/60 must land in the
// correct zone; a first-match order test would catch an off-by-one.
km.zones.push_back(KeyZone{36, 59, 48, 0});
km.zones.push_back(KeyZone{60, 83, 72, 1});
static void testUnplayableCaptureRefusesEveryNote() {
// Nothing decoded -> the defined no-play at every key, in both voice modes, rather
// than a voice started on an empty read span.
SampleData empty; // no frames
VoiceEngine poly(4, empty);
CHECK(poly.noteOn(60, 100) == VoiceEngine::kNoVoice);
CHECK(poly.noteOn(0, 100) == VoiceEngine::kNoVoice);
CHECK(poly.activeVoiceCount() == 0);
CHECK(km.resolve(36, 100).matched);
CHECK(km.resolve(36, 100).zoneIndex == 0);
CHECK(km.resolve(59, 100).zoneIndex == 0); // last note of zone 0
CHECK(km.resolve(60, 100).zoneIndex == 1); // first note of zone 1
CHECK(km.resolve(83, 100).zoneIndex == 1); // last note of zone 1
// Out of every zone -> defined no-play (not a match, not zone 0).
CHECK(!km.resolve(35, 100).matched);
CHECK(!km.resolve(84, 100).matched);
CHECK(!km.resolve(127, 100).matched);
VoiceEngine mono(4, empty, 0, 0, VoiceMode::Mono);
CHECK(mono.noteOn(60, 100) == VoiceEngine::kNoVoice);
CHECK(mono.activeVoiceCount() == 0);
}
static void testFirstMatchOnOverlap() {
// Overlapping zones: the earlier zone wins (documented deterministic rule).
Keymap km;
km.samples.push_back(dcSample(10, 60));
km.samples.push_back(dcSample(10, 60));
km.zones.push_back(KeyZone{0, 127, 60, 0}); // catch-all first
km.zones.push_back(KeyZone{60, 60, 60, 1}); // shadowed by the catch-all
CHECK(km.resolve(60, 100).zoneIndex == 0);
static void testOutOfRangeNotesAreRefusedInMono() {
// The mono held stack keys notes as uint8, so an out-of-range note must be rejected
// BEFORE it can alias onto a real held note.
SampleData km = dcSample(100, 60);
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono);
CHECK(eng.noteOn(-1, 100) == VoiceEngine::kNoVoice);
CHECK(eng.noteOn(128, 100) == VoiceEngine::kNoVoice);
CHECK(eng.activeVoiceCount() == 0);
}
// ---------------------------------------------------------------------------
@@ -178,7 +171,7 @@ static void testRepitchObservedPeriod() {
// Unity: played at root, observed period ~= native.
{
Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60));
SampleData km = (sineSample(frames, cycles, 60));
VoiceEngine eng(4, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
@@ -188,7 +181,7 @@ static void testRepitchObservedPeriod() {
}
// +1 octave: advances 2x, observed period halves.
{
Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60));
SampleData km = (sineSample(frames, cycles, 60));
VoiceEngine eng(4, km);
eng.noteOn(72, 127);
std::vector<AudioSample> out;
@@ -198,7 +191,7 @@ static void testRepitchObservedPeriod() {
}
// -1 octave: advances 0.5x, observed period doubles.
{
Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60));
SampleData km = (sineSample(frames, cycles, 60));
VoiceEngine eng(4, km);
eng.noteOn(48, 127);
std::vector<AudioSample> out;
@@ -217,9 +210,9 @@ static void testKeyTrackVarispeedObservedPeriod() {
auto periodAt = [&](int note, double keyTrack) -> double {
SampleData s = sineSample(frames, cycles, 60);
s.play.pitchEngine = PitchEngine::Varispeed;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
// The single zone spans the keyboard from root 60; stamp the key-track scalar on it.
km.zones[0].keyTrack = keyTrack;
km.keyTrack = keyTrack;
VoiceEngine eng(4, km);
eng.noteOn(note, 127);
std::vector<AudioSample> out;
@@ -247,8 +240,8 @@ static void testKeyTrackPreserveShiftCollapsesAtZero() {
auto renderPreserve = [&](int note, double keyTrack) -> std::vector<AudioSample> {
SampleData s = sineSample(frames, cycles, 60);
s.play.pitchEngine = PitchEngine::Preserve; // Gate, no loop -> runs to sample end
Keymap km = Keymap::singleSampleChromatic(std::move(s));
km.zones[0].keyTrack = keyTrack;
SampleData km = (std::move(s));
km.keyTrack = keyTrack;
VoiceEngine eng(1, km, /*preserveCap=*/0, static_cast<std::int64_t>(window));
eng.noteOn(note, 127);
std::vector<AudioSample> out;
@@ -375,7 +368,7 @@ static void testAdsrZeroAttackDecay() {
// ---------------------------------------------------------------------------
static void testPolyphonicAllocation() {
Keymap km = Keymap::singleSampleChromatic(dcSample(1000, 60));
SampleData km = (dcSample(1000, 60));
VoiceEngine eng(8, km);
// Four simultaneous notes -> four active voices, each on a distinct voice.
@@ -423,11 +416,11 @@ static void testNoteOffReleasesNewestSameNote() {
SampleData sd = dcSample(100000, 60);
sd.play.adsr = flatAdsr();
sd.play.adsr.releaseFrames = 10; // short but non-zero so voice stays active through release
Keymap km = Keymap::singleSampleChromatic(sd);
SampleData km = (sd);
// A LINEAR velocity curve keeps the two velocities distinguishable (velocity/127). The default
// flat y=1 curve (S-VIEW-9 R10-F1) would render both at unity, collapsing the distinction this
// note-off-selection test relies on — so we opt this zone back to the linear response.
km.zones[0].velocityCurve = VelocityCurve::linear();
km.velocityCurve = VelocityCurve::linear();
VoiceEngine eng(8, km);
std::size_t first = eng.noteOn(60, velOld); // older voice, lower gain
@@ -463,17 +456,6 @@ static void testNoteOffReleasesNewestSameNote() {
CHECK(eng.activeVoiceCount() == 0);
}
static void testOutOfZoneNoteConsumesNoVoice() {
Keymap km;
km.samples.push_back(dcSample(100, 60));
km.zones.push_back(KeyZone{60, 72, 60, 0});
VoiceEngine eng(4, km);
std::size_t v = eng.noteOn(30, 100); // below the only zone
CHECK(v == VoiceEngine::kNoVoice);
CHECK(eng.activeVoiceCount() == 0); // no voice consumed
}
// ---------------------------------------------------------------------------
// 2. Voice stealing at the bound.
// ---------------------------------------------------------------------------
@@ -484,7 +466,7 @@ static void testStealsReleasingVoiceFirst() {
SampleData s = dcSample(100000, 60);
s.play.adsr = flatAdsr();
s.play.adsr.releaseFrames = 100000; // long release so a released voice stays "active"
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(2, km);
std::size_t vA = eng.noteOn(60, 100); // startOrder 1
@@ -509,7 +491,7 @@ static void testStealsOldestWhenNoneReleasing() {
SampleData s = dcSample(100000, 60);
s.play.adsr = flatAdsr();
s.play.adsr.releaseFrames = 100000;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(2, km);
std::size_t vA = eng.noteOn(60, 100); // startOrder 1 (oldest)
@@ -547,7 +529,7 @@ static void testLoopSustainSeamless() {
s.loop.start = 20;
s.loop.end = 40;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km);
eng.noteOn(60, 127); // unity ratio, full velocity
@@ -569,7 +551,7 @@ static void testZeroLengthLoopGoesSilent() {
s.loop.hasLoop = true;
s.loop.start = 25;
s.loop.end = 25; // zero length
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
@@ -593,7 +575,7 @@ static void testSingleFrameLoop() {
s.loop.start = 5;
s.loop.end = 6; // single-frame loop: [5, 6)
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km);
eng.noteOn(60, 127); // unity ratio, full velocity
@@ -612,7 +594,7 @@ static void testAbsentLoopGoesSilent() {
// No loop at all: held note runs off the end and goes idle (same as zero-length).
SampleData s = dcSample(50, 60);
// s.loop.hasLoop stays false.
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
@@ -633,7 +615,7 @@ static void testStartFrameOffsetsInitialRead() {
for (int i = 0; i < 100; ++i) s.frames[i] = static_cast<float>(i) * 0.01f;
s.rootNote = 60;
s.startFrame = 30;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km);
eng.noteOn(60, 127); // unity ratio, full velocity, flat gain
std::vector<AudioSample> out;
@@ -649,7 +631,7 @@ static void testStartFrameZeroIsUnchanged() {
s.frames.resize(20);
for (int i = 0; i < 20; ++i) s.frames[i] = static_cast<float>(i) * 0.05f;
s.rootNote = 60; // startFrame stays 0
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
@@ -662,7 +644,7 @@ static void testStartFrameOutOfRangeClampsToZero() {
// out-of-bounds read that would start the voice already exhausted.
SampleData s = dcSample(10, 60); // 10 frames of 1.0
s.startFrame = 10; // == frameCount: out of range
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
@@ -683,7 +665,7 @@ static void testStartFrameWithLoop() {
s.loop.hasLoop = true;
s.loop.start = 20;
s.loop.end = 40;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
@@ -712,7 +694,7 @@ static void testStartAfterLoopEndWrapsIntoLoop() {
s.loop.start = 20;
s.loop.end = 40;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km);
eng.noteOn(60, 127); // unity ratio, full velocity
@@ -734,11 +716,11 @@ static void testStartAfterLoopEndWrapsIntoLoop() {
// velocity -> volume.
// ---------------------------------------------------------------------------
// S-VIEW-9 BEHAVIOR CHANGE (R10-F1 Option A): the DEFAULT velocity curve on a KeyZone is now flat
// S-VIEW-9 BEHAVIOR CHANGE (R10-F1 Option A): the DEFAULT velocity curve is now flat
// y=1, so EVERY velocity plays at unity — NOT the old linear velocity/127. singleSampleChromatic
// builds a zone with the flat default, so the DC-1 sample renders 1.0 at any velocity.
static void testVelocityDefaultCurveIsFlatUnity() {
Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // DC 1.0, flat default curve
SampleData km = (dcSample(100, 60)); // DC 1.0, flat default curve
for (int vel : {1, 64, 100, 127}) {
VoiceEngine eng(1, km);
eng.noteOn(60, vel);
@@ -751,8 +733,8 @@ static void testVelocityDefaultCurveIsFlatUnity() {
// A LINEAR curve on the zone reproduces the pre-r10 velocity/127 ramp exactly — proving the curve
// (not a hardcoded map) drives the gain, and that eval is applied at note-on.
static void testVelocityLinearCurveReproducesRamp() {
Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // DC 1.0
km.zones[0].velocityCurve = VelocityCurve::linear();
SampleData km = (dcSample(100, 60)); // DC 1.0
km.velocityCurve = VelocityCurve::linear();
{
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
@@ -776,10 +758,10 @@ static void testVelocityLinearCurveReproducesRamp() {
// A shaped curve (a single interior knot) drives the gain through eval — a mid velocity reads the
// curve's shaped value, not the linear one. Proves the whole curve, not just the endpoints, applies.
static void testVelocityShapedCurveDrivesGain() {
Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // DC 1.0
SampleData km = (dcSample(100, 60)); // DC 1.0
VelocityCurve curve = VelocityCurve::linear();
curve.addPoint(64.0, 0.9); // pull the mid-velocity response UP to 0.9
km.zones[0].velocityCurve = curve;
km.velocityCurve = curve;
VoiceEngine eng(1, km);
eng.noteOn(60, 64);
std::vector<AudioSample> out; eng.render(out, 1);
@@ -790,7 +772,7 @@ static void testVelocityShapedCurveDrivesGain() {
// Two voices summed: polyphony mixes additively.
static void testPolyphonyMixesAdditively() {
Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // DC 1.0
SampleData km = (dcSample(100, 60)); // DC 1.0
VoiceEngine eng(4, km);
eng.noteOn(60, 127); // gain 1.0
eng.noteOn(60, 127); // gain 1.0 (second voice, same note)
@@ -826,7 +808,7 @@ static void testChannelCount() {
static void testStereoRenderKeepsChannelsDistinct() {
// A stereo sample (L=1.0, R=-1.0) rendered stereo must emit L and R distinctly, each
// scaled by velocity (full here). If the engine copied L to both channels the R check fails.
Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 1.0f, -1.0f, 60));
SampleData km = (stereoDcSample(100, 1.0f, -1.0f, 60));
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
@@ -841,7 +823,7 @@ static void testStereoRenderKeepsChannelsDistinct() {
static void testMonoSamplePlaysDualMonoInStereo() {
// A MONO sample rendered through the stereo path plays dual-mono: both channels equal
// (centered), not silent on the right. The cross-mode "mono source in stereo mode" case.
Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // mono, DC 1.0
SampleData km = (dcSample(100, 60)); // mono, DC 1.0
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> left(8, 0.f), right(8, 0.f);
@@ -862,7 +844,7 @@ static void testDualMonoStereoSampleRendersCentered() {
SampleData s = sineSample(600, 12.0, 60);
s.framesR = s.frames; // dual-mono: identical channels
s.play.pitchEngine = engine;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km, /*preserveCap=*/0, /*preserveWindowFrames=*/128);
eng.noteOn(note, 127);
std::vector<AudioSample> left(256, 0.f), right(256, 0.f);
@@ -884,7 +866,7 @@ static void testDualMonoStereoSampleRendersCentered() {
static void testMonoRenderUnchangedByStereoData() {
// Regression: the mono render path (renderFrame) reads channel 0 ONLY and is byte-identical
// whether or not a second channel is present. A stereo sample rendered mono == its L channel.
Keymap kmS = Keymap::singleSampleChromatic(stereoDcSample(100, 0.75f, -0.25f, 60));
SampleData kmS = (stereoDcSample(100, 0.75f, -0.25f, 60));
VoiceEngine engS(1, kmS);
engS.noteOn(60, 127);
std::vector<AudioSample> mono;
@@ -909,7 +891,7 @@ static void testStereoRenderAdvancesLikeMonoRepitch() {
s.framesR[i] = v;
}
s.rootNote = 60;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(4, km);
eng.noteOn(72, 127); // +1 octave
std::vector<AudioSample> left(frames / 2, 0.f), right(frames / 2, 0.f);
@@ -920,7 +902,7 @@ static void testStereoRenderAdvancesLikeMonoRepitch() {
static void testStereoRenderSumsVoicesPerChannel() {
// Two voices on a stereo sample sum PER CHANNEL (additive polyphony holds in stereo).
Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 0.5f, -0.5f, 60));
SampleData km = (stereoDcSample(100, 0.5f, -0.5f, 60));
VoiceEngine eng(4, km);
eng.noteOn(60, 127);
eng.noteOn(60, 127); // second voice, same note
@@ -931,7 +913,7 @@ static void testStereoRenderSumsVoicesPerChannel() {
}
static void testStereoRenderNullBufferIsNoOp() {
Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 1.0f, -1.0f, 60));
SampleData km = (stereoDcSample(100, 1.0f, -1.0f, 60));
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> buf(4, 0.f);
@@ -960,7 +942,7 @@ static void testStereoStartFrameLoopShareOneReadHead() {
s.loop.start = 20;
s.loop.end = 30; // loop [20,30): frames 20..29
CHECK(s.channelCount() == 2);
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km);
eng.noteOn(60, 127); // unity ratio, full velocity, flat gain
@@ -1058,7 +1040,7 @@ static SampleData triggerSample(std::size_t frames, double lengthFraction,
// --- Trigger %-length frame math: plays exactly round(frac*(frames-start)) frames then frees. ---
static void testTriggerLengthFractionFrames() {
// 200-frame sample, start 0, 50% length -> plays 100 frames then the voice frees.
Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0));
SampleData km = (triggerSample(200, 0.5, 0, 0));
VoiceEngine eng(1, km);
eng.noteOn(60, 127); // unity ratio
std::vector<AudioSample> out;
@@ -1072,7 +1054,7 @@ static void testTriggerLengthFractionFrames() {
// --- Trigger start point: %-length measured from the start offset. ---
static void testTriggerLengthWithStart() {
// 200 frames, start 40, 50% -> span 160, play 80 frames (frames 40..119), then free.
Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0, /*start=*/40));
SampleData km = (triggerSample(200, 0.5, 0, 0, /*start=*/40));
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
@@ -1086,7 +1068,7 @@ static void testTriggerLengthWithStart() {
static void testTriggerFadeShape() {
// 100 frames, 100% length, fadeIn 20, fadeOut 20. Head ramps 0->1, tail ramps 1->0, unity
// between. Equal-power: sin/cos ramps, monotonic, endpoints ~0 and ~1.
Keymap km = Keymap::singleSampleChromatic(triggerSample(100, 1.0, 20, 20));
SampleData km = (triggerSample(100, 1.0, 20, 20));
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
@@ -1106,7 +1088,7 @@ static void testTriggerFadeShape() {
static void testTriggerEdgeCases() {
// %=0: zero play length -> voice frees at once, no sound.
{
Keymap km = Keymap::singleSampleChromatic(triggerSample(100, 0.0, 5, 5));
SampleData km = (triggerSample(100, 0.0, 5, 5));
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
@@ -1117,7 +1099,7 @@ static void testTriggerEdgeCases() {
// Fades that sum beyond the play length are clamped (no crash, no negative gain, amp in [0,1]).
{
// 40 frames, 100% -> playLen 40; fadeIn 30 + fadeOut 30 = 60 > 40 -> clamped.
Keymap km = Keymap::singleSampleChromatic(triggerSample(40, 1.0, 30, 30));
SampleData km = (triggerSample(40, 1.0, 30, 30));
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
@@ -1127,7 +1109,7 @@ static void testTriggerEdgeCases() {
}
// %=100 plays the full post-start span.
{
Keymap km = Keymap::singleSampleChromatic(triggerSample(60, 1.0, 0, 0));
SampleData km = (triggerSample(60, 1.0, 0, 0));
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
@@ -1139,7 +1121,7 @@ static void testTriggerEdgeCases() {
// --- Trigger ignores note-off (S15): the one-shot plays through regardless. ---
static void testTriggerIgnoresNoteOff() {
Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0));
SampleData km = (triggerSample(200, 0.5, 0, 0));
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
@@ -1189,7 +1171,7 @@ static void testPreserveDurationInvariance() {
const std::size_t window = 512; // pre-size the shifters
auto lengthAt = [&](int note) -> std::size_t {
Keymap km = Keymap::singleSampleChromatic(preserveTriggerSample(frames, 1.0));
SampleData km = (preserveTriggerSample(frames, 1.0));
VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/static_cast<std::int64_t>(window));
eng.noteOn(note, 127);
return soundingLength(eng, 4000);
@@ -1216,7 +1198,7 @@ static void testVarispeedStillCouplesDuration() {
s.play.playMode = PlayMode::Trigger;
s.play.pitchEngine = PitchEngine::Varispeed;
s.play.trigger.lengthFraction = 1.0;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km);
eng.noteOn(note, 127);
return soundingLength(eng, 4000);
@@ -1241,7 +1223,7 @@ static void testPitchEnvOffBitIdentical() {
s.play.pitchEnv.attackFrames = 0;
s.play.pitchEnv.decayFrames = 500;
}
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km);
eng.noteOn(67, 127); // a transposed note so ratio != 1 (exercises the ratio path)
std::vector<AudioSample> out;
@@ -1269,7 +1251,7 @@ static void testPitchEnvOnBendsVarispeed() {
s.play.pitchEnv.attackFrames = 0; // start at the peak
s.play.pitchEnv.decayFrames = 3000; // glide to base over 3000 frames
s.play.pitchEnv.peakSemitones = 12.0; // +1 octave at t=0
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km);
eng.noteOn(60, 127); // at root -> base ratio 1.0; the env supplies the bend
std::vector<AudioSample> out;
@@ -1304,7 +1286,7 @@ static void testPreserveGateStereoLoopComposes() {
s.play.playMode = PlayMode::Gate;
s.play.pitchEngine = PitchEngine::Preserve;
CHECK(s.channelCount() == 2);
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km, 0, 512);
eng.noteOn(67, 127); // transposed up a fifth under Preserve (duration held)
std::vector<AudioSample> left(2000, 0.f), right(2000, 0.f);
@@ -1337,7 +1319,7 @@ static void testPreserveGateStereoLoopComposes() {
static void testPreserveVoiceCap() {
SampleData s = dcSample(2000, 60);
s.play.pitchEngine = PitchEngine::Preserve; // held (Gate, no loop -> runs long enough)
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
// 8 voices total, Preserve cap of 2.
VoiceEngine eng(8, km, /*preserveCap=*/2, /*window=*/256);
CHECK(eng.noteOn(62, 127) != VoiceEngine::kNoVoice); // 1st Preserve voice
@@ -1362,7 +1344,7 @@ static void testPreserveUnityEngineVoiceSpeaksImmediately() {
SampleData s = dcSample(4000, 60);
s.play.pitchEngine = PitchEngine::Preserve;
s.play.adsr = flatAdsr(); // isolate the shifter onset from the amp attack
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/512);
eng.noteOn(note, 127);
std::vector<AudioSample> out;
@@ -1394,7 +1376,7 @@ static void testPreserveTransposedVoiceSpeaksImmediately() {
SampleData s = dcSample(4000, 60);
s.play.pitchEngine = PitchEngine::Preserve;
s.play.adsr = flatAdsr(); // isolate the shifter onset from the amp attack
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/512);
eng.noteOn(62, 127); // +2 semitones: a real shift, NOT demoted
std::vector<AudioSample> out;
@@ -1413,7 +1395,7 @@ static void testPreserveTransposedVoiceSpeaksImmediately() {
static void testPreserveUnityVoiceCountsTowardCap() {
SampleData s = dcSample(2000, 60);
s.play.pitchEngine = PitchEngine::Preserve;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(8, km, /*preserveCap=*/2, /*window=*/256);
CHECK(eng.noteOn(60, 127) != VoiceEngine::kNoVoice); // root: a genuine Preserve voice now
CHECK(eng.noteOn(62, 127) != VoiceEngine::kNoVoice); // 2nd (at the cap)
@@ -1429,8 +1411,8 @@ static void testVelocityCurveAppliesUnderPreserve() {
auto steadyLevelAt = [&](int vel) -> double {
SampleData s = dcSample(4000, 60);
s.play.pitchEngine = PitchEngine::Preserve;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
km.zones[0].velocityCurve = VelocityCurve::linear();
SampleData km = (std::move(s));
km.velocityCurve = VelocityCurve::linear();
VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/256);
eng.noteOn(62, vel); // transposed: the genuine shifter path (not the unity demotion)
std::vector<AudioSample> out;
@@ -1460,7 +1442,7 @@ static void testPerZoneAdsrReachesVoiceEnvelope() {
s.play.adsr.sustainLevel = 1.0;
s.play.adsr.releaseFrames = 0;
s.play.pitchEngine = PitchEngine::Varispeed; // isolate from pitch engine machinery
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km);
eng.noteOn(60, 127); // unity pitch, full velocity -> gain 1.0
std::vector<AudioSample> out;
@@ -1483,7 +1465,7 @@ static void testZeroAdsrIsInstantSustain() {
// Default AdsrParams{}: all zeros, sustainLevel = 1.0 (struct default). No attack ramp.
s.play.adsr = AdsrParams{};
s.play.pitchEngine = PitchEngine::Varispeed;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
@@ -1507,17 +1489,20 @@ static SampleData dcLevelSample(std::size_t frames, float level, int rootNote) {
return s;
}
// Two-zone keymap with DISTINCT DC levels (0.25 / 0.75) so the mono tests can read which zone
// holds the voice off the rendered value: zone A = notes [40,59] root 50 -> 0.25; zone B =
// notes [60,80] root 70 -> 0.75.
static Keymap twoLevelKeymap() {
Keymap km;
km.samples.push_back(dcLevelSample(200000, 0.25f, 50));
km.samples.push_back(dcLevelSample(200000, 0.75f, 70));
KeyZone a; a.lowNote = 40; a.highNote = 59; a.rootNote = 50; a.sampleIndex = 0;
KeyZone b; b.lowNote = 60; b.highNote = 80; b.rootNote = 70; b.sampleIndex = 1;
km.zones.push_back(a);
km.zones.push_back(b);
// The mono tests need to read WHICH NOTE holds the single voice off the rendered value, and
// a DC sample makes pitch inaudible. Velocity is the discriminator: a DC 1.0 capture with a
// curve pinned through two probe velocities renders 0.25 for a kVelLow strike and 0.75 for a
// kVelHigh one (the Hermite spline passes exactly through its control points). Each test
// then presses note 50 soft and note 70 hard, so the level names the sounding note.
static constexpr int kVelLow = 32;
static constexpr int kVelHigh = 96;
static SampleData twoLevelSample() {
SampleData km = dcLevelSample(200000, 1.0f, 60);
km.velocityCurve = VelocityCurve::fromPoints({{0.0, 0.0},
{static_cast<double>(kVelLow), 0.25},
{static_cast<double>(kVelHigh), 0.75},
{127.0, 1.0}});
return km;
}
@@ -1532,11 +1517,11 @@ static double probeFrame(VoiceEngine& eng) {
// back to the most-recent still-held note; releasing the last note gates off. Also: mono uses
// ONE voice regardless of the pool size.
static void testMonoLastNotePriorityAndFallback() {
Keymap km = twoLevelKeymap();
SampleData km = twoLevelSample();
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
CHECK(eng.noteOn(50, 127) == 0); // zone A sounds
CHECK(eng.noteOn(50, kVelLow) == 0); // zone A sounds
CHECK(approx(probeFrame(eng), 0.25, 1e-6));
CHECK(eng.noteOn(70, 127) == 0); // zone B TAKES the voice (last-note priority)
CHECK(eng.noteOn(70, kVelHigh) == 0); // zone B TAKES the voice (last-note priority)
CHECK(eng.activeVoiceCount() == 1); // mono: one voice even with 4 in the pool
CHECK(approx(probeFrame(eng), 0.75, 1e-6));
eng.noteOff(70); // top released -> FALLBACK to still-held 50
@@ -1549,10 +1534,10 @@ static void testMonoLastNotePriorityAndFallback() {
// Releasing a LOWER held note (not the sounding one) changes nothing audible; the released
// note also leaves the stack, so the final note-off truly empties it.
static void testMonoReleaseOfLowerHeldNoteIsInaudible() {
Keymap km = twoLevelKeymap();
SampleData km = twoLevelSample();
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
eng.noteOn(50, 127);
eng.noteOn(70, 127); // 70 sounds, 50 held beneath
eng.noteOn(50, kVelLow);
eng.noteOn(70, kVelHigh); // 70 sounds, 50 held beneath
eng.noteOff(50); // releasing the buried note: inaudible
CHECK(approx(probeFrame(eng), 0.75, 1e-6));
eng.noteOff(70); // 50 already left the stack -> silence, no fallback
@@ -1562,11 +1547,11 @@ static void testMonoReleaseOfLowerHeldNoteIsInaudible() {
// Re-pressing a HELD note moves it to the top of the stack (it sounds again), and the note
// beneath becomes the fallback.
static void testMonoRepressHeldNoteMovesToTop() {
Keymap km = twoLevelKeymap();
SampleData km = twoLevelSample();
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
eng.noteOn(50, 127);
eng.noteOn(70, 127);
CHECK(eng.noteOn(50, 127) == 0); // re-press while held: back on top
eng.noteOn(50, kVelLow);
eng.noteOn(70, kVelHigh);
CHECK(eng.noteOn(50, kVelLow) == 0); // re-press while held: back on top
CHECK(approx(probeFrame(eng), 0.25, 1e-6));
eng.noteOff(50); // falls back to 70 (now the most recent held)
CHECK(approx(probeFrame(eng), 0.75, 1e-6));
@@ -1578,8 +1563,8 @@ static void testMonoRepressHeldNoteMovesToTop() {
// held note on the stack), not the departing note's.
static void testMonoRetriggerFallbackUsesOriginalVelocity() {
SampleData s = dcLevelSample(200000, 1.0f, 60);
Keymap km = Keymap::singleSampleChromatic(std::move(s));
km.zones[0].velocityCurve = VelocityCurve::linear(); // gain = velocity/127
SampleData km = (std::move(s));
km.velocityCurve = VelocityCurve::linear(); // gain = velocity/127
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
eng.noteOn(60, 32); // soft first note
CHECK(approx(probeFrame(eng), 32.0 / 127.0, 1e-4));
@@ -1589,16 +1574,17 @@ static void testMonoRetriggerFallbackUsesOriginalVelocity() {
CHECK(approx(probeFrame(eng), 32.0 / 127.0, 1e-4));
}
// An OUT-OF-ZONE note in mono is a defined no-play: it consumes nothing, never joins the
// stack (so it can never take the voice back on a fallback), and its note-off is inert.
static void testMonoOutOfZoneNeverJoinsStack() {
Keymap km = twoLevelKeymap(); // zones cover [40,59] + [60,80] only
// An OUT-OF-RANGE note in mono is a defined no-play: it consumes nothing, never joins the
// stack (so it can never take the voice back on a fallback), and its note-off is inert. The
// stack keys notes as uint8, so an unguarded 200 would alias onto 72 and corrupt it.
static void testMonoOutOfRangeNeverJoinsStack() {
SampleData km = twoLevelSample();
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
eng.noteOn(70, 127);
CHECK(eng.noteOn(20, 127) == VoiceEngine::kNoVoice); // out of every zone
eng.noteOn(70, kVelHigh);
CHECK(eng.noteOn(200, 127) == VoiceEngine::kNoVoice); // past the MIDI range
CHECK(eng.activeVoiceCount() == 1);
CHECK(approx(probeFrame(eng), 0.75, 1e-6)); // 70 undisturbed
eng.noteOff(20); // inert
eng.noteOff(200); // inert
CHECK(approx(probeFrame(eng), 0.75, 1e-6));
eng.noteOff(70);
CHECK(approx(probeFrame(eng), 0.0, 1e-9));
@@ -1609,7 +1595,7 @@ static void testMonoOutOfZoneNeverJoinsStack() {
static void testMonoRetriggerRestartsEnvelope() {
SampleData s = dcLevelSample(200000, 1.0f, 60);
s.play.adsr.attackFrames = 100; // slow linear attack: level at frame i = i/100
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
@@ -1623,7 +1609,7 @@ static void testMonoRetriggerRestartsEnvelope() {
static void testMonoLegatoContinuesEnvelope() {
SampleData s = dcLevelSample(200000, 1.0f, 60);
s.play.adsr.attackFrames = 100;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
@@ -1645,8 +1631,8 @@ static void testMonoLegatoRetunesWithoutReadRestart() {
}
s.rootNote = 60;
s.play.adsr = flatAdsr();
Keymap km = Keymap::singleSampleChromatic(std::move(s));
km.zones[0].velocityCurve = VelocityCurve::linear();
SampleData km = (std::move(s));
km.velocityCurve = VelocityCurve::linear();
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
eng.noteOn(60, 127); // unity: read advances 1/frame, full gain
std::vector<AudioSample> out;
@@ -1657,16 +1643,21 @@ static void testMonoLegatoRetunesWithoutReadRestart() {
CHECK(approx(probeFrame(eng), 12.0, 1e-3)); // and now advances at ratio 2 (the new pitch)
}
// LEGATO applies only to a SAME-SAMPLE takeover: crossing into a zone playing a DIFFERENT
// sample restarts the voice (one read head cannot glide between two PCM streams).
static void testMonoLegatoCrossSampleRestarts() {
Keymap km = twoLevelKeymap();
km.samples[1].play.adsr.attackFrames = 100; // zone B has a slow attack to expose a restart
// LEGATO takeover ALWAYS glides now: with one loaded capture there is no second PCM stream
// to cross into, so the read head never has to restart mid-phrase. (The retired
// cross-sample-restart branch was the multi-zone case.)
static void testMonoLegatoAlwaysGlidesWithinThePhrase() {
SampleData km = twoLevelSample();
km.play.adsr.attackFrames = 100; // a slow attack would expose any restart
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
eng.noteOn(50, 127); // zone A (flat env): 0.25 at once
CHECK(approx(probeFrame(eng), 0.25, 1e-6));
eng.noteOn(70, 127); // cross-sample: RESTART (attack from 0), no retune
CHECK(approx(probeFrame(eng), 0.0, 1e-6)); // zone B's fresh attack origin — not 0.25 held over
eng.noteOn(50, kVelLow);
std::vector<AudioSample> out;
eng.render(out, 50); // mid-attack: level ~0.49 * the 0.25 vel gain
CHECK(approx(out[49], 0.49 * 0.25, 1e-6));
eng.noteOn(70, kVelHigh); // takeover: envelope KEEPS running, no re-attack
// Frame 50 of the SAME attack ramp, still at the FIRST strike's velocity gain (a legato
// phrase is one gesture, one strike) — NOT 0.0 (a restart) and NOT 0.75 (a re-strike).
CHECK(approx(probeFrame(eng), 0.50 * 0.25, 1e-6));
}
// LEGATO after the last note was RELEASED re-attacks: a releasing voice's note has left the
@@ -1675,7 +1666,7 @@ static void testMonoLegatoAfterReleaseReattacks() {
SampleData s = dcLevelSample(200000, 1.0f, 60);
s.play.adsr.attackFrames = 100;
s.play.adsr.releaseFrames = 1000; // long release keeps the voice audibly ringing
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
@@ -1692,7 +1683,7 @@ static void testMonoLegatoAfterReleaseReattacks() {
static void testMonoIgnoresPreserveCap() {
SampleData s = dcSample(4000, 60);
s.play.pitchEngine = PitchEngine::Preserve;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(4, km, /*preserveCap=*/1, /*window=*/256,
VoiceMode::Mono, MonoTrigger::Retrigger);
CHECK(eng.noteOn(62, 127) == 0); // 1st Preserve note: at the cap
@@ -1719,7 +1710,7 @@ static SampleData rampSample(std::size_t frames, int rootNote) {
static void testMonoLegatoTriggerReattacksAfterKeyUp() {
SampleData s = rampSample(200000, 60);
s.play.playMode = PlayMode::Trigger; // default TriggerParams: full length, no fades
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
eng.noteOn(60, 127); // unity: read advances 1/frame
std::vector<AudioSample> out;
@@ -1739,7 +1730,7 @@ static void testMonoLegatoTriggerReattacksAfterKeyUp() {
static void testMonoLegatoTriggerHeldKeyStillRetunes() {
SampleData s = rampSample(200000, 60);
s.play.playMode = PlayMode::Trigger;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
@@ -1751,7 +1742,7 @@ static void testMonoLegatoTriggerHeldKeyStillRetunes() {
// MAJOR-2: allNotesOff releases every gated poly voice (flat release -> instant silence).
static void testAllNotesOffReleasesPolyVoices() {
SampleData s = dcLevelSample(200000, 1.0f, 60);
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(4, km);
eng.noteOn(60, 127);
eng.noteOn(62, 127);
@@ -1765,16 +1756,16 @@ static void testAllNotesOffReleasesPolyVoices() {
// MAJOR-2, the STUCK-NOTE path: allNotesOff clears the mono held stack, so a phantom entry
// (simulating a LOST note-off) can never be resurrected by the fallback afterwards.
static void testAllNotesOffClearsMonoHeldStack() {
Keymap km = twoLevelKeymap();
SampleData km = twoLevelSample();
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
eng.noteOn(50, 127); // 50's note-off will never arrive (phantom)
eng.noteOn(70, 127); // 70 sounds, phantom 50 buried on the stack
eng.noteOn(50, kVelLow); // 50's note-off will never arrive (phantom)
eng.noteOn(70, kVelHigh); // 70 sounds, phantom 50 buried on the stack
eng.allNotesOff(); // PANIC
CHECK(approx(probeFrame(eng), 0.0, 1e-9));
CHECK(eng.activeVoiceCount() == 0);
// The stack is empty: a fresh press + release gates off cleanly, with NO fallback
// restart of the phantom (pre-fix, noteOff(70) here re-struck 50 -> 0.25 forever).
eng.noteOn(70, 127);
eng.noteOn(70, kVelHigh);
CHECK(approx(probeFrame(eng), 0.75, 1e-6));
eng.noteOff(70);
CHECK(approx(probeFrame(eng), 0.0, 1e-9));
@@ -1790,7 +1781,7 @@ static void testAllSoundsOffStopsTriggerOneShot() {
SampleData s = dcLevelSample(200000, 1.0f, 60);
s.play.playMode = PlayMode::Trigger;
s.play.trigger.lengthFraction = 1.0; // full length — would ring for 200000 frames
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
CHECK(eng.activeVoiceCount() == 1);
@@ -1812,7 +1803,7 @@ static void testAllSoundsOffStopsTriggerOneShot() {
static void testAllNotesOffStillReleasesGateVoices() {
SampleData s = dcLevelSample(200000, 1.0f, 60);
// Default Gate mode, instant release (releaseFrames 0).
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(4, km);
eng.noteOn(60, 127);
eng.noteOn(62, 127);
@@ -1829,7 +1820,7 @@ static void testAllNotesOffStillReleasesGateVoices() {
// rather than retune. This is the correct fresh-phrase behavior documented in the comment.
static void testMonoLegatoSameNoteRepressReattacks() {
SampleData s = rampSample(200000, 60);
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
eng.noteOn(60, 127); // first press; read starts at 0
std::vector<AudioSample> out;
@@ -1845,7 +1836,7 @@ static void testMonoLegatoSameNoteRepressReattacks() {
// losing its fallback. Note-ons out of [0,127] are a defined no-play.
static void testMonoOutOfRangeNotesRejected() {
SampleData s = dcLevelSample(200000, 1.0f, 60);
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
CHECK(eng.noteOn(128, 127) == VoiceEngine::kNoVoice);
CHECK(eng.noteOn(-1, 127) == VoiceEngine::kNoVoice);
@@ -1865,7 +1856,7 @@ static void testMonoOutOfRangeNotesRejected() {
// notes and steals (never grows) on the N+1th; 0 clamps to the documented 1-voice degenerate.
static void testVoiceCountBoundsPolyphony() {
SampleData s = dcLevelSample(200000, 1.0f, 60);
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine e3(3, km);
CHECK(e3.maxVoices() == 3);
e3.noteOn(60, 127);
@@ -1894,7 +1885,7 @@ static void testMonoRetrigTakeoverDeclicksRestart() {
s.play.adsr.attackFrames = 100; // real attack: the new tone starts near 0
s.play.adsr.sustainLevel = 1.0;
s.play.adsr.releaseFrames = 0;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger,
/*takeoverDeclick=*/true);
@@ -1930,7 +1921,7 @@ static void testMonoRetrigFallbackDeclicksRestart() {
s.play.adsr.attackFrames = 100;
s.play.adsr.sustainLevel = 1.0;
s.play.adsr.releaseFrames = 0;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger,
/*takeoverDeclick=*/true);
@@ -1965,7 +1956,7 @@ static void testMonoDeclickOnlyOnTakeover() {
s.play.adsr.attackFrames = 100;
s.play.adsr.sustainLevel = 1.0;
s.play.adsr.releaseFrames = 0;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger,
/*takeoverDeclick=*/true);
@@ -1993,7 +1984,7 @@ static void testPolyStealDeclicksRestart() {
s.play.adsr.attackFrames = 100;
s.play.adsr.sustainLevel = 1.0;
s.play.adsr.releaseFrames = 0;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km, 0, 0, VoiceMode::Poly, MonoTrigger::Retrigger,
/*takeoverDeclick=*/true);
@@ -2026,7 +2017,7 @@ static void testSameBlockDoubleTakeoverKeepsDeclickSeed() {
s.play.adsr.attackFrames = 100;
s.play.adsr.sustainLevel = 1.0;
s.play.adsr.releaseFrames = 0;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km, 0, 0, VoiceMode::Poly, MonoTrigger::Retrigger,
/*takeoverDeclick=*/true);
@@ -2062,7 +2053,7 @@ static void testZeroAttackTakeoverNeverExceedsFullScale() {
s.play.adsr.attackFrames = 0; // zero-attack: amp == 1 on the very first frame
s.play.adsr.sustainLevel = 1.0;
s.play.adsr.releaseFrames = 0;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger,
/*takeoverDeclick=*/true);
@@ -2107,7 +2098,7 @@ static double maxDeltaAcross(double lastPre, const std::vector<AudioSample>& pos
static void testMonoRetrigTriggerZoneDeclicksRestart() {
SampleData s = sineSample(48000, 100.0, 60); // period 480 frames; slope <= ~0.013/frame
s.play.playMode = PlayMode::Trigger; // default fades: NO fade-in -> amp 1 at frame 0
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger,
/*takeoverDeclick=*/true);
@@ -2134,7 +2125,7 @@ static void testZeroAttackGateRetrigNoStep() {
s.play.adsr.attackFrames = 0; // instant-unity attack
s.play.adsr.sustainLevel = 1.0;
s.play.adsr.releaseFrames = 0;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger,
/*takeoverDeclick=*/true);
@@ -2158,7 +2149,7 @@ static void testZeroAttackGateRetrigNoStep() {
// preview's exact shape (same note, root, full pool of 1).
static void testPreviewReauditionDeclicksViaEngineSteal() {
SampleData s = sineSample(48000, 100.0, 60); // default ADSR: instant unity (worst case)
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km, 0, 0, VoiceMode::Poly, MonoTrigger::Retrigger,
/*takeoverDeclick=*/true);
@@ -2186,7 +2177,7 @@ static void testOverCapChordStealsExactlyOne() {
s.play.adsr.sustainLevel = 1.0;
s.play.adsr.releaseFrames = 2880; // 60 ms @ 48k
s.play.pitchEngine = PitchEngine::Preserve;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
// Mirrors the processor: kPreserveVoiceCap = 8, 50 ms OLA window at 48k = 2400 frames.
VoiceEngine eng(3, km, /*preserveVoiceCap=*/8, /*preserveWindowFrames=*/2400);
@@ -2238,7 +2229,7 @@ static void testOverCapChordStealsExactlyOne() {
// path. This is the processor's mailbox-drain contract, pinned in the pure core.
static void testPreviewNoteObeysVoicing() {
SampleData s = dcLevelSample(200000, 1.0f, 60);
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(2, km);
eng.noteOn(60, 127);
eng.noteOn(62, 127); // the pool is now FULL
@@ -2260,11 +2251,11 @@ static void testPreviewNoteObeysVoicing() {
// Pins the processor's mailbox-drain contract for Mono the way testPreviewNoteObeysVoicing
// pins it for Poly steal.
static void testPreviewNoteJoinsMonoHeldStack() {
Keymap km = twoLevelKeymap();
SampleData km = twoLevelSample();
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
CHECK(eng.noteOn(50, 127) == 0); // the host-MIDI note: zone A sounds
CHECK(eng.noteOn(50, kVelLow) == 0); // the host-MIDI note: zone A sounds
CHECK(approx(probeFrame(eng), 0.25, 1e-6));
CHECK(eng.noteOn(70, 127) == 0); // the preview press: TAKES the voice
CHECK(eng.noteOn(70, kVelHigh) == 0); // the preview press: TAKES the voice
CHECK(eng.activeVoiceCount() == 1); // still mono — the preview is no side-car
CHECK(approx(probeFrame(eng), 0.75, 1e-6));
eng.noteOff(70); // preview release: FALLBACK to the held note
@@ -2281,10 +2272,10 @@ static void testPreviewNoteJoinsMonoHeldStack() {
// the drain engine must release its voice (otherwise the old-snapshot preview would
// sustain until the next reload hard-cut it).
static void testPreviewNoteOffRoutesToDrainEngine() {
Keymap km = twoLevelKeymap();
SampleData km = twoLevelSample();
VoiceEngine drainEng(2, km); // was live when the preview fired
VoiceEngine liveEng(2, km); // the post-reload fresh snapshot: no voices
CHECK(drainEng.noteOn(70, 127) != VoiceEngine::kNoVoice);
CHECK(drainEng.noteOn(70, kVelHigh) != VoiceEngine::kNoVoice);
CHECK(approx(probeFrame(drainEng), 0.75, 1e-6)); // the preview rings in the old snapshot
CHECK(liveEng.activeVoiceCount() == 0);
// The preview release, drained to BOTH engines like a host note-off:
@@ -2313,7 +2304,7 @@ static void testDeclickBoundedBlendNoOvershoot() {
const double kCycles = 6000.0; // period = 8 frames
SampleData s = sineSample(kFrames, kCycles, 60);
s.play.playMode = PlayMode::Trigger; // no fade-in -> amp 1 on frame 0 (worst case)
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger,
/*takeoverDeclick=*/true);
@@ -2402,7 +2393,7 @@ static void testPreserveTailFinalWindowGapFree() {
SampleData s = tailSine(frames, f0, 60);
s.play.pitchEngine = PitchEngine::Preserve; // Gate, no loop -> runs to the sample end
s.play.adsr = flatAdsr(); // held: amp 1 to the end (isolates the DSP)
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km, /*preserveCap=*/0, static_cast<std::int64_t>(w));
eng.noteOn(note, 127);
std::vector<AudioSample> out;
@@ -2430,7 +2421,7 @@ static void testPreserveTailReleaseContinuous() {
s.play.pitchEngine = PitchEngine::Preserve;
s.play.adsr = flatAdsr();
s.play.adsr.releaseFrames = static_cast<std::int64_t>(w); // release spans the final window
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km, /*preserveCap=*/0, static_cast<std::int64_t>(w));
eng.noteOn(67, 127);
std::vector<AudioSample> out;
@@ -2462,7 +2453,7 @@ static void testPreserveTriggerTailGapFree() {
s.play.pitchEngine = PitchEngine::Preserve;
s.play.playMode = PlayMode::Trigger;
s.play.trigger.lengthFraction = 0.8; // playEnd = 6554 (~40 exact cycles: ends near zero)
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km, /*preserveCap=*/0, static_cast<std::int64_t>(w));
eng.noteOn(67, 127);
const std::size_t playEnd = 6554; // round(0.8 * 8192)
@@ -2497,7 +2488,7 @@ static void testPreservePrimeStopsAtTriggerPlayEnd() {
s.play.playMode = PlayMode::Trigger;
s.play.pitchEngine = PitchEngine::Preserve;
s.play.trigger.lengthFraction = 0.0625; // exactly 500 / 8000
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km, /*preserveCap=*/0, static_cast<std::int64_t>(w));
eng.noteOn(72, 127); // +1 octave: the tap outruns the read head into
// the deepest primed history the ring holds
@@ -2525,7 +2516,7 @@ static void testPreserveSubWindowSampleNoZeroPadInRing() {
SampleData s = tailSine(frames, f0, 60);
s.play.pitchEngine = PitchEngine::Preserve;
s.play.adsr = flatAdsr();
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km, /*preserveCap=*/0, static_cast<std::int64_t>(w));
eng.noteOn(72, 127); // +1 octave up-shift (tap sweeps the whole ring)
std::vector<AudioSample> out;
@@ -2538,9 +2529,9 @@ static void testPreserveSubWindowSampleNoZeroPadInRing() {
}
int main() {
testChromaticSingleRoot();
testZonedRangesBoundaries();
testFirstMatchOnOverlap();
testEveryKeyPlaysTheLoadedCapture();
testUnplayableCaptureRefusesEveryNote();
testOutOfRangeNotesAreRefusedInMono();
testPitchRatioMath();
testKeyTrackedRatioMath();
testRepitchObservedPeriod();
@@ -2551,7 +2542,6 @@ int main() {
testAdsrZeroAttackDecay();
testPolyphonicAllocation();
testNoteOffReleasesNewestSameNote();
testOutOfZoneNoteConsumesNoVoice();
testStealsReleasingVoiceFirst();
testStealsOldestWhenNoneReleasing();
testLoopSustainSeamless();
@@ -2611,11 +2601,11 @@ int main() {
testMonoReleaseOfLowerHeldNoteIsInaudible();
testMonoRepressHeldNoteMovesToTop();
testMonoRetriggerFallbackUsesOriginalVelocity();
testMonoOutOfZoneNeverJoinsStack();
testMonoOutOfRangeNeverJoinsStack();
testMonoRetriggerRestartsEnvelope();
testMonoLegatoContinuesEnvelope();
testMonoLegatoRetunesWithoutReadRestart();
testMonoLegatoCrossSampleRestarts();
testMonoLegatoAlwaysGlidesWithinThePhrase();
testMonoLegatoAfterReleaseReattacks();
testMonoIgnoresPreserveCap();
testMonoLegatoTriggerReattacksAfterKeyUp();