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.
This commit is contained in:
@@ -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;
|
||||
|
||||
/// <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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user