instrument: one VELOCITY deck for all three velocity curves, bipolar and off by default for pitch and filter

Payload v12 appends the new velocity->pitch curve and folds the retired filter velAmount into its now-bipolar curve, so pre-v12 projects reopen sounding identical. Preview button takes a drawn play triangle.
This commit is contained in:
2026-07-31 19:15:17 -04:00
parent 4fecb58c0a
commit 9d38f87a2d
37 changed files with 1020 additions and 320 deletions
+63 -1
View File
@@ -1766,7 +1766,8 @@ static SampleData twoLevelSample() {
km.velocityCurve = VelocityCurve::fromPoints({{0.0, 0.0},
{static_cast<double>(kVelLow), 0.25},
{static_cast<double>(kVelHigh), 0.75},
{127.0, 1.0}});
{127.0, 1.0}},
instrument::engine::CurveDomain::Unipolar);
return km;
}
@@ -1966,6 +1967,65 @@ static SampleData rampSample(std::size_t frames, int rootNote) {
return s;
}
// --- velocity -> pitch ---------------------------------------------------------
//
// A ramp sample reads its own position, so the value on frame N IS the accumulated read rate:
// the transpose is directly observable rather than inferred from a spectrum.
static void testVelocityPitchIsExactlyOffByDefault() {
// The modulation VALUE at every velocity, not a rendered approximation of it: the default
// bipolar curve must yield the identity ratio exactly, so nothing detunes by a hair.
const PlayParams def;
for (int v = 0; v <= 127; ++v) {
CHECK(velocityPitchRatio(def.pitchVelocityCurve, v) == 1.0);
}
// And at the render: two strikes of very different velocity read the ramp identically.
SampleData s = rampSample(4096, 60);
Voice soft;
Voice hard;
soft.start(60, 1, s);
hard.start(60, 127, s);
for (int i = 0; i < 64; ++i) CHECK(soft.renderFrame() == hard.renderFrame());
}
static void testDrawnVelocityPitchCurveTransposesBothWays() {
using instrument::engine::CurveDomain;
SampleData s = rampSample(4096, 60);
const double full = std::pow(2.0, kVelocityPitchRangeSemitones / 12.0);
// A curve pinned at +1 across the domain: every velocity transposes UP by the full scale.
s.play.pitchVelocityCurve =
VelocityCurve::fromPoints({{0.0, 1.0}, {127.0, 1.0}}, CurveDomain::Bipolar);
Voice up;
up.start(60, 100, s);
CHECK(approx(static_cast<double>(up.renderFrame()), 0.0, 1e-9));
CHECK(approx(static_cast<double>(up.renderFrame()), full, 1e-4));
// Pinned at -1: DOWN by the same scale — the half of the domain the old unipolar curve
// could not express at all.
s.play.pitchVelocityCurve =
VelocityCurve::fromPoints({{0.0, -1.0}, {127.0, -1.0}}, CurveDomain::Bipolar);
Voice down;
down.start(60, 100, s);
down.renderFrame();
CHECK(approx(static_cast<double>(down.renderFrame()), 1.0 / full, 1e-4));
// And it follows the curve: a rising ramp gives a soft hit less transpose than a hard one.
s.play.pitchVelocityCurve =
VelocityCurve::fromPoints({{0.0, 0.0}, {127.0, 1.0}}, CurveDomain::Bipolar);
Voice q;
Voice f;
q.start(60, 20, s);
f.start(60, 120, s);
q.renderFrame();
f.renderFrame();
const double quiet = static_cast<double>(q.renderFrame());
const double loud = static_cast<double>(f.renderFrame());
CHECK(quiet > 1.0);
CHECK(loud > quiet);
CHECK(loud < full); // velocity 120 is short of the +1 endpoint
}
// MAJOR-1 regression: MONO+LEGATO with a TRIGGER zone RE-ATTACKS after the last key is up.
// Trigger ignores note-off (Voice::release() is a no-op, so releasing_ never latches), so a
// legato guard keyed on `active && !releasing` saw a ringing one-shot as "still held" and
@@ -2801,6 +2861,8 @@ int main() {
testRepitchObservedPeriod();
testKeyTrackVarispeedObservedPeriod();
testKeyTrackPreserveShiftCollapsesAtZero();
testVelocityPitchIsExactlyOffByDefault();
testDrawnVelocityPitchCurveTransposesBothWays();
testAdsrShape();
testAdsrReleaseBeforeSustain();
testAdsrZeroAttackDecay();