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:
2026-07-31 20:24:40 -04:00
parent cfb53aade3
commit 5e290119c5
22 changed files with 243 additions and 166 deletions
+73 -60
View File
@@ -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();
+10 -7
View File
@@ -120,7 +120,8 @@ static void testFilterGroupCarriesItsToneControlsPlusModulation() {
const std::vector<int> expected = {
cell(DeckParam::kFilterMorph), cell(DeckParam::kFilterCutoff),
cell(DeckParam::kFilterQ), cell(DeckParam::kFilterDrive),
cell(DeckParam::kFilterModAmt), cell(DeckParam::kFilterKeyTrack)};
cell(DeckParam::kFilterModAmt), cell(DeckParam::kFilterVel),
cell(DeckParam::kFilterKeyTrack)};
CHECK(f.cellIds == expected);
// Off by default is a state question, but reachability is a layout one: the enable
// toggle is in the caption row and the morph law in the knob row.
@@ -237,9 +238,10 @@ static void testAmpGroupWidthSurvivesAGateTriggerFlip() {
static void testWrappedDeckHeightAtTheEditorFloorWidth() {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
// At the floor (== default) 840 the deck takes three rows: PITCH + PITCH ENV + FILTER fill
// the first, FILTER ENV + AMP + VELOCITY the second, VOICE + MASTER the third. The eight
// groups total more than two rows can hold at this width — the VELOCITY group's three
// cells are ~150 px more than the FILTER group gave back when its velocity depth retired.
// the first (818 of the 824 available — six px of headroom, so one more FILTER cell would
// wrap the group and reflow everything under it), FILTER ENV + AMP + VELOCITY the second,
// VOICE + MASTER the third. Two rows cannot hold the eight groups in ANY order at this
// width: 1666 px of group plus 72 px of gaps against a 1648 px two-row capacity.
CHECK(deckRowCount(g, kAvailAtMinWidth) == 3);
CHECK(deckHeight(g, kAvailAtMinWidth) == 3 * kDeckGroupH + 2 * kDeckRowGap);
@@ -285,7 +287,7 @@ static void testHitTestResolvesTheNewFilterControls() {
CHECK(hit.kind == DeckHitKind::Knob);
CHECK(hit.id == c.id);
}
CHECK(f.cells.size() == 6);
CHECK(f.cells.size() == 7);
CHECK(f.cells[1].id == cell(DeckParam::kFilterCutoff));
// The enable toggle's two segments and the morph-law row toggle's two.
@@ -333,11 +335,12 @@ static void testBipolarKnobLawRoundTripsAndIsExactAtCentre() {
}
static void testEveryDeckControlIsClassifiedLiveOrReloading() {
// The live set: the six filter tone/modulation knobs, plus every stage time, stage level,
// The live set: the seven filter tone/modulation knobs, plus every stage time, stage level,
// hold fraction and curve exponent on all three envelopes — in BOTH mode shapes.
const DeckParam live[] = {
DeckParam::kFilterMorph, DeckParam::kFilterCutoff, DeckParam::kFilterQ,
DeckParam::kFilterDrive, DeckParam::kFilterModAmt, DeckParam::kFilterKeyTrack,
DeckParam::kFilterDrive, DeckParam::kFilterModAmt, DeckParam::kFilterVel,
DeckParam::kFilterKeyTrack,
DeckParam::kAttack, DeckParam::kHold, DeckParam::kDecay, DeckParam::kSustain,
DeckParam::kRelease,
DeckParam::kTrigAttack, DeckParam::kTrigHold, DeckParam::kTrigDecay,
+10 -1
View File
@@ -63,6 +63,10 @@ static SampleData filteredSine() {
s.play.filter.settings.cutoffNorm = 0.8f;
s.play.filter.settings.resonanceNorm = 0.9f;
s.play.filter.settings.morphNorm = 1.0f;
// A drawn velocity curve, so the velocity-DEPTH knob has something to scale. It costs
// every other case nothing: the depth is 0 until a case moves it, so the product is 0.
s.play.filter.velocityCurve = VelocityCurve::fromPoints(
{{0.0, 0.0}, {127.0, -1.0}}, instrument::engine::CurveDomain::Bipolar);
return s;
}
@@ -601,6 +605,10 @@ static void testEveryLiveFilterControlMovesTheSoundingNote() {
{"morph", [](LiveValues& v) { v.filterSettings.morphNorm = 0.0f; }},
{"drive", [](LiveValues& v) { v.filterSettings.driveNorm = 1.0f; }},
{"mod", [](LiveValues& v) { v.filterModAmount = 1.0; }},
// The velocity DEPTH is live even though the velocity itself is latched: the depth is
// a control over the note's latched curve value, the same shape key-track has over the
// note's latched number (deck_groups.h).
{"velamount", [](LiveValues& v) { v.filterVelAmount = 1.0; }},
{"keytrack", [](LiveValues& v) { v.filterKeyTrack = 2.0; }},
};
@@ -644,7 +652,7 @@ static void testEveryLiveFilterControlMovesTheSoundingNote() {
// The window is a FRACTION OF THE GLIDE, not a frame count: kLiveRampSeconds is the
// full travel time, so at 1/240 of it a working glide has barely started when the
// window closes. kGlideMargin then puts the bound at the geometric middle of the two
// MEASURED populations — with the ramp in place these six controls ratio 0.0005..0.060;
// MEASURED populations — with the ramp in place these seven controls ratio 0.0005..0.060;
// with it defeated (every live move delivered as a snap, run) they ratio 0.52..1.10.
// The bound lands at 0.175: ~3x above the worst glide, ~3x below the tamest snap.
const std::size_t rampFrames =
@@ -745,6 +753,7 @@ static void testPitchRatioAndVelocityGainStayLatched() {
hostile.filterKeyTrack = 2.0;
hostile.filterSettings.cutoffNorm = 0.0f;
hostile.filterModAmount = 1.0;
hostile.filterVelAmount = 1.0;
hostile.pitchEnv.shape.attackFrames = 4800;
hostile.pitchEnv.shape.decayFrames = 4800;
hostile.pitchEnv.peakSemitones = 24.0;
+3
View File
@@ -638,6 +638,7 @@ static void testResolvePlayCarriesTheFilterAndResolvesOnlyItsEnvelope() {
st.filter.settings.driveNorm = 0.125f;
st.filter.settings.morphLaw = reasampler::instrument::engine::filter::MorphLaw::HighNotchLow;
st.filter.modAmount = -0.5;
st.filter.velAmount = -0.375; // distinct from every neighbouring field, so a mis-wire shows
st.filter.velocityCurve = VelocityCurve::fromPoints(
{{0.0, 0.0}, {127.0, 0.25}}, reasampler::instrument::engine::CurveDomain::Bipolar);
st.filter.keyTrack = 1.25;
@@ -655,6 +656,7 @@ static void testResolvePlayCarriesTheFilterAndResolvesOnlyItsEnvelope() {
CHECK(at48.filter.settings.driveNorm == 0.125f);
CHECK(at48.filter.settings.morphLaw == reasampler::instrument::engine::filter::MorphLaw::HighNotchLow);
CHECK(at48.filter.modAmount == -0.5);
CHECK(at48.filter.velAmount == -0.375);
// The transfer curve is dimensionless, so it crosses unchanged — asserted against the
// straight line the two stored knots describe, not against the stored object.
CHECK(at48.filter.velocityCurve.eval(0.0) == 0.0);
@@ -677,6 +679,7 @@ static void testResolvePlayCarriesTheFilterAndResolvesOnlyItsEnvelope() {
const PlayParams bare = resolvePlay(PlaySeconds{}, 48000);
CHECK(!bare.filter.enabled);
CHECK(bare.filter.modAmount == 0.0);
CHECK(bare.filter.velAmount == 0.0);
for (int v = 0; v <= 127; ++v) CHECK(bare.filter.velocityCurve.eval(v) == 0.0);
CHECK(bare.filter.keyTrack == 0.0);
CHECK(bare.filter.env.sustainLevel == 1.0);
+63 -19
View File
@@ -116,6 +116,7 @@ static void testDisengagedFilterIsBitInertEvenWithExtremeSettingsStored() {
stored.play.filter.enabled = false;
stored.play.filter.settings.driveNorm = 1.0f;
stored.play.filter.modAmount = 1.0;
stored.play.filter.velAmount = -1.0;
stored.play.filter.velocityCurve = VelocityCurve::fromPoints(
{{0.0, 0.0}, {127.0, -1.0}}, instrument::engine::CurveDomain::Bipolar);
stored.play.filter.keyTrack = 2.0;
@@ -330,11 +331,12 @@ static void testAnUnmodulatedVoiceIsBitIdenticalToASinglePreparedFilter() {
static void testVelocityAndKeyTrackingReachCutoffAndAreNoOpsAtTheirDefaults() {
// Playback key-tracking off, so both notes read the source at the SAME rate and the only
// note-dependent difference left is the filter's own key-tracking.
const auto tone = [](double velTop, double keyTrack) {
const auto tone = [](double velAmount, double velTop, double keyTrack) {
SampleData s = periodicSine(8000, 64);
s.play.adsr = flatAdsr();
s.keyTrack = 0.0;
s.play.filter = engagedFilter(0.25f, 0.0f, 1.0f);
s.play.filter.velAmount = velAmount;
if (velTop != 0.0) {
s.play.filter.velocityCurve = VelocityCurve::fromPoints(
{{0.0, 0.0}, {127.0, velTop}}, instrument::engine::CurveDomain::Bipolar);
@@ -343,34 +345,75 @@ static void testVelocityAndKeyTrackingReachCutoffAndAreNoOpsAtTheirDefaults() {
return s;
};
// Velocity: a curve rising to +1 opens the filter for a hard hit. The amp's own velocity
// curve is flat, so amplitude is unaffected.
SampleData vel = tone(1.0, 0.0);
// Velocity: a curve rising to +1 at full depth opens the filter for a hard hit. The amp's
// own velocity curve is flat, so amplitude is unaffected.
SampleData vel = tone(1.0, 1.0, 0.0);
const std::vector<double> soft = render(vel, 60, 1, 6000);
const std::vector<double> hard = render(vel, 60, 127, 6000);
CHECK(rms(hard, 2000, 6000) > 2.0 * rms(soft, 2000, 6000));
// Key tracking: two octaves up opens it by two octaves of cutoff.
SampleData key = tone(0.0, 1.0);
SampleData key = tone(0.0, 0.0, 1.0);
const std::vector<double> low = render(key, 60, 100, 6000);
const std::vector<double> high = render(key, 84, 100, 6000);
CHECK(rms(high, 2000, 6000) > 2.0 * rms(low, 2000, 6000));
// Both neutral: neither velocity nor note may move the filter. `tone(0,0)` leaves the
// DEFAULT velocity curve, so this is the off-by-default contract itself.
SampleData neutral = tone(0.0, 0.0);
const std::vector<double> a = render(neutral, 60, 1, 6000);
const std::vector<double> b = render(neutral, 60, 127, 6000);
const std::vector<double> c = render(neutral, 84, 100, 6000);
for (std::size_t i = 0; i < a.size(); ++i) {
CHECK(a[i] == b[i]);
CHECK(a[i] == c[i]);
// Both neutral: neither velocity nor note may move the filter. `tone(_,0,0)` leaves the
// DEFAULT velocity curve, so this is the off-by-default contract itself — and it holds at
// EVERY depth setting, since the curve is what says "nothing", not the knob.
for (const double depth : {-1.0, -0.5, 0.0, 0.5, 1.0}) {
SampleData neutral = tone(depth, 0.0, 0.0);
const std::vector<double> a = render(neutral, 60, 1, 6000);
const std::vector<double> b = render(neutral, 60, 127, 6000);
const std::vector<double> c = render(neutral, 84, 100, 6000);
for (std::size_t i = 0; i < a.size(); ++i) {
CHECK(a[i] == b[i]);
CHECK(a[i] == c[i]);
}
// And the modulation VALUE itself is exactly zero at every velocity, not merely small
// enough that the render came out equal — the render check alone would still pass
// under a cutoff offset too small to survive the coefficient solve's float rounding.
const FilterParams def;
for (int v = 0; v <= 127; ++v) CHECK(depth * def.velocityCurve.eval(v) == 0.0);
}
// And the modulation VALUE itself is exactly zero at every velocity, not merely small
// enough that the render came out equal — the render check alone would still pass under a
// cutoff offset too small to survive the coefficient solve's float rounding.
const FilterParams def;
for (int v = 0; v <= 127; ++v) CHECK(def.velocityCurve.eval(v) == 0.0);
}
// The depth knob and the bipolar curve BOTH apply, as a product: flipping the depth's sign is
// the same modulation as flipping the curve's, and it flips the audible sense with it.
static void testFilterVelocityDepthAndCurveComposeMultiplicatively() {
using instrument::engine::CurveDomain;
using instrument::engine::VelocityPoint;
const std::vector<VelocityPoint> rising = {{0.0, 0.0}, {64.0, 0.4}, {127.0, 1.0}};
std::vector<VelocityPoint> mirrored = rising;
for (VelocityPoint& p : mirrored) p.value = -p.value;
const auto rig = [](double depth, const std::vector<VelocityPoint>& pts) {
SampleData s = periodicSine(8000, 64);
s.play.adsr = flatAdsr();
s.keyTrack = 0.0;
s.play.filter = engagedFilter(0.25f, 0.0f, 1.0f);
s.play.filter.velAmount = depth;
s.play.filter.velocityCurve = VelocityCurve::fromPoints(pts, CurveDomain::Bipolar);
return s;
};
// A negative depth over a rising curve is the SAME cutoff offset as a positive depth over
// the curve's mirror — frame for frame, not approximately.
SampleData byDepth = rig(-0.75, rising);
SampleData byCurve = rig(0.75, mirrored);
const std::vector<double> viaDepth = render(byDepth, 60, 120, 6000);
const std::vector<double> viaCurve = render(byCurve, 60, 120, 6000);
for (std::size_t i = 0; i < viaDepth.size(); ++i) CHECK(viaDepth[i] == viaCurve[i]);
// And the sense really inverts: over the same rising curve a positive depth opens the
// filter for a hard hit while the negative one closes it.
SampleData up = rig(0.75, rising);
const std::vector<double> upSoft = render(up, 60, 1, 6000);
const std::vector<double> upHard = render(up, 60, 120, 6000);
CHECK(rms(upHard, 2000, 6000) > 2.0 * rms(upSoft, 2000, 6000));
const std::vector<double> downSoft = render(byDepth, 60, 1, 6000);
const std::vector<double> downHard = render(byDepth, 60, 120, 6000);
CHECK(rms(downSoft, 2000, 6000) > 2.0 * rms(downHard, 2000, 6000));
}
static void testNoteOnResetsTheFilterSoAPreviousNoteCannotLeak() {
@@ -442,6 +485,7 @@ int main() {
testAModulationTooSmallToCrossTheRetiredQuantumStillMovesTheVoice();
testAnUnmodulatedVoiceIsBitIdenticalToASinglePreparedFilter();
testVelocityAndKeyTrackingReachCutoffAndAreNoOpsAtTheirDefaults();
testFilterVelocityDepthAndCurveComposeMultiplicatively();
testNoteOnResetsTheFilterSoAPreviousNoteCannotLeak();
testStereoRenderOfAMonoSampleMirrorsTheMonoResultExactly();
testStereoFilterChannel1MatchesChannel0ForIdenticalLRInput();
+11 -14
View File
@@ -4,8 +4,8 @@
// * eval — flat y=1 unipolar default (EVERY velocity -> 1.0), linear ramp, curved shape
// between points, box-clamp of an out-of-range velocity, monotonic-in-x over the whole domain.
// * the BIPOLAR domain — zero() is exactly 0 everywhere, the negative half evaluates and clamps
// at -1, eval is homogeneous in y (what the pre-v12 filter lift rests on), and the pixel maps
// put value 0 on the box's centre line rather than its floor.
// at -1, knots inside [0,1] evaluate identically in either domain (what the codec's v12
// re-tag rests on), and the pixel maps put value 0 on the box's centre line, not its floor.
// * editing — addPoint keeps X-order + box-clamp; movePoint clamps an interior point between its
// neighbours (can't cross) and box-clamps the value; endpoints are X-pinned (velocity 0 / 127)
// with only the value mobile; deletePoint removes interior points but REFUSES the two endpoints.
@@ -333,19 +333,16 @@ static void testUnipolarClampsAtZeroWhereBipolarDoesNot() {
CHECK(near(b2.eval(127), 1.0));
}
static void testEvalIsHomogeneousInY() {
// The property the pre-v12 filter lift rests on: scaling every knot's y by k scales the
// whole evaluated curve by k. Asserted against a CURVED (non-collinear) knot set, where
// the Fritsch-Carlson tangents are actually doing work.
static void testTheSameKnotsEvaluateIdenticallyInEitherDomain() {
// What the codec's v12 domain re-tag rests on: a curve whose y values all lie in [0,1] is
// read the same way in either domain — the domain governs the CLAMP, not the evaluation.
// Asserted against a CURVED (non-collinear) knot set, where the tangents are doing work,
// and with ==: the re-tag is bit-identical, not merely close.
const std::vector<VelocityPoint> knots = {
{0.0, 0.1}, {30.0, 0.15}, {64.0, 0.9}, {100.0, 0.4}, {127.0, 1.0}};
const VelocityCurve base = VelocityCurve::fromPoints(knots, CurveDomain::Unipolar);
for (const double k : {0.75, -0.4, 1.0}) {
std::vector<VelocityPoint> scaled = knots;
for (VelocityPoint& p : scaled) p.value *= k;
const VelocityCurve s = VelocityCurve::fromPoints(scaled, CurveDomain::Bipolar);
for (int v = 0; v <= 127; ++v) CHECK(near(s.eval(v), k * base.eval(v), 1e-12));
}
const VelocityCurve u = VelocityCurve::fromPoints(knots, CurveDomain::Unipolar);
const VelocityCurve b = VelocityCurve::fromPoints(knots, CurveDomain::Bipolar);
for (int v = 0; v <= 127; ++v) CHECK(b.eval(v) == u.eval(v));
}
static void testBipolarPixelMapPutsZeroOnTheCentreLine() {
@@ -471,7 +468,7 @@ int main() {
testZeroIsExactlyZeroAtEveryVelocity();
testBipolarEvalSpansTheNegativeHalf();
testUnipolarClampsAtZeroWhereBipolarDoesNot();
testEvalIsHomogeneousInY();
testTheSameKnotsEvaluateIdenticallyInEitherDomain();
testBipolarPixelMapPutsZeroOnTheCentreLine();
testBipolarDragCoversTwiceTheValueRange();
testPixelFromPointMapsCornersAndMidpoint();