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

Default flat y=1 (R10-F1 Option A) replaces the linear velocity/127 at note-on — a deliberate, non-back-compat behavior change; v1-v6 blobs lift to the flat default.
This commit is contained in:
2026-07-27 14:18:01 -04:00
parent 203005e6cf
commit a1f9dcf6f8
10 changed files with 829 additions and 34 deletions
+15 -1
View File
@@ -498,9 +498,17 @@ add_library(pitch_shift STATIC src/vst/pitch_shift.cpp)
target_include_directories(pitch_shift PUBLIC src src/vst) target_include_directories(pitch_shift PUBLIC src src/vst)
target_link_libraries(pitch_shift PUBLIC peaks) 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) add_library(sampler_core STATIC src/vst/sampler_core.cpp)
target_include_directories(sampler_core PUBLIC src src/vst) 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). # 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) target_link_libraries(sampler_core_tests PRIVATE sampler_core)
add_test(NAME sampler_core_tests COMMAND sampler_core_tests) 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. # 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 # editor_geometry: the IPlugView LICE editor's rectangle layout + hit-test math
+35 -2
View File
@@ -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 // 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. // straight through to the resolved zone and applied in the repitch math at play time.
rz.keyTrack = z.keyTrack; 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 // 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 // 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). // 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.highNote = zones[i].highNote;
zone.rootNote = zones[i].rootNote; zone.rootNote = zones[i].rootNote;
zone.keyTrack = zones[i].keyTrack; // S-VIEW-6: applied in keyTrackedRatio at play time 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; zone.sampleIndex = sampleIndex;
km.zones.push_back(zone); 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.decaySeconds));
putU64le(out, doubleToBits(pp.adsr.sustainLevel)); putU64le(out, doubleToBits(pp.adsr.sustainLevel));
putU64le(out, doubleToBits(pp.adsr.releaseSeconds)); 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)); 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 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 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(); const std::uint32_t count = r.u32();
for (std::uint32_t i = 0; i < count && r.ok; ++i) { 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 // 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 // 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. // already-saved instance repitches BIT-IDENTICALLY to the pre-S-VIEW-6 engine.
if (keyTrackTail) z.keyTrack = bitsToDouble(r.u64()); 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 // 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. // 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 if (!r.ok) break; // truncated mid-zone -> keep what parsed cleanly, drop the rest
+31 -11
View File
@@ -206,6 +206,16 @@ struct PerformanceZone {
// resolvePerformance and applied in keyTrackedRatio inside BOTH repitch engines. // resolvePerformance and applied in keyTrackedRatio inside BOTH repitch engines.
double keyTrack = 1.0; 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 // 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 // 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 // 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 highNote = 127;
int rootNote = 60; // effective: override, else bank intrinsic, else 60 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) 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) SampleLoop loop; // effective: loopOverride, else bank S2 intrinsic (S11)
std::int64_t startFrame = 0; // effective initial read frame: startPoint, else 0 (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) 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; inline constexpr std::uint32_t kPerformanceStateVersion = 2;
// The zones-payload format version and its detection marker (S11/S15/S16/S12). serializePerformance // The zones-payload format version and its detection marker (S11/S15/S16/S12/S-VIEW-6/S-VIEW-9).
// and serializeComponentState both emit the CURRENT payload version (v5 marker + version + // serializePerformance and serializeComponentState both emit the CURRENT payload version (v7
// records with the S11 loop/start tail AND the full play-params tail with wall-clock times in // marker + version + records with the S11 loop/start tail, the full play-params tail with wall-clock
// SECONDS) so the overrides round-trip through EITHER envelope. Readers accept a v1 payload (no // times in SECONDS, the v6 keyTrack scalar, and the v7 velocity->amp curve) so the overrides
// marker), a v2 payload (marker + version 2, no play tail), and a v3 payload (legacy S15/S16 // round-trip through EITHER envelope. Readers accept a v1 payload (no marker), a v2 payload (marker +
// play tail with wall-clock frame counts) for back-compat, lifting missing fields to defaults. // version 2, no play tail), and a v3 payload (legacy S15/S16 play tail with wall-clock frame counts)
// v4 was never shipped and is not read. The marker is a high sentinel that a legitimate zone // for back-compat, lifting missing fields to defaults. v4 was never shipped and is not read. The
// count (bounded by 128 MIDI zones in practice, always tiny) can never collide with. // marker is a high sentinel that a legitimate zone count (bounded by 128 MIDI zones in practice,
// * PAYLOAD v6 (S-VIEW-6 — CURRENT WRITE FORMAT): identical to v5, PLUS one field appended to // always tiny) can never collide with.
// each zone record after the full v5 play-params tail: // * 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). // 8-byte LE keyTrack (IEEE-754 double) — the per-zone key-tracking scalar (1.0 = 100% ET).
// A v1v5 payload (no keyTrack field) lifts every zone to keyTrack = 1.0 (the PerformanceZone // A v1v5 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 // 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. // 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 v1v6 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; inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u;
// (No kLegacyV3NominalRate constant.) The legacy v3 zone payload's wall-clock frame counts are // (No kLegacyV3NominalRate constant.) The legacy v3 zone payload's wall-clock frame counts are
+7 -7
View File
@@ -260,16 +260,16 @@ void Voice::presizePreserveShifters(std::int64_t windowFrames) {
} }
void Voice::start(int note, int velocity, const SampleData& sample, int rootNote, void Voice::start(int note, int velocity, const SampleData& sample, int rootNote,
double keyTrack) { double keyTrack, const vst::VelocityCurve& velocityCurve) {
active_ = true; active_ = true;
releasing_ = false; releasing_ = false;
amplitudeDone_ = false; amplitudeDone_ = false;
note_ = note; note_ = note;
// MIDI velocity 1..127 -> linear gain 0..1. Clamp defensively. // S-VIEW-9: the velocity->amp transfer curve maps MIDI velocity to gain, ONCE at note-on (the
int v = velocity; // per-frame render just multiplies the cached velocityGain_ — no new process-thread work). The
if (v < 0) v = 0; // clamp lives inside eval (velocity box-clamped to [0,127]). Replaces the pre-r10 linear
if (v > 127) v = 127; // velocity/127; the default flat y=1 curve (R10-F1 Option A) plays every velocity at unity.
velocityGain_ = static_cast<double>(v) / 127.0; velocityGain_ = velocityCurve.eval(static_cast<double>(velocity));
// S-VIEW-6: the key-tracked repitch ratio feeds BOTH engines through baseRatio_ (Varispeed // 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 // 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. // 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 // 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. // start() only reset()s + warm()s them — no allocation on this audio-thread path.
const std::size_t v = allocateVoice(); 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_++); voices_[v].setStartOrder(nextStartOrder_++);
return v; return v;
} }
+13 -3
View File
@@ -21,8 +21,9 @@
#include <cstdint> #include <cstdint>
#include <vector> #include <vector>
#include "peaks.h" // AudioSample (float) #include "peaks.h" // AudioSample (float)
#include "pitch_shift.h" // PitchShifter (S16 Preserve engine DSP core) #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 { namespace reasampler {
@@ -199,6 +200,12 @@ struct KeyZone {
// key plays root pitch); 2.0 = double-rate tracking. Scales the (note-root) semitone offset // 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_. // in the repitch math (keyTrackedRatio); rides BOTH engines via the voice's baseRatio_.
double keyTrack = 1.0; 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 std::size_t sampleIndex = 0; // index into Keymap::samples
}; };
@@ -370,8 +377,11 @@ public:
// is default (Gate + Varispeed + no pitch env). // is default (Gate + Varispeed + no pitch env).
// `keyTrack` (S-VIEW-6) scales the (note-root) semitone offset feeding the repitch ratio; // `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_. // 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, 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 // 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). // TRIGGER mode it is a NO-OP (Trigger ignores note-off and plays through to its play length).
+194
View File
@@ -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 <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;
}
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<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
+148
View File
@@ -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 <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
// 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<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 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<VelocityPoint> points_;
};
} // namespace reasampler::vst
+102
View File
@@ -1370,6 +1370,104 @@ static void testKeyTrackV5BackCompatLiftsToUnity() {
CHECK(back.zones[0].keyTrack == 1.0); // no keyTrack tail -> default 1.0 (bit-identical repitch) 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() { static void testPlayParamsV2BackCompatLiftsToDefaults() {
// A pre-S15 PAYLOAD v2 blob (marker + version 2 + record with the S11 tail but NO play tail) // 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 // lifts each zone to the PRODUCT defaults: Gate + Preserve (S16-F1) + no fades + env off — the
@@ -1619,6 +1717,10 @@ int main() {
testKeyTrackThroughComponentEnvelope(); testKeyTrackThroughComponentEnvelope();
testKeyTrackResolvesToZone(); testKeyTrackResolvesToZone();
testKeyTrackV5BackCompatLiftsToUnity(); testKeyTrackV5BackCompatLiftsToUnity();
testVelocityCurveRoundTrip();
testVelocityCurveThroughComponentEnvelope();
testVelocityCurveResolvesToZone();
testVelocityCurveV6BackCompatLiftsToFlat();
testPlayParamsV2BackCompatLiftsToDefaults(); testPlayParamsV2BackCompatLiftsToDefaults();
testPlayParamsThroughComponentEnvelope(); testPlayParamsThroughComponentEnvelope();
testFullAdsrSecondsRoundTrip(); testFullAdsrSecondsRoundTrip();
+43 -10
View File
@@ -422,6 +422,10 @@ static void testNoteOffReleasesNewestSameNote() {
sd.play.adsr = flatAdsr(); sd.play.adsr = flatAdsr();
sd.play.adsr.releaseFrames = 10; // short but non-zero so voice stays active through release sd.play.adsr.releaseFrames = 10; // short but non-zero so voice stays active through release
Keymap km = Keymap::singleSampleChromatic(sd); 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); VoiceEngine eng(8, km);
std::size_t first = eng.noteOn(60, velOld); // older voice, lower gain std::size_t first = eng.noteOn(60, velOld); // older voice, lower gain
@@ -728,33 +732,60 @@ static void testStartAfterLoopEndWrapsIntoLoop() {
// velocity -> volume. // 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 Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // DC 1.0
// Full velocity -> full gain; half velocity -> ~half gain (flat envelope so the km.zones[0].velocityCurve = vst::VelocityCurve::linear();
// rendered value is exactly velocity/127 on a DC-1 sample).
{ {
VoiceEngine eng(1, km); VoiceEngine eng(1, km);
eng.noteOn(60, 127); eng.noteOn(60, 127);
std::vector<AudioSample> out; std::vector<AudioSample> out; eng.render(out, 1);
eng.render(out, 1);
CHECK(approx(out[0], 1.0, 1e-4)); CHECK(approx(out[0], 1.0, 1e-4));
} }
{ {
VoiceEngine eng(1, km); VoiceEngine eng(1, km);
eng.noteOn(60, 64); eng.noteOn(60, 64);
std::vector<AudioSample> out; std::vector<AudioSample> out; eng.render(out, 1);
eng.render(out, 1);
CHECK(approx(out[0], 64.0 / 127.0, 1e-4)); CHECK(approx(out[0], 64.0 / 127.0, 1e-4));
} }
{ {
VoiceEngine eng(1, km); VoiceEngine eng(1, km);
eng.noteOn(60, 1); eng.noteOn(60, 1);
std::vector<AudioSample> out; std::vector<AudioSample> out; eng.render(out, 1);
eng.render(out, 1);
CHECK(approx(out[0], 1.0 / 127.0, 1e-4)); 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. // Two voices summed: polyphony mixes additively.
static void testPolyphonyMixesAdditively() { static void testPolyphonyMixesAdditively() {
Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // DC 1.0 Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // DC 1.0
@@ -1348,7 +1379,9 @@ int main() {
testStartFrameOutOfRangeClampsToZero(); testStartFrameOutOfRangeClampsToZero();
testStartFrameWithLoop(); testStartFrameWithLoop();
testStartAfterLoopEndWrapsIntoLoop(); testStartAfterLoopEndWrapsIntoLoop();
testVelocityToVolume(); testVelocityDefaultCurveIsFlatUnity();
testVelocityLinearCurveReproducesRamp();
testVelocityShapedCurveDrivesGain();
testPolyphonyMixesAdditively(); testPolyphonyMixesAdditively();
testChannelCount(); testChannelCount();
testStereoRenderKeepsChannelsDistinct(); testStereoRenderKeepsChannelsDistinct();
+241
View File
@@ -0,0 +1,241 @@
// Standalone tests for reasampler::vst::velocity_curve — no VST3, no REAPER, no framework. Same fast
// assert loop as the sibling pure tests. Assert the S-VIEW-9 velocity->amp transfer curve HARD:
//
// * eval — flat y=1 default (R10-F1 Option A: EVERY velocity -> 1.0), linear ramp, curved shape
// between points, box-clamp of an out-of-range velocity, monotonic-in-x over the whole domain.
// * editing — addPoint keeps X-order + box-clamp; movePoint clamps an interior point between its
// neighbours (can't cross) and box-clamps amp; endpoints are X-pinned (velocity 0 / 127) with
// only amp mobile; deletePoint removes interior points but REFUSES the two endpoints.
// * hit-test + inverse map — pointAtPixel grabs a drawn node; resolvePointDrag maps pixel delta to
// a clamped point (endpoint X-pinned; interior clamped to neighbours); degenerate box -> no motion.
// * fromPoints — the deserialization repair: sorts by X, box-clamps, forces endpoints, and falls
// back to flat() for a sub-2-point list.
#include "../src/vst/velocity_curve.h"
#include <cmath>
#include <cstdio>
using namespace reasampler::vst;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
static bool near(double a, double b, double eps = 1e-9) { return std::fabs(a - b) <= eps; }
using Box = VelocityCurve::Box;
// --- eval ---------------------------------------------------------------------
static void testFlatIsUnityEverywhere() {
const VelocityCurve c = VelocityCurve::flat();
// R10-F1 Option A: every velocity plays at full level. Sweep the whole domain.
for (int v = 0; v <= 127; ++v) CHECK(near(c.eval(v), 1.0));
// Two endpoints only.
CHECK(c.size() == 2);
}
static void testLinearRamp() {
const VelocityCurve c = VelocityCurve::linear();
CHECK(near(c.eval(0), 0.0));
CHECK(near(c.eval(127), 1.0));
// linear() is an EXACT straight line y = velocity/127: at any velocity the amp equals v/127.
CHECK(near(c.eval(63.5), 0.5)); // the exact midpoint
CHECK(near(c.eval(64.0), 64.0 / 127.0));
CHECK(near(c.eval(100.0), 100.0 / 127.0));
}
static void testEvalBoxClampsOutOfRangeVelocity() {
const VelocityCurve c = VelocityCurve::linear();
CHECK(near(c.eval(-10.0), 0.0)); // below 0 -> reads velocity-0 endpoint amp
CHECK(near(c.eval(200.0), 1.0)); // above 127 -> reads velocity-127 endpoint amp
}
static void testEvalMonotonicInX() {
// A curve that dips then rises must still be a well-defined FUNCTION (one amp per velocity) and
// monotonic WITHIN each segment. Build (0,1)->(64,0)->(127,1): eval sweeps must be single-valued
// and each half monotonic (down then up), never oscillating within a segment.
VelocityCurve c = VelocityCurve::flat();
c.movePoint(0, 0, 1.0);
c.addPoint(64.0, 0.0);
c.movePoint(2, 127, 1.0); // index 2 is the last endpoint after the insert
CHECK(c.size() == 3);
// Descending half [0,64]: non-increasing.
double prev = c.eval(0);
for (int v = 1; v <= 64; ++v) {
const double cur = c.eval(v);
CHECK(cur <= prev + 1e-9);
prev = cur;
}
// Ascending half [64,127]: non-decreasing.
prev = c.eval(64);
for (int v = 65; v <= 127; ++v) {
const double cur = c.eval(v);
CHECK(cur >= prev - 1e-9);
prev = cur;
}
CHECK(near(c.eval(64), 0.0)); // the trough sits exactly on the moved point
}
// --- editing: addPoint --------------------------------------------------------
static void testAddPointKeepsXOrderAndClamps() {
VelocityCurve c = VelocityCurve::linear(); // (0,0), (127,1)
const std::size_t i = c.addPoint(60.0, 0.3);
CHECK(i == 1); // inserted between the two endpoints
CHECK(c.size() == 3);
CHECK(near(c.points()[1].velocity, 60.0) && near(c.points()[1].amp, 0.3));
// Out-of-box add clamps into [0,127] x [0,1].
c.addPoint(500.0, 5.0);
const VelocityPoint& last = c.points().back();
CHECK(near(last.velocity, 127.0) && near(last.amp, 1.0));
// Points remain X-ordered.
for (std::size_t k = 1; k < c.size(); ++k)
CHECK(c.points()[k - 1].velocity <= c.points()[k].velocity);
}
// --- editing: movePoint -------------------------------------------------------
static void testMoveInteriorClampsToNeighbours() {
VelocityCurve c = VelocityCurve::linear();
c.addPoint(40.0, 0.4); // idx 1
c.addPoint(80.0, 0.8); // idx 2
CHECK(c.size() == 4); // (0,0)(40,.4)(80,.8)(127,1)
// Try to drag idx 1 PAST idx 2 (velocity 200): clamps to idx 2's velocity (80), not beyond.
const VelocityPoint r = c.movePoint(1, 200.0, 0.5);
CHECK(near(r.velocity, 80.0));
CHECK(near(r.amp, 0.5)); // amp is free (box-clamped only)
// Try to drag idx 1 BELOW idx 0 (velocity -5): clamps to idx 0's velocity (0).
const VelocityPoint r2 = c.movePoint(1, -5.0, 0.5);
CHECK(near(r2.velocity, 0.0));
}
static void testMoveEndpointsArePinnedInX() {
VelocityCurve c = VelocityCurve::linear();
// Move the first endpoint: velocity argument ignored (pinned at 0), amp moves.
const VelocityPoint f = c.movePoint(0, 50.0, 0.25);
CHECK(near(f.velocity, 0.0));
CHECK(near(f.amp, 0.25));
// Move the last endpoint: pinned at 127, amp moves, and amp box-clamps.
const VelocityPoint l = c.movePoint(1, 10.0, 5.0);
CHECK(near(l.velocity, 127.0));
CHECK(near(l.amp, 1.0));
}
static void testMoveOutOfRangeIndexIsNoOp() {
VelocityCurve c = VelocityCurve::linear();
c.movePoint(99, 50.0, 0.5);
CHECK(c.size() == 2);
CHECK(near(c.points()[0].amp, 0.0) && near(c.points()[1].amp, 1.0)); // unchanged
}
// --- editing: deletePoint -----------------------------------------------------
static void testDeleteRemovesInteriorRefusesEndpoints() {
VelocityCurve c = VelocityCurve::linear();
c.addPoint(60.0, 0.5); // idx 1
CHECK(c.size() == 3);
// Endpoints refuse deletion.
CHECK(!c.deletePoint(0));
CHECK(!c.deletePoint(2));
CHECK(c.size() == 3);
// Interior deletes.
CHECK(c.deletePoint(1));
CHECK(c.size() == 2);
// Out-of-range refuses.
CHECK(!c.deletePoint(9));
}
// --- hit-test + inverse map ---------------------------------------------------
// A 127px-wide, 101px-tall box at origin: velocity->x is 1px/unit, amp->y spans 100 rows (1 px per
// 0.01 amp), amp 1 at top (y=0), amp 0 at bottom (y=100).
static Box wideBox() { return Box{0, 0, 127, 101}; }
static void testPointAtPixelGrabsDrawnNode() {
VelocityCurve c = VelocityCurve::linear(); // (0,0) at (0,100); (127,1) at (127,0)
const Box b = wideBox();
// Grab near the first endpoint's drawn point (x=0, y=100).
CHECK(c.pointAtPixel(b, 0, 100) == 0);
// Grab near the last endpoint (x=127, y=0).
CHECK(c.pointAtPixel(b, 127, 0) == 1);
// A point far from any node misses.
CHECK(c.pointAtPixel(b, 63, 50) == -1);
}
static void testResolveDragMovesAndClamps() {
VelocityCurve grab = VelocityCurve::linear();
grab.addPoint(60.0, 0.5); // idx 1, drawn at x=60, y=50
const Box b = wideBox();
// Drag idx 1 right 10px, up 10px: velocity +10 (->70), amp +0.10 (up = higher amp -> 0.60).
const VelocityCurve moved = VelocityCurve::resolvePointDrag(grab, 1, b, 10, -10);
CHECK(near(moved.points()[1].velocity, 70.0, 1e-6));
CHECK(near(moved.points()[1].amp, 0.60, 1e-6));
// Dragging the first endpoint horizontally does not move it in X (pinned), only amp.
const VelocityCurve movedEnd = VelocityCurve::resolvePointDrag(grab, 0, b, 40, -20);
CHECK(near(movedEnd.points()[0].velocity, 0.0));
CHECK(near(movedEnd.points()[0].amp, 0.20, 1e-6)); // dragged up 20px = +0.20 from 0
}
static void testResolveDragDegenerateBoxNoMotion() {
const VelocityCurve grab = VelocityCurve::linear();
const VelocityCurve r = VelocityCurve::resolvePointDrag(grab, 1, Box{0, 0, 0, 0}, 50, 50);
CHECK(r.equals(grab)); // zero-size box -> unchanged
}
// --- fromPoints (deserialization repair) --------------------------------------
static void testFromPointsSortsClampsAndForcesEndpoints() {
// Unsorted, out-of-box, missing endpoints -> repaired to a valid curve.
std::vector<VelocityPoint> raw = {{80.0, 0.9}, {20.0, -1.0}, {50.0, 2.0}};
const VelocityCurve c = VelocityCurve::fromPoints(raw);
// X-ordered.
for (std::size_t k = 1; k < c.size(); ++k)
CHECK(c.points()[k - 1].velocity <= c.points()[k].velocity);
// Endpoints forced present at 0 and 127.
CHECK(near(c.points().front().velocity, 0.0));
CHECK(near(c.points().back().velocity, 127.0));
// Interior amps box-clamped (the -1 became 0, the 2 became 1).
for (const VelocityPoint& p : c.points()) {
CHECK(p.amp >= 0.0 - 1e-12 && p.amp <= 1.0 + 1e-12);
}
}
static void testFromPointsSubTwoFallsBackToFlat() {
const VelocityCurve c0 = VelocityCurve::fromPoints({});
CHECK(c0.equals(VelocityCurve::flat()));
const VelocityCurve c1 = VelocityCurve::fromPoints({{50.0, 0.3}});
CHECK(c1.equals(VelocityCurve::flat()));
}
static void testFromPointsRoundTripsAValidCurve() {
VelocityCurve orig = VelocityCurve::linear();
orig.addPoint(40.0, 0.2);
orig.addPoint(90.0, 0.7);
// fromPoints over its OWN points reproduces it exactly (already valid, sort is stable no-op).
const VelocityCurve rebuilt = VelocityCurve::fromPoints(orig.points());
CHECK(rebuilt.equals(orig));
}
int main() {
testFlatIsUnityEverywhere();
testLinearRamp();
testEvalBoxClampsOutOfRangeVelocity();
testEvalMonotonicInX();
testAddPointKeepsXOrderAndClamps();
testMoveInteriorClampsToNeighbours();
testMoveEndpointsArePinnedInX();
testMoveOutOfRangeIndexIsNoOp();
testDeleteRemovesInteriorRefusesEndpoints();
testPointAtPixelGrabsDrawnNode();
testResolveDragMovesAndClamps();
testResolveDragDegenerateBoxNoMotion();
testFromPointsSortsClampsAndForcesEndpoints();
testFromPointsSubTwoFallsBackToFlat();
testFromPointsRoundTripsAValidCurve();
if (g_fail == 0) std::printf("velocity_curve: all tests passed\n");
else std::printf("velocity_curve: %d FAILURES\n", g_fail);
return g_fail == 0 ? 0 : 1;
}