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); + } +}