Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 34 additions & 5 deletions Semantics.Quantities/AudioEngineering/NormalizedParameter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,12 @@ private NormalizedParameter(T min, T max, ParameterTaper taper, T skew)
/// <param name="min">The value at normalized position <c>0</c>.</param>
/// <param name="max">The value at normalized position <c>1</c>.</param>
/// <returns>A new linear <see cref="NormalizedParameter{T}"/>.</returns>
public static NormalizedParameter<T> Linear(T min, T max) => new(min, max, ParameterTaper.Linear, T.One);
/// <exception cref="ArgumentOutOfRangeException">Thrown when <paramref name="min"/> equals <paramref name="max"/>.</exception>
public static NormalizedParameter<T> Linear(T min, T max)
{
EnsureNonZeroWidth(min, max);
return new(min, max, ParameterTaper.Linear, T.One);
}

/// <summary>
/// Creates a linear parameter range bent by a power-curve skew.
Expand All @@ -56,9 +61,10 @@ private NormalizedParameter(T min, T max, ParameterTaper taper, T skew)
/// <param name="max">The value at normalized position <c>1</c>.</param>
/// <param name="skew">The skew exponent: greater than one biases resolution toward <paramref name="min"/>, less than one toward <paramref name="max"/>.</param>
/// <returns>A new skewed <see cref="NormalizedParameter{T}"/>.</returns>
/// <exception cref="ArgumentOutOfRangeException">Thrown when <paramref name="skew"/> is not positive.</exception>
/// <exception cref="ArgumentOutOfRangeException">Thrown when <paramref name="skew"/> is not positive, or <paramref name="min"/> equals <paramref name="max"/>.</exception>
public static NormalizedParameter<T> 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.");
Expand All @@ -73,9 +79,10 @@ public static NormalizedParameter<T> Skewed(T min, T max, T skew)
/// <param name="min">The value at normalized position <c>0</c>.</param>
/// <param name="max">The value at normalized position <c>1</c>.</param>
/// <returns>A new logarithmic <see cref="NormalizedParameter{T}"/>.</returns>
/// <exception cref="ArgumentOutOfRangeException">Thrown when <paramref name="min"/> and <paramref name="max"/> are not both non-zero and of the same sign.</exception>
/// <exception cref="ArgumentOutOfRangeException">Thrown when <paramref name="min"/> and <paramref name="max"/> are not both non-zero and of the same sign, or are equal.</exception>
public static NormalizedParameter<T> 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.
Expand Down Expand Up @@ -154,17 +161,39 @@ 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);

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.");
}
}

/// <summary>
/// Clamps a value to the inclusive range spanned by <see cref="Min"/> and <see cref="Max"/>.
/// </summary>
Expand Down
38 changes: 38 additions & 0 deletions Semantics.Test/AudioEngineeringTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -202,4 +202,42 @@ public void NormalizedParameter_InvalidArguments_Throw()
Assert.ThrowsExactly<ArgumentOutOfRangeException>(() => NormalizedParameter<double>.Skewed(0.0, 1.0, 0.0));
Assert.ThrowsExactly<ArgumentOutOfRangeException>(() => NormalizedParameter<double>.WithCenter(0.0, 10.0, 20.0));
}

[TestMethod]
public void NormalizedParameter_Logarithmic_ValueAtOrBelowZero_NormalizesToNearerEnd()
{
NormalizedParameter<double> rising = NormalizedParameter<double>.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<double> falling = NormalizedParameter<double>.Logarithmic(20000.0, 20.0);
Assert.AreEqual(1.0, falling.Normalize(-5.0));

NormalizedParameter<double> negative = NormalizedParameter<double>.Logarithmic(-20.0, -20000.0);
Assert.AreEqual(0.0, negative.Normalize(5.0));
}

[TestMethod]
public void NormalizedParameter_Logarithmic_Decimal_ValueBelowZero_DoesNotThrow()
{
NormalizedParameter<decimal> p = NormalizedParameter<decimal>.Logarithmic(20m, 20000m);
Assert.AreEqual(0m, p.Normalize(-5m));
}

[TestMethod]
public void NormalizedParameter_Normalize_NaN_IsZero()
{
Assert.AreEqual(0.0, NormalizedParameter<double>.Linear(0.0, 10.0).Normalize(double.NaN));
Assert.AreEqual(0.0, NormalizedParameter<double>.Logarithmic(20.0, 20000.0).Normalize(double.NaN));
}

[TestMethod]
public void NormalizedParameter_ZeroWidthRange_Throws()
{
Assert.ThrowsExactly<ArgumentOutOfRangeException>(() => NormalizedParameter<double>.Linear(1.0, 1.0));
Assert.ThrowsExactly<ArgumentOutOfRangeException>(() => NormalizedParameter<double>.Skewed(1.0, 1.0, 2.0));
Assert.ThrowsExactly<ArgumentOutOfRangeException>(() => NormalizedParameter<double>.Logarithmic(20.0, 20.0));
Assert.ThrowsExactly<ArgumentOutOfRangeException>(() => NormalizedParameter<decimal>.Linear(1m, 1m));
}
}
Loading