instrument: the filter's velocity depth knob returns and multiplies the bipolar curve; the pre-v12 lift is a pure domain re-tag
This commit is contained in:
@@ -12,7 +12,6 @@
|
||||
#include "../src/core/instrument/engine/master_gain.h" // masterGainMaxLinear (the v8 wire cap)
|
||||
#include "../src/core/util/curve_law.h" // kCurveNeutral (the migration neutral)
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
@@ -475,7 +474,7 @@ static void testGoldenFullBlobFixture() {
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // driveNorm 0.0
|
||||
0x00, // morphLaw = HighBandLow
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // modAmount 0.0
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // velAmount slot: frozen constant 1.0
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // velAmount 0.0
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // keyTrack 0.0
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // env attack 0.0
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // env hold 0.0
|
||||
@@ -638,6 +637,7 @@ static void testFilterTailRoundTripsLosslessly() {
|
||||
f.settings.driveNorm = 0.5f;
|
||||
f.settings.morphLaw = reasampler::instrument::engine::filter::MorphLaw::HighNotchLow;
|
||||
f.modAmount = -0.625;
|
||||
f.velAmount = 0.875;
|
||||
f.keyTrack = 1.5;
|
||||
f.env.attackSeconds = 0.031;
|
||||
f.env.holdSeconds = 0.062;
|
||||
@@ -662,6 +662,7 @@ static void testFilterTailRoundTripsLosslessly() {
|
||||
CHECK(g.settings.driveNorm == f.settings.driveNorm);
|
||||
CHECK(g.settings.morphLaw == reasampler::instrument::engine::filter::MorphLaw::HighNotchLow);
|
||||
CHECK(g.modAmount == f.modAmount);
|
||||
CHECK(g.velAmount == f.velAmount);
|
||||
CHECK(g.keyTrack == f.keyTrack);
|
||||
CHECK(g.env.attackSeconds == f.env.attackSeconds);
|
||||
CHECK(g.env.holdSeconds == f.env.holdSeconds);
|
||||
@@ -714,6 +715,7 @@ static void testNonFiniteFilterFieldsLiftToTheNeutralDefault() {
|
||||
FilterSeconds& f = in.params.play.filter;
|
||||
f.enabled = true;
|
||||
f.modAmount = std::numeric_limits<double>::quiet_NaN();
|
||||
f.velAmount = std::numeric_limits<double>::infinity();
|
||||
f.keyTrack = -std::numeric_limits<double>::infinity();
|
||||
|
||||
const ComponentState out =
|
||||
@@ -721,6 +723,7 @@ static void testNonFiniteFilterFieldsLiftToTheNeutralDefault() {
|
||||
const FilterSeconds& g = out.params.play.filter;
|
||||
const FilterSeconds def;
|
||||
CHECK(g.modAmount == def.modAmount);
|
||||
CHECK(g.velAmount == def.velAmount);
|
||||
CHECK(g.keyTrack == def.keyTrack);
|
||||
// The fallback is per-field, not per-record: the untouched fields still round-trip.
|
||||
CHECK(g.enabled);
|
||||
@@ -815,25 +818,16 @@ static std::vector<std::uint8_t> payloadDowngradedTo(const ComponentState& state
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// Overwrite the frozen filter velAmount slot: the writer emits a constant 1.0 there now, so a
|
||||
// pre-v12 fixture has to plant its own depth. Located by the DISTINCT modAmount immediately
|
||||
// preceding it rather than by a byte offset, so a tail growing ahead of it cannot rot this.
|
||||
static void plantPreV12FilterDepth(std::vector<std::uint8_t>& bytes, double modAmount,
|
||||
double velAmount) {
|
||||
std::vector<std::uint8_t> needle;
|
||||
legacy::f64v(needle, modAmount);
|
||||
std::size_t at = 0;
|
||||
int hits = 0;
|
||||
for (std::size_t i = 0; i + 2 * needle.size() <= bytes.size(); ++i) {
|
||||
if (std::equal(needle.begin(), needle.end(), bytes.begin() + static_cast<long>(i))) {
|
||||
at = i;
|
||||
++hits;
|
||||
}
|
||||
// Every knot of `lifted` equals `stored`'s BIT for bit — the whole claim of a domain re-tag,
|
||||
// which is why this compares with == rather than a tolerance.
|
||||
static bool knotsAreIdentical(const reasampler::instrument::engine::VelocityCurve& lifted,
|
||||
const reasampler::instrument::engine::VelocityCurve& stored) {
|
||||
if (lifted.size() != stored.size()) return false;
|
||||
for (std::size_t i = 0; i < lifted.size(); ++i) {
|
||||
if (lifted.points()[i].velocity != stored.points()[i].velocity) return false;
|
||||
if (lifted.points()[i].value != stored.points()[i].value) return false;
|
||||
}
|
||||
CHECK(hits == 1); // an ambiguous anchor would plant the depth in the wrong slot
|
||||
std::vector<std::uint8_t> depth;
|
||||
legacy::f64v(depth, velAmount);
|
||||
for (std::size_t k = 0; k < depth.size(); ++k) bytes[at + needle.size() + k] = depth[k];
|
||||
return true;
|
||||
}
|
||||
|
||||
// A project saved before this change reopens sounding identical: its loop span still applies,
|
||||
@@ -859,17 +853,18 @@ static void testPriorPayloadVersionsLiftToAHardSeam() {
|
||||
in.params.play.pitchVelocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(
|
||||
{VelocityPoint{0.0, 0.5}, VelocityPoint{127.0, 1.0}},
|
||||
reasampler::instrument::engine::CurveDomain::Bipolar);
|
||||
// The filter's pre-v12 depth fold (see testPreV12FilterVelocityDepthFoldsIntoTheCurve for
|
||||
// the single-version proof); planted here too so EVERY version that carries a filter tail
|
||||
// (v9..v11) is walked, not just v11 — the fold's branch condition is pv < kParamsVelocityVersion.
|
||||
// The filter's velocity pair, so EVERY version that carries a filter tail (v9..v11) walks
|
||||
// the v12 domain re-tag, not just v11 (see testPreV12FilterVelocityLiftsAsAPureDomainReTag
|
||||
// for the single-version proof). The knots stay inside [0,1] — what a pre-v12 unipolar
|
||||
// curve could actually hold.
|
||||
in.params.play.filter.enabled = true;
|
||||
in.params.play.filter.modAmount = -0.6251953125; // distinct, exactly representable anchor
|
||||
in.params.play.filter.modAmount = -0.6251953125;
|
||||
in.params.play.filter.velAmount = -0.75;
|
||||
const reasampler::instrument::engine::VelocityCurve filterShape =
|
||||
reasampler::instrument::engine::VelocityCurve::fromPoints(
|
||||
{VelocityPoint{0.0, 0.0}, VelocityPoint{64.0, 0.25}, VelocityPoint{127.0, 1.0}},
|
||||
reasampler::instrument::engine::CurveDomain::Bipolar);
|
||||
in.params.play.filter.velocityCurve = filterShape;
|
||||
constexpr double kPlantedDepth = -0.75;
|
||||
|
||||
struct Case {
|
||||
std::uint32_t pv;
|
||||
@@ -884,10 +879,7 @@ static void testPriorPayloadVersionsLiftToAHardSeam() {
|
||||
{8, kVelocityTailBytes + kLoopTailBytes + kCurveTailBytes + kFilterTailBytes, false, false},
|
||||
};
|
||||
for (const Case& c : cases) {
|
||||
std::vector<std::uint8_t> bytes = payloadDowngradedTo(in, c.pv, c.cut);
|
||||
if (c.keepsFilterTail) {
|
||||
plantPreV12FilterDepth(bytes, in.params.play.filter.modAmount, kPlantedDepth);
|
||||
}
|
||||
const std::vector<std::uint8_t> bytes = payloadDowngradedTo(in, c.pv, c.cut);
|
||||
const ComponentState out = deserializeComponentState(bytes, 48000.0);
|
||||
// The span itself has been in the format since v2 and must survive untouched.
|
||||
CHECK(out.params.loopOverride && out.params.loopOverride->hasLoop);
|
||||
@@ -904,16 +896,22 @@ static void testPriorPayloadVersionsLiftToAHardSeam() {
|
||||
// And the cut landed on the tail boundary the ladder claims, not somewhere inside it.
|
||||
CHECK(out.params.play.adsr.attackCurve ==
|
||||
(c.keepsCurveTail ? 4.0 : reasampler::util::kCurveNeutral));
|
||||
// The filter's own velocity curve: folded by the planted depth where the tail survives
|
||||
// (v9..v11), the off/neutral default where it was cut away entirely (v8).
|
||||
// The filter's velocity pair where the tail survives (v9..v11): the depth carries
|
||||
// forward untouched and the curve is re-tagged, not rescaled — so the cutoff
|
||||
// contribution, depth * curve(v), is exactly what the pre-change reader computed.
|
||||
// Where the tail was cut away entirely (v8) it is the off/neutral default.
|
||||
if (c.keepsFilterTail) {
|
||||
CHECK(out.params.play.filter.enabled);
|
||||
CHECK(out.params.play.filter.velAmount == in.params.play.filter.velAmount);
|
||||
CHECK(knotsAreIdentical(out.params.play.filter.velocityCurve, filterShape));
|
||||
for (int v = 0; v <= 127; ++v) {
|
||||
CHECK(std::fabs(out.params.play.filter.velocityCurve.eval(v) -
|
||||
kPlantedDepth * filterShape.eval(v)) < 1e-12);
|
||||
CHECK(out.params.play.filter.velAmount *
|
||||
out.params.play.filter.velocityCurve.eval(v) ==
|
||||
in.params.play.filter.velAmount * filterShape.eval(v));
|
||||
}
|
||||
} else {
|
||||
CHECK(!out.params.play.filter.enabled);
|
||||
CHECK(out.params.play.filter.velAmount == 0.0);
|
||||
for (int v = 0; v <= 127; ++v) {
|
||||
CHECK(out.params.play.filter.velocityCurve.eval(v) == 0.0);
|
||||
}
|
||||
@@ -921,44 +919,59 @@ static void testPriorPayloadVersionsLiftToAHardSeam() {
|
||||
}
|
||||
}
|
||||
|
||||
// The sharp edge of the bipolar change: a pre-v12 blob stored the filter's velocity response
|
||||
// as a [0,1] SHAPE times a separate depth, and the lift folds that depth into the knots. The
|
||||
// lifted curve must evaluate to exactly the product the pre-change voice computed.
|
||||
static void testPreV12FilterVelocityDepthFoldsIntoTheCurve() {
|
||||
ComponentState in;
|
||||
in.selectionId = "pad";
|
||||
FilterSeconds& f = in.params.play.filter;
|
||||
f.enabled = true;
|
||||
f.modAmount = -0.6251953125; // distinct and exactly representable: the planting anchor
|
||||
// What an old blob's curve looked like: a shape confined to [0,1], with the sign and the
|
||||
// amount living in the depth beside it.
|
||||
const reasampler::instrument::engine::VelocityCurve shape =
|
||||
// The sharp edge of the bipolar change: v12 widened the filter curve's y domain, and the lift
|
||||
// is a pure DOMAIN RE-TAG — no rescaling, no rounding. A pre-v12 curve's y values all lie in
|
||||
// [0,1], which is inside [-1,+1], so every knot must come back bit-identical, the depth beside
|
||||
// it untouched, and the cutoff contribution equal to the pre-change product at every velocity.
|
||||
static void testPreV12FilterVelocityLiftsAsAPureDomainReTag() {
|
||||
// A shape confined to [0,1] — what a pre-v12 unipolar curve could actually store. The
|
||||
// reference reads the SAME knots through the old domain, so the comparison is against what
|
||||
// the pre-change reader built, not against another read of the new one.
|
||||
const std::vector<VelocityPoint> knots = {VelocityPoint{0.0, 0.0}, VelocityPoint{40.0, 0.125},
|
||||
VelocityPoint{64.0, 0.75}, VelocityPoint{127.0, 1.0}};
|
||||
const reasampler::instrument::engine::VelocityCurve asStored =
|
||||
reasampler::instrument::engine::VelocityCurve::fromPoints(
|
||||
{VelocityPoint{0.0, 0.0}, VelocityPoint{64.0, 0.25}, VelocityPoint{127.0, 1.0}},
|
||||
reasampler::instrument::engine::CurveDomain::Bipolar);
|
||||
f.velocityCurve = shape;
|
||||
knots, reasampler::instrument::engine::CurveDomain::Unipolar);
|
||||
|
||||
for (const double depth : {-0.75, 0.5, 0.0}) {
|
||||
std::vector<std::uint8_t> bytes =
|
||||
ComponentState in;
|
||||
in.selectionId = "pad";
|
||||
FilterSeconds& f = in.params.play.filter;
|
||||
f.enabled = true;
|
||||
f.modAmount = -0.6251953125;
|
||||
f.velAmount = depth;
|
||||
f.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(
|
||||
knots, reasampler::instrument::engine::CurveDomain::Bipolar);
|
||||
|
||||
const std::vector<std::uint8_t> bytes =
|
||||
payloadDowngradedTo(in, kParamsLoopVersion, kVelocityTailBytes);
|
||||
plantPreV12FilterDepth(bytes, f.modAmount, depth);
|
||||
const ComponentState out = deserializeComponentState(bytes, 48000.0);
|
||||
const reasampler::instrument::engine::VelocityCurve& lifted =
|
||||
out.params.play.filter.velocityCurve;
|
||||
|
||||
CHECK(lifted.domain() == reasampler::instrument::engine::CurveDomain::Bipolar);
|
||||
CHECK(knotsAreIdentical(lifted, asStored)); // bit-identical, not within a tolerance
|
||||
CHECK(out.params.play.filter.velAmount == depth); // the depth carries forward untouched
|
||||
CHECK(out.params.play.filter.modAmount == f.modAmount);
|
||||
// Sound-identical, stated as the product the voice actually computes.
|
||||
for (int v = 0; v <= 127; ++v) {
|
||||
CHECK(std::fabs(lifted.eval(v) - depth * shape.eval(v)) < 1e-12);
|
||||
CHECK(out.params.play.filter.velAmount * lifted.eval(v) == depth * asStored.eval(v));
|
||||
}
|
||||
CHECK(out.params.play.filter.modAmount == f.modAmount); // the anchor itself survives
|
||||
}
|
||||
|
||||
// At v12 the same slot is ignored: the curve is read verbatim, whatever sits in it.
|
||||
std::vector<std::uint8_t> current = serializeComponentState(in);
|
||||
plantPreV12FilterDepth(current, f.modAmount, 0.0);
|
||||
const ComponentState now = deserializeComponentState(current, 48000.0);
|
||||
for (int v = 0; v <= 127; ++v) {
|
||||
CHECK(std::fabs(now.params.play.filter.velocityCurve.eval(v) - shape.eval(v)) < 1e-12);
|
||||
}
|
||||
// The v12 blob of the same state reads back the same way — the re-tag is what the reader
|
||||
// does at EVERY version, so the pre-v12 and current paths cannot diverge.
|
||||
ComponentState now;
|
||||
now.selectionId = "pad";
|
||||
now.params.play.filter.enabled = true;
|
||||
now.params.play.filter.velAmount = -0.75;
|
||||
now.params.play.filter.velocityCurve =
|
||||
reasampler::instrument::engine::VelocityCurve::fromPoints(
|
||||
knots, reasampler::instrument::engine::CurveDomain::Bipolar);
|
||||
const ComponentState back =
|
||||
deserializeComponentState(serializeComponentState(now), 48000.0);
|
||||
CHECK(knotsAreIdentical(back.params.play.filter.velocityCurve, asStored));
|
||||
CHECK(back.params.play.filter.velAmount == -0.75);
|
||||
}
|
||||
|
||||
// The WRITER emits the CURRENT payload version, and the marker + version sit at the head of
|
||||
@@ -1534,7 +1547,7 @@ int main() {
|
||||
testLoopSpanAndCrossfadeRoundTrip();
|
||||
testNegativeCrossfadeOnTheWireLiftsToZero();
|
||||
testPriorPayloadVersionsLiftToAHardSeam();
|
||||
testPreV12FilterVelocityDepthFoldsIntoTheCurve();
|
||||
testPreV12FilterVelocityLiftsAsAPureDomainReTag();
|
||||
testWriterEmitsCurrentPayloadVersion();
|
||||
testSingleZoneMigrationIsLossless();
|
||||
testMigratedFadeContourTracksTheRetiredEqualPowerShape();
|
||||
|
||||
Reference in New Issue
Block a user