feat(audio): the dock's dB scale, and the mono fold primitive

Unit 1 of the sound-bar refactor. The model stores linear gain; the dock shows decibels, because
"−6 dB" is readable and "0.50" is not. `Helpers/GainDb` owns the mapping:

- 0 dB = unity (the old 1.0), range −60 dB (true silence, exactly 0 gain) … +6 dB.
- Labels for the fader readout: "−∞", "-6.0", "+1.5".
- `MonoFold(L,R)` = (L+R)/2, the accessible downmix: hard-panned content becomes audible on both
  sides at half amplitude instead of absent from one. Creator context: profoundly deaf on the
  right, so single-sided audio is inaudible, not merely annoying.

Guard: GainDbTests pins unity, the −6 dB half, the exact-zero floor, the +6 dB ceiling, a
round-trip across the range, the labels, and the fold arithmetic (a right-only 0.5 arrives on the
left at 0.25).
This commit is contained in:
2026-10-30 12:32:07 -07:00
parent 17249229c8
commit afe21682c2
2 changed files with 87 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
using System;
namespace ytLive.Helpers;
/// <summary>The fader scale for the audio dock: the model stores LINEAR gain (0..1), the dock
/// shows and edits DECIBELS, because "−6 dB" means something to a person and "0.50" does not.
///
/// <para>Range: −∞ (silence) to +6 dB of headroom above unity. 0 dB is unity gain, which is what
/// the old 1.0 meant — so a persisted 0.8 comes back as ≈ −1.9 dB and nothing about the stored
/// data changes.</para>
///
/// <para>Creator context (2026-10-30): nearly deaf on the right, so the fader number is read, not
/// heard. The scale therefore has to be the true one, not a cosmetic curve.</para></summary>
public static class GainDb
{
/// <summary>Silence floor. Anything at or below this reads as −∞ and writes as 0 gain.</summary>
public const double FloorDb = -60;
public const double MaxDb = 6;
/// <summary>Linear gain → dB, clamped to the fader's range. Zero becomes <see cref="FloorDb"/>.</summary>
public static double ToDb(double gain)
{
if (gain <= 0) return FloorDb;
var db = 20 * Math.Log10(gain);
return Math.Clamp(db, FloorDb, MaxDb);
}
/// <summary>dB → linear gain. At the floor the result is exactly 0 (true silence), so the
/// bottom of the fader cannot leave a residue of noise.</summary>
public static double FromDb(double db)
{
if (db <= FloorDb) return 0;
return Math.Pow(10, Math.Clamp(db, FloorDb, MaxDb) / 20);
}
/// <summary>"−∞", "−6.0", "+1.5" — the label beside a fader.</summary>
public static string Label(double db) =>
db <= FloorDb ? "−∞" : (db > 0 ? "+" : string.Empty) + db.ToString("0.0");
/// <summary>Mono fold: the accessible downmix. Averages the pair so hard-panned content is
/// audible on both sides at half amplitude instead of absent from one.</summary>
public static float MonoFold(float left, float right) => (left + right) * 0.5f;
}
+43
View File
@@ -0,0 +1,43 @@
using Xunit;
using ytLive.Helpers;
namespace ytLive.Tests;
/// <summary>The dock's faders speak dB while the model stores linear gain, so the mapping is load
/// bearing: get it wrong and every reported level is wrong by a constant. Unity, the floor and
/// the +6 dB ceiling are the three values a creator actually checks.</summary>
public class GainDbTests
{
[Fact]
public void Db_and_linear_gain_round_trip_and_the_ends_are_honest()
{
// Unity is the anchor: 1.0 ↔ 0 dB, and the default mic volume reads −1.9 dB.
Assert.Equal(0, GainDb.ToDb(1.0), 6);
Assert.Equal(1.0, GainDb.FromDb(0), 6);
Assert.Equal(-1.9, GainDb.ToDb(0.8), 1);
// Halving is −6 dB — the number a creator reads as "half".
Assert.Equal(-6.02, GainDb.ToDb(0.5), 1);
// The floor is TRUE silence: no residue left at the bottom of the fader.
Assert.Equal(0.0, GainDb.FromDb(GainDb.FloorDb));
Assert.Equal(0.0, GainDb.FromDb(-999));
// Ceiling: +6 dB is twice the amplitude and nothing beyond it.
Assert.Equal(2.0, GainDb.FromDb(6), 2);
Assert.Equal(6, GainDb.ToDb(GainDb.FromDb(99)), 6);
// Round-trip across the usable range.
foreach (var db in new[] { -60.0, -40, -20, -12, -6, -3, 0, 3, 6 })
Assert.Equal(db, GainDb.ToDb(GainDb.FromDb(db)), 6);
// Labels are what the creator reads.
Assert.Equal("−∞", GainDb.Label(GainDb.FloorDb));
Assert.Equal("-6.0", GainDb.Label(-6));
Assert.Equal("+1.5", GainDb.Label(1.5));
// The mono fold: a hard-right signal becomes audible on the left at half amplitude.
Assert.Equal(0.25f, GainDb.MonoFold(0f, 0.5f));
Assert.Equal(0.5f, GainDb.MonoFold(0.5f, 0.5f));
}
}