diff --git a/Services/Audio/AudioLevelMeter.cs b/Services/Audio/AudioLevelMeter.cs
index ca98286..f3d5e99 100644
--- a/Services/Audio/AudioLevelMeter.cs
+++ b/Services/Audio/AudioLevelMeter.cs
@@ -11,9 +11,39 @@ public sealed class AudioLevelMeter
private const float Smoothing = 0.2f;
private const float DecaySmoothing = 0.5f;
private float _level;
+ private float _left;
+ private float _right;
public float Level => _level;
+ /// Per-channel levels, smoothed with the same attack/decay as .
+ /// A mono sample reports the same value on both sides (a mono source is centred), so the two
+ /// never disagree about whether audio exists.
+ ///
+ /// Why they exist: the dock shows ONE ladder per source, but a creator who is deaf on
+ /// one side needs to know when content is panned away from it. The imbalance flag is computed
+ /// from these, not from a second ladder.
+ public float Left => _left;
+ public float Right => _right;
+
+ /// How far apart the channels are, in dB. 0 only when BOTH are silent.
+ ///
+ /// A side that carries nothing is compared at the −60 dBFS floor, so a fully one-sided
+ /// signal reads as a large imbalance (≈54 dB) rather than "nothing to compare" — one-sided is
+ /// the maximum imbalance, not the absence of one. That distinction is the whole point for a
+ /// creator who cannot hear the missing side.
+ public float ImbalanceDb
+ {
+ get
+ {
+ const float floor = 0.001f; // −60 dBFS
+ if (_left < floor && _right < floor) return 0f;
+ var l = MathF.Max(_left, floor);
+ var r = MathF.Max(_right, floor);
+ return MathF.Abs(20f * MathF.Log10(l / r));
+ }
+ }
+
///
/// Maps a linear level (0..1) onto the meter's display scale: -60 dBFS..0 dBFS
/// spread linearly across 0..1 — an HONEST dBFS scale. Linear RMS of real speech is
@@ -49,8 +79,51 @@ public sealed class AudioLevelMeter
var rms = (float)Math.Sqrt(sumSquares / count);
var alpha = rms > _level ? Smoothing : DecaySmoothing;
_level = _level * (1 - alpha) + rms * alpha;
+
+ MeasureChannels(sample);
return _level;
}
- public void Reset() => _level = 0;
+ private void MeasureChannels(AudioSample sample)
+ {
+ double leftSquares = 0, rightSquares = 0;
+ int frames;
+
+ if (sample.Channels <= 1)
+ {
+ // Mono is centred: one number, both sides.
+ for (var i = 0; i < sample.Samples.Length; i++) leftSquares += sample.Samples[i] * sample.Samples[i];
+ frames = Math.Max(1, sample.Samples.Length);
+ rightSquares = leftSquares;
+ }
+ else
+ {
+ frames = Math.Max(1, sample.Samples.Length / sample.Channels);
+ for (var frame = 0; frame < frames; frame++)
+ {
+ var l = sample.Samples[frame * sample.Channels];
+ var r = sample.Samples[frame * sample.Channels + 1];
+ leftSquares += l * l;
+ rightSquares += r * r;
+ }
+ }
+
+ var left = (float)Math.Sqrt(leftSquares / frames);
+ var right = (float)Math.Sqrt(rightSquares / frames);
+ _left = Smooth(_left, left);
+ _right = Smooth(_right, right);
+
+ static float Smooth(float current, float incoming)
+ {
+ var a = incoming > current ? Smoothing : DecaySmoothing;
+ return current * (1 - a) + incoming * a;
+ }
+ }
+
+ public void Reset()
+ {
+ _level = 0;
+ _left = 0;
+ _right = 0;
+ }
}
diff --git a/ytLive.Tests/StereoMeterTests.cs b/ytLive.Tests/StereoMeterTests.cs
new file mode 100644
index 0000000..d859e77
--- /dev/null
+++ b/ytLive.Tests/StereoMeterTests.cs
@@ -0,0 +1,56 @@
+using Xunit;
+using ytLive.Services.Audio;
+
+namespace ytLive.Tests;
+
+/// The dock shows ONE ladder per source, but the creator is profoundly deaf on the right,
+/// so the meter has to know which side a signal is on: hard-panned content is inaudible, not merely
+/// quieter. These levels drive the imbalance flag, and they must be measured — never decorative.
+///
+public class StereoMeterTests
+{
+ private const int Rate = 48000;
+
+ private static AudioLevelMeter Pushed(int channels, float left, float right, int frames = 64, int pushes = 12)
+ {
+ var meter = new AudioLevelMeter();
+ var buffer = new float[frames * channels];
+ for (var f = 0; f < frames; f++)
+ {
+ buffer[f * channels] = left;
+ if (channels > 1) buffer[f * channels + 1] = right;
+ }
+ for (var i = 0; i < pushes; i++) meter.Push(new AudioSample(buffer, Rate, channels));
+ return meter;
+ }
+
+ [Fact]
+ public void The_meter_knows_which_side_the_signal_is_on()
+ {
+ // Hard left: the left side carries it, the right is silent, and the gap is reported.
+ var hardLeft = Pushed(channels: 2, left: 0.5f, right: 0f);
+ Assert.True(hardLeft.Left > 0.1f, $"left should carry the tone, got {hardLeft.Left}");
+ Assert.True(hardLeft.Right < 0.01f, $"right should be silent, got {hardLeft.Right}");
+ Assert.True(hardLeft.ImbalanceDb > 20f, "a one-sided signal must report a large imbalance");
+
+ // Hard right is the mirror case — the one this creator cannot hear.
+ var hardRight = Pushed(channels: 2, left: 0f, right: 0.5f);
+ Assert.True(hardRight.Right > 0.1f);
+ Assert.True(hardRight.Left < 0.01f);
+
+ // Balanced stereo reports no imbalance.
+ var centred = Pushed(channels: 2, left: 0.5f, right: 0.5f);
+ Assert.True(centred.Left > 0.1f && centred.Right > 0.1f);
+ Assert.True(centred.ImbalanceDb < 1f, $"balanced audio must not flag, got {centred.ImbalanceDb}");
+
+ // Mono is centred by definition: both sides equal, no flag — never "one-sided".
+ var mono = Pushed(channels: 1, left: 0.5f, right: 0f);
+ Assert.True(mono.Left > 0.1f);
+ Assert.Equal(mono.Left, mono.Right, 5);
+ Assert.Equal(0f, mono.ImbalanceDb);
+
+ // Silence on both sides is not an imbalance (nothing to compare).
+ var silent = Pushed(channels: 2, left: 0f, right: 0f);
+ Assert.Equal(0f, silent.ImbalanceDb);
+ }
+}