diff --git a/Semantics.Quantities/AudioEngineering/NormalizedParameter.cs b/Semantics.Quantities/AudioEngineering/NormalizedParameter.cs
index c09a9225..f4ce9d98 100644
--- a/Semantics.Quantities/AudioEngineering/NormalizedParameter.cs
+++ b/Semantics.Quantities/AudioEngineering/NormalizedParameter.cs
@@ -47,7 +47,12 @@ private NormalizedParameter(T min, T max, ParameterTaper taper, T skew)
/// The value at normalized position 0.
/// The value at normalized position 1.
/// A new linear .
- public static NormalizedParameter Linear(T min, T max) => new(min, max, ParameterTaper.Linear, T.One);
+ /// Thrown when equals .
+ public static NormalizedParameter Linear(T min, T max)
+ {
+ EnsureNonZeroWidth(min, max);
+ return new(min, max, ParameterTaper.Linear, T.One);
+ }
///
/// Creates a linear parameter range bent by a power-curve skew.
@@ -56,9 +61,10 @@ private NormalizedParameter(T min, T max, ParameterTaper taper, T skew)
/// The value at normalized position 1.
/// The skew exponent: greater than one biases resolution toward , less than one toward .
/// A new skewed .
- /// Thrown when is not positive.
+ /// Thrown when is not positive, or equals .
public static NormalizedParameter Skewed(T min, T max, T skew)
{
+ EnsureNonZeroWidth(min, max);
if (T.IsNaN(skew) || skew <= T.Zero)
{
throw new ArgumentOutOfRangeException(nameof(skew), skew, "Skew must be a positive value.");
@@ -73,9 +79,10 @@ public static NormalizedParameter Skewed(T min, T max, T skew)
/// The value at normalized position 0.
/// The value at normalized position 1.
/// A new logarithmic .
- /// Thrown when and are not both non-zero and of the same sign.
+ /// Thrown when and are not both non-zero and of the same sign, or are equal.
public static NormalizedParameter Logarithmic(T min, T max)
{
+ EnsureNonZeroWidth(min, max);
// Via Math.Sign rather than a direct == 0.0: the zero check has to be exact (a logarithmic
// range is undefined at zero but perfectly well defined at 1e-9, so a tolerance band would
// reject legitimate ranges), and comparing signs says that without comparing floats.
@@ -154,10 +161,11 @@ public T Normalize(T value)
double hi = double.CreateChecked(Max);
double shaped = Taper == ParameterTaper.Logarithmic
- ? Math.Log(v / lo) / Math.Log(hi / lo)
+ ? NormalizeLogarithmic(v, lo, hi)
: (v - lo) / (hi - lo);
- shaped = Math.Clamp(shaped, 0.0, 1.0);
+ // Math.Clamp passes NaN through, and the host must always get a position in [0, 1].
+ shaped = double.IsNaN(shaped) ? 0.0 : Math.Clamp(shaped, 0.0, 1.0);
// Invert the power-curve skew.
double x = skew == 1.0 ? shaped : Math.Pow(shaped, 1.0 / skew);
@@ -165,6 +173,27 @@ public T Normalize(T value)
return T.CreateChecked(Math.Clamp(x, 0.0, 1.0));
}
+ private static double NormalizeLogarithmic(double v, double lo, double hi)
+ {
+ double ratio = v / lo;
+ if (ratio > 0.0 || double.IsNaN(ratio))
+ {
+ return Math.Log(ratio) / Math.Log(hi / lo);
+ }
+
+ // The value is zero or on the other side of zero from the range, where the logarithm is
+ // undefined. The nearer end is the one with the smaller magnitude.
+ return Math.Abs(lo) < Math.Abs(hi) ? 0.0 : 1.0;
+ }
+
+ private static void EnsureNonZeroWidth(T min, T max)
+ {
+ if (T.IsZero(max - min))
+ {
+ throw new ArgumentOutOfRangeException(nameof(max), max, "The range must not be empty: min and max must differ.");
+ }
+ }
+
///
/// Clamps a value to the inclusive range spanned by and .
///
diff --git a/Semantics.Test/AudioEngineeringTests.cs b/Semantics.Test/AudioEngineeringTests.cs
index c3ef709f..c3c8c371 100644
--- a/Semantics.Test/AudioEngineeringTests.cs
+++ b/Semantics.Test/AudioEngineeringTests.cs
@@ -202,4 +202,42 @@ public void NormalizedParameter_InvalidArguments_Throw()
Assert.ThrowsExactly(() => NormalizedParameter.Skewed(0.0, 1.0, 0.0));
Assert.ThrowsExactly(() => NormalizedParameter.WithCenter(0.0, 10.0, 20.0));
}
+
+ [TestMethod]
+ public void NormalizedParameter_Logarithmic_ValueAtOrBelowZero_NormalizesToNearerEnd()
+ {
+ NormalizedParameter rising = NormalizedParameter.Logarithmic(20.0, 20000.0);
+ Assert.AreEqual(0.0, rising.Normalize(-5.0));
+ Assert.AreEqual(0.0, rising.Normalize(0.0));
+
+ // A falling range has its small-magnitude end at position 1, so that is the nearer end.
+ NormalizedParameter falling = NormalizedParameter.Logarithmic(20000.0, 20.0);
+ Assert.AreEqual(1.0, falling.Normalize(-5.0));
+
+ NormalizedParameter negative = NormalizedParameter.Logarithmic(-20.0, -20000.0);
+ Assert.AreEqual(0.0, negative.Normalize(5.0));
+ }
+
+ [TestMethod]
+ public void NormalizedParameter_Logarithmic_Decimal_ValueBelowZero_DoesNotThrow()
+ {
+ NormalizedParameter p = NormalizedParameter.Logarithmic(20m, 20000m);
+ Assert.AreEqual(0m, p.Normalize(-5m));
+ }
+
+ [TestMethod]
+ public void NormalizedParameter_Normalize_NaN_IsZero()
+ {
+ Assert.AreEqual(0.0, NormalizedParameter.Linear(0.0, 10.0).Normalize(double.NaN));
+ Assert.AreEqual(0.0, NormalizedParameter.Logarithmic(20.0, 20000.0).Normalize(double.NaN));
+ }
+
+ [TestMethod]
+ public void NormalizedParameter_ZeroWidthRange_Throws()
+ {
+ Assert.ThrowsExactly(() => NormalizedParameter.Linear(1.0, 1.0));
+ Assert.ThrowsExactly(() => NormalizedParameter.Skewed(1.0, 1.0, 2.0));
+ Assert.ThrowsExactly(() => NormalizedParameter.Logarithmic(20.0, 20.0));
+ Assert.ThrowsExactly(() => NormalizedParameter.Linear(1m, 1m));
+ }
}