instrument: one VELOCITY deck for all three velocity curves, bipolar and off by default for pitch and filter

Payload v12 appends the new velocity->pitch curve and folds the retired filter velAmount into its now-bipolar curve, so pre-v12 projects reopen sounding identical. Preview button takes a drawn play triangle.
This commit is contained in:
2026-07-31 19:15:17 -04:00
parent 4fecb58c0a
commit 9d38f87a2d
37 changed files with 1020 additions and 320 deletions
+146 -32
View File
@@ -12,6 +12,7 @@
#include "../src/core/instrument/engine/master_gain.h" // masterGainMaxLinear (the v8 wire cap)
#include "../src/core/util/curve_law.h" // kCurveNeutral (the migration neutral)
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <cstring>
@@ -134,7 +135,7 @@ static void putRecordBody(std::vector<std::uint8_t>& out, const Zone& z, std::ui
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); }
for (const VelocityPoint& p : pts) { f64v(out, p.velocity); f64v(out, p.value); }
}
}
@@ -281,7 +282,8 @@ static void testComponentStateRoundTrip() {
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}});
{VelocityPoint{0.0, 0.2}, VelocityPoint{64.0, 0.6}, VelocityPoint{127.0, 1.0}},
reasampler::instrument::engine::CurveDomain::Unipolar);
in.params.play.playMode = PlayMode::Trigger;
in.params.play.adsr.attackSeconds = 0.01;
in.params.play.adsr.holdSeconds = 0.05;
@@ -422,7 +424,8 @@ static void testGoldenFullBlobFixture() {
in.params.startPoint = 250;
in.params.keyTrack = 0.5;
in.params.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(
{VelocityPoint{0.0, 0.2}, VelocityPoint{64.0, 0.6}, VelocityPoint{127.0, 1.0}});
{VelocityPoint{0.0, 0.2}, VelocityPoint{64.0, 0.6}, VelocityPoint{127.0, 1.0}},
reasampler::instrument::engine::CurveDomain::Unipolar);
in.params.play.playMode = PlayMode::Trigger;
in.params.play.adsr.attackSeconds = 0.01;
in.params.play.adsr.holdSeconds = 0.05;
@@ -450,7 +453,7 @@ static void testGoldenFullBlobFixture() {
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,0x0b,0x00,0x00,
0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x0c,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,
@@ -472,18 +475,18 @@ static void testGoldenFullBlobFixture() {
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // driveNorm 0.0
0x00, // morphLaw = HighBandLow
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // modAmount 0.0
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // velAmount 0.0
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // velAmount slot: frozen constant 1.0
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // keyTrack 0.0
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // env attack 0.0
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // env hold 0.0
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // env decay 0.0
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // env sustain 1.0
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // env release 0.0
0x02,0x00,0x00,0x00, // filter curve: 2 points (linear)
0x02,0x00,0x00,0x00, // filter curve: 2 points (flat at zero)
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // velocity 0.0
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // amp 0.0
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // value 0.0
0x00,0x00,0x00,0x00,0x00,0xc0,0x5f,0x40, // velocity 127.0
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // amp 1.0
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // value 0.0
// --- payload v10 staged-curve tail, at its NEUTRAL default (this fixture sets no
// curve or AHD field), in the header's documented order ---
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // amp attack curve 1.0
@@ -507,6 +510,12 @@ static void testGoldenFullBlobFixture() {
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // filt AHD dec curve 1.0
// --- payload v11 loop-crossfade tail ---
0x00,0x01,0x00,0x00,0x00,0x00,0x00,0x00, // loopCrossfadeFrames 256
// --- payload v12 velocity->pitch curve, at its off default (flat at zero) ---
0x02,0x00,0x00,0x00, // 2 points
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // velocity 0.0
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // value 0.0
0x00,0x00,0x00,0x00,0x00,0xc0,0x5f,0x40, // velocity 127.0
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // value 0.0
};
// clang-format on
CHECK(bytes.size() == sizeof(kGolden));
@@ -554,16 +563,17 @@ static void testEnvelopePrefixBytesFrozen() {
CHECK(bytes[4] == 0); // ChannelMode::Mono
}
CHECK(kComponentStateVersion == 11);
CHECK(kParamsPayloadVersion == 11);
CHECK(kParamsPayloadVersion == 12);
CHECK(kParamsSingleRecordVersion == 8);
CHECK(kParamsFormatMarker == 0xFFFFFF00u);
// The filter, staged-curve and loop tails rode PAYLOAD bumps, not envelope ones — the two
// axes stay independent, so a future envelope field cannot collide with any of them on one
// number.
// The filter, staged-curve, loop and velocity tails rode PAYLOAD bumps, not envelope ones
// — the two axes stay independent, so a future envelope field cannot collide with any of
// them on one number.
CHECK(kParamsFilterVersion > kParamsSingleRecordVersion);
CHECK(kParamsCurveVersion > kParamsFilterVersion);
CHECK(kParamsLoopVersion > kParamsCurveVersion);
CHECK(kParamsPayloadVersion == kParamsLoopVersion);
CHECK(kParamsVelocityVersion > kParamsLoopVersion);
CHECK(kParamsPayloadVersion == kParamsVelocityVersion);
}
// --- The filter tail (payload v9) --------------------------------------------
@@ -601,7 +611,7 @@ static void testV8RecordLiftsToTheOffNeutralFilter() {
CHECK(f.settings.driveNorm == def.settings.driveNorm);
CHECK(f.settings.morphLaw == reasampler::instrument::engine::filter::MorphLaw::HighBandLow);
CHECK(f.modAmount == 0.0);
CHECK(f.velAmount == 0.0);
for (int v = 0; v <= 127; ++v) CHECK(f.velocityCurve.eval(v) == 0.0);
CHECK(f.keyTrack == 0.0);
CHECK(f.env.sustainLevel == 1.0);
CHECK(f.env.attackSeconds == 0.0 && f.env.decaySeconds == 0.0 &&
@@ -628,15 +638,17 @@ static void testFilterTailRoundTripsLosslessly() {
f.settings.driveNorm = 0.5f;
f.settings.morphLaw = reasampler::instrument::engine::filter::MorphLaw::HighNotchLow;
f.modAmount = -0.625;
f.velAmount = 0.5;
f.keyTrack = 1.5;
f.env.attackSeconds = 0.031;
f.env.holdSeconds = 0.062;
f.env.decaySeconds = 0.125;
f.env.sustainLevel = 0.25;
f.env.releaseSeconds = 0.5;
// Bipolar, and reaching into the negative half the retired unipolar shape could not
// express: a codec that read this back through the old domain would clamp it to 0.
f.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(
{VelocityPoint{0.0, 0.1}, VelocityPoint{100.0, 0.4}, VelocityPoint{127.0, 0.9}});
{VelocityPoint{0.0, -0.75}, VelocityPoint{100.0, 0.4}, VelocityPoint{127.0, 0.9}},
reasampler::instrument::engine::CurveDomain::Bipolar);
// The amp's own curve stays different, so a codec that read one into the other fails here.
in.params.velocityCurve = reasampler::instrument::engine::VelocityCurve::flat();
@@ -650,7 +662,6 @@ static void testFilterTailRoundTripsLosslessly() {
CHECK(g.settings.driveNorm == f.settings.driveNorm);
CHECK(g.settings.morphLaw == reasampler::instrument::engine::filter::MorphLaw::HighNotchLow);
CHECK(g.modAmount == f.modAmount);
CHECK(g.velAmount == f.velAmount);
CHECK(g.keyTrack == f.keyTrack);
CHECK(g.env.attackSeconds == f.env.attackSeconds);
CHECK(g.env.holdSeconds == f.env.holdSeconds);
@@ -658,11 +669,41 @@ static void testFilterTailRoundTripsLosslessly() {
CHECK(g.env.sustainLevel == f.env.sustainLevel);
CHECK(g.env.releaseSeconds == f.env.releaseSeconds);
CHECK(g.velocityCurve.size() == 3);
CHECK(g.velocityCurve.equals(f.velocityCurve));
CHECK(g.velocityCurve.domain() == reasampler::instrument::engine::CurveDomain::Bipolar);
CHECK(g.velocityCurve.eval(0.0) == -0.75); // the negative half survives the round trip
CHECK(g.velocityCurve.eval(100.0) == 0.4);
CHECK(g.velocityCurve.eval(127.0) == 0.9);
CHECK(out.params.velocityCurve.equals(
reasampler::instrument::engine::VelocityCurve::flat()));
}
// The velocity->PITCH curve (payload v12) is a third, independent slot: it round-trips whole,
// and neither of the other two leaks into it.
static void testPitchVelocityCurveRoundTripsIndependently() {
ComponentState in;
in.selectionId = "pad";
in.params.play.pitchVelocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(
{VelocityPoint{0.0, -1.0}, VelocityPoint{64.0, 0.25}, VelocityPoint{127.0, 0.5}},
reasampler::instrument::engine::CurveDomain::Bipolar);
in.params.play.filter.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(
{VelocityPoint{0.0, 0.2}, VelocityPoint{127.0, -0.6}},
reasampler::instrument::engine::CurveDomain::Bipolar);
in.params.velocityCurve = reasampler::instrument::engine::VelocityCurve::linear();
const ComponentState out =
deserializeComponentState(serializeComponentState(in), 48000.0);
const reasampler::instrument::engine::VelocityCurve& p = out.params.play.pitchVelocityCurve;
CHECK(p.size() == 3);
CHECK(p.domain() == reasampler::instrument::engine::CurveDomain::Bipolar);
CHECK(p.eval(0.0) == -1.0);
CHECK(p.eval(64.0) == 0.25);
CHECK(p.eval(127.0) == 0.5);
// The other two slots kept their own values — no cross-talk between the three curves.
CHECK(out.params.play.filter.velocityCurve.eval(127.0) == -0.6);
CHECK(out.params.velocityCurve.eval(127.0) == 1.0);
CHECK(out.params.velocityCurve.eval(0.0) == 0.0);
}
// A non-finite modAmount/velAmount/keyTrack (a corrupt blob, or any writer that skipped the
// same guard the v8 master gain already applies) must lift to the neutral default rather than
// reach Voice::tickFilterCutoff, where both clamp compares are false against NaN and the
@@ -673,7 +714,6 @@ static void testNonFiniteFilterFieldsLiftToTheNeutralDefault() {
FilterSeconds& f = in.params.play.filter;
f.enabled = true;
f.modAmount = std::numeric_limits<double>::quiet_NaN();
f.velAmount = std::numeric_limits<double>::infinity();
f.keyTrack = -std::numeric_limits<double>::infinity();
const ComponentState out =
@@ -681,7 +721,6 @@ static void testNonFiniteFilterFieldsLiftToTheNeutralDefault() {
const FilterSeconds& g = out.params.play.filter;
const FilterSeconds def;
CHECK(g.modAmount == def.modAmount);
CHECK(g.velAmount == def.velAmount);
CHECK(g.keyTrack == def.keyTrack);
// The fallback is per-field, not per-record: the untouched fields still round-trip.
CHECK(g.enabled);
@@ -747,6 +786,7 @@ static void testNegativeCrossfadeOnTheWireLiftsToZero() {
// output with version N stamped in and the (N+1..current) tails cut. Building the older blobs
// that way exercises the tolerant-reader path rather than assuming it: if a tail ever stopped
// being a pure suffix, these would decode as garbage instead of as the documented lift.
static const std::size_t kVelocityTailBytes = 4 + 2 * 2 * 8; // v12: the 2-pt pitch curve
static const std::size_t kLoopTailBytes = 8; // v11: crossfade, one int64
static const std::size_t kCurveTailBytes = 19 * 8; // v10: nineteen doubles
static const std::size_t kFilterTailBytes =
@@ -775,8 +815,30 @@ static std::vector<std::uint8_t> payloadDowngradedTo(const ComponentState& state
return bytes;
}
// A project saved before this change reopens sounding identical: its loop span still applies
// and its seam is still hard, at EVERY prior single-record version.
// Overwrite the frozen filter velAmount slot: the writer emits a constant 1.0 there now, so a
// pre-v12 fixture has to plant its own depth. Located by the DISTINCT modAmount immediately
// preceding it rather than by a byte offset, so a tail growing ahead of it cannot rot this.
static void plantPreV12FilterDepth(std::vector<std::uint8_t>& bytes, double modAmount,
double velAmount) {
std::vector<std::uint8_t> needle;
legacy::f64v(needle, modAmount);
std::size_t at = 0;
int hits = 0;
for (std::size_t i = 0; i + 2 * needle.size() <= bytes.size(); ++i) {
if (std::equal(needle.begin(), needle.end(), bytes.begin() + static_cast<long>(i))) {
at = i;
++hits;
}
}
CHECK(hits == 1); // an ambiguous anchor would plant the depth in the wrong slot
std::vector<std::uint8_t> depth;
legacy::f64v(depth, velAmount);
for (std::size_t k = 0; k < depth.size(); ++k) bytes[at + needle.size() + k] = depth[k];
}
// A project saved before this change reopens sounding identical: its loop span still applies,
// its seam is still hard, and velocity still modulates pitch not at all, at EVERY prior
// single-record version.
static void testPriorPayloadVersionsLiftToAHardSeam() {
ComponentState in;
in.selectionId = "pad";
@@ -792,6 +854,11 @@ static void testPriorPayloadVersionsLiftToAHardSeam() {
// it — proving the cuts land where the ladder says they do.
in.params.play.adsr.attackCurve = 4.0;
in.params.loopCrossfadeFrames = 777; // present in the bytes only at v11
// Present in the bytes only at v12: an off-default pitch curve, so a lift that leaked one
// in from anywhere else fails rather than coincidentally matching the default.
in.params.play.pitchVelocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(
{VelocityPoint{0.0, 0.5}, VelocityPoint{127.0, 1.0}},
reasampler::instrument::engine::CurveDomain::Bipolar);
struct Case {
std::uint32_t pv;
@@ -799,9 +866,10 @@ static void testPriorPayloadVersionsLiftToAHardSeam() {
bool keepsCurveTail;
};
const Case cases[] = {
{10, kLoopTailBytes, true},
{9, kLoopTailBytes + kCurveTailBytes, false},
{8, kLoopTailBytes + kCurveTailBytes + kFilterTailBytes, false},
{11, kVelocityTailBytes, true},
{10, kVelocityTailBytes + kLoopTailBytes, true},
{9, kVelocityTailBytes + kLoopTailBytes + kCurveTailBytes, false},
{8, kVelocityTailBytes + kLoopTailBytes + kCurveTailBytes + kFilterTailBytes, false},
};
for (const Case& c : cases) {
const ComponentState out =
@@ -813,14 +881,57 @@ static void testPriorPayloadVersionsLiftToAHardSeam() {
CHECK(out.params.startPoint && *out.params.startPoint == 128);
CHECK(out.params.keyTrack == 0.5);
CHECK(out.params.play.adsr.releaseSeconds == 0.25);
// The documented pre-change behaviour: a hard seam.
CHECK(out.params.loopCrossfadeFrames == 0);
// The documented pre-change behaviour: a hard seam and no velocity->pitch at all.
CHECK(out.params.loopCrossfadeFrames == (c.pv >= 11 ? 777 : 0));
for (int v = 0; v <= 127; ++v) {
CHECK(out.params.play.pitchVelocityCurve.eval(v) == 0.0);
}
// And the cut landed on the tail boundary the ladder claims, not somewhere inside it.
CHECK(out.params.play.adsr.attackCurve ==
(c.keepsCurveTail ? 4.0 : reasampler::util::kCurveNeutral));
}
}
// The sharp edge of the bipolar change: a pre-v12 blob stored the filter's velocity response
// as a [0,1] SHAPE times a separate depth, and the lift folds that depth into the knots. The
// lifted curve must evaluate to exactly the product the pre-change voice computed.
static void testPreV12FilterVelocityDepthFoldsIntoTheCurve() {
ComponentState in;
in.selectionId = "pad";
FilterSeconds& f = in.params.play.filter;
f.enabled = true;
f.modAmount = -0.6251953125; // distinct and exactly representable: the planting anchor
// What an old blob's curve looked like: a shape confined to [0,1], with the sign and the
// amount living in the depth beside it.
const reasampler::instrument::engine::VelocityCurve shape =
reasampler::instrument::engine::VelocityCurve::fromPoints(
{VelocityPoint{0.0, 0.0}, VelocityPoint{64.0, 0.25}, VelocityPoint{127.0, 1.0}},
reasampler::instrument::engine::CurveDomain::Bipolar);
f.velocityCurve = shape;
for (const double depth : {-0.75, 0.5, 0.0}) {
std::vector<std::uint8_t> bytes =
payloadDowngradedTo(in, kParamsLoopVersion, kVelocityTailBytes);
plantPreV12FilterDepth(bytes, f.modAmount, depth);
const ComponentState out = deserializeComponentState(bytes, 48000.0);
const reasampler::instrument::engine::VelocityCurve& lifted =
out.params.play.filter.velocityCurve;
CHECK(lifted.domain() == reasampler::instrument::engine::CurveDomain::Bipolar);
for (int v = 0; v <= 127; ++v) {
CHECK(std::fabs(lifted.eval(v) - depth * shape.eval(v)) < 1e-12);
}
CHECK(out.params.play.filter.modAmount == f.modAmount); // the anchor itself survives
}
// At v12 the same slot is ignored: the curve is read verbatim, whatever sits in it.
std::vector<std::uint8_t> current = serializeComponentState(in);
plantPreV12FilterDepth(current, f.modAmount, 0.0);
const ComponentState now = deserializeComponentState(current, 48000.0);
for (int v = 0; v <= 127; ++v) {
CHECK(std::fabs(now.params.play.filter.velocityCurve.eval(v) - shape.eval(v)) < 1e-12);
}
}
// 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.
@@ -1306,11 +1417,12 @@ static void testSampleRefsTruncatedMidEntry() {
// The tail after the refs table is instanceGuid(4, empty) + selectionId(4+4="kick") +
// the current params payload for DEFAULT params (marker4+version4 + overrides3 + the
// 91-byte play tail + keyTrack8 + curve(4+2*16, the flat 2-point default) + the 134-byte
// v9 filter tail + the 160-byte v10 staged-curve tail) = 452 bytes; entry two is 47 bytes
// (id 4+3, path 4+7, root4, loop 1+8+8, channels4, name 4+0). Cutting 472 keeps the first
// 27 of entry two's 47 — mid loop.start (offset 23..31).
CHECK(bytes.size() > 472);
bytes.resize(bytes.size() - 472);
// v9 filter tail + the 152-byte v10 staged-curve tail + the 8-byte v11 crossfade + the
// 36-byte v12 pitch curve) = 488 bytes; entry two is 47 bytes (id 4+3, path 4+7, root4,
// loop 1+8+8, channels4, name 4+0). Cutting 508 keeps the first 27 of entry two's 47 —
// mid loop.start (offset 23..31).
CHECK(bytes.size() > 508);
bytes.resize(bytes.size() - 508);
const ComponentState back = deserializeComponentState(bytes, 44100.0);
CHECK(back.sampleRefs.size() == 1);
CHECK(back.sampleRefs.size() == 1 && back.sampleRefs[0].sampleId == "kick");
@@ -1393,6 +1505,7 @@ int main() {
testLoopSpanAndCrossfadeRoundTrip();
testNegativeCrossfadeOnTheWireLiftsToZero();
testPriorPayloadVersionsLiftToAHardSeam();
testPreV12FilterVelocityDepthFoldsIntoTheCurve();
testWriterEmitsCurrentPayloadVersion();
testSingleZoneMigrationIsLossless();
testMigratedFadeContourTracksTheRetiredEqualPowerShape();
@@ -1415,6 +1528,7 @@ int main() {
testTruncationDegradesCleanly();
testV8RecordLiftsToTheOffNeutralFilter();
testFilterTailRoundTripsLosslessly();
testPitchVelocityCurveRoundTripsIndependently();
testNonFiniteFilterFieldsLiftToTheNeutralDefault();
testNonFiniteAhdSecondsLiftToZero();
if (failures == 0) {
+37
View File
@@ -6,10 +6,14 @@
#include "../src/core/instrument/ui/curve_popup.h"
#include "../src/core/instrument/engine/velocity_curve.h"
#include <cstdio>
using namespace reasampler;
using namespace reasampler::instrument::ui;
using reasampler::instrument::engine::CurveDomain;
using reasampler::instrument::engine::VelocityCurve;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
@@ -80,7 +84,40 @@ static void testOutsideSheetDismissTest() {
CHECK(!popupOutsideSheet(pl, pl.sheet.right() - 1, pl.sheet.bottom() - 1));
}
// The sheet hosts all three curves, and its box is domain-agnostic: the SAME curveBox drives a
// unipolar and a bipolar editor, and only the curve's own y map differs. Asserted here, against
// the popup's real geometry, because that is what makes one popup code path legitimate.
static void testTheSameCurveBoxHostsBothDomains() {
const CurvePopupLayout pl = computeCurvePopup(840, 620);
// The shell insets this rect before mapping; the inset is uniform, so any box inside the
// curveBox exercises the same relationship. Use the rect itself.
const VelocityCurve::Box box{pl.curveBox.x, pl.curveBox.y, pl.curveBox.width,
pl.curveBox.height};
CHECK(box.width > 1 && box.height > 1);
const VelocityCurve amp = VelocityCurve::flat();
const VelocityCurve mod = VelocityCurve::zero();
const int top = box.top;
const int bottom = box.top + box.height - 1;
// Both domains put their MAX on the top row and their MIN on the bottom row...
CHECK(amp.pixelFromPoint(box, {0.0, 1.0}).y == top);
CHECK(amp.pixelFromPoint(box, {0.0, 0.0}).y == bottom);
CHECK(mod.pixelFromPoint(box, {0.0, 1.0}).y == top);
CHECK(mod.pixelFromPoint(box, {0.0, -1.0}).y == bottom);
// ...so value 0 is the FLOOR for the amp curve and the MIDLINE for a modulation curve.
CHECK(mod.pixelFromPoint(box, {0.0, 0.0}).y == (top + bottom) / 2);
// And a click at the vertical centre adds a point at 0 in the bipolar editor, at 0.5 in
// the unipolar one — one hit-test path, two correct answers.
const int midY = (top + bottom) / 2;
CHECK(mod.pointFromPixel(box, box.left, midY).value == 0.0);
CHECK(amp.pointFromPixel(box, box.left, midY).value > 0.49);
CHECK(amp.pointFromPixel(box, box.left, midY).value < 0.51);
}
int main() {
testTheSameCurveBoxHostsBothDomains();
testDefaultWindowMidClamp();
testMinClamp();
testMaxClamp();
+78 -13
View File
@@ -1,7 +1,9 @@
// Standalone tests for reasampler::instrument::ui::deck_groups — no VST3, no REAPER, no
// framework. knob_deck's own tests pin how a descriptor list LAYS OUT; these pin WHICH
// descriptors the Sample face carries: the signal-flow group order (pitch -> filter -> amp),
// the Filter group's contents, the wrapped deck height at the editor's floor width and its fit
// the Filter group's contents, the VELOCITY group's exclusive ownership of the three curve
// cells and its placement immediately left of VOICE, the wrapped deck height at the editor's
// floor width and its fit
// inside the floor window, the hit-test reaching the new filter controls, the bipolar knob
// law's inverse pair, the commit-tier routing — which controls are live, and which drags take
// the live tier — and the overlay-selection state machine (exclusivity, the none resting state,
@@ -48,20 +50,77 @@ static void testDeckReadsPitchThenFilterThenAmpLeftToRight() {
CHECK(penv < filt);
CHECK(filt < fenv);
CHECK(fenv < amp);
// The two instance-wide groups stay at the end.
CHECK(amp < indexOfGroup(g, kGroupVoice));
// VELOCITY then the two instance-wide groups at the end. Velocity sits IMMEDIATELY
// left of VOICE — MASTER is reserved for post-voice-mixer concerns, so the curves
// must not drift into it.
const int vel = indexOfGroup(g, kGroupVelocity);
CHECK(amp < vel);
CHECK(vel + 1 == indexOfGroup(g, kGroupVoice));
CHECK(indexOfGroup(g, kGroupVoice) < indexOfGroup(g, kGroupMaster));
}
}
static void testFilterGroupCarriesItsFiveToneControlsPlusModulation() {
// The three velocity curves live together in VELOCITY and nowhere else: no other group may
// carry a curve cell, or the "one home" the group exists for is not one.
static void testVelocityGroupOwnsTheThreeCurvesExclusively() {
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
const DeckGroupDesc& v =
g[static_cast<std::size_t>(indexOfGroup(g, kGroupVelocity))];
const std::vector<int> expected = {cell(DeckParam::kAmpVelCurve),
cell(DeckParam::kPitchVelCurve),
cell(DeckParam::kFilterVelCurve)};
CHECK(v.cellIds == expected);
CHECK(v.captionToggle.id == -1 && v.rowToggle.id == -1 && v.captionRadio.id == -1);
for (const DeckGroupDesc& d : g) {
if (d.id == kGroupVelocity) continue;
for (int id : d.cellIds) CHECK(curveTargetFor(id) == CurveTarget::kNone);
CHECK(curveTargetFor(d.captionToggle.id) == CurveTarget::kNone);
CHECK(curveTargetFor(d.rowToggle.id) == CurveTarget::kNone);
}
}
}
// Each curve cell names its OWN destination, and an ordinary knob names none — the predicate
// the shell uses to tell a popup opener from a dial.
static void testCurveTargetNamesEachCellsOwnDestination() {
CHECK(curveTargetFor(cell(DeckParam::kAmpVelCurve)) == CurveTarget::kAmp);
CHECK(curveTargetFor(cell(DeckParam::kPitchVelCurve)) == CurveTarget::kPitch);
CHECK(curveTargetFor(cell(DeckParam::kFilterVelCurve)) == CurveTarget::kFilter);
CHECK(curveTargetFor(cell(DeckParam::kFilterCutoff)) == CurveTarget::kNone);
CHECK(curveTargetFor(cell(DeckParam::kMasterGain)) == CurveTarget::kNone);
CHECK(curveTargetFor(-1) == CurveTarget::kNone); // a blank reserved cell
CHECK(curveTargetFor(9999) == CurveTarget::kNone); // out of the id space
}
// The cells hit-test inside their own group, from the centre of each cell — the deck grammar
// treats them as knob cells, so the popup routing rides an ordinary Knob hit.
static void testVelocityCellsHitTestWithinTheirGroup() {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
const DeckLayout dl = layoutDeck(g, kPad, 40, kAvailAtMinWidth);
const DeckGroupLayout& v =
dl.groups[static_cast<std::size_t>(indexOfGroup(g, kGroupVelocity))];
CHECK(v.cells.size() == 3);
const CurveTarget want[] = {CurveTarget::kAmp, CurveTarget::kPitch, CurveTarget::kFilter};
for (std::size_t i = 0; i < v.cells.size(); ++i) {
const DeckCellLayout& c = v.cells[i];
const DeckHit hit = hitTestDeck(dl, c.cell.x + c.cell.width / 2,
c.cell.y + c.cell.height / 2);
CHECK(hit.kind == DeckHitKind::Knob);
CHECK(hit.id == c.id);
CHECK(curveTargetFor(hit.id) == want[i]);
// Inside its own group box, and the cell the hit resolved is this one.
CHECK(c.cell.x >= v.box.x && c.cell.right() <= v.box.right());
}
}
static void testFilterGroupCarriesItsToneControlsPlusModulation() {
const std::vector<DeckGroupDesc>& g = sampleDeckGroups(PlayMode::Gate);
const DeckGroupDesc& f = g[static_cast<std::size_t>(indexOfGroup(g, kGroupFilter))];
const std::vector<int> expected = {
cell(DeckParam::kFilterMorph), cell(DeckParam::kFilterCutoff),
cell(DeckParam::kFilterQ), cell(DeckParam::kFilterDrive),
cell(DeckParam::kFilterModAmt), cell(DeckParam::kFilterVel),
cell(DeckParam::kFilterKeyTrack)};
cell(DeckParam::kFilterModAmt), cell(DeckParam::kFilterKeyTrack)};
CHECK(f.cellIds == expected);
// Off by default is a state question, but reachability is a layout one: the enable
// toggle is in the caption row and the morph law in the knob row.
@@ -177,10 +236,12 @@ static void testAmpGroupWidthSurvivesAGateTriggerFlip() {
static void testWrappedDeckHeightAtTheEditorFloorWidth() {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
// At the floor (== default) 840 the deck takes two rows: PITCH + PITCH ENV + FILTER fill
// the first, the remaining four fit the second.
CHECK(deckRowCount(g, kAvailAtMinWidth) == 2);
CHECK(deckHeight(g, kAvailAtMinWidth) == 2 * kDeckGroupH + kDeckRowGap);
// At the floor (== default) 840 the deck takes three rows: PITCH + PITCH ENV + FILTER fill
// the first, FILTER ENV + AMP + VELOCITY the second, VOICE + MASTER the third. The eight
// groups total more than two rows can hold at this width — the VELOCITY group's three
// cells are ~150 px more than the FILTER group gave back when its velocity depth retired.
CHECK(deckRowCount(g, kAvailAtMinWidth) == 3);
CHECK(deckHeight(g, kAvailAtMinWidth) == 3 * kDeckGroupH + 2 * kDeckRowGap);
// Whole groups only, never split: every group's box lies inside the available width or is
// the first of its row.
@@ -224,7 +285,7 @@ static void testHitTestResolvesTheNewFilterControls() {
CHECK(hit.kind == DeckHitKind::Knob);
CHECK(hit.id == c.id);
}
CHECK(f.cells.size() == 7);
CHECK(f.cells.size() == 6);
CHECK(f.cells[1].id == cell(DeckParam::kFilterCutoff));
// The enable toggle's two segments and the morph-law row toggle's two.
@@ -298,7 +359,8 @@ static void testEveryDeckControlIsClassifiedLiveOrReloading() {
// is excluded.
const DeckParam reloads[] = {
DeckParam::kPlayMode, DeckParam::kPitchEngine, DeckParam::kPitchEnvEnable,
DeckParam::kFilterEnable, DeckParam::kFilterLaw, DeckParam::kFilterVel,
DeckParam::kFilterEnable, DeckParam::kFilterLaw,
DeckParam::kAmpVelCurve, DeckParam::kPitchVelCurve, DeckParam::kFilterVelCurve,
DeckParam::kKeyTrack, DeckParam::kTrigLength,
DeckParam::kAmpEnvSelect, DeckParam::kPitchEnvSelect, DeckParam::kFilterEnvSelect,
DeckParam::kVoiceCount, DeckParam::kVoiceMode,
@@ -405,7 +467,10 @@ int main() {
testEveryDeckControlIsClassifiedLiveOrReloading();
testOnlyALiveControlsDragTakesTheLiveTier();
testDeckReadsPitchThenFilterThenAmpLeftToRight();
testFilterGroupCarriesItsFiveToneControlsPlusModulation();
testVelocityGroupOwnsTheThreeCurvesExclusively();
testCurveTargetNamesEachCellsOwnDestination();
testVelocityCellsHitTestWithinTheirGroup();
testFilterGroupCarriesItsToneControlsPlusModulation();
testOnlyTheThreeEnvelopeDecksCarryARadio();
testGateAndTriggerFacesCarryTheirOwnShapes();
testOnlySlopedStageKnobsCarryAnInnerCurveDial();
+4 -5
View File
@@ -786,14 +786,13 @@ static void testPitchRatioAndVelocityGainStayLatched() {
static void testVelocityGainSurvivesAHostilePublishThatReallyLands() {
// Filter AND pitch envelope enabled, so every field the block carries actually reaches the
// voice. velAmount is 0, so velocity enters the render exactly once — as the amp gain
// latched at note-on — which makes two runs at different velocities exactly proportional
// unless the publish moved that gain (a re-derived gain would have to preserve the ratio
// 100:64 to slip through).
// voice. The filter and pitch velocity curves are left at their off defaults, so velocity
// enters the render exactly once — as the amp gain latched at note-on — which makes two
// runs at different velocities exactly proportional unless the publish moved that gain (a
// re-derived gain would have to preserve the ratio 100:64 to slip through).
SampleData rig = periodicSine(200000, 64.0);
rig.velocityCurve = VelocityCurve::linear();
filterSweep(rig);
rig.play.filter.velAmount = 0.0;
rig.play.pitchEnv.enabled = true;
rig.play.pitchEnv.shape.decayFrames = 24000;
rig.play.pitchEnv.peakSemitones = 3.0;
+40 -11
View File
@@ -2,10 +2,11 @@
// test framework.
//
// Covers: the chrome band's two rows (toolbar over strip row, tiling the band exactly); the
// toolbar's fixed right-anchored run in order (preview, velocity cell, curve button,
// Mono|Stereo, Browse) with the title taking the remainder; the velocity knob centred in its
// toolbar's fixed right-anchored run in order (preview, velocity cell, Mono|Stereo,
// Browse) with the title taking the remainder; the velocity knob centred in its
// cell above its label; the piano strip owning its whole row at every width; no rect on the
// toolbar overlapping any other; and degenerate bands yielding no inverted rects.
// toolbar overlapping any other; degenerate bands yielding no inverted rects; and the preview
// button's play-triangle glyph, which sits inside the button without changing its rect.
#include "../src/core/instrument/ui/sample_bands.h"
#include "../src/core/instrument/ui/sample_chrome.h"
@@ -48,20 +49,19 @@ static void testRowsTileTheBandExactly() {
static void testToolbarRunIsOrderedRightToLeftWithoutOverlap() {
const Rect band = chromeBand();
const ChromeRects r = chromeRects(band, kKnob);
// Rightmost first: Browse, stereo, mono, curve button, velocity cell, preview, title.
// Rightmost first: Browse, stereo, mono, velocity cell, preview, title.
CHECK(r.navBrowse.right() == band.right() - kPad);
CHECK(r.navBrowse.width == kNavButtonWidth);
CHECK(r.chanStereo.right() <= r.navBrowse.x);
CHECK(r.chanMono.right() == r.chanStereo.x);
CHECK(r.curveBtn.right() <= r.chanMono.x);
CHECK(r.velCell.right() <= r.curveBtn.x);
CHECK(r.velCell.right() <= r.chanMono.x);
CHECK(r.preview.right() <= r.velCell.x);
CHECK(r.title.right() <= r.preview.x);
CHECK(r.title.x == band.x + kPad);
CHECK(r.title.width > 0);
// Every toolbar rect sits inside the toolbar row.
const Rect items[] = {r.title, r.preview, r.velCell, r.curveBtn, r.chanMono,
const Rect items[] = {r.title, r.preview, r.velCell, r.chanMono,
r.chanStereo, r.navBrowse};
for (const Rect& it : items) {
CHECK(it.y >= r.toolbar.y && it.bottom() <= r.toolbar.bottom());
@@ -75,7 +75,7 @@ static void testChromePartsNeverOverlapAtAnyWidth() {
// stay inside its own row, clear of every control.
CHECK(!overlaps(r.toolbar, r.rootStrip));
CHECK(r.rootStrip.y >= r.controls.y && r.rootStrip.bottom() <= r.controls.bottom());
const Rect items[] = {r.preview, r.velCell, r.curveBtn, r.chanMono, r.chanStereo,
const Rect items[] = {r.preview, r.velCell, r.chanMono, r.chanStereo,
r.navBrowse};
for (const Rect& it : items) {
CHECK(!overlaps(it, r.rootStrip));
@@ -83,8 +83,8 @@ static void testChromePartsNeverOverlapAtAnyWidth() {
}
// The run's own members are pairwise disjoint (velKnob/velLabel are inside velCell,
// so they are checked against the cell's neighbours, not the cell).
for (int i = 0; i < 6; ++i) {
for (int j = i + 1; j < 6; ++j) CHECK(!overlaps(items[i], items[j]));
for (int i = 0; i < 5; ++i) {
for (int j = i + 1; j < 5; ++j) CHECK(!overlaps(items[i], items[j]));
}
}
}
@@ -127,11 +127,38 @@ static void testDegenerateBandYieldsNoInvertedRects() {
const ChromeRects tiny = chromeRects(Rect::ltrb(0, 0, 40, kTitleHeight + kChromeRowHeight),
kKnob);
const Rect items[] = {tiny.title, tiny.preview, tiny.velCell, tiny.velKnob, tiny.velLabel,
tiny.curveBtn, tiny.chanMono, tiny.chanStereo, tiny.navBrowse,
tiny.chanMono, tiny.chanStereo, tiny.navBrowse,
tiny.rootStrip};
for (const Rect& it : items) CHECK(it.right() >= it.x && it.bottom() >= it.y);
}
static void testPreviewGlyphSitsInsideTheButtonAndPointsRight() {
const ChromeRects r = chromeRects(chromeBand(), kKnob);
const PreviewGlyph g = previewGlyph(r.preview);
CHECK(!g.empty());
// Wholly inside the button — the glyph replaces the label, it does not resize the target.
CHECK(g.leftX >= r.preview.x && g.apexX <= r.preview.right());
CHECK(g.topY >= r.preview.y && g.bottomY <= r.preview.bottom());
// Right-pointing, and the apex on the button's own centre line so it reads as balanced.
CHECK(g.apexX > g.leftX);
CHECK(g.apexY == r.preview.y + r.preview.height / 2);
CHECK(g.apexY - g.topY == g.bottomY - g.apexY); // isosceles about the centre line
// The button's rect is what the hit-test uses, and the glyph must not have moved it: the
// preview button still sits at the run's fixed size, exactly where the text button did.
CHECK(r.preview.width == 64 && r.preview.height == 24);
}
static void testPreviewGlyphDegradesRatherThanOverflowing() {
// An unusably small button yields an empty glyph (draw nothing) rather than a triangle
// spilling past the button edge.
CHECK(previewGlyph(Rect{}).empty());
CHECK(previewGlyph(Rect::ltrb(0, 0, 6, 6)).empty());
// A tall, wide button caps the glyph instead of scaling without bound.
const PreviewGlyph big = previewGlyph(Rect::ltrb(0, 0, 400, 200));
CHECK(!big.empty());
CHECK(big.bottomY - big.topY <= 14);
}
static void testToolbarOnlyBandStillPlacesTheNav() {
// A band clipped to just the toolbar row: the strip row is empty but Browse still
// resolves, so the empty state's call-to-action is never unreachable.
@@ -149,6 +176,8 @@ int main() {
testStripOwnsItsWholeRowAndGrowsWithTheWindow();
testVelocityKnobIsCentredInItsCellAboveTheLabel();
testDegenerateBandYieldsNoInvertedRects();
testPreviewGlyphSitsInsideTheButtonAndPointsRight();
testPreviewGlyphDegradesRatherThanOverflowing();
testToolbarOnlyBandStillPlacesTheNav();
if (g_fail == 0) {
+25 -3
View File
@@ -638,7 +638,8 @@ static void testResolvePlayCarriesTheFilterAndResolvesOnlyItsEnvelope() {
st.filter.settings.driveNorm = 0.125f;
st.filter.settings.morphLaw = reasampler::instrument::engine::filter::MorphLaw::HighNotchLow;
st.filter.modAmount = -0.5;
st.filter.velAmount = 0.25;
st.filter.velocityCurve = VelocityCurve::fromPoints(
{{0.0, 0.0}, {127.0, 0.25}}, reasampler::instrument::engine::CurveDomain::Bipolar);
st.filter.keyTrack = 1.25;
st.filter.env.attackSeconds = 0.01;
st.filter.env.holdSeconds = 0.02;
@@ -654,7 +655,11 @@ static void testResolvePlayCarriesTheFilterAndResolvesOnlyItsEnvelope() {
CHECK(at48.filter.settings.driveNorm == 0.125f);
CHECK(at48.filter.settings.morphLaw == reasampler::instrument::engine::filter::MorphLaw::HighNotchLow);
CHECK(at48.filter.modAmount == -0.5);
CHECK(at48.filter.velAmount == 0.25);
// The transfer curve is dimensionless, so it crosses unchanged — asserted against the
// straight line the two stored knots describe, not against the stored object.
CHECK(at48.filter.velocityCurve.eval(0.0) == 0.0);
CHECK(approx(at48.filter.velocityCurve.eval(127.0), 0.25));
CHECK(approx(at48.filter.velocityCurve.eval(63.5), 0.125));
CHECK(at48.filter.keyTrack == 1.25);
CHECK(at48.filter.env.attackFrames == 480);
CHECK(at48.filter.env.holdFrames == 960);
@@ -672,9 +677,25 @@ static void testResolvePlayCarriesTheFilterAndResolvesOnlyItsEnvelope() {
const PlayParams bare = resolvePlay(PlaySeconds{}, 48000);
CHECK(!bare.filter.enabled);
CHECK(bare.filter.modAmount == 0.0);
CHECK(bare.filter.velAmount == 0.0);
for (int v = 0; v <= 127; ++v) CHECK(bare.filter.velocityCurve.eval(v) == 0.0);
CHECK(bare.filter.keyTrack == 0.0);
CHECK(bare.filter.env.sustainLevel == 1.0);
// The velocity->pitch curve rides the same boundary and is off by the same default.
for (int v = 0; v <= 127; ++v) CHECK(bare.pitchVelocityCurve.eval(v) == 0.0);
}
// The velocity->pitch curve is dimensionless like the filter's, so resolvePlay carries it
// across the seconds->frames boundary untouched at any rate.
static void testResolvePlayCarriesThePitchVelocityCurve() {
PlaySeconds st;
st.pitchVelocityCurve = VelocityCurve::fromPoints(
{{0.0, -1.0}, {127.0, 1.0}}, reasampler::instrument::engine::CurveDomain::Bipolar);
for (const int rate : {44100, 96000}) {
const PlayParams p = resolvePlay(st, rate);
CHECK(p.pitchVelocityCurve.eval(0.0) == -1.0);
CHECK(p.pitchVelocityCurve.eval(127.0) == 1.0);
CHECK(approx(p.pitchVelocityCurve.eval(63.5), 0.0));
}
}
static void testResolvePlayRoundsAndFloorsNegatives() {
@@ -937,6 +958,7 @@ int main() {
testLegacyLiftDecision();
testResolvePlayConvertsWallClockAtTheRate();
testResolvePlayCarriesTheFilterAndResolvesOnlyItsEnvelope();
testResolvePlayCarriesThePitchVelocityCurve();
testResolvePlayRoundsAndFloorsNegatives();
testMigratedFadeStretchesWhenTheDecodeRateDiffersFromTheProjectRate();
testResolveCaptureUsesIntrinsicsWhenNoOverride();
+63 -1
View File
@@ -1766,7 +1766,8 @@ static SampleData twoLevelSample() {
km.velocityCurve = VelocityCurve::fromPoints({{0.0, 0.0},
{static_cast<double>(kVelLow), 0.25},
{static_cast<double>(kVelHigh), 0.75},
{127.0, 1.0}});
{127.0, 1.0}},
instrument::engine::CurveDomain::Unipolar);
return km;
}
@@ -1966,6 +1967,65 @@ static SampleData rampSample(std::size_t frames, int rootNote) {
return s;
}
// --- velocity -> pitch ---------------------------------------------------------
//
// A ramp sample reads its own position, so the value on frame N IS the accumulated read rate:
// the transpose is directly observable rather than inferred from a spectrum.
static void testVelocityPitchIsExactlyOffByDefault() {
// The modulation VALUE at every velocity, not a rendered approximation of it: the default
// bipolar curve must yield the identity ratio exactly, so nothing detunes by a hair.
const PlayParams def;
for (int v = 0; v <= 127; ++v) {
CHECK(velocityPitchRatio(def.pitchVelocityCurve, v) == 1.0);
}
// And at the render: two strikes of very different velocity read the ramp identically.
SampleData s = rampSample(4096, 60);
Voice soft;
Voice hard;
soft.start(60, 1, s);
hard.start(60, 127, s);
for (int i = 0; i < 64; ++i) CHECK(soft.renderFrame() == hard.renderFrame());
}
static void testDrawnVelocityPitchCurveTransposesBothWays() {
using instrument::engine::CurveDomain;
SampleData s = rampSample(4096, 60);
const double full = std::pow(2.0, kVelocityPitchRangeSemitones / 12.0);
// A curve pinned at +1 across the domain: every velocity transposes UP by the full scale.
s.play.pitchVelocityCurve =
VelocityCurve::fromPoints({{0.0, 1.0}, {127.0, 1.0}}, CurveDomain::Bipolar);
Voice up;
up.start(60, 100, s);
CHECK(approx(static_cast<double>(up.renderFrame()), 0.0, 1e-9));
CHECK(approx(static_cast<double>(up.renderFrame()), full, 1e-4));
// Pinned at -1: DOWN by the same scale — the half of the domain the old unipolar curve
// could not express at all.
s.play.pitchVelocityCurve =
VelocityCurve::fromPoints({{0.0, -1.0}, {127.0, -1.0}}, CurveDomain::Bipolar);
Voice down;
down.start(60, 100, s);
down.renderFrame();
CHECK(approx(static_cast<double>(down.renderFrame()), 1.0 / full, 1e-4));
// And it follows the curve: a rising ramp gives a soft hit less transpose than a hard one.
s.play.pitchVelocityCurve =
VelocityCurve::fromPoints({{0.0, 0.0}, {127.0, 1.0}}, CurveDomain::Bipolar);
Voice q;
Voice f;
q.start(60, 20, s);
f.start(60, 120, s);
q.renderFrame();
f.renderFrame();
const double quiet = static_cast<double>(q.renderFrame());
const double loud = static_cast<double>(f.renderFrame());
CHECK(quiet > 1.0);
CHECK(loud > quiet);
CHECK(loud < full); // velocity 120 is short of the +1 endpoint
}
// MAJOR-1 regression: MONO+LEGATO with a TRIGGER zone RE-ATTACKS after the last key is up.
// Trigger ignores note-off (Voice::release() is a no-op, so releasing_ never latches), so a
// legato guard keyed on `active && !releasing` saw a ringing one-shot as "still held" and
@@ -2801,6 +2861,8 @@ int main() {
testRepitchObservedPeriod();
testKeyTrackVarispeedObservedPeriod();
testKeyTrackPreserveShiftCollapsesAtZero();
testVelocityPitchIsExactlyOffByDefault();
testDrawnVelocityPitchCurveTransposesBothWays();
testAdsrShape();
testAdsrReleaseBeforeSustain();
testAdsrZeroAttackDecay();
+16 -6
View File
@@ -116,7 +116,8 @@ static void testDisengagedFilterIsBitInertEvenWithExtremeSettingsStored() {
stored.play.filter.enabled = false;
stored.play.filter.settings.driveNorm = 1.0f;
stored.play.filter.modAmount = 1.0;
stored.play.filter.velAmount = -1.0;
stored.play.filter.velocityCurve = VelocityCurve::fromPoints(
{{0.0, 0.0}, {127.0, -1.0}}, instrument::engine::CurveDomain::Bipolar);
stored.play.filter.keyTrack = 2.0;
const std::vector<double> inert = render(stored, 60, 100, 1500);
for (std::size_t i = 0; i < inert.size(); ++i) CHECK(inert[i] == bare[i]);
@@ -329,18 +330,21 @@ static void testAnUnmodulatedVoiceIsBitIdenticalToASinglePreparedFilter() {
static void testVelocityAndKeyTrackingReachCutoffAndAreNoOpsAtTheirDefaults() {
// Playback key-tracking off, so both notes read the source at the SAME rate and the only
// note-dependent difference left is the filter's own key-tracking.
const auto tone = [](double velAmount, double keyTrack) {
const auto tone = [](double velTop, double keyTrack) {
SampleData s = periodicSine(8000, 64);
s.play.adsr = flatAdsr();
s.keyTrack = 0.0;
s.play.filter = engagedFilter(0.25f, 0.0f, 1.0f);
s.play.filter.velAmount = velAmount;
if (velTop != 0.0) {
s.play.filter.velocityCurve = VelocityCurve::fromPoints(
{{0.0, 0.0}, {127.0, velTop}}, instrument::engine::CurveDomain::Bipolar);
}
s.play.filter.keyTrack = keyTrack;
return s;
};
// Velocity: the default linear curve rises with velocity, so a positive depth opens the
// filter for a hard hit. The amp's own velocity curve is flat, so amplitude is unaffected.
// Velocity: a curve rising to +1 opens the filter for a hard hit. The amp's own velocity
// curve is flat, so amplitude is unaffected.
SampleData vel = tone(1.0, 0.0);
const std::vector<double> soft = render(vel, 60, 1, 6000);
const std::vector<double> hard = render(vel, 60, 127, 6000);
@@ -352,7 +356,8 @@ static void testVelocityAndKeyTrackingReachCutoffAndAreNoOpsAtTheirDefaults() {
const std::vector<double> high = render(key, 84, 100, 6000);
CHECK(rms(high, 2000, 6000) > 2.0 * rms(low, 2000, 6000));
// Both neutral: neither velocity nor note may move the filter.
// Both neutral: neither velocity nor note may move the filter. `tone(0,0)` leaves the
// DEFAULT velocity curve, so this is the off-by-default contract itself.
SampleData neutral = tone(0.0, 0.0);
const std::vector<double> a = render(neutral, 60, 1, 6000);
const std::vector<double> b = render(neutral, 60, 127, 6000);
@@ -361,6 +366,11 @@ static void testVelocityAndKeyTrackingReachCutoffAndAreNoOpsAtTheirDefaults() {
CHECK(a[i] == b[i]);
CHECK(a[i] == c[i]);
}
// And the modulation VALUE itself is exactly zero at every velocity, not merely small
// enough that the render came out equal — the render check alone would still pass under a
// cutoff offset too small to survive the coefficient solve's float rounding.
const FilterParams def;
for (int v = 0; v <= 127; ++v) CHECK(def.velocityCurve.eval(v) == 0.0);
}
static void testNoteOnResetsTheFilterSoAPreviousNoteCannotLeak() {
+143 -36
View File
@@ -1,20 +1,24 @@
// Standalone tests for reasampler::instrument::engine::velocity_curve — no VST3, no REAPER, no framework. Same fast
// assert loop as the sibling pure tests. Assert the S-VIEW-9 velocity->amp transfer curve HARD:
// assert loop as the sibling pure tests. Assert the velocity transfer curve HARD:
//
// * eval — flat y=1 default (R10-F1 Option A: EVERY velocity -> 1.0), linear ramp, curved shape
// * eval — flat y=1 unipolar default (EVERY velocity -> 1.0), linear ramp, curved shape
// between points, box-clamp of an out-of-range velocity, monotonic-in-x over the whole domain.
// * the BIPOLAR domain — zero() is exactly 0 everywhere, the negative half evaluates and clamps
// at -1, eval is homogeneous in y (what the pre-v12 filter lift rests on), and the pixel maps
// put value 0 on the box's centre line rather than its floor.
// * editing — addPoint keeps X-order + box-clamp; movePoint clamps an interior point between its
// neighbours (can't cross) and box-clamps amp; endpoints are X-pinned (velocity 0 / 127) with
// only amp mobile; deletePoint removes interior points but REFUSES the two endpoints.
// neighbours (can't cross) and box-clamps the value; endpoints are X-pinned (velocity 0 / 127)
// with only the value mobile; deletePoint removes interior points but REFUSES the two endpoints.
// * hit-test + inverse map — pointAtPixel grabs a drawn node; resolvePointDrag maps pixel delta to
// a clamped point (endpoint X-pinned; interior clamped to neighbours); degenerate box -> no motion.
// * fromPoints — the deserialization repair: sorts by X, box-clamps, forces endpoints, and falls
// back to flat() for a sub-2-point list.
// back to each domain's OWN neutral for a sub-2-point list.
#include "../src/core/instrument/engine/velocity_curve.h"
#include <cmath>
#include <cstdio>
#include <vector>
using namespace reasampler;
using namespace reasampler::instrument::engine;
@@ -27,6 +31,17 @@ static bool near(double a, double b, double eps = 1e-9) { return std::fabs(a - b
using Box = VelocityCurve::Box;
// The pixel maps are members (the y domain lives on the curve), so a mapping test speaks
// through a curve of the domain under test rather than a free function.
static const VelocityCurve& uni() {
static const VelocityCurve c = VelocityCurve::flat();
return c;
}
static const VelocityCurve& bip() {
static const VelocityCurve c = VelocityCurve::zero();
return c;
}
// --- eval ---------------------------------------------------------------------
static void testFlatIsUnityEverywhere() {
@@ -151,11 +166,11 @@ static void testAddPointKeepsXOrderAndClamps() {
const std::size_t i = c.addPoint(60.0, 0.3);
CHECK(i == 1); // inserted between the two endpoints
CHECK(c.size() == 3);
CHECK(near(c.points()[1].velocity, 60.0) && near(c.points()[1].amp, 0.3));
CHECK(near(c.points()[1].velocity, 60.0) && near(c.points()[1].value, 0.3));
// Out-of-box add clamps into [0,127] x [0,1].
c.addPoint(500.0, 5.0);
const VelocityPoint& last = c.points().back();
CHECK(near(last.velocity, 127.0) && near(last.amp, 1.0));
CHECK(near(last.velocity, 127.0) && near(last.value, 1.0));
// Points remain X-ordered.
for (std::size_t k = 1; k < c.size(); ++k)
CHECK(c.points()[k - 1].velocity <= c.points()[k].velocity);
@@ -171,7 +186,7 @@ static void testMoveInteriorClampsToNeighbours() {
// Try to drag idx 1 PAST idx 2 (velocity 200): clamps to idx 2's velocity (80), not beyond.
const VelocityPoint r = c.movePoint(1, 200.0, 0.5);
CHECK(near(r.velocity, 80.0));
CHECK(near(r.amp, 0.5)); // amp is free (box-clamped only)
CHECK(near(r.value, 0.5)); // amp is free (box-clamped only)
// Try to drag idx 1 BELOW idx 0 (velocity -5): clamps to idx 0's velocity (0).
const VelocityPoint r2 = c.movePoint(1, -5.0, 0.5);
CHECK(near(r2.velocity, 0.0));
@@ -182,18 +197,18 @@ static void testMoveEndpointsArePinnedInX() {
// Move the first endpoint: velocity argument ignored (pinned at 0), amp moves.
const VelocityPoint f = c.movePoint(0, 50.0, 0.25);
CHECK(near(f.velocity, 0.0));
CHECK(near(f.amp, 0.25));
CHECK(near(f.value, 0.25));
// Move the last endpoint: pinned at 127, amp moves, and amp box-clamps.
const VelocityPoint l = c.movePoint(1, 10.0, 5.0);
CHECK(near(l.velocity, 127.0));
CHECK(near(l.amp, 1.0));
CHECK(near(l.value, 1.0));
}
static void testMoveOutOfRangeIndexIsNoOp() {
VelocityCurve c = VelocityCurve::linear();
c.movePoint(99, 50.0, 0.5);
CHECK(c.size() == 2);
CHECK(near(c.points()[0].amp, 0.0) && near(c.points()[1].amp, 1.0)); // unchanged
CHECK(near(c.points()[0].value, 0.0) && near(c.points()[1].value, 1.0)); // unchanged
}
// --- editing: deletePoint -----------------------------------------------------
@@ -237,11 +252,11 @@ static void testResolveDragMovesAndClamps() {
// Drag idx 1 right 10px, up 10px: velocity +10 (->70), amp +0.10 (up = higher amp -> 0.60).
const VelocityCurve moved = VelocityCurve::resolvePointDrag(grab, 1, b, 10, -10);
CHECK(near(moved.points()[1].velocity, 70.0, 1e-6));
CHECK(near(moved.points()[1].amp, 0.60, 1e-6));
CHECK(near(moved.points()[1].value, 0.60, 1e-6));
// Dragging the first endpoint horizontally does not move it in X (pinned), only amp.
const VelocityCurve movedEnd = VelocityCurve::resolvePointDrag(grab, 0, b, 40, -20);
CHECK(near(movedEnd.points()[0].velocity, 0.0));
CHECK(near(movedEnd.points()[0].amp, 0.20, 1e-6)); // dragged up 20px = +0.20 from 0
CHECK(near(movedEnd.points()[0].value, 0.20, 1e-6)); // dragged up 20px = +0.20 from 0
}
static void testResolveDragDegenerateBoxNoMotion() {
@@ -255,7 +270,7 @@ static void testResolveDragDegenerateBoxNoMotion() {
static void testFromPointsSortsClampsAndForcesEndpoints() {
// Unsorted, out-of-box, missing endpoints -> repaired to a valid curve.
std::vector<VelocityPoint> raw = {{80.0, 0.9}, {20.0, -1.0}, {50.0, 2.0}};
const VelocityCurve c = VelocityCurve::fromPoints(raw);
const VelocityCurve c = VelocityCurve::fromPoints(raw, CurveDomain::Unipolar);
// X-ordered.
for (std::size_t k = 1; k < c.size(); ++k)
CHECK(c.points()[k - 1].velocity <= c.points()[k].velocity);
@@ -264,15 +279,101 @@ static void testFromPointsSortsClampsAndForcesEndpoints() {
CHECK(near(c.points().back().velocity, 127.0));
// Interior amps box-clamped (the -1 became 0, the 2 became 1).
for (const VelocityPoint& p : c.points()) {
CHECK(p.amp >= 0.0 - 1e-12 && p.amp <= 1.0 + 1e-12);
CHECK(p.value >= 0.0 - 1e-12 && p.value <= 1.0 + 1e-12);
}
}
static void testFromPointsSubTwoFallsBackToFlat() {
const VelocityCurve c0 = VelocityCurve::fromPoints({});
const VelocityCurve c0 = VelocityCurve::fromPoints({}, CurveDomain::Unipolar);
CHECK(c0.equals(VelocityCurve::flat()));
const VelocityCurve c1 = VelocityCurve::fromPoints({{50.0, 0.3}});
const VelocityCurve c1 = VelocityCurve::fromPoints({{50.0, 0.3}}, CurveDomain::Unipolar);
CHECK(c1.equals(VelocityCurve::flat()));
// The bipolar fallback is the domain's OWN neutral, not the unipolar one: degrading a
// corrupt pitch/filter curve to flat-at-unity would transpose or open the filter fully.
const VelocityCurve b0 = VelocityCurve::fromPoints({}, CurveDomain::Bipolar);
CHECK(b0.equals(VelocityCurve::zero()));
const VelocityCurve b1 = VelocityCurve::fromPoints({{50.0, 0.3}}, CurveDomain::Bipolar);
CHECK(b1.equals(VelocityCurve::zero()));
}
// --- the bipolar domain -------------------------------------------------------
static void testZeroIsExactlyZeroAtEveryVelocity() {
// The off-by-default contract: not "approximately zero" — EXACTLY zero, so a pitch or
// cutoff offset derived from it cannot nudge anything.
const VelocityCurve c = VelocityCurve::zero();
CHECK(c.domain() == CurveDomain::Bipolar);
for (int v = -20; v <= 200; ++v) CHECK(c.eval(v) == 0.0);
CHECK(c.size() == 2);
}
static void testBipolarEvalSpansTheNegativeHalf() {
// A ramp from -1 at velocity 0 to +1 at 127: collinear knots, so the spline is the exact
// straight line through zero — the whole point of the widened domain.
const VelocityCurve c =
VelocityCurve::fromPoints({{0.0, -1.0}, {127.0, 1.0}}, CurveDomain::Bipolar);
CHECK(near(c.eval(0), -1.0));
CHECK(near(c.eval(127), 1.0));
CHECK(near(c.eval(63.5), 0.0, 1e-12));
for (int v = 0; v <= 127; ++v) CHECK(near(c.eval(v), 2.0 * v / 127.0 - 1.0, 1e-12));
}
static void testUnipolarClampsAtZeroWhereBipolarDoesNot() {
// The same negative knot, read in the two domains: unipolar floors it at 0 (an amp gain
// cannot be negative), bipolar keeps it.
const std::vector<VelocityPoint> raw = {{0.0, -0.5}, {127.0, 0.5}};
const VelocityCurve u = VelocityCurve::fromPoints(raw, CurveDomain::Unipolar);
const VelocityCurve b = VelocityCurve::fromPoints(raw, CurveDomain::Bipolar);
CHECK(near(u.eval(0), 0.0));
CHECK(near(b.eval(0), -0.5));
// Out-of-domain magnitudes clamp to each domain's own floor.
const VelocityCurve b2 =
VelocityCurve::fromPoints({{0.0, -9.0}, {127.0, 9.0}}, CurveDomain::Bipolar);
CHECK(near(b2.eval(0), -1.0));
CHECK(near(b2.eval(127), 1.0));
}
static void testEvalIsHomogeneousInY() {
// The property the pre-v12 filter lift rests on: scaling every knot's y by k scales the
// whole evaluated curve by k. Asserted against a CURVED (non-collinear) knot set, where
// the Fritsch-Carlson tangents are actually doing work.
const std::vector<VelocityPoint> knots = {
{0.0, 0.1}, {30.0, 0.15}, {64.0, 0.9}, {100.0, 0.4}, {127.0, 1.0}};
const VelocityCurve base = VelocityCurve::fromPoints(knots, CurveDomain::Unipolar);
for (const double k : {0.75, -0.4, 1.0}) {
std::vector<VelocityPoint> scaled = knots;
for (VelocityPoint& p : scaled) p.value *= k;
const VelocityCurve s = VelocityCurve::fromPoints(scaled, CurveDomain::Bipolar);
for (int v = 0; v <= 127; ++v) CHECK(near(s.eval(v), k * base.eval(v), 1e-12));
}
}
static void testBipolarPixelMapPutsZeroOnTheCentreLine() {
// The same box, read in the two domains: value 0 sits at the vertical centre for a bipolar
// curve and at the bottom row for a unipolar one — the one mapping difference the shared
// popup code path has to get right.
const Box box{10, 20, 100, 101}; // 100 value rows: centre is 50 rows down
CHECK(bip().pixelFromPoint(box, {0.0, 0.0}).y == 70);
CHECK(uni().pixelFromPoint(box, {0.0, 0.0}).y == 120);
// Each domain's floor lands on the bottom row, its ceiling on the top.
CHECK(bip().pixelFromPoint(box, {0.0, -1.0}).y == 120);
CHECK(bip().pixelFromPoint(box, {0.0, 1.0}).y == 20);
// And the inverse agrees: the centre row reads back as 0 in bipolar, mid-scale in unipolar.
CHECK(near(bip().pointFromPixel(box, 10, 70).value, 0.0, 1e-12));
CHECK(near(uni().pointFromPixel(box, 10, 70).value, 0.5, 1e-12));
}
static void testBipolarDragCoversTwiceTheValueRange() {
// A drag of N pixels moves twice as much value in bipolar as in unipolar over the same box
// — the domain spans 2.0, not 1.0. Both still land inside their own domain.
const Box box{0, 0, 127, 101}; // 100 value rows
VelocityCurve u = VelocityCurve::flat();
u.movePoint(0, 0.0, 0.5);
VelocityCurve b = VelocityCurve::zero();
const VelocityCurve uMoved = VelocityCurve::resolvePointDrag(u, 0, box, 0, -10);
const VelocityCurve bMoved = VelocityCurve::resolvePointDrag(b, 0, box, 0, -10);
CHECK(near(uMoved.points()[0].value, 0.60, 1e-6));
CHECK(near(bMoved.points()[0].value, 0.20, 1e-6));
}
// --- S-VIEW-10 pixel maps (the editor draw/add seam) -----------------------------
@@ -282,29 +383,29 @@ static void testPixelFromPointMapsCornersAndMidpoint() {
// (h - 1) rows with amp 1 at the top — assert the drawn corners land where the module's own
// hit-test mapping puts them.
const Box box{10, 20, 100, 51};
const auto tl = VelocityCurve::pixelFromPoint(box, {0.0, 1.0});
const auto tl = uni().pixelFromPoint(box, {0.0, 1.0});
CHECK(tl.x == 10 && tl.y == 20);
const auto br = VelocityCurve::pixelFromPoint(box, {127.0, 0.0});
const auto br = uni().pixelFromPoint(box, {127.0, 0.0});
CHECK(br.x == 110 && br.y == 70);
const auto mid = VelocityCurve::pixelFromPoint(box, {63.5, 0.5});
const auto mid = uni().pixelFromPoint(box, {63.5, 0.5});
CHECK(mid.x == 60 && mid.y == 45);
// Out-of-box values are clamped by the mapping (velocity 200 draws at the right edge).
const auto clamped = VelocityCurve::pixelFromPoint(box, {200.0, 2.0});
const auto clamped = uni().pixelFromPoint(box, {200.0, 2.0});
CHECK(clamped.x == 110 && clamped.y == 20);
}
static void testPointFromPixelInvertsAndClamps() {
const Box box{10, 20, 100, 51};
// Exact corners invert exactly.
const VelocityPoint tl = VelocityCurve::pointFromPixel(box, 10, 20);
CHECK(near(tl.velocity, 0.0) && near(tl.amp, 1.0));
const VelocityPoint br = VelocityCurve::pointFromPixel(box, 110, 70);
CHECK(near(br.velocity, 127.0) && near(br.amp, 0.0));
const VelocityPoint tl = uni().pointFromPixel(box, 10, 20);
CHECK(near(tl.velocity, 0.0) && near(tl.value, 1.0));
const VelocityPoint br = uni().pointFromPixel(box, 110, 70);
CHECK(near(br.velocity, 127.0) && near(br.value, 0.0));
// A pixel OUTSIDE the box clamps into the domain (never an invariant-violating point).
const VelocityPoint out = VelocityCurve::pointFromPixel(box, -50, 500);
CHECK(near(out.velocity, 0.0) && near(out.amp, 0.0));
const VelocityPoint out2 = VelocityCurve::pointFromPixel(box, 500, -50);
CHECK(near(out2.velocity, 127.0) && near(out2.amp, 1.0));
const VelocityPoint out = uni().pointFromPixel(box, -50, 500);
CHECK(near(out.velocity, 0.0) && near(out.value, 0.0));
const VelocityPoint out2 = uni().pointFromPixel(box, 500, -50);
CHECK(near(out2.velocity, 127.0) && near(out2.value, 1.0));
}
static void testPixelMapsRoundTripWithinOnePixelQuantum() {
@@ -315,10 +416,10 @@ static void testPixelMapsRoundTripWithinOnePixelQuantum() {
const double ampQuantum = 1.0 / 119.0;
const VelocityPoint pts[] = {{0.0, 1.0}, {127.0, 0.0}, {40.0, 0.25}, {90.5, 0.66}, {63.5, 0.5}};
for (const VelocityPoint& p : pts) {
const auto px = VelocityCurve::pixelFromPoint(box, p);
const VelocityPoint back = VelocityCurve::pointFromPixel(box, px.x, px.y);
const auto px = uni().pixelFromPoint(box, p);
const VelocityPoint back = uni().pointFromPixel(box, px.x, px.y);
CHECK(std::fabs(back.velocity - p.velocity) <= velQuantum);
CHECK(std::fabs(back.amp - p.amp) <= ampQuantum);
CHECK(std::fabs(back.value - p.value) <= ampQuantum);
}
}
@@ -328,15 +429,15 @@ static void testPixelFromPointAgreesWithPointAtPixel() {
VelocityCurve c = VelocityCurve::linear();
const std::size_t idx = c.addPoint(70.0, 0.3);
const Box box{0, 0, 200, 100};
const auto px = VelocityCurve::pixelFromPoint(box, c.points()[idx]);
const auto px = uni().pixelFromPoint(box, c.points()[idx]);
CHECK(c.pointAtPixel(box, px.x, px.y) == static_cast<int>(idx));
}
static void testPointFromPixelDegenerateBox() {
// Zero width -> velocity 0; height <= 1 -> amp 1 (mirrors the forward map's degenerate pins).
const Box flat{5, 5, 0, 0};
const VelocityPoint p = VelocityCurve::pointFromPixel(flat, 50, 50);
CHECK(near(p.velocity, 0.0) && near(p.amp, 1.0));
const VelocityPoint p = uni().pointFromPixel(flat, 50, 50);
CHECK(near(p.velocity, 0.0) && near(p.value, 1.0));
}
static void testFromPointsRoundTripsAValidCurve() {
@@ -344,7 +445,7 @@ static void testFromPointsRoundTripsAValidCurve() {
orig.addPoint(40.0, 0.2);
orig.addPoint(90.0, 0.7);
// fromPoints over its OWN points reproduces it exactly (already valid, sort is stable no-op).
const VelocityCurve rebuilt = VelocityCurve::fromPoints(orig.points());
const VelocityCurve rebuilt = VelocityCurve::fromPoints(orig.points(), CurveDomain::Unipolar);
CHECK(rebuilt.equals(orig));
}
@@ -367,6 +468,12 @@ int main() {
testResolveDragDegenerateBoxNoMotion();
testFromPointsSortsClampsAndForcesEndpoints();
testFromPointsSubTwoFallsBackToFlat();
testZeroIsExactlyZeroAtEveryVelocity();
testBipolarEvalSpansTheNegativeHalf();
testUnipolarClampsAtZeroWhereBipolarDoesNot();
testEvalIsHomogeneousInY();
testBipolarPixelMapPutsZeroOnTheCentreLine();
testBipolarDragCoversTwiceTheValueRange();
testPixelFromPointMapsCornersAndMidpoint();
testPointFromPixelInvertsAndClamps();
testPixelMapsRoundTripWithinOnePixelQuantum();