Audio: speech leveler (auto gain to -20 dBFS speech, -6..+20 dB, gated) between gate and compressor; honest mic meter (drop the +10 dB display boost and x1.2). Creator upload measured -28.5 LUFS; same audio through the new chain: -17.9 LUFS / -0.7 dBFS peak (Unit: audio loudness)
This commit is contained in:
@@ -16,18 +16,20 @@ public sealed class AudioLevelMeter
|
||||
|
||||
/// <summary>
|
||||
/// Maps a linear level (0..1) onto the meter's display scale: -60 dBFS..0 dBFS
|
||||
/// spread linearly across 0..1, with +10 dB of input amplification. Linear RMS
|
||||
/// of real speech or game audio 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 and the amplification pushes real speech peaks into the red zone
|
||||
/// (0.8+) at maxed volume instead of hovering at its edge. Inputs at or below
|
||||
/// -60 dBFS (0.001 linear) read as zero — the meter never idles on background noise.
|
||||
/// 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) + 10f;
|
||||
var db = 20f * MathF.Log10(linear);
|
||||
return Math.Clamp(1f + db / 60f, 0f, 1f);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,15 +3,21 @@ namespace ytLive.Services.Audio;
|
||||
/// <summary>
|
||||
/// The always-on voice processing chain for the mic (TASK 9 audio milestone).
|
||||
/// Pure, per-sample, unit-tested: bass boost → treble lift → noise gate →
|
||||
/// compressor. Applied BEFORE the meter and the stream mix so what the creator
|
||||
/// hears on the meter is what viewers hear — and so background hum is gated
|
||||
/// out before it reaches the encoder.
|
||||
/// <b>speech leveler</b> → compressor. Applied BEFORE the meter and the stream mix so
|
||||
/// what the creator sees on the meter is what viewers hear — and so background hum
|
||||
/// is gated out before it reaches the encoder.
|
||||
/// <para>The leveler (2026-10-09) is the gain stage the chain was missing: a creator's
|
||||
/// upload measured −28.5 LUFS against YouTube's −14 reference because nothing ever
|
||||
/// brought quiet speech UP — the compressor only folds peaks above −6 dBFS and the
|
||||
/// master limiter only caps at −1 dBFS. Compressor → gain → limiter is the standard
|
||||
/// streaming voice chain.</para>
|
||||
/// </summary>
|
||||
public sealed class VoiceFilterChain
|
||||
{
|
||||
private readonly LowShelfFilter _bass;
|
||||
private readonly HighShelfFilter _treble;
|
||||
private readonly NoiseGate _gate;
|
||||
private readonly SpeechLeveler _leveler;
|
||||
private readonly Compressor _compressor;
|
||||
|
||||
/// <param name="sampleRate">The (post-resample) mic rate — the RBJ shelf
|
||||
@@ -21,15 +27,20 @@ public sealed class VoiceFilterChain
|
||||
_bass = new LowShelfFilter(sampleRate, 120f, 4f);
|
||||
_treble = new HighShelfFilter(sampleRate, 8000f, 3f);
|
||||
_gate = new NoiseGate();
|
||||
_leveler = new SpeechLeveler(sampleRate);
|
||||
_compressor = new Compressor();
|
||||
}
|
||||
|
||||
/// <summary>The leveler's current gain (linear) — for diagnostics and tests.</summary>
|
||||
public float LevelerGain => _leveler.Gain;
|
||||
|
||||
public float Process(float sample)
|
||||
{
|
||||
var bass = _bass.Process(sample);
|
||||
var treble = _treble.Process(bass);
|
||||
var gated = _gate.Process(treble);
|
||||
return _compressor.Process(gated);
|
||||
var leveled = _leveler.Process(gated, _gate.IsOpen);
|
||||
return _compressor.Process(leveled);
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
@@ -37,10 +48,87 @@ public sealed class VoiceFilterChain
|
||||
_bass.Reset();
|
||||
_treble.Reset();
|
||||
_gate.Reset();
|
||||
_leveler.Reset();
|
||||
_compressor.Reset();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Automatic gain for speech: tracks the voice's RMS over ~0.3 s and slowly steers a
|
||||
/// gain so speech sits at <see cref="TargetRms"/> (−20 dBFS mono ≈ −17 LUFS once the mic
|
||||
/// is mixed to stereo — close to YouTube's −14 reference while leaving the limiter room).
|
||||
/// Different mics differ by 20 dB+ in sensitivity; this is what makes "it just sounds
|
||||
/// right" true for all of them.
|
||||
/// <list type="bullet">
|
||||
/// <item>Only learns while the noise gate is open and the voice is above a silence floor —
|
||||
/// pauses and background hum never get pumped up.</item>
|
||||
/// <item>Gain rises at most <see cref="RiseDbPerSecond"/> and falls at most
|
||||
/// <see cref="FallDbPerSecond"/> (faster down than up, so a sudden shout is tamed quickly
|
||||
/// and a quiet stretch isn't chased), within <see cref="MinGain"/>..<see cref="MaxGain"/>.</item>
|
||||
/// <item>Peaks it pushes up are caught by the <see cref="Compressor"/> after it and the
|
||||
/// master limiter at the end of the mix.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class SpeechLeveler
|
||||
{
|
||||
public const float TargetRms = 0.1f; // −20 dBFS
|
||||
public const float MaxGain = 10f; // +20 dB
|
||||
public const float MinGain = 0.5f; // −6 dB
|
||||
public const float SilenceFloorRms = 0.002f; // −54 dBFS: below this, hold the gain
|
||||
public const float RiseDbPerSecond = 10f;
|
||||
public const float FallDbPerSecond = 30f;
|
||||
private const float WindowSeconds = 0.3f;
|
||||
private const float UpdateSeconds = 0.01f;
|
||||
|
||||
private readonly float _meanAlpha;
|
||||
private readonly int _updateEvery;
|
||||
private readonly float _maxRiseDb;
|
||||
private readonly float _maxFallDb;
|
||||
private float _meanSquare;
|
||||
private float _gainDb;
|
||||
private float _gain = 1f;
|
||||
private int _sinceUpdate;
|
||||
|
||||
public SpeechLeveler(int sampleRate)
|
||||
{
|
||||
_meanAlpha = 1f / (WindowSeconds * sampleRate);
|
||||
_updateEvery = Math.Max(1, (int)(UpdateSeconds * sampleRate));
|
||||
_maxRiseDb = RiseDbPerSecond * UpdateSeconds;
|
||||
_maxFallDb = FallDbPerSecond * UpdateSeconds;
|
||||
}
|
||||
|
||||
public float Gain => _gain;
|
||||
|
||||
public float Process(float sample, bool voiceActive)
|
||||
{
|
||||
if (voiceActive)
|
||||
_meanSquare += (sample * sample - _meanSquare) * _meanAlpha;
|
||||
|
||||
if (++_sinceUpdate >= _updateEvery)
|
||||
{
|
||||
_sinceUpdate = 0;
|
||||
var rms = MathF.Sqrt(_meanSquare);
|
||||
if (voiceActive && rms > SilenceFloorRms)
|
||||
{
|
||||
var desiredDb = 20f * MathF.Log10(Math.Clamp(TargetRms / rms, MinGain, MaxGain));
|
||||
var step = Math.Clamp(desiredDb - _gainDb, -_maxFallDb, _maxRiseDb);
|
||||
_gainDb += step;
|
||||
_gain = MathF.Pow(10f, _gainDb / 20f);
|
||||
}
|
||||
}
|
||||
|
||||
return sample * _gain;
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
_meanSquare = 0;
|
||||
_gainDb = 0;
|
||||
_gain = 1f;
|
||||
_sinceUpdate = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RBJ audio EQ cookbook low-shelf biquad (bass boost). Transposed direct
|
||||
/// form II — stable, cheap, standard.
|
||||
|
||||
Reference in New Issue
Block a user