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:
@@ -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{};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user