// params_payload.cpp — see params_payload.h. The format ladder it implements is documented // in component_state_io.h; every wire format below is FROZEN. #include "core/instrument/map/params_payload.h" #include // std::min (bounded curve-point reserve) #include // assert (v3-lift projectRate guard) #include // std::isfinite (wire-value validation) #include // std::move #include "core/util/curve_law.h" // clampCurve / kCurveNeutral (wire validation) #include "core/wire/bytes.h" // putLE / ByteReader / doubleToBits (the ONE LE codec) namespace reasampler::instrument::map { using reasampler::wire::ByteReader; using reasampler::wire::asU64; using reasampler::wire::bitsToDouble; using reasampler::wire::doubleToBits; using reasampler::wire::putLE; namespace { // Emit the OVERRIDE trio shared by the v2..v7 per-zone record and the v8 single record, so // the two shapes cannot drift byte-for-byte. void putOverrides(std::vector& out, const InstrumentParams& p) { out.push_back(p.rootOverride ? 1 : 0); if (p.rootOverride) { putLE(out, static_cast(static_cast(*p.rootOverride))); } out.push_back(p.loopOverride ? 1 : 0); if (p.loopOverride) { out.push_back(p.loopOverride->hasLoop ? 1 : 0); putLE(out, asU64(p.loopOverride->start)); putLE(out, asU64(p.loopOverride->end)); } out.push_back(p.startPoint ? 1 : 0); if (p.startPoint) putLE(out, asU64(*p.startPoint)); } // 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& out, const VelocityCurve& curve) { const std::vector& pts = curve.points(); putLE(out, static_cast(pts.size())); for (const VelocityPoint& pt : pts) { putLE(out, doubleToBits(pt.velocity)); putLE(out, doubleToBits(pt.value)); } } // 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& out, const SplineEnv& s) { out.push_back(s.mode == EnvMode::Spline ? 1 : 0); const std::vector& pts = s.contour.points(); putLE(out, static_cast(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& out, const VelocityCurve& curve) { const std::vector& pts = curve.points(); putLE(out, static_cast(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& out, const AhdSeconds& a) { putLE(out, doubleToBits(a.attackSeconds)); putLE(out, doubleToBits(a.decaySeconds)); putLE(out, doubleToBits(a.holdFraction)); putLE(out, doubleToBits(a.attackCurve)); putLE(out, doubleToBits(a.decayCurve)); } // THE lift of the retired Trigger fade pair onto the AHD that replaced it: Attack takes the // fade-in, Decay the fade-out, Hold the whole remainder — so a zero fade-out lands Decay = 0 // and the abrupt end an old instance could express stays representable. The seconds conversion // and its rate-mismatch bound, and the two fitted exponents, are documented in // component_state_io.h. A v10-or-newer blob overwrites all five fields from its own tail. void liftTriggerFades(std::int64_t fadeInFrames, std::int64_t fadeOutFrames, double projectRate, AhdSeconds& out) { const double rate = projectRate > 0.0 ? projectRate : 1.0; out.attackSeconds = static_cast(fadeInFrames > 0 ? fadeInFrames : 0) / rate; out.decaySeconds = static_cast(fadeOutFrames > 0 ? fadeOutFrames : 0) / rate; out.holdFraction = 1.0; out.attackCurve = kTriggerFadeLiftAttackCurve; out.decayCurve = kTriggerFadeLiftDecayCurve; } // Read the play tail (v5 shape onward) into `p`. Shared by the legacy zone reader and the // v8 single-record reader so the two can never disagree about field order. void readSecondsPlayTail(ByteReader& r, InstrumentParams& p, double projectRate) { p.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate; p.play.adsr.holdSeconds = bitsToDouble(r.u64()); p.play.trigger.lengthFraction = bitsToDouble(r.u64()); const std::int64_t fadeIn = r.i64(); const std::int64_t fadeOut = r.i64(); liftTriggerFades(fadeIn, fadeOut, projectRate, p.play.trigAhd); p.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed; p.play.pitchEnv.enabled = (r.u8() != 0); p.play.pitchEnv.shape.attackSeconds = bitsToDouble(r.u64()); p.play.pitchEnv.shape.decaySeconds = bitsToDouble(r.u64()); p.play.pitchEnv.peakSemitones = bitsToDouble(r.u64()); p.play.adsr.attackSeconds = bitsToDouble(r.u64()); p.play.adsr.decaySeconds = bitsToDouble(r.u64()); p.play.adsr.sustainLevel = bitsToDouble(r.u64()); p.play.adsr.releaseSeconds = bitsToDouble(r.u64()); } // Read a velocity curve tail into `curve`, interpreting its y values in `domain` — the domain // is not on the wire, it is a property of the slot. 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) { const std::uint32_t ptCount = r.u32(); std::vector pts; // Bound the reserve to what the blob can hold (16 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(ptCount), remaining / 16)); for (std::uint32_t i = 0; i < ptCount && r.ok; ++i) { const double vel = bitsToDouble(r.u64()); const double value = bitsToDouble(r.u64()); // A NaN velocity breaks fromPoints' stable_sort (not a strict weak ordering with NaN // present); a NaN value reaches the RT eval's multiply. Same non-finite-falls-back-to-0 // guard as every other wire double this codec reads. pts.push_back(VelocityPoint{std::isfinite(vel) ? vel : 0.0, std::isfinite(value) ? value : 0.0}); } if (r.ok) { curve = reasampler::instrument::engine::VelocityCurve::fromPoints(std::move(pts), domain); } } // 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 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(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); // Same NaN guard as readCurveTail: an x NaN breaks fromPoints' sort, a y NaN reaches // SplineCursor::eval's multiply into the per-sample amp gain. pts.push_back(VelocityPoint{std::isfinite(x) ? x : 0.0, std::isfinite(y) ? y : 0.0, hard}); } if (!r.ok) return; s.mode = spline ? EnvMode::Spline : EnvMode::Staged; if (pts.size() < 2) { // fromPoints' own sub-2-point fallback is flat()/zero() by DOMAIN — the neutral velocity // curve response (a full-open gate). A spline EG's documented neutral is y = 1 - x // instead, so a malformed/short block substitutes that rather than fromPoints' default. s.contour = VelocityCurve::rampDown(); return; } 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 — including an out-of-bounds or truncated one — is // dropped rather than applied to shifted knots, and the whole params record parsed ahead of // this tail survives (component_state_io.h's documented promise): if THIS call is what tripped // r.ok (a truncated count field), it is revived before returning. An r.ok already false on // entry (an earlier, unrelated field genuinely truncated) is left alone — that failure is not // this tail's to forgive. void readHardFlags(ByteReader& r, VelocityCurve& curve) { const bool enteredOk = r.ok; const std::uint32_t count = r.u32(); if (!r.ok) { if (enteredOk) r.ok = true; // a truncated count field: nothing to apply return; } const std::size_t remaining = r.bytes.size() > r.pos ? r.bytes.size() - r.pos : 0; if (count > remaining) return; // bound-and-skip: cannot safely reserve/read this many std::vector flags; flags.reserve(count); for (std::uint32_t i = 0; i < count; ++i) flags.push_back(r.u8()); if (flags.size() != curve.size()) return; for (std::size_t i = 0; i < flags.size(); ++i) curve.setHard(i, flags[i] != 0); } // Read the v14 bake Hold. Same revive discipline as readHardFlags directly above, and for the // same reason: this tail reaches no audio path, so a blob truncated inside it must cost the // Hold alone and not reset the whole record that parsed cleanly ahead of it. It sits LAST, so // a truncation stranding the hard flags strands this too — reviving in only one of the two // would still wipe the record. void readBakeHold(ByteReader& r, InstrumentParams& p) { const bool enteredOk = r.ok; const std::int32_t exponent = r.i32(); const std::uint8_t modifier = r.u8(); if (!r.ok) { if (enteredOk) r.ok = true; return; } // makeDivision clamps BOTH fields, so a corrupt pair becomes the nearest legal rung // rather than an unrepresentable one — never a memcpy into the type. p.bakeHold = note::makeDivision(exponent, static_cast(modifier)); } // 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 // v12 domain re-tag needs no version branch (see component_state_io.h). void readFilterTail(ByteReader& r, InstrumentParams& p) { FilterSeconds& f = p.play.filter; f.enabled = (r.u8() != 0); f.settings.cutoffNorm = static_cast(bitsToDouble(r.u64())); f.settings.resonanceNorm = static_cast(bitsToDouble(r.u64())); f.settings.morphNorm = static_cast(bitsToDouble(r.u64())); f.settings.driveNorm = static_cast(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, 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, reasampler::instrument::engine::CurveDomain::Bipolar); } // A curve exponent off the wire. A corrupt/non-finite value degrades to the LINEAR neutral // rather than to an endpoint: neutral is the one exponent that cannot change how a stage // sounds, so a damaged blob loses the shaping instead of inventing one. double readCurveExponent(ByteReader& r) { const double v = bitsToDouble(r.u64()); return std::isfinite(v) ? reasampler::util::clampCurve(v) : reasampler::util::kCurveNeutral; } void readAhd(ByteReader& r, AhdSeconds& a) { // attackSeconds/decaySeconds reach resolvePlay's static_cast (sample_map.cpp) // unguarded — UB on NaN, and on a large-enough finite value — so a corrupt/non-finite wire // value degrades to 0 seconds rather than reaching that cast, the same guard readSecondsPlayTail // and the v9 filter tail already apply to their own wall-clock fields. const double attack = bitsToDouble(r.u64()); const double decay = bitsToDouble(r.u64()); a.attackSeconds = std::isfinite(attack) ? attack : 0.0; a.decaySeconds = std::isfinite(decay) ? decay : 0.0; const double frac = bitsToDouble(r.u64()); a.holdFraction = std::isfinite(frac) ? frac : 0.0; a.attackCurve = readCurveExponent(r); a.decayCurve = readCurveExponent(r); } // Read the v10 staged-curve tail into `p`. A blob that stops short leaves the neutral // exponents and the fade-lifted Trigger AHD, which is what makes a v9 blob play as before. void readCurveStageTail(ByteReader& r, InstrumentParams& p) { PlaySeconds& pp = p.play; pp.adsr.attackCurve = readCurveExponent(r); pp.adsr.decayCurve = readCurveExponent(r); pp.adsr.releaseCurve = readCurveExponent(r); readAhd(r, pp.trigAhd); const double pitchHold = bitsToDouble(r.u64()); pp.pitchEnv.shape.holdFraction = std::isfinite(pitchHold) ? pitchHold : 0.0; pp.pitchEnv.shape.attackCurve = readCurveExponent(r); pp.pitchEnv.shape.decayCurve = readCurveExponent(r); pp.filter.env.attackCurve = readCurveExponent(r); pp.filter.env.decayCurve = readCurveExponent(r); pp.filter.env.releaseCurve = readCurveExponent(r); readAhd(r, pp.filter.trigEnv); } // Read a RETIRED zone-list payload (v1..v7) and adopt zone ONE. Every zone is still parsed // so the truncation ladder behaves exactly as it did — a record that fails mid-way stops the // walk — but only the first zone's capture and parameters survive; the rest drop, touching // no file and no bank entry. // `pv` is the already-consumed payload version (0 = v1, no marker). `projectRate` converts // the LEGACY v3 wall-clock frame counts to seconds (seconds = frames / projectRate); v5+ // blobs carry seconds directly and need no rate. PayloadRead readLegacyZonePayload(ByteReader& r, std::uint32_t pv, double projectRate) { PayloadRead out; const bool extended = (pv >= 2); // v2+: the loop/start tail is present const bool legacyV3Play = (pv == 3); // legacy play tail, wall-clock in nominal frames const bool secondsPlay = (pv >= 5); // v5+: full play params, wall-clock in seconds const bool keyTrackTail = (pv >= 6); // v6+: keyTrack scalar const bool curveTail = (pv >= 7); // v7+: velocity->amp curve, appended last const std::uint32_t count = r.u32(); bool adopted = false; for (std::uint32_t i = 0; i < count && r.ok; ++i) { // A v1/v2 payload (no play tail) lifts to the product defaults (Gate + Preserve + // tier-0 AHDSR seconds) — InstrumentParams' own construction defaults. InstrumentParams p; std::string sampleId; const std::uint32_t idLen = r.u32(); sampleId = r.str(idLen); r.i32(); // lowNote — the retired key range; read to keep the record walk aligned r.i32(); // highNote const std::uint8_t hasOverride = r.u8(); if (hasOverride) p.rootOverride = r.i32(); if (extended) { const std::uint8_t hasLoop = r.u8(); if (hasLoop) { SampleLoop lp; lp.hasLoop = (r.u8() != 0); lp.start = r.i64(); lp.end = r.i64(); p.loopOverride = lp; } const std::uint8_t hasStart = r.u8(); if (hasStart) p.startPoint = r.i64(); } if (legacyV3Play) { // LEGACY v3 play tail. Wall-clock fields (hold, pitchEnv A/D) were written as // frames -> divide by `projectRate` to reach seconds. Trigger %-length + fades // are source-timeline, read as-is. A/D/S/R are ABSENT in v3 -> keep the defaults. assert(projectRate > 0.0 && "readLegacyZonePayload: projectRate must be > 0 for v3 lift"); const double liftRate = projectRate > 0.0 ? projectRate : 1.0; // avoids div-by-zero; assert fires first p.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate; p.play.adsr.holdSeconds = static_cast(r.i64()) / liftRate; p.play.trigger.lengthFraction = bitsToDouble(r.u64()); const std::int64_t fadeIn = r.i64(); const std::int64_t fadeOut = r.i64(); liftTriggerFades(fadeIn, fadeOut, liftRate, p.play.trigAhd); p.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed; p.play.pitchEnv.enabled = (r.u8() != 0); p.play.pitchEnv.shape.attackSeconds = static_cast(r.i64()) / liftRate; p.play.pitchEnv.shape.decaySeconds = static_cast(r.i64()) / liftRate; p.play.pitchEnv.peakSemitones = bitsToDouble(r.u64()); } else if (secondsPlay) { readSecondsPlayTail(r, p, projectRate); } // 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, reasampler::instrument::engine::CurveDomain::Unipolar); } // 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 if (!adopted) { out.params = std::move(p); out.adoptedSampleId = std::move(sampleId); adopted = true; } } return out; } } // namespace // Append the params payload: marker + version + the single parameter record. Always emits // the CURRENT payload version; the marker precedes the record so any reader detects the // shape independent of the envelope version (see component_state_io.h). void putParamsPayload(std::vector& out, const InstrumentParams& p) { putLE(out, kParamsFormatMarker); putLE(out, kParamsPayloadVersion); putOverrides(out, p); // Play params: wall-clock times are SECONDS (doubles); trigger %-length + fades stay // source frames/fraction. Field order matches the header's v5 tail spec verbatim. const PlaySeconds& pp = p.play; out.push_back(pp.playMode == PlayMode::Trigger ? 1 : 0); putLE(out, doubleToBits(pp.adsr.holdSeconds)); // wall-clock seconds putLE(out, doubleToBits(pp.trigger.lengthFraction)); // fraction // The retired fade pair's two frozen slots (see the header): the shape stays, the values // moved into the Trigger AHD tail below. putLE(out, asU64(std::int64_t{0})); putLE(out, asU64(std::int64_t{0})); out.push_back(pp.pitchEngine == PitchEngine::Preserve ? 1 : 0); out.push_back(pp.pitchEnv.enabled ? 1 : 0); putLE(out, doubleToBits(pp.pitchEnv.shape.attackSeconds)); // wall-clock seconds putLE(out, doubleToBits(pp.pitchEnv.shape.decaySeconds)); // wall-clock seconds putLE(out, doubleToBits(pp.pitchEnv.peakSemitones)); // depth // Full AHDSR A/D/S/R tail — wall-clock SECONDS (sustainLevel is a level). putLE(out, doubleToBits(pp.adsr.attackSeconds)); putLE(out, doubleToBits(pp.adsr.decaySeconds)); putLE(out, doubleToBits(pp.adsr.sustainLevel)); putLE(out, doubleToBits(pp.adsr.releaseSeconds)); // Key-tracking scalar (1.0 = 100% ET). putLE(out, doubleToBits(p.keyTrack)); // The velocity->amp transfer curve: 4-byte LE control-point count, then per point // velocity + amp as doubles (endpoints included, so N >= 2). putCurve(out, p.velocityCurve); // v9: the per-voice filter tail. The module's floats widen to doubles on the wire so the // whole payload stays one numeric shape. const FilterSeconds& f = pp.filter; out.push_back(f.enabled ? 1 : 0); putLE(out, doubleToBits(static_cast(f.settings.cutoffNorm))); putLE(out, doubleToBits(static_cast(f.settings.resonanceNorm))); putLE(out, doubleToBits(static_cast(f.settings.morphNorm))); putLE(out, doubleToBits(static_cast(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)); putLE(out, doubleToBits(f.keyTrack)); putLE(out, doubleToBits(f.env.attackSeconds)); putLE(out, doubleToBits(f.env.holdSeconds)); putLE(out, doubleToBits(f.env.decaySeconds)); putLE(out, doubleToBits(f.env.sustainLevel)); putLE(out, doubleToBits(f.env.releaseSeconds)); putCurve(out, f.velocityCurve); // v10: the staged-curve tail. putLE(out, doubleToBits(pp.adsr.attackCurve)); putLE(out, doubleToBits(pp.adsr.decayCurve)); putLE(out, doubleToBits(pp.adsr.releaseCurve)); putAhd(out, pp.trigAhd); putLE(out, doubleToBits(pp.pitchEnv.shape.holdFraction)); putLE(out, doubleToBits(pp.pitchEnv.shape.attackCurve)); putLE(out, doubleToBits(pp.pitchEnv.shape.decayCurve)); putLE(out, doubleToBits(f.env.attackCurve)); putLE(out, doubleToBits(f.env.decayCurve)); putLE(out, doubleToBits(f.env.releaseCurve)); 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); // 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); // v14: the bake's Hold division, as its {quarterExponent, modifier} pair — never its // picker index, which the ladder gaining a rung would silently re-map. putLE(out, static_cast( static_cast(p.bakeHold.quarterExponent()))); out.push_back(static_cast(p.bakeHold.modifier())); } // Read whichever payload shape follows: the single-record shape (v8 onward, growing by // appended tails), or a retired v1..v7 zone list (adopting zone one). An absent marker means // v1 (a plain small zone count). PayloadRead readParamsPayload(ByteReader& r, double projectRate) { std::uint32_t pv = 0; // 0 = v1, no marker if (r.peekU32() == kParamsFormatMarker) { r.u32(); // consume the marker pv = r.u32(); // payload version } if (pv < kParamsSingleRecordVersion) return readLegacyZonePayload(r, pv, projectRate); PayloadRead out; InstrumentParams& p = out.params; const std::uint8_t hasRoot = r.u8(); if (hasRoot) p.rootOverride = r.i32(); const std::uint8_t hasLoop = r.u8(); if (hasLoop) { SampleLoop lp; lp.hasLoop = (r.u8() != 0); lp.start = r.i64(); lp.end = r.i64(); p.loopOverride = lp; } const std::uint8_t hasStart = r.u8(); if (hasStart) p.startPoint = r.i64(); readSecondsPlayTail(r, p, projectRate); p.keyTrack = bitsToDouble(r.u64()); readCurveTail(r, p.velocityCurve, reasampler::instrument::engine::CurveDomain::Unipolar); if (pv >= kParamsFilterVersion) readFilterTail(r, p); if (pv >= kParamsCurveVersion) readCurveStageTail(r, p); if (pv >= kParamsLoopVersion) { // A negative fade is meaningless and would reach resolveLoop's clamp anyway; refusing // it here keeps the parameter set itself sane for the editor that reads it back. 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); } 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); } if (pv >= kParamsBakeHoldVersion) readBakeHold(r, p); // 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{}; return out; } } // namespace reasampler::instrument::map