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:
+35
-2
@@ -219,6 +219,9 @@ ResolvedPerformance resolvePerformance(const std::string& banksJson,
|
||||
// S-VIEW-6: the key-tracking scalar is instrument state (not a bank fact) — carried
|
||||
// straight through to the resolved zone and applied in the repitch math at play time.
|
||||
rz.keyTrack = z.keyTrack;
|
||||
// S-VIEW-9: the velocity->amp curve is likewise instrument state — carried through and
|
||||
// eval'd at Voice::start to set the voice's amp gain from the note-on velocity.
|
||||
rz.velocityCurve = z.velocityCurve;
|
||||
// Effective loop / start (S11): the instrument's per-zone override wins over the
|
||||
// bank's S2 intrinsic; absent -> the intrinsic (loop) / frame 0 (start). The bank is
|
||||
// never mutated — this only shapes what the core plays for THIS instance (D-B).
|
||||
@@ -264,6 +267,7 @@ Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
|
||||
zone.highNote = zones[i].highNote;
|
||||
zone.rootNote = zones[i].rootNote;
|
||||
zone.keyTrack = zones[i].keyTrack; // S-VIEW-6: applied in keyTrackedRatio at play time
|
||||
zone.velocityCurve = zones[i].velocityCurve; // S-VIEW-9: eval'd in Voice::start
|
||||
zone.sampleIndex = sampleIndex;
|
||||
km.zones.push_back(zone);
|
||||
}
|
||||
@@ -407,8 +411,16 @@ void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map)
|
||||
putU64le(out, doubleToBits(pp.adsr.decaySeconds));
|
||||
putU64le(out, doubleToBits(pp.adsr.sustainLevel));
|
||||
putU64le(out, doubleToBits(pp.adsr.releaseSeconds));
|
||||
// PAYLOAD v6 (S-VIEW-6): the per-zone key-tracking scalar, appended last (1.0 = 100% ET).
|
||||
// PAYLOAD v6 (S-VIEW-6): the per-zone key-tracking scalar (1.0 = 100% ET).
|
||||
putU64le(out, doubleToBits(z.keyTrack));
|
||||
// PAYLOAD v7 (S-VIEW-9): the per-zone velocity->amp transfer curve, appended last. 4-byte LE
|
||||
// control-point count, then per point velocity + amp as IEEE-754 doubles (endpoints included).
|
||||
const std::vector<reasampler::vst::VelocityPoint>& pts = z.velocityCurve.points();
|
||||
putU32le(out, static_cast<std::uint32_t>(pts.size()));
|
||||
for (const reasampler::vst::VelocityPoint& p : pts) {
|
||||
putU64le(out, doubleToBits(p.velocity));
|
||||
putU64le(out, doubleToBits(p.amp));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -430,7 +442,8 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) {
|
||||
}
|
||||
const bool legacyV3Play = (pv == 3); // legacy S15/S16 play tail, wall-clock in 44.1k frames
|
||||
const bool secondsPlay = (pv >= 5); // v5+: full play params, wall-clock in seconds
|
||||
const bool keyTrackTail = (pv >= 6); // v6+ (S-VIEW-6): per-zone keyTrack scalar appended last
|
||||
const bool keyTrackTail = (pv >= 6); // v6+ (S-VIEW-6): per-zone keyTrack scalar
|
||||
const bool curveTail = (pv >= 7); // v7+ (S-VIEW-9): per-zone velocity->amp curve, appended last
|
||||
const std::uint32_t count = r.u32();
|
||||
for (std::uint32_t i = 0; i < count && r.ok; ++i) {
|
||||
// z.play defaults to the PRODUCT defaults (Gate + Preserve + tier-0 AHDSR seconds). A
|
||||
@@ -493,6 +506,26 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) {
|
||||
// payload (no field) leaves the PerformanceZone default (keyTrack = 1.0 = 100% ET), so an
|
||||
// already-saved instance repitches BIT-IDENTICALLY to the pre-S-VIEW-6 engine.
|
||||
if (keyTrackTail) z.keyTrack = bitsToDouble(r.u64());
|
||||
// PAYLOAD v7 (S-VIEW-9): the velocity->amp transfer curve, appended after the v6 keyTrack. A
|
||||
// pre-v7 payload (no field) leaves the PerformanceZone default (VelocityCurve::flat() — R10-F1
|
||||
// Option A, flat y=1), the deliberate NON-back-compat behavior change for already-saved zones.
|
||||
// fromPoints repairs the X-order/endpoint invariant defensively; a truncated read (r.ok flips
|
||||
// false mid-curve) leaves the flat default and the mid-zone break below drops the rest.
|
||||
if (curveTail) {
|
||||
const std::uint32_t ptCount = r.u32();
|
||||
std::vector<reasampler::vst::VelocityPoint> pts;
|
||||
// Bound the reserve to what the blob can actually hold (16 bytes/point) so a corrupt huge
|
||||
// count can't trigger a giant allocation before the bounded reads fail — the loop still
|
||||
// stops on r.ok, this only caps the speculative reserve.
|
||||
const std::size_t remaining = r.bytes.size() > r.pos ? r.bytes.size() - r.pos : 0;
|
||||
pts.reserve(std::min(static_cast<std::size_t>(ptCount), remaining / 16));
|
||||
for (std::uint32_t p = 0; p < ptCount && r.ok; ++p) {
|
||||
const double vel = bitsToDouble(r.u64());
|
||||
const double amp = bitsToDouble(r.u64());
|
||||
pts.push_back(reasampler::vst::VelocityPoint{vel, amp});
|
||||
}
|
||||
if (r.ok) z.velocityCurve = reasampler::vst::VelocityCurve::fromPoints(std::move(pts));
|
||||
}
|
||||
// Payload versions 4 (branch-only frames tail, never shipped) and any unknown pv leave the
|
||||
// seconds product defaults on z.play — a v4 blob cannot exist outside this branch.
|
||||
if (!r.ok) break; // truncated mid-zone -> keep what parsed cleanly, drop the rest
|
||||
|
||||
+31
-11
@@ -206,6 +206,16 @@ struct PerformanceZone {
|
||||
// resolvePerformance and applied in keyTrackedRatio inside BOTH repitch engines.
|
||||
double keyTrack = 1.0;
|
||||
|
||||
// S-VIEW-9 velocity->amp transfer curve (instrument-owned, D-B — mirror of keyTrack): maps the
|
||||
// note-on MIDI velocity (0..127) to the voice's amp gain, replacing the fixed linear velocity/127.
|
||||
// A per-sound performance characteristic, so it varies PER ZONE. DEFAULT = flat y=1 (R10-F1
|
||||
// Option A, Daniel-approved): every velocity plays at unity. This is a DELIBERATE, non-back-compat
|
||||
// behavior change — a pre-S-VIEW-9 blob (no velocityCurve field) lifts to flat y=1, so an
|
||||
// already-saved zone's soft hits play LOUDER than under the old linear map. Intended; do NOT
|
||||
// preserve the linear response. Carried to KeyZone by resolvePerformance, eval'd in Voice::start.
|
||||
// Sequenced on the zones-payload axis AFTER keyTrack (payload v6 -> v7).
|
||||
vst::VelocityCurve velocityCurve = vst::VelocityCurve::flat();
|
||||
|
||||
// S15/S16 per-zone play parameters (play mode + AHDSR + Trigger %-length/fades; pitch
|
||||
// engine + AD pitch envelope). Instrument-owned (D-B), never a bank fact — mirror of the
|
||||
// loop/start overrides. Wall-clock times are stored in SECONDS (rate-free); the keymap build
|
||||
@@ -236,6 +246,7 @@ struct ResolvedZone {
|
||||
int highNote = 127;
|
||||
int rootNote = 60; // effective: override, else bank intrinsic, else 60
|
||||
double keyTrack = 1.0; // S-VIEW-6 key-tracking scalar, carried from PerformanceZone (1.0 = 100% ET)
|
||||
vst::VelocityCurve velocityCurve = vst::VelocityCurve::flat(); // S-VIEW-9 velocity->amp curve, carried from PerformanceZone
|
||||
SampleLoop loop; // effective: loopOverride, else bank S2 intrinsic (S11)
|
||||
std::int64_t startFrame = 0; // effective initial read frame: startPoint, else 0 (S11)
|
||||
ZonePlaySeconds play; // S15/S16 per-zone play params (SECONDS; resolved to frames at build)
|
||||
@@ -364,21 +375,30 @@ DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
|
||||
|
||||
inline constexpr std::uint32_t kPerformanceStateVersion = 2;
|
||||
|
||||
// The zones-payload format version and its detection marker (S11/S15/S16/S12). serializePerformance
|
||||
// and serializeComponentState both emit the CURRENT payload version (v5 — marker + version +
|
||||
// records with the S11 loop/start tail AND the full play-params tail with wall-clock times in
|
||||
// SECONDS) so the overrides round-trip through EITHER envelope. Readers accept a v1 payload (no
|
||||
// marker), a v2 payload (marker + version 2, no play tail), and a v3 payload (legacy S15/S16
|
||||
// play tail with wall-clock frame counts) for back-compat, lifting missing fields to defaults.
|
||||
// v4 was never shipped and is not read. The marker is a high sentinel that a legitimate zone
|
||||
// count (bounded by 128 MIDI zones in practice, always tiny) can never collide with.
|
||||
// * PAYLOAD v6 (S-VIEW-6 — CURRENT WRITE FORMAT): identical to v5, PLUS one field appended to
|
||||
// each zone record after the full v5 play-params tail:
|
||||
// The zones-payload format version and its detection marker (S11/S15/S16/S12/S-VIEW-6/S-VIEW-9).
|
||||
// serializePerformance and serializeComponentState both emit the CURRENT payload version (v7 —
|
||||
// marker + version + records with the S11 loop/start tail, the full play-params tail with wall-clock
|
||||
// times in SECONDS, the v6 keyTrack scalar, and the v7 velocity->amp curve) so the overrides
|
||||
// round-trip through EITHER envelope. Readers accept a v1 payload (no marker), a v2 payload (marker +
|
||||
// version 2, no play tail), and a v3 payload (legacy S15/S16 play tail with wall-clock frame counts)
|
||||
// for back-compat, lifting missing fields to defaults. v4 was never shipped and is not read. The
|
||||
// marker is a high sentinel that a legitimate zone count (bounded by 128 MIDI zones in practice,
|
||||
// always tiny) can never collide with.
|
||||
// * PAYLOAD v6 (S-VIEW-6): identical to v5, PLUS one field appended to each zone record after the
|
||||
// full v5 play-params tail:
|
||||
// 8-byte LE keyTrack (IEEE-754 double) — the per-zone key-tracking scalar (1.0 = 100% ET).
|
||||
// A v1–v5 payload (no keyTrack field) lifts every zone to keyTrack = 1.0 (the PerformanceZone
|
||||
// default), so already-saved instances are BIT-IDENTICAL — the 100% default reproduces the
|
||||
// pre-S-VIEW-6 repitch exactly. A truncated mid-keyTrack record keeps the zones that parsed.
|
||||
inline constexpr std::uint32_t kZonesPayloadVersion = 6; // S-VIEW-6: + per-zone keyTrack scalar
|
||||
// * PAYLOAD v7 (S-VIEW-9 — CURRENT WRITE FORMAT): identical to v6, PLUS the per-zone velocity->amp
|
||||
// transfer curve appended to each zone record after the v6 keyTrack field:
|
||||
// 4-byte LE control-point count N, then per point: 8-byte LE velocity (double), 8-byte LE amp
|
||||
// (double). The two endpoints (velocity 0 and 127) are always included, so N >= 2.
|
||||
// A v1–v6 payload (no velocity-curve field) lifts every zone to VelocityCurve::flat() (R10-F1
|
||||
// Option A — flat y=1). This is a DELIBERATE, Daniel-approved NON-back-compat behavior change:
|
||||
// an already-saved zone's soft hits play LOUDER than under the pre-r10 linear velocity/127. A
|
||||
// truncated mid-curve record leaves the zone's flat default and keeps the zones that parsed.
|
||||
inline constexpr std::uint32_t kZonesPayloadVersion = 7; // S-VIEW-9: + per-zone velocity->amp curve
|
||||
inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u;
|
||||
|
||||
// (No kLegacyV3NominalRate constant.) The legacy v3 zone payload's wall-clock frame counts are
|
||||
|
||||
@@ -260,16 +260,16 @@ void Voice::presizePreserveShifters(std::int64_t windowFrames) {
|
||||
}
|
||||
|
||||
void Voice::start(int note, int velocity, const SampleData& sample, int rootNote,
|
||||
double keyTrack) {
|
||||
double keyTrack, const vst::VelocityCurve& velocityCurve) {
|
||||
active_ = true;
|
||||
releasing_ = false;
|
||||
amplitudeDone_ = false;
|
||||
note_ = note;
|
||||
// MIDI velocity 1..127 -> linear gain 0..1. Clamp defensively.
|
||||
int v = velocity;
|
||||
if (v < 0) v = 0;
|
||||
if (v > 127) v = 127;
|
||||
velocityGain_ = static_cast<double>(v) / 127.0;
|
||||
// S-VIEW-9: the velocity->amp transfer curve maps MIDI velocity to gain, ONCE at note-on (the
|
||||
// per-frame render just multiplies the cached velocityGain_ — no new process-thread work). The
|
||||
// clamp lives inside eval (velocity box-clamped to [0,127]). Replaces the pre-r10 linear
|
||||
// velocity/127; the default flat y=1 curve (R10-F1 Option A) plays every velocity at unity.
|
||||
velocityGain_ = velocityCurve.eval(static_cast<double>(velocity));
|
||||
// S-VIEW-6: the key-tracked repitch ratio feeds BOTH engines through baseRatio_ (Varispeed
|
||||
// read-rate bias and Preserve shift amount both derive from it below). keyTrack == 1.0 is
|
||||
// the pre-S-VIEW-6 pitchRatio bit-for-bit.
|
||||
@@ -572,7 +572,7 @@ std::size_t VoiceEngine::noteOn(int note, int velocity) {
|
||||
// The voice's Preserve shifters were pre-sized at engine construction (off-thread), so
|
||||
// start() only reset()s + warm()s them — no allocation on this audio-thread path.
|
||||
const std::size_t v = allocateVoice();
|
||||
voices_[v].start(note, velocity, sample, zone.rootNote, zone.keyTrack);
|
||||
voices_[v].start(note, velocity, sample, zone.rootNote, zone.keyTrack, zone.velocityCurve);
|
||||
voices_[v].setStartOrder(nextStartOrder_++);
|
||||
return v;
|
||||
}
|
||||
|
||||
+13
-3
@@ -21,8 +21,9 @@
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "peaks.h" // AudioSample (float)
|
||||
#include "pitch_shift.h" // PitchShifter (S16 Preserve engine DSP core)
|
||||
#include "peaks.h" // AudioSample (float)
|
||||
#include "pitch_shift.h" // PitchShifter (S16 Preserve engine DSP core)
|
||||
#include "velocity_curve.h" // VelocityCurve (S-VIEW-9 velocity->amp transfer curve; eval at start)
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
@@ -199,6 +200,12 @@ struct KeyZone {
|
||||
// key plays root pitch); 2.0 = double-rate tracking. Scales the (note-root) semitone offset
|
||||
// in the repitch math (keyTrackedRatio); rides BOTH engines via the voice's baseRatio_.
|
||||
double keyTrack = 1.0;
|
||||
// S-VIEW-9 velocity->amp transfer curve: maps the note-on velocity (0..127) to the voice's amp
|
||||
// gain, replacing the fixed linear velocity/127. A per-zone performance characteristic (mirror
|
||||
// of keyTrack), carried from PerformanceZone by resolvePerformance and eval'd ONCE in
|
||||
// Voice::start (never per frame). DEFAULT flat y=1 (R10-F1 Option A) — every velocity plays at
|
||||
// unity, a deliberate behavior change from the pre-r10 linear map.
|
||||
vst::VelocityCurve velocityCurve = vst::VelocityCurve::flat();
|
||||
std::size_t sampleIndex = 0; // index into Keymap::samples
|
||||
};
|
||||
|
||||
@@ -370,8 +377,11 @@ public:
|
||||
// is default (Gate + Varispeed + no pitch env).
|
||||
// `keyTrack` (S-VIEW-6) scales the (note-root) semitone offset feeding the repitch ratio;
|
||||
// 1.0 (the default) is standard 12-tone-ET, bit-identical to the pre-S-VIEW-6 baseRatio_.
|
||||
// `velocityCurve` (S-VIEW-9) maps the note-on velocity to the voice's amp gain, evaluated ONCE
|
||||
// here (off the per-frame path); defaults to flat y=1 (R10-F1) — every velocity plays at unity.
|
||||
void start(int note, int velocity, const SampleData& sample, int rootNote,
|
||||
double keyTrack = 1.0);
|
||||
double keyTrack = 1.0,
|
||||
const vst::VelocityCurve& velocityCurve = vst::VelocityCurve::flat());
|
||||
|
||||
// Gate off — begins the amplitude release. In GATE mode this enters the AHDSR release; in
|
||||
// TRIGGER mode it is a NO-OP (Trigger ignores note-off and plays through to its play length).
|
||||
|
||||
@@ -0,0 +1,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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user