// 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::bitsToDouble; using reasampler::wire::doubleToBits; using reasampler::wire::putLE; namespace { // Signed 64-bit values ride the wire as their two's-complement unsigned image. std::uint64_t asU64(std::int64_t v) { return static_cast(v); } // 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 + amp as doubles. // The amp curve (v7) and the filter's own curve (v9) share this shape. 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.amp)); } } // 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 fades were SOURCE // frames and the AHD stores wall-clock seconds, so the conversion goes through the same // project rate the v3 lift already uses. A v10-or-newer blob overwrites this 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; } // 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`. 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) { 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 amp = bitsToDouble(r.u64()); pts.push_back(VelocityPoint{vel, amp}); } if (r.ok) { curve = reasampler::instrument::engine::VelocityCurve::fromPoints(std::move(pts)); } } // 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) { 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); } // 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) { a.attackSeconds = bitsToDouble(r.u64()); a.decaySeconds = bitsToDouble(r.u64()); 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); // 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); } // 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); if (pv >= kParamsFilterVersion) readFilterTail(r, p); if (pv >= kParamsCurveVersion) readCurveStageTail(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