From a1f9dcf6f8e79ca8db02a747269d862a24c7353f Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 27 Jul 2026 14:18:01 -0400 Subject: [PATCH] S-VIEW-9: velocity->amp transfer curve (pure velocity_curve module + zones-payload v7 + Voice::start apply) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CMakeLists.txt | 16 ++- src/vst/sample_map.cpp | 37 +++++- src/vst/sample_map.h | 42 ++++-- src/vst/sampler_core.cpp | 14 +- src/vst/sampler_core.h | 16 ++- src/vst/velocity_curve.cpp | 194 +++++++++++++++++++++++++++ src/vst/velocity_curve.h | 148 +++++++++++++++++++++ tests/test_sample_map.cpp | 102 ++++++++++++++ tests/test_sampler_core.cpp | 53 ++++++-- tests/test_velocity_curve.cpp | 241 ++++++++++++++++++++++++++++++++++ 10 files changed, 829 insertions(+), 34 deletions(-) create mode 100644 src/vst/velocity_curve.cpp create mode 100644 src/vst/velocity_curve.h create mode 100644 tests/test_velocity_curve.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 5de3a92..f2431fc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/src/vst/sample_map.cpp b/src/vst/sample_map.cpp index 6369c93..dd096eb 100644 --- a/src/vst/sample_map.cpp +++ b/src/vst/sample_map.cpp @@ -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& 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& 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& pts = z.velocityCurve.points(); + putU32le(out, static_cast(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 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(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 diff --git a/src/vst/sample_map.h b/src/vst/sample_map.h index db9b361..ba2d1cf 100644 --- a/src/vst/sample_map.h +++ b/src/vst/sample_map.h @@ -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& 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 diff --git a/src/vst/sampler_core.cpp b/src/vst/sampler_core.cpp index 370a49c..22222c3 100644 --- a/src/vst/sampler_core.cpp +++ b/src/vst/sampler_core.cpp @@ -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(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(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; } diff --git a/src/vst/sampler_core.h b/src/vst/sampler_core.h index fa2f31b..adee55c 100644 --- a/src/vst/sampler_core.h +++ b/src/vst/sampler_core.h @@ -21,8 +21,9 @@ #include #include -#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). diff --git a/src/vst/velocity_curve.cpp b/src/vst/velocity_curve.cpp new file mode 100644 index 0000000..f69e7d1 --- /dev/null +++ b/src/vst/velocity_curve.cpp @@ -0,0 +1,194 @@ +// velocity_curve.cpp — see velocity_curve.h. Pure eval + editing/clamp/inverse map; no host types. + +#include "velocity_curve.h" + +#include // std::max, std::min, std::abs, std::stable_sort +#include // std::fabs +#include // 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(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(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(frac * static_cast(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((1.0 - frac) * static_cast(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 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; +} + +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); + 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); + // Linear interpolation between the two knots. Linear (not smoothstep) is what makes + // linear() an EXACT straight line y = velocity/127 (the Option-B / null-response contract + // some callers opt back into) and keeps eval trivially monotonic in X. The "curved" feel + // the editor offers comes from the user placing more control points, not from bending a + // single segment. + const double t = (v - a.velocity) / span; + return clampAmp(a.amp + (b.amp - a.amp) * t); + } + } + 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(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(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(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(dxPixels) * velPerPx; + // Y increases downward but amp increases upward, so a downward drag (positive dy) LOWERS amp. + const double newAmp = grab.amp - static_cast(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 diff --git a/src/vst/velocity_curve.h b/src/vst/velocity_curve.h new file mode 100644 index 0000000..1b2653f --- /dev/null +++ b/src/vst/velocity_curve.h @@ -0,0 +1,148 @@ +// 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 +#include + +// 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 +// LINEAR interpolation between adjacent points (a monotonic polyline: each velocity maps to exactly +// one amp). Linear-between-knots is deliberate — it makes linear() an EXACT straight line y = +// velocity/127 (the Option-B / null-response contract) and keeps monotonicity trivial; the "curved" +// shape a sound wants comes from placing more control points, not from bending one segment. 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 pts); + + // The control points, X-ordered, first at velocity 0 and last at velocity 127 (invariant). + const std::vector& 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 interpolates LINEARLY across the normalized X position — monotonic in X. A + // degenerate curve (0 or 1 point, shouldn't occur post-construction) returns kAmpMax (flat). + 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 points_; +}; + +} // namespace reasampler::vst diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp index 00c04f4..0a4a2a8 100644 --- a/tests/test_sample_map.cpp +++ b/tests/test_sample_map.cpp @@ -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 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((bits >> (i * 8)) & 0xFF)); + }; + auto i64 = [&](std::int64_t v) { + std::uint64_t bits = static_cast(v); + for (int i = 0; i < 8; ++i) b.push_back(static_cast((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(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(); diff --git a/tests/test_sampler_core.cpp b/tests/test_sampler_core.cpp index c6b41b9..341aaf0 100644 --- a/tests/test_sampler_core.cpp +++ b/tests/test_sampler_core.cpp @@ -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 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 out; - eng.render(out, 1); + std::vector out; eng.render(out, 1); CHECK(approx(out[0], 1.0, 1e-4)); } { VoiceEngine eng(1, km); eng.noteOn(60, 64); - std::vector out; - eng.render(out, 1); + std::vector 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 out; - eng.render(out, 1); + std::vector 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 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(); diff --git a/tests/test_velocity_curve.cpp b/tests/test_velocity_curve.cpp new file mode 100644 index 0000000..a5b4431 --- /dev/null +++ b/tests/test_velocity_curve.cpp @@ -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 +#include + +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 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; +}