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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user