Files
LlamaCasty/Services/Audio/AudioLevelMeter.cs
T
gramps 575caa7bc4 feat(audio): the meter knows which side a signal is on (unit 2 of the dock)
`AudioLevelMeter` measured one number for everything. It now also keeps smoothed per-channel
levels with the same attack/decay, plus `ImbalanceDb`. Mono samples report both sides equal (a mono
source is centred), so the two never disagree about whether audio exists.

The dock shows ONE ladder per source — two ladders per bay was visual noise for a rare failure —
but this creator is profoundly deaf on the right, so the one case that matters is "the sound is
panned away from me". That is now measurable and will drive a flag, not a second ladder.

Semantics that mattered: a silent side is compared at the −60 dBFS floor, so a fully one-sided
signal reads ≈54 dB rather than "nothing to compare". My first version returned 0 for exactly the
case the feature exists for; the test caught it.

Guard: StereoMeterTests — hard left, hard right (the inaudible case), balanced stereo, mono
centred, and silence-is-not-an-imbalance. 31 audio tests green.
2026-10-30 12:33:40 -07:00

130 lines
4.7 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
namespace ytLive.Services.Audio;
/// <summary>
/// Computes the smoothed mic level (0..1) from captured samples. Pure —
/// unit-tested; the <see cref="AudioMixer"/> only feeds it and forwards the
/// result. RMS-based so it tracks perceived loudness, smoothed so the meter
/// does not flicker.
/// </summary>
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;
/// <summary>Per-channel levels, smoothed with the same attack/decay as <see cref="Level"/>.
/// A mono sample reports the same value on both sides (a mono source is centred), so the two
/// never disagree about whether audio exists.
///
/// <para>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.</para></summary>
public float Left => _left;
public float Right => _right;
/// <summary>How far apart the channels are, in dB. 0 only when BOTH are silent.
///
/// <para>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.</para></summary>
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));
}
}
/// <summary>
/// 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
/// ~0.01..0.1 (-40..-20 dBFS), which leaves a flat (linear) meter looking dead; the
/// log scale makes typical levels occupy the bar. Inputs at or below -60 dBFS
/// (0.001 linear) read as zero — the meter never idles on background noise.
/// <para>2026-10-09: the +10 dB "input amplification" that used to sit here is gone.
/// It made a −30 dBFS voice look dialed in, and a creator's upload came out at
/// −28.5 LUFS. The speech leveler now puts voice at −20 dBFS, which lands at 2/3 of
/// the bar (the green/yellow edge); loud peaks reach the red.</para>
/// </summary>
public static float ToDisplay(float linear)
{
if (linear <= 0.001f)
return 0f;
var db = 20f * MathF.Log10(linear);
return Math.Clamp(1f + db / 60f, 0f, 1f);
}
public float Push(AudioSample sample)
{
if (sample.Samples.Length == 0)
return _level;
double sumSquares = 0;
var count = 0;
foreach (var value in sample.Samples)
{
sumSquares += value * value;
count++;
}
var rms = (float)Math.Sqrt(sumSquares / count);
var alpha = rms > _level ? Smoothing : DecaySmoothing;
_level = _level * (1 - alpha) + rms * alpha;
MeasureChannels(sample);
return _level;
}
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;
}
}