502 lines
26 KiB
C++
502 lines
26 KiB
C++
// 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 <algorithm> // std::min (bounded curve-point reserve)
|
|
#include <cassert> // assert (v3-lift projectRate guard)
|
|
#include <cmath> // std::isfinite (wire-value validation)
|
|
#include <utility> // 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<std::uint8_t>& out, const InstrumentParams& p) {
|
|
out.push_back(p.rootOverride ? 1 : 0);
|
|
if (p.rootOverride) {
|
|
putLE(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(*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<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.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<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));
|
|
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<double>(fadeInFrames > 0 ? fadeInFrames : 0) / rate;
|
|
out.decaySeconds = static_cast<double>(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<VelocityPoint> 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<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 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<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);
|
|
// 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<std::uint8_t> 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<note::DivisionModifier>(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<float>(bitsToDouble(r.u64()));
|
|
f.settings.resonanceNorm = static_cast<float>(bitsToDouble(r.u64()));
|
|
f.settings.morphNorm = static_cast<float>(bitsToDouble(r.u64()));
|
|
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.
|
|
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<std::int64_t> (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<double>(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<double>(r.i64()) / liftRate;
|
|
p.play.pitchEnv.shape.decaySeconds = static_cast<double>(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<std::uint8_t>& 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<double>(f.settings.cutoffNorm)));
|
|
putLE(out, doubleToBits(static_cast<double>(f.settings.resonanceNorm)));
|
|
putLE(out, doubleToBits(static_cast<double>(f.settings.morphNorm)));
|
|
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));
|
|
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<std::uint32_t>(
|
|
static_cast<std::int32_t>(p.bakeHold.quarterExponent())));
|
|
out.push_back(static_cast<std::uint8_t>(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
|