instrument: spline EGs — hard points on the one shared spline, a drawn contour per envelope beside its staged state, payload v13

This commit is contained in:
2026-07-31 21:33:59 -04:00
parent f115904e4f
commit e44bd42dd9
33 changed files with 1739 additions and 364 deletions
+22 -2
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..v12) must be preserved exactly. This header is the ONE home for both ladders
// payload v1..v13) 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>
@@ -104,6 +104,22 @@ namespace reasampler::instrument::map {
// which transposes nothing. A DOWNGRADE to a pre-v12 binary re-narrows the domain, so a curve
// drawn into the negative half comes back with that half clamped to 0.
//
// v13 (CURRENT WRITE FORMAT) is v12 PLUS the DUAL Staged/Spline envelope state, appended after
// the velocity->pitch curve. Its two halves, in order:
// (a) the three spline EGs — amp, pitch, filter, in that order. Each: 1 byte mode (0 Staged /
// 1 Spline), then a SPLINE CURVE block: 4-byte LE point count N, then per point 8-byte LE
// x + 8-byte LE y (doubles) + 1 byte hard. x spans the curve's canonical [0,127] (a
// normalized-time contour maps onto that same span — velocity_curve.h owns why one span
// serves both), y is UNIPOLAR [0,1]; the pitch and filter depth knobs scale it.
// (b) the HARD-FLAG tails for the three v7/v9/v12 velocity curves — amp, filter, pitch, in
// that order. Each: 4-byte LE count N, then N bytes. Those three curve blocks are FROZEN
// at 16 bytes/point and cannot grow a per-point flag, so the flags ride here instead. A
// tail whose count does not match the curve as read is IGNORED (the curve keeps its
// flags-off default) rather than applied to the wrong knots — a repaired blob loses the
// hard points, never misplaces them.
// A v12-or-older blob is a strict prefix and lifts to {Staged, the y = 1 - x default contour}
// on all three EGs with no hard point anywhere, so it plays exactly as it did.
//
// 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
// them (attack <- fade-in, decay <- fade-out, hold <- the whole remainder), converted to
@@ -135,7 +151,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 = 12; // v11 + the velocity->pitch curve
inline constexpr std::uint32_t kParamsPayloadVersion = 13; // v12 + the dual Staged/Spline state
inline constexpr std::uint32_t kParamsFormatMarker = 0xFFFFFF00u;
// The first SINGLE-RECORD payload version. Everything below it is a retired zone list and
@@ -159,6 +175,10 @@ inline constexpr std::uint32_t kParamsLoopVersion = 11;
// pre-v12 curve's y values are already valid bipolar ones.
inline constexpr std::uint32_t kParamsVelocityVersion = 12;
// v12 + the dual Staged/Spline state; the appended tail branches on THIS, never on
// kParamsPayloadVersion.
inline constexpr std::uint32_t kParamsSplineVersion = 13;
// (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
@@ -50,6 +50,27 @@ void putCurve(std::vector<std::uint8_t>& out, const VelocityCurve& curve) {
}
}
// A spline EG: 1 byte mode, then the contour as count + (x, y, hard) per point. Distinct from
// putCurve because the three velocity-curve blocks are frozen at 16 bytes/point and cannot grow
// the hard flag; this block was born with it.
void putSplineEnv(std::vector<std::uint8_t>& out, const SplineEnv& s) {
out.push_back(s.mode == EnvMode::Spline ? 1 : 0);
const std::vector<VelocityPoint>& pts = s.contour.points();
putLE(out, static_cast<std::uint32_t>(pts.size()));
for (const VelocityPoint& pt : pts) {
putLE(out, doubleToBits(pt.velocity));
putLE(out, doubleToBits(pt.value));
out.push_back(pt.hard ? 1 : 0);
}
}
// The hard flags of an already-written velocity curve: count + one byte per point.
void putHardFlags(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) out.push_back(pt.hard ? 1 : 0);
}
// A stored AHD's five doubles, in one order shared by every AHD on the wire.
void putAhd(std::vector<std::uint8_t>& out, const AhdSeconds& a) {
putLE(out, doubleToBits(a.attackSeconds));
@@ -114,6 +135,41 @@ void readCurveTail(ByteReader& r, VelocityCurve& curve,
}
}
// Read a spline EG. A truncated read leaves `s` at its Staged/default-contour construction
// value, which is what makes a pre-v13 blob play exactly as it did.
void readSplineEnv(ByteReader& r, SplineEnv& s) {
const bool spline = (r.u8() != 0);
const std::uint32_t ptCount = r.u32();
std::vector<VelocityPoint> pts;
// Bound the reserve to what the blob can hold (17 bytes/point) so a corrupt huge count
// can't trigger a giant allocation before the bounded reads fail.
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 / 17));
for (std::uint32_t i = 0; i < ptCount && r.ok; ++i) {
const double x = bitsToDouble(r.u64());
const double y = bitsToDouble(r.u64());
const bool hard = (r.u8() != 0);
pts.push_back(VelocityPoint{x, y, hard});
}
if (!r.ok) return;
s.mode = spline ? EnvMode::Spline : EnvMode::Staged;
s.contour = VelocityCurve::fromPoints(std::move(pts),
reasampler::instrument::engine::CurveDomain::Unipolar);
}
// Apply a hard-flag tail to an already-read velocity curve. A count that disagrees with the
// curve fromPoints actually produced is dropped rather than applied to shifted knots.
void readHardFlags(ByteReader& r, VelocityCurve& curve) {
const std::uint32_t count = r.u32();
const std::size_t remaining = r.bytes.size() > r.pos ? r.bytes.size() - r.pos : 0;
if (count > remaining) { r.ok = false; return; }
std::vector<std::uint8_t> flags;
flags.reserve(count);
for (std::uint32_t i = 0; i < count && r.ok; ++i) flags.push_back(r.u8());
if (!r.ok || flags.size() != curve.size()) return;
for (std::size_t i = 0; i < flags.size(); ++i) curve.setHard(i, flags[i] != 0);
}
// 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. The curve reads as
// bipolar at EVERY version — a pre-v12 blob's y values are already valid bipolar ones, so its
@@ -331,6 +387,14 @@ void putParamsPayload(std::vector<std::uint8_t>& out, const InstrumentParams& p)
putLE(out, asU64(p.loopCrossfadeFrames));
// v12: the velocity->pitch curve.
putCurve(out, pp.pitchVelocityCurve);
// v13: the dual Staged/Spline state — the three contours, then the hard flags the three
// frozen velocity-curve blocks above had no room for.
putSplineEnv(out, pp.ampSpline);
putSplineEnv(out, pp.pitchSpline);
putSplineEnv(out, pp.filterSpline);
putHardFlags(out, p.velocityCurve);
putHardFlags(out, f.velocityCurve);
putHardFlags(out, pp.pitchVelocityCurve);
}
// Read whichever payload shape follows: the single-record shape (v8 onward, growing by
@@ -373,6 +437,14 @@ PayloadRead readParamsPayload(ByteReader& r, double projectRate) {
readCurveTail(r, p.play.pitchVelocityCurve,
reasampler::instrument::engine::CurveDomain::Bipolar);
}
if (pv >= kParamsSplineVersion) {
readSplineEnv(r, p.play.ampSpline);
readSplineEnv(r, p.play.pitchSpline);
readSplineEnv(r, p.play.filterSpline);
readHardFlags(r, p.velocityCurve);
readHardFlags(r, p.play.filter.velocityCurve);
readHardFlags(r, p.play.pitchVelocityCurve);
}
// 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{};
+10
View File
@@ -256,6 +256,16 @@ PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate) {
out.filter.env.decayCurve = stored.filter.env.decayCurve;
out.filter.env.releaseCurve = stored.filter.env.releaseCurve;
out.filter.trigEnv = resolveAhd(stored.filter.trigEnv);
// The three drawn contours are normalized over the sample's own length, so no rate resolves
// them — they carry through verbatim, which is also what makes a different-length capture
// replay the same shape proportionally.
out.ampSpline = stored.ampSpline;
out.pitchSpline = stored.pitchSpline;
out.filterSpline = stored.filterSpline;
// Gate is unavailable while any EG is drawn — see splineActive (play_params.h) for why.
// The editor refuses the Gate segment for the same reason; enforcing it HERE as well is
// what keeps a hand-edited or downgraded blob from reaching the engine as Gate + spline.
if (splineActive(stored)) out.playMode = PlayMode::Trigger;
return out;
}
+6
View File
@@ -202,6 +202,12 @@ struct PlaySeconds {
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
// The three drawn contours, in the same slots the engine bundle carries them (play_params.h
// owns why they sit beside the envelopes rather than inside them). Normalized over the
// sample's own length, so resolvePlay needs no rate for them.
SplineEnv ampSpline;
SplineEnv pitchSpline;
SplineEnv filterSpline;
};
// Resolve a stored seconds bundle to the engine's frame-domain PlayParams against a live