PITCH/RATE deck: Rate and Pitch knobs compounded into one read increment, on a three-state commit predicate and payload v16

This commit is contained in:
2026-08-02 05:34:33 -04:00
parent ef59265e7a
commit 248f2f3842
32 changed files with 1098 additions and 163 deletions
+86
View File
@@ -14,6 +14,7 @@
using namespace reasampler;
using namespace reasampler::instrument::ui;
namespace engine = reasampler::instrument::engine; // the stretcher's own rate bounds + clamp
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
@@ -88,6 +89,88 @@ static void testNormRoundTripsThroughEveryValueDomain() {
CHECK(p.adsr.decaySeconds == 0.0);
}
// Rate's range is the STRETCHER's, aliased rather than restated, so the knob's two ends and the
// engine's clamp cannot become two opinions. Asserted against the engine constants themselves.
static void testRateKnobEndsAreTheStretchersOwnBounds() {
CHECK(kRateMinRatio == engine::kStretchRateMin);
CHECK(kRateMaxRatio == engine::kStretchRateMax);
PlaySeconds p;
setDeckParam(DeckParam::kRate, p, 0.0, 0);
CHECK(p.playRate == engine::kStretchRateMin);
CHECK(engine::clampStretchRate(p.playRate) == p.playRate); // the clamp has nothing to do
setDeckParam(DeckParam::kRate, p, 1.0, 0);
CHECK(p.playRate == engine::kStretchRateMax);
CHECK(engine::clampStretchRate(p.playRate) == p.playRate);
// And nowhere on the travel does the knob produce a rate the engine would move.
for (int i = 0; i <= 1000; ++i) {
setDeckParam(DeckParam::kRate, p, static_cast<double>(i) / 1000.0, 0);
CHECK(engine::clampStretchRate(p.playRate) == p.playRate);
if (engine::clampStretchRate(p.playRate) != p.playRate) return;
}
}
// The two new bindings write the two new fields and nothing else — both are doubles on
// PlaySeconds with adjacent homes, so a getter/setter pair that crossed them would still
// round-trip. The centre detent is exact on both, which is what lets an untouched knob persist
// unity rate and zero transposition.
static void testRateAndPitchBindTheirOwnFields() {
PlaySeconds p;
setDeckParam(DeckParam::kRate, p, 0.5, 0);
CHECK(p.playRate == 1.0);
CHECK(p.pitchOffsetSemitones == 0.0);
CHECK(deckParamNorm(DeckParam::kRate, p) == 0.5);
setDeckParam(DeckParam::kPitch, p, 0.5, 0);
CHECK(p.pitchOffsetSemitones == 0.0);
CHECK(p.playRate == 1.0);
CHECK(deckParamNorm(DeckParam::kPitch, p) == 0.5);
// Pitch rides the SAME centre-expanded depth taper as the pitch envelope's own depth, over
// the SAME throw — a second constant here would be the defect the spec names.
setDeckParam(DeckParam::kPitch, p, 1.0, 0);
CHECK(p.pitchOffsetSemitones == kPitchDepthMaxSemis);
CHECK(kPitchDepthMaxSemis == kVelocityPitchRangeSemitones);
setDeckParam(DeckParam::kPitch, p, 0.0, 0);
CHECK(p.pitchOffsetSemitones == -kPitchDepthMaxSemis);
CHECK(p.playRate == 1.0); // untouched by every write above but its own
// A move on Rate leaves the offset alone, in the other direction.
setDeckParam(DeckParam::kPitch, p, 0.5, 0);
setDeckParam(DeckParam::kRate, p, 0.0, 0);
CHECK(p.pitchOffsetSemitones == 0.0);
}
// Shift's whole unit on BOTH new knobs is the semitone, not the percent their labels read in.
// Asserted through the deck's own snap entry point (the shell calls nothing else), and in
// semitones, which is the unit the rule is stated in.
static void testShiftSnapsBothNewKnobsToWholeSemitones() {
CHECK(deckParamUnit(DeckParam::kRate) == UnitCategory::Semitones);
CHECK(deckParamUnit(DeckParam::kPitch) == UnitCategory::Semitones);
PlaySeconds p;
// Rate: a norm a third of the way up is 8 semitones below unity — snapping must land on a
// whole one, and the knob must still be able to reach an octave and a fifth by hand.
for (double norm : {0.13, 0.37, 0.5, 0.62, 0.88}) {
setDeckParam(DeckParam::kRate, p, snapDeckParamNorm(DeckParam::kRate, norm), 0);
const double semis = 12.0 * std::log2(p.playRate);
CHECK(std::fabs(semis - std::round(semis)) < 1e-9);
if (!(std::fabs(semis - std::round(semis)) < 1e-9)) return;
}
// The two landmarks by name: unity, and a fifth up.
setDeckParam(DeckParam::kRate, p, snapDeckParamNorm(DeckParam::kRate, 0.5), 0);
CHECK(p.playRate == 1.0);
setDeckParam(DeckParam::kRate, p, snapDeckParamNorm(DeckParam::kRate, 0.5 + 7.0 / 24.0), 0);
CHECK(std::fabs(12.0 * std::log2(p.playRate) - 7.0) < 1e-9);
// Pitch: whole semitones on the centre-expanded taper, exactly (its taper resolves onto a
// micro-semitone grid, so a whole semitone is ON that grid).
for (double norm : {0.17, 0.33, 0.71, 0.94}) {
setDeckParam(DeckParam::kPitch, p, snapDeckParamNorm(DeckParam::kPitch, norm), 0);
CHECK(p.pitchOffsetSemitones == std::round(p.pitchOffsetSemitones));
if (p.pitchOffsetSemitones != std::round(p.pitchOffsetSemitones)) return;
}
}
// The dual-ring reset contract: the outer ring resets the stage VALUE and the inner dial resets
// the EXPONENT, each leaving the other exactly as it was. Both fields are asserted in both
// directions — checking only the field that changed would pass even if the reset clobbered its
@@ -342,6 +425,9 @@ static void testTimeConstantsAlwaysReadInMilliseconds() {
int main() {
testTheTwoCeilingNamesAreOneNumber();
testNormRoundTripsThroughEveryValueDomain();
testRateKnobEndsAreTheStretchersOwnBounds();
testRateAndPitchBindTheirOwnFields();
testShiftSnapsBothNewKnobsToWholeSemitones();
testResetTouchesOnlyItsOwnRingOnADualRingKnob();
testInnerResetLandsOnTheExactLinearNeutral();
testResetLandsOnTheStoredDefaultOfEachControl();