Bake window derives itself: %-knob fold, declick pad, Gate held to exhaustion, preview velocity; Hold is the one knob a loop needs

This commit is contained in:
2026-08-01 20:26:04 -04:00
parent d3894dae6d
commit 19aeb92775
36 changed files with 1040 additions and 274 deletions
+6 -5
View File
@@ -2,9 +2,9 @@ reasampler_pure_library(bridge_marshal SOURCES bridge_marshal.cpp)
reasampler_test(bridge_marshal LINK bridge_marshal)
reasampler_pure_library(trigger_seam SOURCES trigger_seam.cpp)
# Links only trigger_seam — not even editor_geometry — the plainest data-boundary proof
# available.
reasampler_test(trigger_seam LINK trigger_seam)
# The %-length fold lives beside the span formula, so the header reads play_params' value
# layer; the test links that set and nothing else — still no engine, no editor geometry.
reasampler_test(trigger_seam LINK trigger_seam velocity_curve peaks filter curve_law)
reasampler_pure_library(bank_sync
SOURCES bank_sync.cpp
@@ -21,7 +21,7 @@ reasampler_test(bank_sync LINK bank_sync)
# the format already keeps on independent version axes (see component_state_io.h).
reasampler_pure_library(component_state_io
SOURCES component_state_io.cpp params_payload.cpp
LINK PUBLIC velocity_curve master_gain curve_law)
LINK PUBLIC velocity_curve master_gain curve_law musical_division)
# Links only component_state_io, deliberately no sampler_core/pitch_shift: the structural
# proof the codec is engine-free, which is what keeps engine object code out of the extension.
reasampler_test(component_state_io LINK component_state_io)
@@ -39,7 +39,8 @@ target_link_libraries(play_seconds INTERFACE velocity_curve peaks curve_law)
# The mapping's product is plain SampleData, so the voice engine is not a dependency.
reasampler_pure_library(sample_map
SOURCES sample_map.cpp
LINK PUBLIC bank_book wav_codec play_seconds velocity_curve peaks curve_law)
LINK PUBLIC bank_book wav_codec play_seconds velocity_curve peaks curve_law
musical_division)
# Links only sample_map + component_state_io: the same plain-data-boundary proof, spanning
# both halves of the mapping/codec split where the frozen-format assertions live.
reasampler_test(sample_map LINK sample_map component_state_io)
+15 -2
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..v13) must be preserved exactly. This header is the ONE home for both ladders
// payload v1..v14) 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>
@@ -120,6 +120,15 @@ namespace reasampler::instrument::map {
// 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.
//
// v14 (CURRENT WRITE FORMAT) is v13 PLUS the resample bake's Hold division, appended after the
// hard-flag tails: 4-byte LE quarterExponent (two's-complement int32) + 1 byte modifier (0
// Straight / 1 Dotted / 2 Triplet). Decoded through makeDivision, which clamps both fields —
// never memcpy'd into the type (core/instrument/note/CLAUDE.md owns why). A v13-or-older blob
// is a strict prefix and lifts to one bar, and Hold reaches no audio path, so a pre-v14
// instance plays and bakes identically except where its window was underived to begin with.
// A blob truncated INSIDE this tail costs the Hold alone rather than resetting the record —
// the same revive discipline the v13 hard-flag tails follow, and for the same reason.
//
// 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
@@ -151,7 +160,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 = 13; // v12 + the dual Staged/Spline state
inline constexpr std::uint32_t kParamsPayloadVersion = 14; // v13 + the bake Hold division
inline constexpr std::uint32_t kParamsFormatMarker = 0xFFFFFF00u;
// The first SINGLE-RECORD payload version. Everything below it is a retired zone list and
@@ -179,6 +188,10 @@ inline constexpr std::uint32_t kParamsVelocityVersion = 12;
// kParamsPayloadVersion.
inline constexpr std::uint32_t kParamsSplineVersion = 13;
// v13 + the bake Hold division; the appended tail branches on THIS, never on
// kParamsPayloadVersion.
inline constexpr std::uint32_t kParamsBakeHoldVersion = 14;
// (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
@@ -193,6 +193,24 @@ void readHardFlags(ByteReader& r, VelocityCurve& curve) {
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
@@ -418,6 +436,11 @@ void putParamsPayload(std::vector<std::uint8_t>& out, const InstrumentParams& p)
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
@@ -468,6 +491,7 @@ PayloadRead readParamsPayload(ByteReader& r, double projectRate) {
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{};
+7
View File
@@ -15,6 +15,7 @@
#include "core/model/bank_book.h" // BankBook::deserialize (shared bank JSON parse)
#include "core/instrument/engine/play_params.h" // SampleData, SampleLoop, PlayParams
#include "core/instrument/map/play_seconds.h" // PlaySeconds (the stored seconds value layer)
#include "core/instrument/note/musical_division.h" // Division (the bake hold's value domain)
#include "core/capture/wav_codec.h" // parseWavLayout, extractFloatFrames (shared WAV parse)
namespace reasampler::instrument::map {
@@ -188,6 +189,12 @@ struct InstrumentParams {
// tier-0 AHDSR seconds (0.003 attack / 0.060 release), hold 0, no fades, Preserve pitch
// engine, pitch env off. An older blob lacking this tail lifts to exactly these.
PlaySeconds play;
// How long the resample bake holds the gate. Read ONLY when the bake window cannot be
// derived — a Gate voice over an active sustain loop, which sounds indefinitely
// (bake_plan.h's bakeWindowNeedsHold is the predicate). Default one bar; a blob predating
// the field lifts to it, and no other bake changes.
note::Division bakeHold = note::makeDivision(2, note::DivisionModifier::Straight);
};
// The loaded capture resolved for decode + build: project-relative WAV path (file seam)
+19 -6
View File
@@ -1,19 +1,32 @@
// trigger_seam — the shared Trigger play-span formula: how the stored %-length becomes the
// source-frame span the voice plays and the overlay draws over. One home so the engine's
// note-on resolve and the editor's overlay pack cannot disagree about where a Trigger note
// ends.
// source-frame span the voice plays, the overlay draws over, and the bake's window holds.
// One home so those three cannot disagree about where a Trigger note ends.
//
// playLengthFrames = round(lengthFraction * (frameCount - startFrame))
// playLengthFrames = round(effectiveLengthFraction * (frameCount - startFrame))
#pragma once
#include <cstdint>
#include "core/instrument/engine/play_params.h" // splineActive (the %-knob's inert rule)
namespace reasampler::instrument::map {
// The %-length the voice ACTUALLY plays. A drawn contour is a pure time function over the
// full sample length, so any active spline EG folds the fraction to 1.0 and the stored knob
// goes inert (splineActive, play_params.h) — but the stored value survives, so a pre-spline
// setting is still there to be read. Every consumer of the span must fold it here or it
// silently plays/draws/bakes a fraction of the take.
//
// Templated over the two parameter representations for the same reason splineActive is.
template <class Play>
double effectiveLengthFraction(const Play& p) {
return splineActive(p) ? 1.0 : p.trigger.lengthFraction;
}
// postStart = max(0, frameCount - startFrame); playLength = round(lengthFraction * postStart).
// `startFrame` is the effective start point (0 when absent). Returns 0 when postStart == 0
// or lengthFraction <= 0.
// `startFrame` is the effective start point (0 when absent), `lengthFraction` the EFFECTIVE
// one above. Returns 0 when postStart == 0 or lengthFraction <= 0.
std::int64_t triggerPlayLength(double lengthFraction,
std::int64_t frameCount,
std::int64_t startFrame);