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) {