S-VIEW-9: velocity->amp transfer curve (pure velocity_curve module + zones-payload v7 + Voice::start apply)

Default flat y=1 (R10-F1 Option A) replaces the linear velocity/127 at note-on — a deliberate, non-back-compat behavior change; v1-v6 blobs lift to the flat default.
This commit is contained in:
2026-07-27 14:18:01 -04:00
parent 203005e6cf
commit a1f9dcf6f8
10 changed files with 829 additions and 34 deletions
+102
View File
@@ -1370,6 +1370,104 @@ static void testKeyTrackV5BackCompatLiftsToUnity() {
CHECK(back.zones[0].keyTrack == 1.0); // no keyTrack tail -> default 1.0 (bit-identical repitch)
}
// --- S-VIEW-9 velocity->amp curve: v7 round-trip + resolve-through + v6 back-compat lift ---------
static void testVelocityCurveRoundTrip() {
// A per-zone velocity curve survives the payload-v7 round trip losslessly (exact point coords).
// A second zone left at the flat default proves the field is per-record and defaults to flat y=1.
PerformanceMap m;
PerformanceZone z = zone("lead", 20, 100);
z.velocityCurve = vst::VelocityCurve::linear();
z.velocityCurve.addPoint(60.0, 0.3); // an interior knot to exercise multi-point round-trip
m.zones.push_back(z);
m.zones.push_back(zone("pad", 0, 19)); // default flat curve
const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0);
CHECK(back.zones.size() == 2);
if (back.zones.size() != 2) return;
CHECK(back.zones[0].velocityCurve.equals(z.velocityCurve)); // exact point round-trip
CHECK(back.zones[1].velocityCurve.equals(vst::VelocityCurve::flat())); // default preserved
// And the flat default really is unity everywhere (R10-F1 Option A), not the old linear ramp.
CHECK(back.zones[1].velocityCurve.eval(1.0) == 1.0);
CHECK(back.zones[1].velocityCurve.eval(64.0) == 1.0);
}
static void testVelocityCurveThroughComponentEnvelope() {
// The curve round-trips through the ComponentState envelope too (zones-payload is envelope-
// independent, so it carries the v7 tail unchanged).
ComponentState s;
s.selectionId = "pick";
PerformanceZone z = zone("pick", 0, 127);
z.velocityCurve = vst::VelocityCurve::linear();
s.map.zones.push_back(z);
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
CHECK(back.map.zones.size() == 1);
if (back.map.zones.size() != 1) return;
CHECK(back.map.zones[0].velocityCurve.equals(vst::VelocityCurve::linear()));
}
static void testVelocityCurveResolvesToZone() {
// resolvePerformance carries the curve from PerformanceZone through to ResolvedZone, so the
// keymap build (and thus the voice engine at start()) sees the authored curve.
const std::string json = bookJson({makeSample("a", "Kick", "b/a.wav", 36)}, {});
PerformanceMap m;
PerformanceZone z = zone("a", 0, 127);
z.velocityCurve = vst::VelocityCurve::linear();
m.zones.push_back(z);
const ResolvedPerformance r = resolvePerformance(json, m);
CHECK(r.zones.size() == 1);
if (r.zones.size() != 1) return;
CHECK(r.zones[0].velocityCurve.equals(vst::VelocityCurve::linear()));
}
static void testVelocityCurveV6BackCompatLiftsToFlat() {
// A v6 PAYLOAD blob (marker + version 6 + full play tail + keyTrack, but NO velocity-curve field)
// lifts every zone to VelocityCurve::flat() (R10-F1 Option A — flat y=1). This is the DELIBERATE
// non-back-compat behavior change: an instance saved BEFORE S-VIEW-9 now plays every velocity at
// unity, NOT the old linear velocity/127. Hand-build the exact v6 record shape.
std::vector<std::uint8_t> b;
auto u32 = [&](std::uint32_t v) {
b.push_back(v & 0xFF); b.push_back((v >> 8) & 0xFF);
b.push_back((v >> 16) & 0xFF); b.push_back((v >> 24) & 0xFF);
};
auto f64 = [&](double d) {
std::uint64_t bits; std::memcpy(&bits, &d, sizeof(bits));
for (int i = 0; i < 8; ++i) b.push_back(static_cast<std::uint8_t>((bits >> (i * 8)) & 0xFF));
};
auto i64 = [&](std::int64_t v) {
std::uint64_t bits = static_cast<std::uint64_t>(v);
for (int i = 0; i < 8; ++i) b.push_back(static_cast<std::uint8_t>((bits >> (i * 8)) & 0xFF));
};
u32(kPerformanceStateVersion); // envelope version (2)
u32(kZonesFormatMarker); // marker -> a versioned payload
u32(6); // PAYLOAD VERSION 6 (pre-S-VIEW-9, keyTrack but no curve)
u32(1); // zone count 1
const std::string id = "v6saved";
u32(static_cast<std::uint32_t>(id.size()));
b.insert(b.end(), id.begin(), id.end());
u32(10); u32(70); // low/high
b.push_back(0); // hasRootOverride = 0
b.push_back(0); // hasLoopOverride = 0
b.push_back(0); // hasStartPoint = 0
// v5 play tail.
b.push_back(0); // playMode = Gate
f64(0.0); // adsr.holdSeconds
f64(1.0); // trigger.lengthFraction
i64(0); i64(0); // trigger fades
b.push_back(1); // pitchEngine = Preserve
b.push_back(0); // pitchEnv.enabled = false
f64(0.0); f64(0.0); f64(0.0); // pitchEnv attack/decay/peak
f64(0.003); f64(0.0); f64(1.0); f64(0.060); // adsr A/D/S/R
f64(0.5); // v6 keyTrack (0.5) — present, but no curve tail follows
const PerformanceMap back = deserializePerformance(b, 44100.0);
CHECK(back.zones.size() == 1);
if (back.zones.size() != 1) return;
CHECK(back.zones[0].sampleId == "v6saved");
CHECK(back.zones[0].keyTrack == 0.5); // the v6 field still read correctly
// No curve tail -> flat y=1 default (the deliberate behavior change).
CHECK(back.zones[0].velocityCurve.equals(vst::VelocityCurve::flat()));
CHECK(back.zones[0].velocityCurve.eval(20.0) == 1.0); // a soft hit now plays at unity
}
static void testPlayParamsV2BackCompatLiftsToDefaults() {
// A pre-S15 PAYLOAD v2 blob (marker + version 2 + record with the S11 tail but NO play tail)
// lifts each zone to the PRODUCT defaults: Gate + Preserve (S16-F1) + no fades + env off — the
@@ -1619,6 +1717,10 @@ int main() {
testKeyTrackThroughComponentEnvelope();
testKeyTrackResolvesToZone();
testKeyTrackV5BackCompatLiftsToUnity();
testVelocityCurveRoundTrip();
testVelocityCurveThroughComponentEnvelope();
testVelocityCurveResolvesToZone();
testVelocityCurveV6BackCompatLiftsToFlat();
testPlayParamsV2BackCompatLiftsToDefaults();
testPlayParamsThroughComponentEnvelope();
testFullAdsrSecondsRoundTrip();
+43 -10
View File
@@ -422,6 +422,10 @@ static void testNoteOffReleasesNewestSameNote() {
sd.play.adsr = flatAdsr();
sd.play.adsr.releaseFrames = 10; // short but non-zero so voice stays active through release
Keymap km = Keymap::singleSampleChromatic(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 = vst::VelocityCurve::linear();
VoiceEngine eng(8, km);
std::size_t first = eng.noteOn(60, velOld); // older voice, lower gain
@@ -728,33 +732,60 @@ static void testStartAfterLoopEndWrapsIntoLoop() {
// velocity -> volume.
// ---------------------------------------------------------------------------
static void testVelocityToVolume() {
// S-VIEW-9 BEHAVIOR CHANGE (R10-F1 Option A): the DEFAULT velocity curve on a KeyZone 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
for (int vel : {1, 64, 100, 127}) {
VoiceEngine eng(1, km);
eng.noteOn(60, vel);
std::vector<AudioSample> out;
eng.render(out, 1);
CHECK(approx(out[0], 1.0, 1e-4)); // flat y=1: any velocity -> unity gain
}
}
// 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
// Full velocity -> full gain; half velocity -> ~half gain (flat envelope so the
// rendered value is exactly velocity/127 on a DC-1 sample).
km.zones[0].velocityCurve = vst::VelocityCurve::linear();
{
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 1);
std::vector<AudioSample> out; eng.render(out, 1);
CHECK(approx(out[0], 1.0, 1e-4));
}
{
VoiceEngine eng(1, km);
eng.noteOn(60, 64);
std::vector<AudioSample> out;
eng.render(out, 1);
std::vector<AudioSample> out; eng.render(out, 1);
CHECK(approx(out[0], 64.0 / 127.0, 1e-4));
}
{
VoiceEngine eng(1, km);
eng.noteOn(60, 1);
std::vector<AudioSample> out;
eng.render(out, 1);
std::vector<AudioSample> out; eng.render(out, 1);
CHECK(approx(out[0], 1.0 / 127.0, 1e-4));
}
}
// 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
vst::VelocityCurve curve = vst::VelocityCurve::linear();
curve.addPoint(64.0, 0.9); // pull the mid-velocity response UP to 0.9
km.zones[0].velocityCurve = curve;
VoiceEngine eng(1, km);
eng.noteOn(60, 64);
std::vector<AudioSample> out; eng.render(out, 1);
// At exactly velocity 64 the curve passes through the knot -> gain 0.9 (well above the linear
// 64/127 ~= 0.504), so the rendered DC value is the shaped 0.9.
CHECK(approx(out[0], 0.9, 1e-4));
}
// Two voices summed: polyphony mixes additively.
static void testPolyphonyMixesAdditively() {
Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // DC 1.0
@@ -1348,7 +1379,9 @@ int main() {
testStartFrameOutOfRangeClampsToZero();
testStartFrameWithLoop();
testStartAfterLoopEndWrapsIntoLoop();
testVelocityToVolume();
testVelocityDefaultCurveIsFlatUnity();
testVelocityLinearCurveReproducesRamp();
testVelocityShapedCurveDrivesGain();
testPolyphonyMixesAdditively();
testChannelCount();
testStereoRenderKeepsChannelsDistinct();
+241
View File
@@ -0,0 +1,241 @@
// Standalone tests for reasampler::vst::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:
//
// * eval — flat y=1 default (R10-F1 Option A: 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.
// * 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.
// * 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.
#include "../src/vst/velocity_curve.h"
#include <cmath>
#include <cstdio>
using namespace reasampler::vst;
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 bool near(double a, double b, double eps = 1e-9) { return std::fabs(a - b) <= eps; }
using Box = VelocityCurve::Box;
// --- eval ---------------------------------------------------------------------
static void testFlatIsUnityEverywhere() {
const VelocityCurve c = VelocityCurve::flat();
// R10-F1 Option A: every velocity plays at full level. Sweep the whole domain.
for (int v = 0; v <= 127; ++v) CHECK(near(c.eval(v), 1.0));
// Two endpoints only.
CHECK(c.size() == 2);
}
static void testLinearRamp() {
const VelocityCurve c = VelocityCurve::linear();
CHECK(near(c.eval(0), 0.0));
CHECK(near(c.eval(127), 1.0));
// linear() is an EXACT straight line y = velocity/127: at any velocity the amp equals v/127.
CHECK(near(c.eval(63.5), 0.5)); // the exact midpoint
CHECK(near(c.eval(64.0), 64.0 / 127.0));
CHECK(near(c.eval(100.0), 100.0 / 127.0));
}
static void testEvalBoxClampsOutOfRangeVelocity() {
const VelocityCurve c = VelocityCurve::linear();
CHECK(near(c.eval(-10.0), 0.0)); // below 0 -> reads velocity-0 endpoint amp
CHECK(near(c.eval(200.0), 1.0)); // above 127 -> reads velocity-127 endpoint amp
}
static void testEvalMonotonicInX() {
// A curve that dips then rises must still be a well-defined FUNCTION (one amp per velocity) and
// monotonic WITHIN each segment. Build (0,1)->(64,0)->(127,1): eval sweeps must be single-valued
// and each half monotonic (down then up), never oscillating within a segment.
VelocityCurve c = VelocityCurve::flat();
c.movePoint(0, 0, 1.0);
c.addPoint(64.0, 0.0);
c.movePoint(2, 127, 1.0); // index 2 is the last endpoint after the insert
CHECK(c.size() == 3);
// Descending half [0,64]: non-increasing.
double prev = c.eval(0);
for (int v = 1; v <= 64; ++v) {
const double cur = c.eval(v);
CHECK(cur <= prev + 1e-9);
prev = cur;
}
// Ascending half [64,127]: non-decreasing.
prev = c.eval(64);
for (int v = 65; v <= 127; ++v) {
const double cur = c.eval(v);
CHECK(cur >= prev - 1e-9);
prev = cur;
}
CHECK(near(c.eval(64), 0.0)); // the trough sits exactly on the moved point
}
// --- editing: addPoint --------------------------------------------------------
static void testAddPointKeepsXOrderAndClamps() {
VelocityCurve c = VelocityCurve::linear(); // (0,0), (127,1)
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));
// 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));
// Points remain X-ordered.
for (std::size_t k = 1; k < c.size(); ++k)
CHECK(c.points()[k - 1].velocity <= c.points()[k].velocity);
}
// --- editing: movePoint -------------------------------------------------------
static void testMoveInteriorClampsToNeighbours() {
VelocityCurve c = VelocityCurve::linear();
c.addPoint(40.0, 0.4); // idx 1
c.addPoint(80.0, 0.8); // idx 2
CHECK(c.size() == 4); // (0,0)(40,.4)(80,.8)(127,1)
// 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)
// 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));
}
static void testMoveEndpointsArePinnedInX() {
VelocityCurve c = VelocityCurve::linear();
// 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));
// 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));
}
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
}
// --- editing: deletePoint -----------------------------------------------------
static void testDeleteRemovesInteriorRefusesEndpoints() {
VelocityCurve c = VelocityCurve::linear();
c.addPoint(60.0, 0.5); // idx 1
CHECK(c.size() == 3);
// Endpoints refuse deletion.
CHECK(!c.deletePoint(0));
CHECK(!c.deletePoint(2));
CHECK(c.size() == 3);
// Interior deletes.
CHECK(c.deletePoint(1));
CHECK(c.size() == 2);
// Out-of-range refuses.
CHECK(!c.deletePoint(9));
}
// --- hit-test + inverse map ---------------------------------------------------
// A 127px-wide, 101px-tall box at origin: velocity->x is 1px/unit, amp->y spans 100 rows (1 px per
// 0.01 amp), amp 1 at top (y=0), amp 0 at bottom (y=100).
static Box wideBox() { return Box{0, 0, 127, 101}; }
static void testPointAtPixelGrabsDrawnNode() {
VelocityCurve c = VelocityCurve::linear(); // (0,0) at (0,100); (127,1) at (127,0)
const Box b = wideBox();
// Grab near the first endpoint's drawn point (x=0, y=100).
CHECK(c.pointAtPixel(b, 0, 100) == 0);
// Grab near the last endpoint (x=127, y=0).
CHECK(c.pointAtPixel(b, 127, 0) == 1);
// A point far from any node misses.
CHECK(c.pointAtPixel(b, 63, 50) == -1);
}
static void testResolveDragMovesAndClamps() {
VelocityCurve grab = VelocityCurve::linear();
grab.addPoint(60.0, 0.5); // idx 1, drawn at x=60, y=50
const Box b = wideBox();
// 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));
// 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
}
static void testResolveDragDegenerateBoxNoMotion() {
const VelocityCurve grab = VelocityCurve::linear();
const VelocityCurve r = VelocityCurve::resolvePointDrag(grab, 1, Box{0, 0, 0, 0}, 50, 50);
CHECK(r.equals(grab)); // zero-size box -> unchanged
}
// --- fromPoints (deserialization repair) --------------------------------------
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);
// X-ordered.
for (std::size_t k = 1; k < c.size(); ++k)
CHECK(c.points()[k - 1].velocity <= c.points()[k].velocity);
// Endpoints forced present at 0 and 127.
CHECK(near(c.points().front().velocity, 0.0));
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);
}
}
static void testFromPointsSubTwoFallsBackToFlat() {
const VelocityCurve c0 = VelocityCurve::fromPoints({});
CHECK(c0.equals(VelocityCurve::flat()));
const VelocityCurve c1 = VelocityCurve::fromPoints({{50.0, 0.3}});
CHECK(c1.equals(VelocityCurve::flat()));
}
static void testFromPointsRoundTripsAValidCurve() {
VelocityCurve orig = VelocityCurve::linear();
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());
CHECK(rebuilt.equals(orig));
}
int main() {
testFlatIsUnityEverywhere();
testLinearRamp();
testEvalBoxClampsOutOfRangeVelocity();
testEvalMonotonicInX();
testAddPointKeepsXOrderAndClamps();
testMoveInteriorClampsToNeighbours();
testMoveEndpointsArePinnedInX();
testMoveOutOfRangeIndexIsNoOp();
testDeleteRemovesInteriorRefusesEndpoints();
testPointAtPixelGrabsDrawnNode();
testResolveDragMovesAndClamps();
testResolveDragDegenerateBoxNoMotion();
testFromPointsSortsClampsAndForcesEndpoints();
testFromPointsSubTwoFallsBackToFlat();
testFromPointsRoundTripsAValidCurve();
if (g_fail == 0) std::printf("velocity_curve: all tests passed\n");
else std::printf("velocity_curve: %d FAILURES\n", g_fail);
return g_fail == 0 ? 0 : 1;
}