instrument: one VELOCITY deck for all three velocity curves, bipolar and off by default for pitch and filter

Payload v12 appends the new velocity->pitch curve and folds the retired filter velAmount into its now-bipolar curve, so pre-v12 projects reopen sounding identical. Preview button takes a drawn play triangle.
This commit is contained in:
2026-07-31 19:15:17 -04:00
parent 4fecb58c0a
commit 9d38f87a2d
37 changed files with 1020 additions and 320 deletions
+24 -5
View File
@@ -8,7 +8,7 @@
// own links are velocity_curve + master_gain (wire value validation), never the engine.
//
// EVERY wire format below is FROZEN; the full version ladders (envelope v1..v11, params
// payload v1..v11) must be preserved exactly. This header is the ONE home for both ladders
// payload v1..v12) must be preserved exactly. This header is the ONE home for both ladders
// and every version constant; the payload half is IMPLEMENTED in params_payload.
#include <cstdint>
@@ -85,9 +85,23 @@ namespace reasampler::instrument::map {
// AHDSR's attack/decay/release curve exponents; the filter's Trigger AHD (same five fields as
// the amp's). A v9-or-older blob is a strict prefix and lifts to the neutral exponent 1.0.
//
// v11 (CURRENT WRITE FORMAT) is v10 PLUS one 8-byte LE int64: the loop crossfade in SOURCE
// frames (a source-timeline quantity like the loop points, so no rate resolves it). A v10-or-
// older blob is a strict prefix and lifts to 0 — the hard seam it always played.
// v11 is v10 PLUS one 8-byte LE int64: the loop crossfade in SOURCE frames (a source-timeline
// quantity like the loop points, so no rate resolves it). A v10-or-older blob is a strict
// prefix and lifts to 0 — the hard seam it always played.
//
// v12 (CURRENT WRITE FORMAT) is v11 PLUS the velocity->PITCH transfer curve (count + points,
// the same shape as v7's), appended after the loop crossfade. It also RE-INTERPRETS two frozen
// slots inside the v9 filter tail — the byte shape is untouched, only the meaning at v12+:
// * the filter's velocity curve is now BIPOLAR [-1,+1] and is the whole velocity->cutoff
// amount, not a [0,1] shape scaled by a separate depth;
// * the retired filter velAmount slot is written as a constant 1.0 and ignored on read.
// PRE-v12 LIFT: the stored [0,1] filter curve has every knot's y multiplied by that blob's
// velAmount and is re-read as bipolar. eval is homogeneous in y, so the lifted curve evaluates
// to exactly velAmount * oldCurve(v) — the product the voice used to compute per note — and a
// pre-v12 project sounds identical. A pre-v12 blob carries no pitch curve at all and lifts to
// the bipolar flat-at-zero default, which transposes nothing. A DOWNGRADE to a pre-v12 binary
// reads the constant 1.0 depth against a curve whose negative half clamps away, so it
// reproduces the curve's positive half only.
//
// The two int64 slots the v5 play tail spends on the RETIRED Trigger fade pair are frozen in
// shape and still read: a pre-v10 blob's fade-in/fade-out become the Trigger AHD that replaced
@@ -120,7 +134,7 @@ inline constexpr std::uint32_t kPerformanceStateVersion = 2;
// The params-payload format version and its detection marker. The marker is a high sentinel
// no legitimate v1 zone count (bounded by 128 MIDI zones, always tiny) could ever equal, so
// a reader detects record shape independent of the envelope version.
inline constexpr std::uint32_t kParamsPayloadVersion = 11; // v10 + the loop-crossfade tail
inline constexpr std::uint32_t kParamsPayloadVersion = 12; // v11 + the velocity->pitch curve
inline constexpr std::uint32_t kParamsFormatMarker = 0xFFFFFF00u;
// The first SINGLE-RECORD payload version. Everything below it is a retired zone list and
@@ -139,6 +153,11 @@ inline constexpr std::uint32_t kParamsCurveVersion = 10;
// v10 + the loop-crossfade frame count.
inline constexpr std::uint32_t kParamsLoopVersion = 11;
// v11 + the velocity->pitch curve, and the version from which the filter's velocity curve is
// bipolar and self-scaling. Both the appended tail and the filter-tail lift branch on THIS,
// never on kParamsPayloadVersion.
inline constexpr std::uint32_t kParamsVelocityVersion = 12;
// (No nominal-rate constant.) The legacy v3 payload's wall-clock frame counts convert to
// seconds at the v3 read boundary using the PROJECT sample rate threaded in as a parameter
// (frames / projectRate = seconds) — the same rate the build already receives, so the
+40 -19
View File
@@ -38,14 +38,15 @@ void putOverrides(std::vector<std::uint8_t>& out, const InstrumentParams& p) {
if (p.startPoint) putLE(out, asU64(*p.startPoint));
}
// A velocity curve: 4-byte LE control-point count, then per point velocity + amp as doubles.
// The amp curve (v7) and the filter's own curve (v9) share this shape.
// A velocity curve: 4-byte LE control-point count, then per point velocity + value as doubles.
// The amp curve (v7), the filter's own curve (v9) and the pitch curve (v12) share this shape;
// the y DOMAIN is not on the wire — it is a property of the slot, so the reader supplies it.
void putCurve(std::vector<std::uint8_t>& out, const VelocityCurve& curve) {
const std::vector<VelocityPoint>& pts = curve.points();
putLE(out, static_cast<std::uint32_t>(pts.size()));
for (const VelocityPoint& pt : pts) {
putLE(out, doubleToBits(pt.velocity));
putLE(out, doubleToBits(pt.amp));
putLE(out, doubleToBits(pt.value));
}
}
@@ -92,9 +93,12 @@ void readSecondsPlayTail(ByteReader& r, InstrumentParams& p, double projectRate)
p.play.adsr.releaseSeconds = bitsToDouble(r.u64());
}
// Read a velocity curve tail into `curve`. fromPoints repairs the X-order/endpoint invariant
// defensively; a truncated read leaves `curve` at whatever default it came in with.
void readCurveTail(ByteReader& r, VelocityCurve& curve) {
// Read a velocity curve tail into `curve`, interpreting its y values in `domain` and scaling
// them by `yScale` (the pre-v12 filter lift folds a retired depth in that way — see
// component_state_io.h). fromPoints repairs the X-order/endpoint invariant defensively; a
// truncated read leaves `curve` at whatever default it came in with.
void readCurveTail(ByteReader& r, VelocityCurve& curve,
reasampler::instrument::engine::CurveDomain domain, double yScale) {
const std::uint32_t ptCount = r.u32();
std::vector<VelocityPoint> pts;
// Bound the reserve to what the blob can hold (16 bytes/point) so a corrupt huge count
@@ -103,17 +107,19 @@ void readCurveTail(ByteReader& r, VelocityCurve& curve) {
pts.reserve(std::min(static_cast<std::size_t>(ptCount), remaining / 16));
for (std::uint32_t i = 0; i < ptCount && r.ok; ++i) {
const double vel = bitsToDouble(r.u64());
const double amp = bitsToDouble(r.u64());
pts.push_back(VelocityPoint{vel, amp});
const double value = bitsToDouble(r.u64());
pts.push_back(VelocityPoint{vel, value * yScale});
}
if (r.ok) {
curve = reasampler::instrument::engine::VelocityCurve::fromPoints(std::move(pts));
curve = reasampler::instrument::engine::VelocityCurve::fromPoints(std::move(pts), domain);
}
}
// Read the v9 filter tail into `p`. A blob that stops short leaves the off/neutral default,
// which is what makes a v8 blob play bit-identically under the new codec.
void readFilterTail(ByteReader& r, InstrumentParams& p) {
// which is what makes a v8 blob play bit-identically under the new codec. `preVelocityVersion`
// selects the pre-v12 lift: the frozen velAmount slot is folded into the curve's knots instead
// of being kept as a separate depth.
void readFilterTail(ByteReader& r, InstrumentParams& p, bool preVelocityVersion) {
FilterSeconds& f = p.play.filter;
f.enabled = (r.u8() != 0);
f.settings.cutoffNorm = static_cast<float>(bitsToDouble(r.u64()));
@@ -122,20 +128,22 @@ void readFilterTail(ByteReader& r, InstrumentParams& p) {
f.settings.driveNorm = static_cast<float>(bitsToDouble(r.u64()));
f.settings.morphLaw = (r.u8() != 0) ? engine::filter::MorphLaw::HighNotchLow
: engine::filter::MorphLaw::HighBandLow;
// Same non-finite-falls-back-to-neutral guard as the v8 master gain above: these three
// reach Voice::tickFilterCutoff's clamp compares and a static_cast<int>, both UB on NaN.
// Same non-finite-falls-back-to-neutral guard as the v8 master gain above: these reach
// Voice::tickFilterCutoff's clamp compares and a static_cast<int>, both UB on NaN.
double modAmount = bitsToDouble(r.u64());
double velAmount = bitsToDouble(r.u64());
double keyTrack = bitsToDouble(r.u64());
f.modAmount = std::isfinite(modAmount) ? modAmount : 0.0;
f.velAmount = std::isfinite(velAmount) ? velAmount : 0.0;
f.keyTrack = std::isfinite(keyTrack) ? keyTrack : 0.0;
f.env.attackSeconds = bitsToDouble(r.u64());
f.env.holdSeconds = bitsToDouble(r.u64());
f.env.decaySeconds = bitsToDouble(r.u64());
f.env.sustainLevel = bitsToDouble(r.u64());
f.env.releaseSeconds = bitsToDouble(r.u64());
readCurveTail(r, f.velocityCurve);
const double velFold =
preVelocityVersion ? (std::isfinite(velAmount) ? velAmount : 0.0) : 1.0;
readCurveTail(r, f.velocityCurve, reasampler::instrument::engine::CurveDomain::Bipolar,
velFold);
}
// A curve exponent off the wire. A corrupt/non-finite value degrades to the LINEAR neutral
@@ -240,7 +248,10 @@ PayloadRead readLegacyZonePayload(ByteReader& r, std::uint32_t pv, double projec
// A pre-v6 payload leaves keyTrack = 1.0 (100% ET), so an already-saved instance
// repitches BIT-IDENTICALLY. A pre-v7 payload leaves VelocityCurve::flat().
if (keyTrackTail) p.keyTrack = bitsToDouble(r.u64());
if (curveTail) readCurveTail(r, p.velocityCurve);
if (curveTail) {
readCurveTail(r, p.velocityCurve,
reasampler::instrument::engine::CurveDomain::Unipolar, 1.0);
}
// Payload version 4 (a branch-only frames tail, never shipped) and any unknown pv
// leave the seconds product defaults on p.play.
if (!r.ok) break; // truncated mid-record -> keep what parsed cleanly, drop the rest
@@ -299,7 +310,9 @@ void putParamsPayload(std::vector<std::uint8_t>& out, const InstrumentParams& p)
putLE(out, doubleToBits(static_cast<double>(f.settings.driveNorm)));
out.push_back(f.settings.morphLaw == engine::filter::MorphLaw::HighNotchLow ? 1 : 0);
putLE(out, doubleToBits(f.modAmount));
putLE(out, doubleToBits(f.velAmount));
// The retired filter velAmount's frozen slot: a constant 1.0 so a pre-v12 binary reading
// this blob scales the curve by unity rather than silencing it (see component_state_io.h).
putLE(out, doubleToBits(1.0));
putLE(out, doubleToBits(f.keyTrack));
putLE(out, doubleToBits(f.env.attackSeconds));
putLE(out, doubleToBits(f.env.holdSeconds));
@@ -321,6 +334,8 @@ void putParamsPayload(std::vector<std::uint8_t>& out, const InstrumentParams& p)
putAhd(out, f.trigEnv);
// v11: the loop crossfade, in SOURCE frames.
putLE(out, asU64(p.loopCrossfadeFrames));
// v12: the velocity->pitch curve.
putCurve(out, pp.pitchVelocityCurve);
}
// Read whichever payload shape follows: the single-record shape (v8 onward, growing by
@@ -350,8 +365,10 @@ PayloadRead readParamsPayload(ByteReader& r, double projectRate) {
if (hasStart) p.startPoint = r.i64();
readSecondsPlayTail(r, p, projectRate);
p.keyTrack = bitsToDouble(r.u64());
readCurveTail(r, p.velocityCurve);
if (pv >= kParamsFilterVersion) readFilterTail(r, p);
readCurveTail(r, p.velocityCurve, reasampler::instrument::engine::CurveDomain::Unipolar, 1.0);
if (pv >= kParamsFilterVersion) {
readFilterTail(r, p, /*preVelocityVersion=*/pv < kParamsVelocityVersion);
}
if (pv >= kParamsCurveVersion) readCurveStageTail(r, p);
if (pv >= kParamsLoopVersion) {
// A negative fade is meaningless and would reach resolveLoop's clamp anyway; refusing
@@ -359,6 +376,10 @@ PayloadRead readParamsPayload(ByteReader& r, double projectRate) {
const std::int64_t xf = r.i64();
p.loopCrossfadeFrames = xf > 0 ? xf : 0;
}
if (pv >= kParamsVelocityVersion) {
readCurveTail(r, p.play.pitchVelocityCurve,
reasampler::instrument::engine::CurveDomain::Bipolar, 1.0);
}
// A truncated record leaves whatever parsed plus construction defaults for the rest —
// the same degrade-don't-throw contract the zone ladder always had.
if (!r.ok) return PayloadRead{};
+1 -1
View File
@@ -238,12 +238,12 @@ PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate) {
out.pitchEnv.enabled = stored.pitchEnv.enabled;
out.pitchEnv.peakSemitones = stored.pitchEnv.peakSemitones; // depth, not a time
out.pitchEnv.shape = resolveAhd(stored.pitchEnv.shape);
out.pitchVelocityCurve = stored.pitchVelocityCurve; // transfer curve, not a time
// Filter: the control positions are already rate-free and carry through untouched; only
// its envelope resolves to frames.
out.filter.enabled = stored.filter.enabled;
out.filter.settings = stored.filter.settings;
out.filter.modAmount = stored.filter.modAmount;
out.filter.velAmount = stored.filter.velAmount;
out.filter.keyTrack = stored.filter.keyTrack;
out.filter.velocityCurve = stored.filter.velocityCurve;
out.filter.env.attackFrames = secToFrames(stored.filter.env.attackSeconds);
+2 -2
View File
@@ -183,11 +183,10 @@ struct FilterSeconds {
bool enabled = false;
engine::filter::FilterSettings settings;
double modAmount = 0.0;
double velAmount = 0.0;
double keyTrack = 0.0;
AdsrSeconds env{0.0, 0.0, 0.0, 1.0, 0.0}; // Gate
AhdSeconds trigEnv; // Trigger
VelocityCurve velocityCurve = VelocityCurve::linear();
VelocityCurve velocityCurve = VelocityCurve::zero();
};
// The stored play bundle: wall-clock times in SECONDS, source-timeline quantities in
@@ -200,6 +199,7 @@ struct PlaySeconds {
AhdSeconds trigAhd; // Trigger amp: AHD (seconds + fraction)
PitchEngine pitchEngine = kDefaultPitchEngine; // product default: Preserve
PitchEnvSeconds pitchEnv; // AHD pitch modulation, off by default
VelocityCurve pitchVelocityCurve = VelocityCurve::zero(); // velocity -> pitch, off by default
FilterSeconds filter; // per-voice filter, off by default
};