One taper, one modifier law: extract param_taper, raise the stage ceiling to 10 s, and make the AHDSR schematic axis the taper itself
This commit is contained in:
+165
-7
@@ -5,6 +5,9 @@
|
||||
|
||||
#include "../src/core/instrument/ui/deck_values.h"
|
||||
|
||||
#include "../src/core/instrument/engine/master_gain.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
@@ -22,13 +25,31 @@ static std::string msLabel(double seconds) {
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
// Every domain the binding maps: a stage time over the seconds ceiling, a level, a fraction,
|
||||
// The stage-time ceiling has TWO names — the overlay's schematic domain and the knob's — and they
|
||||
// must be the same number or a maxed knob stops landing on the canvas edge. Asserted, not assumed.
|
||||
static void testTheTwoCeilingNamesAreOneNumber() {
|
||||
CHECK(kEnvTimeMaxSeconds == kGateStageMaxSeconds);
|
||||
CHECK(kEnvTimeMaxSeconds == kStageTimeMaxSeconds);
|
||||
CHECK(kEnvTimeMaxSeconds == 10.0);
|
||||
}
|
||||
|
||||
// Every domain the binding maps: a stage time through the shared taper, a level, a fraction,
|
||||
// a normalized filter position, a bipolar depth, and a curve exponent over its log travel.
|
||||
static void testNormRoundTripsThroughEveryValueDomain() {
|
||||
PlaySeconds p;
|
||||
setDeckParam(DeckParam::kAttack, p, 0.25, 0);
|
||||
CHECK(p.adsr.attackSeconds == 0.25 * kEnvTimeMaxSeconds);
|
||||
CHECK(deckParamNorm(DeckParam::kAttack, p) == 0.25);
|
||||
CHECK(p.adsr.attackSeconds == timeSecondsFromNorm(0.25));
|
||||
// The VALUE round trip is what has to be exact (param_taper.h); the needle returning to the
|
||||
// very same norm double is explicitly NOT required of a log map. The residual is bounded by
|
||||
// the taper's output quantum read back through the map — under 1e-7 of the travel across the
|
||||
// whole domain, which is four orders below one drag pixel.
|
||||
CHECK(std::fabs(deckParamNorm(DeckParam::kAttack, p) - 0.25) < 1e-7);
|
||||
// The raised ceiling costs the low end nothing: a several-second stage is reachable by hand,
|
||||
// AND everything under 100 ms still gets more than 40 % of the knob's travel to itself.
|
||||
setDeckParam(DeckParam::kDecay, p, 0.95, 0);
|
||||
CHECK(p.adsr.decaySeconds > 5.0 && p.adsr.decaySeconds < kEnvTimeMaxSeconds);
|
||||
setDeckParam(DeckParam::kDecay, p, 0.42, 0);
|
||||
CHECK(p.adsr.decaySeconds < 0.100);
|
||||
|
||||
setDeckParam(DeckParam::kSustain, p, 0.4, 0);
|
||||
CHECK(p.adsr.sustainLevel == 0.4);
|
||||
@@ -122,9 +143,8 @@ static void testInnerResetLandsOnTheExactLinearNeutral() {
|
||||
}
|
||||
|
||||
// A reset lands on the field's own stored default, EXACTLY — the defaults are read off a fresh
|
||||
// PlaySeconds and arrive through the norm round trip, so the two stage times whose defaults are
|
||||
// neither 0 nor 1 are the cases that actually exercise that exactness (see resetDeckParam's
|
||||
// note on what the seconds ceiling has to be for it to hold).
|
||||
// PlaySeconds and COPIED rather than round-tripped, which is what makes the two stage times whose
|
||||
// defaults are neither 0 nor 1 land bit for bit at a non-power-of-two ceiling.
|
||||
static void testResetLandsOnTheStoredDefaultOfEachControl() {
|
||||
const PlaySeconds defaults;
|
||||
PlaySeconds p;
|
||||
@@ -152,6 +172,138 @@ static void testResetLandsOnTheStoredDefaultOfEachControl() {
|
||||
CHECK(p.adsr.releaseSeconds == defaults.adsr.releaseSeconds);
|
||||
}
|
||||
|
||||
// EVERY knob resets to its own stored default, not just the six dual-ring pairs above. Swept
|
||||
// over the whole control-id space so a control added later cannot quietly miss the reset table:
|
||||
// perturb, reset, and require the control to read exactly what a fresh PlaySeconds reads.
|
||||
static void testEveryKnobIdResetsToItsDefault() {
|
||||
const PlaySeconds defaults;
|
||||
for (int i = 0; i < static_cast<int>(DeckParam::kCount); ++i) {
|
||||
const DeckParam id = static_cast<DeckParam>(i);
|
||||
if (deckParamUnit(id) == UnitCategory::None) continue; // no reset gesture
|
||||
if (id == DeckParam::kMasterGain || id == DeckParam::kKeyTrack) continue; // not in PlaySeconds
|
||||
PlaySeconds p;
|
||||
setDeckParam(id, p, 0.37, 0);
|
||||
setDeckParam(id, p, 0.83, 0); // two writes: one of the two is off every default
|
||||
CHECK(deckParamNorm(id, p) != deckParamNorm(id, defaults));
|
||||
resetDeckParam(id, p);
|
||||
CHECK(deckParamNorm(id, p) == deckParamNorm(id, defaults));
|
||||
}
|
||||
}
|
||||
|
||||
// THE exact-preimage criterion, per unit category, against a default-constructed PlaySeconds and
|
||||
// against master gain's unity. A host's reset-to-default arrives as toPlain(defaultNorm) with no
|
||||
// bypass available, so this is the assertion the reset bypass CANNOT stand in for.
|
||||
static void testEveryDefaultHasAnExactNormalizedPreimage() {
|
||||
const PlaySeconds d;
|
||||
const struct { DeckParam id; double stored; } msKnobs[] = {
|
||||
{DeckParam::kAttack, d.adsr.attackSeconds},
|
||||
{DeckParam::kHold, d.adsr.holdSeconds},
|
||||
{DeckParam::kDecay, d.adsr.decaySeconds},
|
||||
{DeckParam::kRelease, d.adsr.releaseSeconds},
|
||||
{DeckParam::kTrigAttack, d.trigAhd.attackSeconds},
|
||||
{DeckParam::kTrigDecay, d.trigAhd.decaySeconds},
|
||||
{DeckParam::kPitchEnvAttack, d.pitchEnv.shape.attackSeconds},
|
||||
{DeckParam::kPitchEnvDecay, d.pitchEnv.shape.decaySeconds},
|
||||
{DeckParam::kFilterEnvAttack, d.filter.env.attackSeconds},
|
||||
{DeckParam::kFilterEnvHold, d.filter.env.holdSeconds},
|
||||
{DeckParam::kFilterEnvDecay, d.filter.env.decaySeconds},
|
||||
{DeckParam::kFilterEnvRelease, d.filter.env.releaseSeconds},
|
||||
{DeckParam::kFilterTrigAttack, d.filter.trigEnv.attackSeconds},
|
||||
{DeckParam::kFilterTrigDecay, d.filter.trigEnv.decaySeconds},
|
||||
};
|
||||
for (const auto& k : msKnobs) {
|
||||
CHECK(timeSecondsFromNorm(deckParamNorm(k.id, d)) == k.stored);
|
||||
}
|
||||
// The two whose defaults are neither 0 nor the ceiling are the ones that can actually fail.
|
||||
CHECK(d.adsr.attackSeconds == 0.003 && d.adsr.releaseSeconds == 0.060);
|
||||
|
||||
CHECK(depthSemitonesFromNorm(deckParamNorm(DeckParam::kPitchEnvDepth, d),
|
||||
kPitchDepthMaxSemis) == d.pitchEnv.peakSemitones);
|
||||
CHECK(deckParamNorm(DeckParam::kSustain, d) == d.adsr.sustainLevel);
|
||||
CHECK(deckParamNorm(DeckParam::kTrigLength, d) == d.trigger.lengthFraction);
|
||||
CHECK(deckParamNorm(DeckParam::kTrigHold, d) == d.trigAhd.holdFraction);
|
||||
CHECK(deckBipolarFromNorm(deckParamNorm(DeckParam::kFilterModAmt, d)) == d.filter.modAmount);
|
||||
CHECK(util::curveFromKnobNorm(deckParamNorm(DeckParam::kAttackCurve, d)) ==
|
||||
d.adsr.attackCurve);
|
||||
// Master gain's unity: the case where a hair off is an audible gain error rather than a
|
||||
// cosmetic one. Its taper is engine/master_gain's — consumed here, not defined here.
|
||||
CHECK(instrument::engine::masterGainLinearFromNorm(instrument::engine::masterGainNormFromLinear(1.0)) == 1.0);
|
||||
}
|
||||
|
||||
// Shift's snap unit is a property of the control's UNIT and lands on a whole unit of what the
|
||||
// control DISPLAYS — which is why three controls sharing the Percent category take three
|
||||
// different norm steps.
|
||||
static void testShiftSnapsToAWholeUnitOfTheDisplayedValue() {
|
||||
// Milliseconds: the snapped norm reads back as an exact whole millisecond.
|
||||
const double ms = timeSecondsFromNorm(snapDeckParamNorm(DeckParam::kAttack,
|
||||
timeNormFromSeconds(0.03472)));
|
||||
CHECK(ms == 0.035);
|
||||
// Semitones.
|
||||
CHECK(depthSemitonesFromNorm(
|
||||
snapDeckParamNorm(DeckParam::kPitchEnvDepth,
|
||||
depthNormFromSemitones(6.6, kPitchDepthMaxSemis)),
|
||||
kPitchDepthMaxSemis) == 7.0);
|
||||
// Percent, 0..100 %: the norm IS the fraction.
|
||||
CHECK(snapDeckParamNorm(DeckParam::kSustain, 0.4162) == 0.42);
|
||||
// Percent, 0..200 %: a whole DISPLAYED percent is half a norm percent.
|
||||
CHECK(snapDeckParamNorm(DeckParam::kFilterKeyTrack, 0.4162) == 0.4150);
|
||||
// Percent, +/-100 %: likewise, measured on the bipolar value.
|
||||
CHECK(snapDeckParamNorm(DeckParam::kFilterVel, deckNormFromBipolar(-0.4162)) ==
|
||||
deckNormFromBipolar(-0.42));
|
||||
// Exponent: whole numbers, which puts the linear neutral one snap from centre. Compared as
|
||||
// the norm the snap RETURNS — the exponent's own log travel is not an exact round trip.
|
||||
CHECK(snapDeckParamNorm(DeckParam::kAttackCurve, util::knobNormFromCurve(2.6)) ==
|
||||
util::knobNormFromCurve(3.0));
|
||||
CHECK(snapDeckParamNorm(DeckParam::kAttackCurve, util::knobNormFromCurve(1.4)) ==
|
||||
util::knobNormFromCurve(util::kCurveNeutral));
|
||||
// Decibels, likewise compared as the returned norm.
|
||||
CHECK(snapDeckParamNorm(DeckParam::kMasterGain,
|
||||
instrument::engine::masterGainNormFromDb(-6.4)) ==
|
||||
instrument::engine::masterGainNormFromDb(-6.0));
|
||||
// Already-integer and discrete controls are untouched.
|
||||
CHECK(snapDeckParamNorm(DeckParam::kVoiceCount, 0.4162) == 0.4162);
|
||||
CHECK(snapDeckParamNorm(DeckParam::kPlayMode, 0.4162) == 0.4162);
|
||||
CHECK(deckParamUnit(DeckParam::kVoiceCount) == UnitCategory::None);
|
||||
CHECK(deckParamUnit(DeckParam::kAmpVelCurve) == UnitCategory::None);
|
||||
}
|
||||
|
||||
// The taper and the raised ceiling are persistence-neutral BY CONSTRUCTION: the binding only
|
||||
// READS the stored seconds, so a value dialled under the old 2 s ceiling reloads bit-identical
|
||||
// and simply sits somewhere else on the knob. Nothing on the load path rewrites it.
|
||||
static void testAValueStoredUnderTheOldCeilingIsReadNotRewritten() {
|
||||
PlaySeconds p;
|
||||
p.adsr.decaySeconds = 1.75; // reachable by hand at the retired 2 s ceiling
|
||||
p.adsr.releaseSeconds = 2.0;
|
||||
const double normDecay = deckParamNorm(DeckParam::kDecay, p);
|
||||
CHECK(p.adsr.decaySeconds == 1.75); // reading the norm mutated nothing
|
||||
CHECK(p.adsr.releaseSeconds == 2.0);
|
||||
CHECK(normDecay > 0.0 && normDecay < 1.0); // still on the knob, just at a new angle
|
||||
CHECK(deckParamNorm(DeckParam::kRelease, p) > normDecay);
|
||||
// And it survives the norm the knob would hand back, so a no-op touch of the control does
|
||||
// not quantize a legacy value away.
|
||||
setDeckParam(DeckParam::kDecay, p, normDecay, 0);
|
||||
CHECK(p.adsr.decaySeconds == 1.75);
|
||||
}
|
||||
|
||||
// The filter's four tone controls are wire-frozen in the payload: their stored value IS their
|
||||
// normalized position, and nothing in the taper pass may re-map it. Their snap is display-side
|
||||
// only, which is what this separates.
|
||||
static void testTheFilterFourKeepTheirIdentityTaper() {
|
||||
PlaySeconds p;
|
||||
const double positions[] = {0.0, 0.125, 0.5, 0.73, 1.0};
|
||||
for (double n : positions) {
|
||||
setDeckParam(DeckParam::kFilterCutoff, p, n, 0);
|
||||
setDeckParam(DeckParam::kFilterQ, p, n, 0);
|
||||
setDeckParam(DeckParam::kFilterMorph, p, n, 0);
|
||||
setDeckParam(DeckParam::kFilterDrive, p, n, 0);
|
||||
CHECK(p.filter.settings.cutoffNorm == static_cast<float>(n));
|
||||
CHECK(p.filter.settings.resonanceNorm == static_cast<float>(n));
|
||||
CHECK(p.filter.settings.morphNorm == static_cast<float>(n));
|
||||
CHECK(p.filter.settings.driveNorm == static_cast<float>(n));
|
||||
CHECK(deckParamNorm(DeckParam::kFilterCutoff, p) == static_cast<double>(static_cast<float>(n)));
|
||||
}
|
||||
}
|
||||
|
||||
// One unit, everywhere, across the formatter's whole range: a sub-millisecond value keeps a
|
||||
// decimal rather than reading as a bare zero, and a multi-second one stays in ms rather than
|
||||
// switching units mid-deck.
|
||||
@@ -162,7 +314,7 @@ static void testTimeConstantsAlwaysReadInMilliseconds() {
|
||||
CHECK(msLabel(0.012) == "12 ms"); // the use case's own reading
|
||||
CHECK(msLabel(0.25) == "250 ms");
|
||||
CHECK(msLabel(1.5) == "1500 ms"); // multi-second, still ms
|
||||
CHECK(msLabel(kEnvTimeMaxSeconds) == "2000 ms");
|
||||
CHECK(msLabel(kEnvTimeMaxSeconds) == "10000 ms");
|
||||
// The 10 ms hinge belongs to the integer form, not the decimal one.
|
||||
CHECK(msLabel(0.01) == "10 ms");
|
||||
CHECK(msLabel(0.0099) == "9.9 ms");
|
||||
@@ -175,10 +327,16 @@ static void testTimeConstantsAlwaysReadInMilliseconds() {
|
||||
}
|
||||
|
||||
int main() {
|
||||
testTheTwoCeilingNamesAreOneNumber();
|
||||
testNormRoundTripsThroughEveryValueDomain();
|
||||
testResetTouchesOnlyItsOwnRingOnADualRingKnob();
|
||||
testInnerResetLandsOnTheExactLinearNeutral();
|
||||
testResetLandsOnTheStoredDefaultOfEachControl();
|
||||
testEveryKnobIdResetsToItsDefault();
|
||||
testEveryDefaultHasAnExactNormalizedPreimage();
|
||||
testShiftSnapsToAWholeUnitOfTheDisplayedValue();
|
||||
testAValueStoredUnderTheOldCeilingIsReadNotRewritten();
|
||||
testTheFilterFourKeepTheirIdentityTaper();
|
||||
testTimeConstantsAlwaysReadInMilliseconds();
|
||||
if (g_fail) {
|
||||
std::printf("%d FAILURE(S)\n", g_fail);
|
||||
|
||||
Reference in New Issue
Block a user