Merge pS-w2-t2-velcurve: velocity->amp transfer curve (velocity_curve spline + zones-payload v7 + Voice::start apply)
This commit is contained in:
+15
-1
@@ -498,9 +498,17 @@ add_library(pitch_shift STATIC src/vst/pitch_shift.cpp)
|
||||
target_include_directories(pitch_shift PUBLIC src src/vst)
|
||||
target_link_libraries(pitch_shift PUBLIC peaks)
|
||||
|
||||
# velocity_curve (S-VIEW-9) — the pure velocity->amp transfer curve (eval + editing/clamp/inverse
|
||||
# map). NO VST3/REAPER/SWELL/vendor and DELIBERATELY no editor_geometry (its hit-test takes an
|
||||
# explicit pixel box, not a Rect) so the engine can depend on it WITHOUT gaining a transitive
|
||||
# dependency on the editor's layout types. sampler_core depends on it (KeyZone carries a
|
||||
# VelocityCurve; Voice::start eval's it). Mirror of pitch_shift's role, one layer below the engine.
|
||||
add_library(velocity_curve STATIC src/vst/velocity_curve.cpp)
|
||||
target_include_directories(velocity_curve PUBLIC src/vst)
|
||||
|
||||
add_library(sampler_core STATIC src/vst/sampler_core.cpp)
|
||||
target_include_directories(sampler_core PUBLIC src src/vst)
|
||||
target_link_libraries(sampler_core PUBLIC peaks pitch_shift)
|
||||
target_link_libraries(sampler_core PUBLIC peaks pitch_shift velocity_curve)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3) Standalone tests for the pure modules (run without launching REAPER).
|
||||
@@ -665,6 +673,12 @@ add_executable(sampler_core_tests tests/test_sampler_core.cpp)
|
||||
target_link_libraries(sampler_core_tests PRIVATE sampler_core)
|
||||
add_test(NAME sampler_core_tests COMMAND sampler_core_tests)
|
||||
|
||||
# velocity_curve (S-VIEW-9): the pure velocity->amp transfer curve. Links ONLY velocity_curve —
|
||||
# NEITHER SDK, and specifically not editor_geometry — the plain-data-boundary + engine-clean proof.
|
||||
add_executable(velocity_curve_tests tests/test_velocity_curve.cpp)
|
||||
target_link_libraries(velocity_curve_tests PRIVATE velocity_curve)
|
||||
add_test(NAME velocity_curve_tests COMMAND velocity_curve_tests)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2i) Pure VST3-instrument helpers (Phase S1) — NO VST3, NO REAPER, NO SWELL/LICE.
|
||||
# editor_geometry: the IPlugView LICE editor's rectangle layout + hit-test math
|
||||
|
||||
+35
-2
@@ -219,6 +219,9 @@ ResolvedPerformance resolvePerformance(const std::string& banksJson,
|
||||
// S-VIEW-6: the key-tracking scalar is instrument state (not a bank fact) — carried
|
||||
// straight through to the resolved zone and applied in the repitch math at play time.
|
||||
rz.keyTrack = z.keyTrack;
|
||||
// S-VIEW-9: the velocity->amp curve is likewise instrument state — carried through and
|
||||
// eval'd at Voice::start to set the voice's amp gain from the note-on velocity.
|
||||
rz.velocityCurve = z.velocityCurve;
|
||||
// Effective loop / start (S11): the instrument's per-zone override wins over the
|
||||
// bank's S2 intrinsic; absent -> the intrinsic (loop) / frame 0 (start). The bank is
|
||||
// never mutated — this only shapes what the core plays for THIS instance (D-B).
|
||||
@@ -264,6 +267,7 @@ Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
|
||||
zone.highNote = zones[i].highNote;
|
||||
zone.rootNote = zones[i].rootNote;
|
||||
zone.keyTrack = zones[i].keyTrack; // S-VIEW-6: applied in keyTrackedRatio at play time
|
||||
zone.velocityCurve = zones[i].velocityCurve; // S-VIEW-9: eval'd in Voice::start
|
||||
zone.sampleIndex = sampleIndex;
|
||||
km.zones.push_back(zone);
|
||||
}
|
||||
@@ -407,8 +411,16 @@ void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map)
|
||||
putU64le(out, doubleToBits(pp.adsr.decaySeconds));
|
||||
putU64le(out, doubleToBits(pp.adsr.sustainLevel));
|
||||
putU64le(out, doubleToBits(pp.adsr.releaseSeconds));
|
||||
// PAYLOAD v6 (S-VIEW-6): the per-zone key-tracking scalar, appended last (1.0 = 100% ET).
|
||||
// PAYLOAD v6 (S-VIEW-6): the per-zone key-tracking scalar (1.0 = 100% ET).
|
||||
putU64le(out, doubleToBits(z.keyTrack));
|
||||
// PAYLOAD v7 (S-VIEW-9): the per-zone velocity->amp transfer curve, appended last. 4-byte LE
|
||||
// control-point count, then per point velocity + amp as IEEE-754 doubles (endpoints included).
|
||||
const std::vector<reasampler::vst::VelocityPoint>& pts = z.velocityCurve.points();
|
||||
putU32le(out, static_cast<std::uint32_t>(pts.size()));
|
||||
for (const reasampler::vst::VelocityPoint& p : pts) {
|
||||
putU64le(out, doubleToBits(p.velocity));
|
||||
putU64le(out, doubleToBits(p.amp));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -430,7 +442,8 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) {
|
||||
}
|
||||
const bool legacyV3Play = (pv == 3); // legacy S15/S16 play tail, wall-clock in 44.1k frames
|
||||
const bool secondsPlay = (pv >= 5); // v5+: full play params, wall-clock in seconds
|
||||
const bool keyTrackTail = (pv >= 6); // v6+ (S-VIEW-6): per-zone keyTrack scalar appended last
|
||||
const bool keyTrackTail = (pv >= 6); // v6+ (S-VIEW-6): per-zone keyTrack scalar
|
||||
const bool curveTail = (pv >= 7); // v7+ (S-VIEW-9): per-zone velocity->amp curve, appended last
|
||||
const std::uint32_t count = r.u32();
|
||||
for (std::uint32_t i = 0; i < count && r.ok; ++i) {
|
||||
// z.play defaults to the PRODUCT defaults (Gate + Preserve + tier-0 AHDSR seconds). A
|
||||
@@ -493,6 +506,26 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) {
|
||||
// payload (no field) leaves the PerformanceZone default (keyTrack = 1.0 = 100% ET), so an
|
||||
// already-saved instance repitches BIT-IDENTICALLY to the pre-S-VIEW-6 engine.
|
||||
if (keyTrackTail) z.keyTrack = bitsToDouble(r.u64());
|
||||
// PAYLOAD v7 (S-VIEW-9): the velocity->amp transfer curve, appended after the v6 keyTrack. A
|
||||
// pre-v7 payload (no field) leaves the PerformanceZone default (VelocityCurve::flat() — R10-F1
|
||||
// Option A, flat y=1), the deliberate NON-back-compat behavior change for already-saved zones.
|
||||
// fromPoints repairs the X-order/endpoint invariant defensively; a truncated read (r.ok flips
|
||||
// false mid-curve) leaves the flat default and the mid-zone break below drops the rest.
|
||||
if (curveTail) {
|
||||
const std::uint32_t ptCount = r.u32();
|
||||
std::vector<reasampler::vst::VelocityPoint> pts;
|
||||
// Bound the reserve to what the blob can actually hold (16 bytes/point) so a corrupt huge
|
||||
// count can't trigger a giant allocation before the bounded reads fail — the loop still
|
||||
// stops on r.ok, this only caps the speculative reserve.
|
||||
const std::size_t remaining = r.bytes.size() > r.pos ? r.bytes.size() - r.pos : 0;
|
||||
pts.reserve(std::min(static_cast<std::size_t>(ptCount), remaining / 16));
|
||||
for (std::uint32_t p = 0; p < ptCount && r.ok; ++p) {
|
||||
const double vel = bitsToDouble(r.u64());
|
||||
const double amp = bitsToDouble(r.u64());
|
||||
pts.push_back(reasampler::vst::VelocityPoint{vel, amp});
|
||||
}
|
||||
if (r.ok) z.velocityCurve = reasampler::vst::VelocityCurve::fromPoints(std::move(pts));
|
||||
}
|
||||
// Payload versions 4 (branch-only frames tail, never shipped) and any unknown pv leave the
|
||||
// seconds product defaults on z.play — a v4 blob cannot exist outside this branch.
|
||||
if (!r.ok) break; // truncated mid-zone -> keep what parsed cleanly, drop the rest
|
||||
|
||||
+31
-11
@@ -206,6 +206,16 @@ struct PerformanceZone {
|
||||
// resolvePerformance and applied in keyTrackedRatio inside BOTH repitch engines.
|
||||
double keyTrack = 1.0;
|
||||
|
||||
// S-VIEW-9 velocity->amp transfer curve (instrument-owned, D-B — mirror of keyTrack): maps the
|
||||
// note-on MIDI velocity (0..127) to the voice's amp gain, replacing the fixed linear velocity/127.
|
||||
// A per-sound performance characteristic, so it varies PER ZONE. DEFAULT = flat y=1 (R10-F1
|
||||
// Option A, Daniel-approved): every velocity plays at unity. This is a DELIBERATE, non-back-compat
|
||||
// behavior change — a pre-S-VIEW-9 blob (no velocityCurve field) lifts to flat y=1, so an
|
||||
// already-saved zone's soft hits play LOUDER than under the old linear map. Intended; do NOT
|
||||
// preserve the linear response. Carried to KeyZone by resolvePerformance, eval'd in Voice::start.
|
||||
// Sequenced on the zones-payload axis AFTER keyTrack (payload v6 -> v7).
|
||||
vst::VelocityCurve velocityCurve = vst::VelocityCurve::flat();
|
||||
|
||||
// S15/S16 per-zone play parameters (play mode + AHDSR + Trigger %-length/fades; pitch
|
||||
// engine + AD pitch envelope). Instrument-owned (D-B), never a bank fact — mirror of the
|
||||
// loop/start overrides. Wall-clock times are stored in SECONDS (rate-free); the keymap build
|
||||
@@ -236,6 +246,7 @@ struct ResolvedZone {
|
||||
int highNote = 127;
|
||||
int rootNote = 60; // effective: override, else bank intrinsic, else 60
|
||||
double keyTrack = 1.0; // S-VIEW-6 key-tracking scalar, carried from PerformanceZone (1.0 = 100% ET)
|
||||
vst::VelocityCurve velocityCurve = vst::VelocityCurve::flat(); // S-VIEW-9 velocity->amp curve, carried from PerformanceZone
|
||||
SampleLoop loop; // effective: loopOverride, else bank S2 intrinsic (S11)
|
||||
std::int64_t startFrame = 0; // effective initial read frame: startPoint, else 0 (S11)
|
||||
ZonePlaySeconds play; // S15/S16 per-zone play params (SECONDS; resolved to frames at build)
|
||||
@@ -364,21 +375,30 @@ DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
|
||||
|
||||
inline constexpr std::uint32_t kPerformanceStateVersion = 2;
|
||||
|
||||
// The zones-payload format version and its detection marker (S11/S15/S16/S12). serializePerformance
|
||||
// and serializeComponentState both emit the CURRENT payload version (v5 — marker + version +
|
||||
// records with the S11 loop/start tail AND the full play-params tail with wall-clock times in
|
||||
// SECONDS) so the overrides round-trip through EITHER envelope. Readers accept a v1 payload (no
|
||||
// marker), a v2 payload (marker + version 2, no play tail), and a v3 payload (legacy S15/S16
|
||||
// play tail with wall-clock frame counts) for back-compat, lifting missing fields to defaults.
|
||||
// v4 was never shipped and is not read. The marker is a high sentinel that a legitimate zone
|
||||
// count (bounded by 128 MIDI zones in practice, always tiny) can never collide with.
|
||||
// * PAYLOAD v6 (S-VIEW-6 — CURRENT WRITE FORMAT): identical to v5, PLUS one field appended to
|
||||
// each zone record after the full v5 play-params tail:
|
||||
// The zones-payload format version and its detection marker (S11/S15/S16/S12/S-VIEW-6/S-VIEW-9).
|
||||
// serializePerformance and serializeComponentState both emit the CURRENT payload version (v7 —
|
||||
// marker + version + records with the S11 loop/start tail, the full play-params tail with wall-clock
|
||||
// times in SECONDS, the v6 keyTrack scalar, and the v7 velocity->amp curve) so the overrides
|
||||
// round-trip through EITHER envelope. Readers accept a v1 payload (no marker), a v2 payload (marker +
|
||||
// version 2, no play tail), and a v3 payload (legacy S15/S16 play tail with wall-clock frame counts)
|
||||
// for back-compat, lifting missing fields to defaults. v4 was never shipped and is not read. The
|
||||
// marker is a high sentinel that a legitimate zone count (bounded by 128 MIDI zones in practice,
|
||||
// always tiny) can never collide with.
|
||||
// * PAYLOAD v6 (S-VIEW-6): identical to v5, PLUS one field appended to each zone record after the
|
||||
// full v5 play-params tail:
|
||||
// 8-byte LE keyTrack (IEEE-754 double) — the per-zone key-tracking scalar (1.0 = 100% ET).
|
||||
// A v1–v5 payload (no keyTrack field) lifts every zone to keyTrack = 1.0 (the PerformanceZone
|
||||
// default), so already-saved instances are BIT-IDENTICAL — the 100% default reproduces the
|
||||
// pre-S-VIEW-6 repitch exactly. A truncated mid-keyTrack record keeps the zones that parsed.
|
||||
inline constexpr std::uint32_t kZonesPayloadVersion = 6; // S-VIEW-6: + per-zone keyTrack scalar
|
||||
// * PAYLOAD v7 (S-VIEW-9 — CURRENT WRITE FORMAT): identical to v6, PLUS the per-zone velocity->amp
|
||||
// transfer curve appended to each zone record after the v6 keyTrack field:
|
||||
// 4-byte LE control-point count N, then per point: 8-byte LE velocity (double), 8-byte LE amp
|
||||
// (double). The two endpoints (velocity 0 and 127) are always included, so N >= 2.
|
||||
// A v1–v6 payload (no velocity-curve field) lifts every zone to VelocityCurve::flat() (R10-F1
|
||||
// Option A — flat y=1). This is a DELIBERATE, Daniel-approved NON-back-compat behavior change:
|
||||
// an already-saved zone's soft hits play LOUDER than under the pre-r10 linear velocity/127. A
|
||||
// truncated mid-curve record leaves the zone's flat default and keeps the zones that parsed.
|
||||
inline constexpr std::uint32_t kZonesPayloadVersion = 7; // S-VIEW-9: + per-zone velocity->amp curve
|
||||
inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u;
|
||||
|
||||
// (No kLegacyV3NominalRate constant.) The legacy v3 zone payload's wall-clock frame counts are
|
||||
|
||||
@@ -260,16 +260,16 @@ void Voice::presizePreserveShifters(std::int64_t windowFrames) {
|
||||
}
|
||||
|
||||
void Voice::start(int note, int velocity, const SampleData& sample, int rootNote,
|
||||
double keyTrack) {
|
||||
double keyTrack, const vst::VelocityCurve& velocityCurve) {
|
||||
active_ = true;
|
||||
releasing_ = false;
|
||||
amplitudeDone_ = false;
|
||||
note_ = note;
|
||||
// MIDI velocity 1..127 -> linear gain 0..1. Clamp defensively.
|
||||
int v = velocity;
|
||||
if (v < 0) v = 0;
|
||||
if (v > 127) v = 127;
|
||||
velocityGain_ = static_cast<double>(v) / 127.0;
|
||||
// S-VIEW-9: the velocity->amp transfer curve maps MIDI velocity to gain, ONCE at note-on (the
|
||||
// per-frame render just multiplies the cached velocityGain_ — no new process-thread work). The
|
||||
// clamp lives inside eval (velocity box-clamped to [0,127]). Replaces the pre-r10 linear
|
||||
// velocity/127; the default flat y=1 curve (R10-F1 Option A) plays every velocity at unity.
|
||||
velocityGain_ = velocityCurve.eval(static_cast<double>(velocity));
|
||||
// S-VIEW-6: the key-tracked repitch ratio feeds BOTH engines through baseRatio_ (Varispeed
|
||||
// read-rate bias and Preserve shift amount both derive from it below). keyTrack == 1.0 is
|
||||
// the pre-S-VIEW-6 pitchRatio bit-for-bit.
|
||||
@@ -572,7 +572,7 @@ std::size_t VoiceEngine::noteOn(int note, int velocity) {
|
||||
// The voice's Preserve shifters were pre-sized at engine construction (off-thread), so
|
||||
// start() only reset()s + warm()s them — no allocation on this audio-thread path.
|
||||
const std::size_t v = allocateVoice();
|
||||
voices_[v].start(note, velocity, sample, zone.rootNote, zone.keyTrack);
|
||||
voices_[v].start(note, velocity, sample, zone.rootNote, zone.keyTrack, zone.velocityCurve);
|
||||
voices_[v].setStartOrder(nextStartOrder_++);
|
||||
return v;
|
||||
}
|
||||
|
||||
+13
-3
@@ -21,8 +21,9 @@
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "peaks.h" // AudioSample (float)
|
||||
#include "pitch_shift.h" // PitchShifter (S16 Preserve engine DSP core)
|
||||
#include "peaks.h" // AudioSample (float)
|
||||
#include "pitch_shift.h" // PitchShifter (S16 Preserve engine DSP core)
|
||||
#include "velocity_curve.h" // VelocityCurve (S-VIEW-9 velocity->amp transfer curve; eval at start)
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
@@ -199,6 +200,12 @@ struct KeyZone {
|
||||
// key plays root pitch); 2.0 = double-rate tracking. Scales the (note-root) semitone offset
|
||||
// in the repitch math (keyTrackedRatio); rides BOTH engines via the voice's baseRatio_.
|
||||
double keyTrack = 1.0;
|
||||
// S-VIEW-9 velocity->amp transfer curve: maps the note-on velocity (0..127) to the voice's amp
|
||||
// gain, replacing the fixed linear velocity/127. A per-zone performance characteristic (mirror
|
||||
// of keyTrack), carried from PerformanceZone by resolvePerformance and eval'd ONCE in
|
||||
// Voice::start (never per frame). DEFAULT flat y=1 (R10-F1 Option A) — every velocity plays at
|
||||
// unity, a deliberate behavior change from the pre-r10 linear map.
|
||||
vst::VelocityCurve velocityCurve = vst::VelocityCurve::flat();
|
||||
std::size_t sampleIndex = 0; // index into Keymap::samples
|
||||
};
|
||||
|
||||
@@ -370,8 +377,11 @@ public:
|
||||
// is default (Gate + Varispeed + no pitch env).
|
||||
// `keyTrack` (S-VIEW-6) scales the (note-root) semitone offset feeding the repitch ratio;
|
||||
// 1.0 (the default) is standard 12-tone-ET, bit-identical to the pre-S-VIEW-6 baseRatio_.
|
||||
// `velocityCurve` (S-VIEW-9) maps the note-on velocity to the voice's amp gain, evaluated ONCE
|
||||
// here (off the per-frame path); defaults to flat y=1 (R10-F1) — every velocity plays at unity.
|
||||
void start(int note, int velocity, const SampleData& sample, int rootNote,
|
||||
double keyTrack = 1.0);
|
||||
double keyTrack = 1.0,
|
||||
const vst::VelocityCurve& velocityCurve = vst::VelocityCurve::flat());
|
||||
|
||||
// Gate off — begins the amplitude release. In GATE mode this enters the AHDSR release; in
|
||||
// TRIGGER mode it is a NO-OP (Trigger ignores note-off and plays through to its play length).
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
// velocity_curve.cpp — see velocity_curve.h. Pure eval + editing/clamp/inverse map; no host types.
|
||||
|
||||
#include "velocity_curve.h"
|
||||
|
||||
#include <algorithm> // std::max, std::min, std::abs, std::stable_sort
|
||||
#include <cmath> // std::fabs
|
||||
#include <utility> // std::move
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
namespace {
|
||||
|
||||
double clamp(double v, double lo, double hi) {
|
||||
if (v < lo) return lo;
|
||||
if (v > hi) return hi;
|
||||
return v;
|
||||
}
|
||||
|
||||
double clampVelocity(double v) { return clamp(v, kVelMin, kVelMax); }
|
||||
double clampAmp(double a) { return clamp(a, kAmpMin, kAmpMax); }
|
||||
|
||||
// Pixel<->box maps (mirror of envelope_edit's timeToX/levelToY). X spans the width for [0,127]; Y
|
||||
// spans (height-1) rows for amp [0,1] with amp 1 at the TOP (y increases downward).
|
||||
double velPerPixel(const VelocityCurve::Box& box) {
|
||||
const int w = std::max(0, box.width);
|
||||
if (w <= 0) return 0.0;
|
||||
return (kVelMax - kVelMin) / static_cast<double>(w);
|
||||
}
|
||||
double ampPerPixel(const VelocityCurve::Box& box) {
|
||||
const int h = std::max(0, box.height);
|
||||
if (h <= 1) return 0.0;
|
||||
return (kAmpMax - kAmpMin) / static_cast<double>(h - 1);
|
||||
}
|
||||
int velToX(const VelocityCurve::Box& box, double velocity) {
|
||||
const int w = std::max(0, box.width);
|
||||
if (w <= 0) return box.left;
|
||||
const double frac = (clampVelocity(velocity) - kVelMin) / (kVelMax - kVelMin);
|
||||
return box.left + static_cast<int>(frac * static_cast<double>(w) + 0.5);
|
||||
}
|
||||
int ampToY(const VelocityCurve::Box& box, double amp) {
|
||||
const int h = std::max(0, box.height);
|
||||
if (h <= 1) return box.top;
|
||||
// amp 1 at top (box.top), amp 0 at bottom (box.top + h - 1).
|
||||
const double frac = (clampAmp(amp) - kAmpMin) / (kAmpMax - kAmpMin);
|
||||
return box.top + static_cast<int>((1.0 - frac) * static_cast<double>(h - 1) + 0.5);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
VelocityCurve VelocityCurve::flat() {
|
||||
VelocityCurve c;
|
||||
c.points_ = {{kVelMin, kAmpMax}, {kVelMax, kAmpMax}}; // y = 1 everywhere (R10-F1 Option A)
|
||||
return c;
|
||||
}
|
||||
|
||||
VelocityCurve VelocityCurve::linear() {
|
||||
VelocityCurve c;
|
||||
c.points_ = {{kVelMin, kAmpMin}, {kVelMax, kAmpMax}}; // y = velocity/127
|
||||
return c;
|
||||
}
|
||||
|
||||
VelocityCurve VelocityCurve::fromPoints(std::vector<VelocityPoint> pts) {
|
||||
// Box-clamp every point, then stable-sort by velocity (X-order; stable so coincident-X points
|
||||
// keep their wire order). A stable sort keeps the eval well-defined for duplicate-X knots.
|
||||
for (VelocityPoint& p : pts) {
|
||||
p.velocity = clampVelocity(p.velocity);
|
||||
p.amp = clampAmp(p.amp);
|
||||
}
|
||||
std::stable_sort(pts.begin(), pts.end(),
|
||||
[](const VelocityPoint& a, const VelocityPoint& b) {
|
||||
return a.velocity < b.velocity;
|
||||
});
|
||||
// Fewer than 2 usable points -> can't span [0,127] as a function; fall back to the flat default.
|
||||
if (pts.size() < 2) return flat();
|
||||
// Force endpoints present at velocity 0 and 127 (they must exist for eval to be total).
|
||||
if (pts.front().velocity > kVelMin) {
|
||||
pts.insert(pts.begin(), VelocityPoint{kVelMin, pts.front().amp});
|
||||
} else {
|
||||
pts.front().velocity = kVelMin; // snap a near-0 first point exactly onto the endpoint
|
||||
}
|
||||
if (pts.back().velocity < kVelMax) {
|
||||
pts.push_back(VelocityPoint{kVelMax, pts.back().amp});
|
||||
} else {
|
||||
pts.back().velocity = kVelMax; // snap a near-127 last point exactly onto the endpoint
|
||||
}
|
||||
VelocityCurve c;
|
||||
c.points_ = std::move(pts);
|
||||
return c;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// Fritsch–Carlson monotone-cubic tangent for one interior knot i, given the secant slopes of the
|
||||
// two adjacent segments (dPrev = secant into knot i, dNext = secant out of knot i). Returns the
|
||||
// limited tangent that keeps the cubic Hermite piece monotone and inside the data range.
|
||||
//
|
||||
// The rule: a tangent whose adjacent secants have opposite signs (or either is flat) is a local
|
||||
// extremum — pin the tangent to 0 so the curve does not overshoot past the knot. Otherwise use the
|
||||
// weighted-harmonic-mean tangent (Fritsch–Carlson eq. 4), which for COLLINEAR knots (dPrev==dNext)
|
||||
// reduces to that common secant — so collinear control points reproduce the straight line to within
|
||||
// floating-point rounding (~1e-15), preserving the Option-B / null-response contract for linear().
|
||||
double fritschCarlsonTangent(double dPrev, double dNext, double spanPrev, double spanNext) {
|
||||
if (dPrev * dNext <= 0.0) return 0.0; // sign change or a flat neighbour -> local extremum
|
||||
// Weighted harmonic mean of the two secants (weights = the two segment widths). Collinear case:
|
||||
// dPrev==dNext==d makes this (w1+w2)*d / ((w1+w2)/... ) collapse to d exactly.
|
||||
const double w1 = 2.0 * spanNext + spanPrev;
|
||||
const double w2 = spanNext + 2.0 * spanPrev;
|
||||
return (w1 + w2) / (w1 / dPrev + w2 / dNext);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
double VelocityCurve::eval(double velocity) const {
|
||||
if (points_.empty()) return kAmpMax; // degenerate (shouldn't occur) -> flat unity
|
||||
if (points_.size() == 1) return clampAmp(points_[0].amp); // 1-point -> that point's amp
|
||||
const double v = clampVelocity(velocity);
|
||||
// At or before the first point / at or after the last, read the endpoint amp (the endpoints are
|
||||
// at 0 and 127, so this only fires exactly at the ends for an in-range velocity).
|
||||
if (v <= points_.front().velocity) return clampAmp(points_.front().amp);
|
||||
if (v >= points_.back().velocity) return clampAmp(points_.back().amp);
|
||||
// Find the segment [points_[i], points_[i+1]] containing v (X-ordered, so a linear scan).
|
||||
for (std::size_t i = 0; i + 1 < points_.size(); ++i) {
|
||||
const VelocityPoint& a = points_[i];
|
||||
const VelocityPoint& b = points_[i + 1];
|
||||
if (v >= a.velocity && v <= b.velocity) {
|
||||
const double span = b.velocity - a.velocity;
|
||||
// Coincident-X neighbours (a step): jump straight to the later point's amp — the segment
|
||||
// has zero width so there is no interior to blend.
|
||||
if (span <= 0.0) return clampAmp(b.amp);
|
||||
|
||||
// --- Monotone cubic Hermite (Fritsch–Carlson) interpolation on segment [a,b] ---------
|
||||
// Curved (spline) response, not straight lines. The interpolant provably stays within
|
||||
// [a.amp, b.amp] between the two knots (no bulge below 0 / above 1), and for collinear
|
||||
// control points its tangents reduce to the secant slope — so it reproduces the straight
|
||||
// line to within floating-point rounding (~1e-15), preserving linear()'s null-response
|
||||
// contract (y = velocity/127 to ~1e-15; the test tolerance of 1e-12 is appropriate).
|
||||
const double d = (b.amp - a.amp) / span; // secant of THIS segment
|
||||
|
||||
// Tangent at a: 0 if a is the first knot (endpoint), else the FC-limited tangent using
|
||||
// the previous segment's secant. Same for the tangent at b (0 at the last knot).
|
||||
double mA = d;
|
||||
if (i > 0) {
|
||||
const VelocityPoint& prev = points_[i - 1];
|
||||
const double spanPrev = a.velocity - prev.velocity;
|
||||
if (spanPrev > 0.0) {
|
||||
const double dPrev = (a.amp - prev.amp) / spanPrev;
|
||||
mA = fritschCarlsonTangent(dPrev, d, spanPrev, span);
|
||||
} else {
|
||||
mA = 0.0; // coincident-X predecessor (a step at a) -> flat tangent
|
||||
}
|
||||
}
|
||||
double mB = d;
|
||||
if (i + 2 < points_.size()) {
|
||||
const VelocityPoint& next = points_[i + 2];
|
||||
const double spanNext = next.velocity - b.velocity;
|
||||
if (spanNext > 0.0) {
|
||||
const double dNext = (next.amp - b.amp) / spanNext;
|
||||
mB = fritschCarlsonTangent(d, dNext, span, spanNext);
|
||||
} else {
|
||||
mB = 0.0; // coincident-X successor (a step at b) -> flat tangent
|
||||
}
|
||||
}
|
||||
|
||||
// Cubic Hermite basis on the normalized position t across [a,b]. For collinear knots
|
||||
// mA==mB==d, so h00*a + (h10*span)*d + h01*b + (h11*span)*d collapses to the straight
|
||||
// line to within floating-point rounding (~1e-15).
|
||||
const double t = (v - a.velocity) / span;
|
||||
const double t2 = t * t;
|
||||
const double t3 = t2 * t;
|
||||
const double h00 = 2.0 * t3 - 3.0 * t2 + 1.0;
|
||||
const double h10 = t3 - 2.0 * t2 + t;
|
||||
const double h01 = -2.0 * t3 + 3.0 * t2;
|
||||
const double h11 = t3 - t2;
|
||||
const double y = h00 * a.amp + h10 * span * mA + h01 * b.amp + h11 * span * mB;
|
||||
return clampAmp(y);
|
||||
}
|
||||
}
|
||||
return clampAmp(points_.back().amp); // unreachable (v is between the endpoints)
|
||||
}
|
||||
|
||||
std::size_t VelocityCurve::addPoint(double velocity, double amp) {
|
||||
const VelocityPoint p{clampVelocity(velocity), clampAmp(amp)};
|
||||
// Insert keeping X-order: first index whose velocity is STRICTLY greater than the new one, so a
|
||||
// duplicate-X point lands immediately after the existing one (a later move can separate them).
|
||||
std::size_t i = 0;
|
||||
while (i < points_.size() && points_[i].velocity <= p.velocity) ++i;
|
||||
points_.insert(points_.begin() + static_cast<std::ptrdiff_t>(i), p);
|
||||
return i;
|
||||
}
|
||||
|
||||
VelocityPoint VelocityCurve::movePoint(std::size_t index, double velocity, double amp) {
|
||||
if (index >= points_.size()) return VelocityPoint{}; // no-op (out of range)
|
||||
const bool isFirst = (index == 0);
|
||||
const bool isLast = (index + 1 == points_.size());
|
||||
|
||||
double newAmp = clampAmp(amp);
|
||||
double newVel;
|
||||
if (isFirst) {
|
||||
newVel = kVelMin; // endpoint pinned in X at 0 — only amp moves
|
||||
} else if (isLast) {
|
||||
newVel = kVelMax; // endpoint pinned in X at 127 — only amp moves
|
||||
} else {
|
||||
// Interior point: clamp X strictly within its immediate neighbours so it can't cross them.
|
||||
const double lo = points_[index - 1].velocity;
|
||||
const double hi = points_[index + 1].velocity;
|
||||
newVel = clamp(clampVelocity(velocity), lo, hi);
|
||||
}
|
||||
points_[index] = VelocityPoint{newVel, newAmp};
|
||||
return points_[index];
|
||||
}
|
||||
|
||||
bool VelocityCurve::deletePoint(std::size_t index) {
|
||||
if (index >= points_.size()) return false;
|
||||
if (index == 0 || index + 1 == points_.size()) return false; // endpoints are not deletable
|
||||
points_.erase(points_.begin() + static_cast<std::ptrdiff_t>(index));
|
||||
return true;
|
||||
}
|
||||
|
||||
int VelocityCurve::pointAtPixel(const Box& box, int x, int y) const {
|
||||
for (std::size_t i = 0; i < points_.size(); ++i) {
|
||||
const int px = velToX(box, points_[i].velocity);
|
||||
const int py = ampToY(box, points_[i].amp);
|
||||
if (std::abs(x - px) <= kCurveNodeGrabRadius && std::abs(y - py) <= kCurveNodeGrabRadius) {
|
||||
return static_cast<int>(i);
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
VelocityCurve VelocityCurve::resolvePointDrag(const VelocityCurve& grabCurve, std::size_t index,
|
||||
const Box& box, int dxPixels, int dyPixels) {
|
||||
VelocityCurve out = grabCurve;
|
||||
if (index >= out.points_.size()) return out; // out of range -> no motion
|
||||
const double velPerPx = velPerPixel(box);
|
||||
const double ampPerPx = ampPerPixel(box);
|
||||
if (velPerPx <= 0.0 || ampPerPx <= 0.0) return out; // degenerate box -> no motion
|
||||
|
||||
const VelocityPoint& grab = grabCurve.points_[index];
|
||||
const double newVel = grab.velocity + static_cast<double>(dxPixels) * velPerPx;
|
||||
// Y increases downward but amp increases upward, so a downward drag (positive dy) LOWERS amp.
|
||||
const double newAmp = grab.amp - static_cast<double>(dyPixels) * ampPerPx;
|
||||
out.movePoint(index, newVel, newAmp); // applies box + neighbour-X + endpoint-pin clamps
|
||||
return out;
|
||||
}
|
||||
|
||||
bool VelocityCurve::equals(const VelocityCurve& other, double eps) const {
|
||||
if (points_.size() != other.points_.size()) return false;
|
||||
for (std::size_t i = 0; i < points_.size(); ++i) {
|
||||
if (std::fabs(points_[i].velocity - other.points_[i].velocity) > eps) return false;
|
||||
if (std::fabs(points_[i].amp - other.points_[i].amp) > eps) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
@@ -0,0 +1,155 @@
|
||||
// velocity_curve.h — PURE velocity->amp transfer curve (S-VIEW-9, r10). NO VST3, NO REAPER, NO
|
||||
// SWELL/LICE, NO vendor/ includes at the boundary. The mirror of envelope_edit / card_drag: the
|
||||
// eval + the clamp/order/inverse-map arithmetic live here, unit-tested outside the DAW; the future
|
||||
// editor shell (reasampler_editor.cpp, S-VIEW-10) draws the box + node handles and feeds each move's
|
||||
// pixel delta back through here, committing the result to the zone through the same off-audio-thread
|
||||
// path a slider edit uses.
|
||||
//
|
||||
// WHAT IT IS. A monotonic-in-x transfer function mapping MIDI velocity (X: 0..127) to an amp scalar
|
||||
// (Y: 0..1), authored as an ordered list of control points. eval(velocity) is called ONCE per
|
||||
// note-on in Voice::start() (never per frame) to set the voice's velocityGain_, replacing the fixed
|
||||
// linear velocity/127 map. The curve is a per-PerformanceZone performance characteristic (D-B) — a
|
||||
// sibling of the AHDSR envelope, pitch engine, and keyTrack scalar — so it varies per sound, stored
|
||||
// on PerformanceZone and resolved onto the KeyZone at keymap build (mirror of keyTrack).
|
||||
//
|
||||
// DEFAULT — flat y=1 (fork R10-F1 Option A, Daniel 2026-07-27). VelocityCurve::flat() is the seeded
|
||||
// default: EVERY velocity plays at unity amp. This is a DELIBERATE, Daniel-approved behavior change
|
||||
// vs. the shipped linear velocity/127 map — soft hits are now full level until a curve is drawn.
|
||||
// NOT bit-identical to the pre-r10 engine, by design; do not "preserve" the linear response.
|
||||
//
|
||||
// THE INVARIANT (mirror of envelope_edit's S-VIEW-F2). A drag/edit can NEVER produce a curve eval
|
||||
// couldn't handle:
|
||||
// * X-ORDERED — a point clamps between its predecessor's and successor's velocity, so control
|
||||
// points never cross in X. This is what makes eval a well-defined FUNCTION (one amp per
|
||||
// velocity): each X falls in exactly one [p_i, p_{i+1}] segment.
|
||||
// * BOX-CLAMPED — velocity clamps to [0,127], amp clamps to [0,1] (the drawn box).
|
||||
// Both endpoints (velocity 0 and 127) are always present so eval is total over [0,127]; delete
|
||||
// refuses to remove them, and the constructors seed them.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
// DELIBERATELY dependency-free at the boundary (no editor_geometry / Rect). This module sits BELOW
|
||||
// sampler_core in the link graph (KeyZone carries a VelocityCurve; Voice::start calls eval), and the
|
||||
// engine must not gain a transitive dependency on the editor's layout types. The editor hit-test /
|
||||
// inverse-map therefore takes an explicit pixel box (boxLeft/boxTop/boxWidth/boxHeight) rather than a
|
||||
// Rect — the future editor shell (S-VIEW-10) passes its box coords directly. Mirror of envelope_edit's
|
||||
// role, but one layer lower, so the coupling stays out of the engine core.
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
// The MIDI velocity domain [0,127] and the amp range [0,1] — the box every point clamps into.
|
||||
inline constexpr double kVelMin = 0.0;
|
||||
inline constexpr double kVelMax = 127.0;
|
||||
inline constexpr double kAmpMin = 0.0;
|
||||
inline constexpr double kAmpMax = 1.0;
|
||||
|
||||
// One control point: a (velocity, amp) knot the curve passes through. Both fields are box-clamped
|
||||
// by the mutators; a raw-constructed point is NOT auto-clamped (the mutators own the invariant), so
|
||||
// build curves through the named constructors / addPoint rather than pushing raw points.
|
||||
struct VelocityPoint {
|
||||
double velocity = 0.0; // X, [0,127]
|
||||
double amp = 0.0; // Y, [0,1]
|
||||
};
|
||||
|
||||
// The pick radius (px) around a node's drawn point for the editor hit-test. Mirrors
|
||||
// envelope_edit::kNodeGrabRadius / waveform_view::kMarkerGrabWidth.
|
||||
inline constexpr int kCurveNodeGrabRadius = 6;
|
||||
|
||||
// A velocity->amp transfer curve: an X-ORDERED list of control points spanning [0,127], evaluated by
|
||||
// a MONOTONE cubic Hermite spline (Fritsch–Carlson slope limiting) through the knots — a genuine
|
||||
// curved response (Daniel 2026-07-27: "straight lines sound like shit"), not a polyline. Each
|
||||
// velocity still maps to exactly one amp: the interpolant is single-valued and provably stays within
|
||||
// each segment's amp range, so the curve never overshoots below 0 or above 1. For COLLINEAR knots the
|
||||
// Fritsch–Carlson tangents reduce to the secant slope, so the spline reproduces the straight line to
|
||||
// within floating-point rounding (~1e-15) — that preserves linear()'s null-response contract
|
||||
// (y = velocity/127 to ~1e-15; the 1e-12 test tolerance is deliberately conservative). The two endpoints
|
||||
// (velocity 0 and 127) are load-bearing: they keep eval total and are never deletable.
|
||||
class VelocityCurve {
|
||||
public:
|
||||
// R10-F1 default (Option A): flat y=1 — endpoints (0,1) and (127,1); every velocity -> unity.
|
||||
static VelocityCurve flat();
|
||||
// The classic linear ramp y = velocity/127 — endpoints (0,0) and (127,1). Retained for tests
|
||||
// and as the Option-B seed; NOT the default (see R10-F1).
|
||||
static VelocityCurve linear();
|
||||
|
||||
// Rebuild a curve from a deserialized point list, REPAIRING the invariant defensively (the
|
||||
// deserialization seam, sample_map's zones-payload v7). Each point is box-clamped; the list is
|
||||
// stable-sorted by velocity (X-ordered); endpoints at velocity 0 and 127 are forced present
|
||||
// (an absent endpoint is synthesized at the nearest interior amp, or unity for an empty list).
|
||||
// A list with fewer than 2 usable points falls back to flat(). Never trusts the wire blindly —
|
||||
// a corrupt/truncated blob yields a well-formed curve, never an invariant-violating one.
|
||||
static VelocityCurve fromPoints(std::vector<VelocityPoint> pts);
|
||||
|
||||
// The control points, X-ordered, first at velocity 0 and last at velocity 127 (invariant).
|
||||
const std::vector<VelocityPoint>& points() const { return points_; }
|
||||
std::size_t size() const { return points_.size(); }
|
||||
|
||||
// Evaluate the curve at `velocity` -> amp in [0,1]. Velocity is box-clamped to [0,127] first,
|
||||
// so an out-of-range note (shouldn't occur) reads the nearest endpoint. Between two adjacent
|
||||
// points the amp follows a MONOTONE cubic Hermite spline (Fritsch–Carlson slope limiting) — a
|
||||
// true curve that provably stays within the two knots' amp range (no overshoot below 0 / above
|
||||
// 1) and reproduces the straight line to within floating-point rounding (~1e-15) for collinear
|
||||
// knots. Single-valued / monotonic in X.
|
||||
// Degenerate cases (shouldn't occur post-construction): an EMPTY curve returns kAmpMax (flat
|
||||
// unity); a ONE-point curve returns that point's amp.
|
||||
double eval(double velocity) const;
|
||||
|
||||
// --- Editing (for the S-VIEW-10 editor UI) --------------------------------------------------
|
||||
// Insert a new control point, box-clamped, keeping the list X-ordered by velocity. Returns the
|
||||
// index of the inserted point. A new point at a velocity that duplicates an existing one is
|
||||
// inserted immediately AFTER it (so a subsequent move can separate them); the endpoints are not
|
||||
// special-cased on insert (a point at exactly 0 or 127 inserts adjacent to that endpoint).
|
||||
std::size_t addPoint(double velocity, double amp);
|
||||
|
||||
// Move point `index` to (velocity, amp), box-clamped AND X-clamped between its immediate
|
||||
// neighbours so it cannot cross them (monotonic-X grammar). The two ENDPOINTS are pinned in X
|
||||
// (index 0 stays at velocity 0, the last stays at 127) — only their AMP moves; their velocity
|
||||
// argument is ignored. An out-of-range index is a no-op. Returns the (possibly clamped)
|
||||
// resulting point.
|
||||
VelocityPoint movePoint(std::size_t index, double velocity, double amp);
|
||||
|
||||
// Delete point `index`. The two endpoints (index 0 and the last) are NOT deletable — a request
|
||||
// to remove either, or an out-of-range index, is a no-op returning false. Returns true iff a
|
||||
// point was removed.
|
||||
bool deletePoint(std::size_t index);
|
||||
|
||||
// --- Editor hit-test + inverse map (mirror of envelope_edit) --------------------------------
|
||||
// The drawn box, in pixels: origin (boxLeft, boxTop), `boxWidth` px wide, `boxHeight` px tall.
|
||||
// X = velocity across the width (0 at boxLeft, 127 at boxLeft+boxWidth); Y = amp UP the height
|
||||
// (amp 1 at boxTop, amp 0 at boxTop+boxHeight-1). Passed explicitly (not a Rect) so this module
|
||||
// stays free of editor-layout types — see the header preamble.
|
||||
struct Box {
|
||||
int left = 0;
|
||||
int top = 0;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
};
|
||||
|
||||
// Which control point a grab at (x,y) lands on, given the drawn `box`. Returns the index of the
|
||||
// first point within the pick radius in BOTH axes, or -1 for a miss. First-match in point order
|
||||
// for determinism (mirror of nodeAtPoint).
|
||||
int pointAtPixel(const Box& box, int x, int y) const;
|
||||
|
||||
// Resolve a drag of point `index` by a pixel delta since grab, given the curve AS OF GRAB TIME
|
||||
// (`grabCurve` — the shell snapshots it on mouse-down so the delta is absolute) and the box.
|
||||
// Maps the pixel delta to a (velocity, amp) delta over the box, then applies movePoint's clamp
|
||||
// (box + neighbour X + endpoint X-pin). A zero-width/height box or out-of-range index returns
|
||||
// `grabCurve` unchanged. Pure — mirror of resolveNodeDrag.
|
||||
static VelocityCurve resolvePointDrag(const VelocityCurve& grabCurve, std::size_t index,
|
||||
const Box& box, int dxPixels, int dyPixels);
|
||||
|
||||
// Equality (for tests + round-trip assertions): same point count + each point equal within a
|
||||
// tight epsilon.
|
||||
bool equals(const VelocityCurve& other, double eps = 1e-9) const;
|
||||
|
||||
private:
|
||||
// Points are always X-ordered with an endpoint at 0 and 127. Constructed only through the named
|
||||
// constructors + deserialize (see sample_map), which establish that invariant; the mutators
|
||||
// preserve it.
|
||||
std::vector<VelocityPoint> points_;
|
||||
};
|
||||
|
||||
} // namespace reasampler::vst
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
// 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
|
||||
}
|
||||
|
||||
// --- eval: monotone cubic Hermite spline (Fritsch–Carlson) --------------------
|
||||
|
||||
static void testCollinearControlPointsReproduceExactLinearRamp() {
|
||||
// The Option-B guarantee: for COLLINEAR knots the FC tangents reduce to the secant slope, so the
|
||||
// spline IS the straight line y = velocity/127 — bit-exact, not merely close. Add an interior
|
||||
// point that sits EXACTLY on the linear ramp so all three knots are collinear.
|
||||
VelocityCurve c = VelocityCurve::linear(); // (0,0),(127,1)
|
||||
c.addPoint(60.0, 60.0 / 127.0); // on the line -> still collinear
|
||||
// Every velocity must equal velocity/127 to full double precision (bit-exact reproduction).
|
||||
for (int v = 0; v <= 127; ++v) CHECK(near(c.eval(v), v / 127.0, 1e-12));
|
||||
// And the untouched linear() with only its two endpoints, too.
|
||||
const VelocityCurve line = VelocityCurve::linear();
|
||||
for (int v = 0; v <= 127; ++v) CHECK(near(line.eval(v), v / 127.0, 1e-12));
|
||||
}
|
||||
|
||||
static void testNoOvershootWithSharpInteriorDip() {
|
||||
// A sharp interior dip is the classic overshoot trap: a NON-monotone interpolant (Catmull-Rom /
|
||||
// natural cubic) would bulge the curve below 0 near the trough. Fritsch–Carlson must keep every
|
||||
// sampled amp inside [0,1] across the whole domain. Build (0,1)->(64,0)->(127,1).
|
||||
VelocityCurve c = VelocityCurve::flat();
|
||||
c.movePoint(0, 0, 1.0);
|
||||
c.addPoint(64.0, 0.0);
|
||||
c.movePoint(2, 127, 1.0);
|
||||
for (int v = 0; v <= 127; ++v) {
|
||||
const double y = c.eval(v);
|
||||
CHECK(y >= 0.0 - 1e-12 && y <= 1.0 + 1e-12);
|
||||
}
|
||||
// A dense sub-integer sweep too (the spline could overshoot between integer velocities).
|
||||
for (int k = 0; k <= 1270; ++k) {
|
||||
const double y = c.eval(k / 10.0);
|
||||
CHECK(y >= 0.0 - 1e-12 && y <= 1.0 + 1e-12);
|
||||
}
|
||||
CHECK(near(c.eval(64), 0.0)); // knot honored exactly
|
||||
}
|
||||
|
||||
static void testSplineStaysSingleValuedMonotoneInEachSegment() {
|
||||
// A rising staircase of knots: the spline must be non-decreasing across the whole domain (the FC
|
||||
// limiter forbids overshoot, so a monotone-increasing knot set yields a monotone-increasing
|
||||
// curve — no local wiggles that would make eval multi-valued in feel).
|
||||
VelocityCurve c = VelocityCurve::linear();
|
||||
c.addPoint(30.0, 0.1);
|
||||
c.addPoint(60.0, 0.15); // a near-flat run then a steep rise: overshoot bait for a plain cubic
|
||||
c.addPoint(90.0, 0.9);
|
||||
double prev = c.eval(0);
|
||||
for (int k = 1; k <= 1270; ++k) {
|
||||
const double cur = c.eval(k / 10.0);
|
||||
CHECK(cur >= prev - 1e-9); // non-decreasing everywhere -> single-valued, no wiggle
|
||||
CHECK(cur >= 0.0 - 1e-12 && cur <= 1.0 + 1e-12);
|
||||
prev = cur;
|
||||
}
|
||||
}
|
||||
|
||||
static void testSplinePinsEndpointsExactly() {
|
||||
// The curve must pass THROUGH every control point, endpoints included, regardless of curvature.
|
||||
VelocityCurve c = VelocityCurve::flat();
|
||||
c.movePoint(0, 0, 0.2); // first endpoint amp 0.2
|
||||
c.addPoint(40.0, 0.9);
|
||||
c.addPoint(80.0, 0.1);
|
||||
c.movePoint(3, 127, 0.7); // last endpoint amp 0.7
|
||||
CHECK(near(c.eval(0), 0.2));
|
||||
CHECK(near(c.eval(40), 0.9));
|
||||
CHECK(near(c.eval(80), 0.1));
|
||||
CHECK(near(c.eval(127), 0.7));
|
||||
}
|
||||
|
||||
// --- 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();
|
||||
testCollinearControlPointsReproduceExactLinearRamp();
|
||||
testNoOvershootWithSharpInteriorDip();
|
||||
testSplineStaysSingleValuedMonotoneInEachSegment();
|
||||
testSplinePinsEndpointsExactly();
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user