Retire the zone system: one capture = one parameter set, and re-seam the engine and Sample face into bands
This commit is contained in:
@@ -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
@@ -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 : {¤t, &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;
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+177
-187
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user