loop: crossfade the Gate sustain seam, and unshadow the loop handles that made loop points look gone

This commit is contained in:
2026-07-31 17:36:36 -04:00
parent a90ccd9a00
commit 0fe4166d7d
25 changed files with 991 additions and 64 deletions
@@ -1,5 +1,5 @@
// component_state_io — the ComponentState ENVELOPE codec. See component_state_io.h for both
// format ladders (envelope v1..v11, params payload v1..v10); the payload half lives in
// format ladders (envelope v1..v11, params payload v1..v11); the payload half lives in
// params_payload, which grows on its own version axis. Every wire format is FROZEN —
// byte-identical across revisions.
+12 -5
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..v10) must be preserved exactly. This header is the ONE home for both ladders
// payload v1..v11) 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>
@@ -78,17 +78,21 @@ namespace reasampler::instrument::map {
// the filter's OWN velocity curve (count + points, same shape as v7's). A v8 blob is a strict
// prefix, so it lifts to the off/neutral filter default and plays bit-identically.
//
// v10 (CURRENT WRITE FORMAT) is v9 PLUS the staged-curve tail, appended after the filter's
// velocity curve, all 8-byte LE doubles in this order: amp AHDSR attack/decay/release curve
// v10 is v9 PLUS the staged-curve tail, appended after the filter's velocity curve, all
// 8-byte LE doubles in this order: amp AHDSR attack/decay/release curve
// exponents; the Trigger amp AHD (attack SECONDS, decay SECONDS, hold FRACTION, attack curve,
// decay curve); the pitch envelope's hold FRACTION + attack/decay curve exponents; the filter
// AHDSR's attack/decay/release curve exponents; the filter's Trigger AHD (same five fields as
// the amp's). A v9-or-older blob is a strict prefix and lifts to the neutral exponent 1.0.
//
// v11 (CURRENT WRITE FORMAT) is v10 PLUS one 8-byte LE int64: the loop crossfade in SOURCE
// frames (a source-timeline quantity like the loop points, so no rate resolves it). A v10-or-
// older blob is a strict prefix and lifts to 0 — the hard seam it always played.
//
// 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
// seconds at the project rate the reader is handed. v10 writes ZERO into both — the values
// seconds at the project rate the reader is handed. v10+ writes ZERO into both — the values
// live in the AHD now, so a DOWNGRADE to a pre-v10 binary loses the Trigger amp shape.
//
// LOSSY UNDER A RATE MISMATCH. The fades were SOURCE frames and the AHD stores wall-clock
@@ -116,7 +120,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 = 10; // v9 + the staged-curve tail
inline constexpr std::uint32_t kParamsPayloadVersion = 11; // v10 + the loop-crossfade tail
inline constexpr std::uint32_t kParamsFormatMarker = 0xFFFFFF00u;
// The first SINGLE-RECORD payload version. Everything below it is a retired zone list and
@@ -132,6 +136,9 @@ inline constexpr std::uint32_t kParamsFilterVersion = 9;
// v9 + the staged-curve tail (curve exponents, the Trigger AHDs, the pitch Hold fraction).
inline constexpr std::uint32_t kParamsCurveVersion = 10;
// v10 + the loop-crossfade frame count.
inline constexpr std::uint32_t kParamsLoopVersion = 11;
// (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
@@ -319,6 +319,8 @@ void putParamsPayload(std::vector<std::uint8_t>& out, const InstrumentParams& p)
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));
}
// Read whichever payload shape follows: the single-record shape (v8 onward, growing by
@@ -351,6 +353,12 @@ PayloadRead readParamsPayload(ByteReader& r, double projectRate) {
readCurveTail(r, p.velocityCurve);
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;
}
// 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{};
+1 -1
View File
@@ -5,7 +5,7 @@
// responsibilities. An INTERNAL seam of `component_state_io` — the public entry points stay
// serialize/deserializeComponentState; nothing outside the codec calls these.
//
// The format ladder (payload v1..v10) is documented in component_state_io.h, which stays its
// The format ladder (payload v1..v11) is documented in component_state_io.h, which stays its
// one home. EVERY wire format is FROZEN.
#include <cstdint>
+2
View File
@@ -270,6 +270,7 @@ ResolvedCapture resolveCapture(const SelectedSample& ref, const InstrumentParams
// The override wins over the intrinsic; absent -> intrinsic (loop) / frame 0 (start).
// The bank is never mutated.
rs.loop = params.loopOverride ? *params.loopOverride : ref.loop;
rs.loopCrossfadeFrames = params.loopCrossfadeFrames;
rs.startFrame = params.startPoint ? *params.startPoint : 0;
rs.play = params.play; // SECONDS; buildSampleData resolves to frames
return rs;
@@ -306,6 +307,7 @@ SampleData buildSampleData(const ResolvedCapture& resolved, DecodedPcm decoded)
data.sampleRate = decoded.sampleRate;
data.rootNote = resolved.rootNote;
data.loop = resolved.loop;
data.loopCrossfadeFrames = resolved.loopCrossfadeFrames;
data.startFrame = resolved.startFrame;
data.keyTrack = resolved.keyTrack;
data.velocityCurve = resolved.velocityCurve;
+7
View File
@@ -227,6 +227,12 @@ struct InstrumentParams {
std::optional<SampleLoop> loopOverride; // instrument-owned sustain loop; absent -> intrinsic
std::optional<std::int64_t> startPoint; // instrument-owned initial read frame; absent -> 0
// Pre-seam crossfade at the loop reset, in SOURCE frames — a source-timeline quantity
// like the loop points, so it needs no rate to resolve and cannot be rescaled by a
// project/file rate mismatch. 0 is the hard seam a blob predating the field lifts to.
// Never a bank fact: the fade is a performance choice, the loop points are the file's.
std::int64_t loopCrossfadeFrames = 0;
// Key-tracking scalar: how far playback pitch tracks the keyboard around the root. 1.0
// (100%, standard 12-tone-ET) is the default — a blob predating this field lifts to
// exactly 1.0, so already-saved instances are bit-identical. 0.0 = no tracking (every
@@ -259,6 +265,7 @@ struct ResolvedCapture {
double keyTrack = 1.0;
VelocityCurve velocityCurve = VelocityCurve::flat();
SampleLoop loop; // effective: loopOverride, else bank intrinsic
std::int64_t loopCrossfadeFrames = 0; // instrument-owned; no bank intrinsic to beat
std::int64_t startFrame = 0; // effective initial read frame: startPoint, else 0
PlaySeconds play; // stored SECONDS; resolved to frames at build
};