From 4c28daa423366cf840d73e62db2e5b5126707dd5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 05:27:32 +0000 Subject: [PATCH] [patch] Keep NormalizedParameter.Normalize in [0, 1] for out-of-domain values and reject empty ranges Normalize relied on Math.Clamp, which passes NaN through, so a value at or below zero on a logarithmic range returned NaN for double and threw OverflowException for decimal. Such a value now maps to the nearer end (the one with the smaller magnitude), and any remaining NaN maps to 0. Linear, Skewed and Logarithmic now reject min == max, which used to reach the same NaN path. Fixes #319 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01A5MkmzQ4qxxRmHStdQ8oJA --- .../AudioEngineering/NormalizedParameter.cs | 39 ++++++++++++++++--- Semantics.Test/AudioEngineeringTests.cs | 38 ++++++++++++++++++ 2 files changed, 72 insertions(+), 5 deletions(-) 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)); + } }