Files
LlamaCasty/Services/Audio/VoiceFilterChain.cs
T

293 lines
9.4 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>
/// The always-on voice processing chain for the mic (TASK 9 audio milestone).
/// Pure, per-sample, unit-tested: bass boost → treble lift → noise gate →
/// <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
/// coefficients are rate-dependent, so the chain is built once at 48 kHz.</param>
public VoiceFilterChain(int sampleRate)
{
_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);
var leveled = _leveler.Process(gated, _gate.IsOpen);
return _compressor.Process(leveled);
}
public void Reset()
{
_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.
/// </summary>
public sealed class LowShelfFilter
{
private readonly float _b0, _b1, _b2, _a1, _a2;
private float _z1, _z2;
public LowShelfFilter(int sampleRate, float cutoffHz, float gainDb)
{
var a = MathF.Pow(10f, gainDb / 40f);
var omega = 2f * MathF.PI * cutoffHz / sampleRate;
var sin = MathF.Sin(omega);
var cos = MathF.Cos(omega);
var alpha = sin / 2f * MathF.Sqrt(2f);
var twoSqrtA = 2f * MathF.Sqrt(a);
var b0 = a * ((a + 1f) - (a - 1f) * cos + twoSqrtA * alpha);
var b1 = 2f * a * ((a - 1f) - (a + 1f) * cos);
var b2 = a * ((a + 1f) - (a - 1f) * cos - twoSqrtA * alpha);
var a0 = (a + 1f) + (a - 1f) * cos + twoSqrtA * alpha;
var a1 = -2f * ((a - 1f) + (a + 1f) * cos);
var a2 = (a + 1f) + (a - 1f) * cos - twoSqrtA * alpha;
_b0 = b0 / a0;
_b1 = b1 / a0;
_b2 = b2 / a0;
_a1 = a1 / a0;
_a2 = a2 / a0;
}
public float Process(float x)
{
var y = _b0 * x + _z1;
_z1 = _b1 * x - _a1 * y + _z2;
_z2 = _b2 * x - _a2 * y;
return y;
}
public void Reset()
{
_z1 = 0;
_z2 = 0;
}
}
/// <summary>RBJ audio EQ cookbook high-shelf biquad (treble lift).</summary>
public sealed class HighShelfFilter
{
private readonly float _b0, _b1, _b2, _a1, _a2;
private float _z1, _z2;
public HighShelfFilter(int sampleRate, float cutoffHz, float gainDb)
{
var a = MathF.Pow(10f, gainDb / 40f);
var omega = 2f * MathF.PI * cutoffHz / sampleRate;
var sin = MathF.Sin(omega);
var cos = MathF.Cos(omega);
var alpha = sin / 2f * MathF.Sqrt(2f);
var twoSqrtA = 2f * MathF.Sqrt(a);
var b0 = a * ((a + 1f) + (a - 1f) * cos + twoSqrtA * alpha);
var b1 = -2f * a * ((a - 1f) + (a + 1f) * cos);
var b2 = a * ((a + 1f) + (a - 1f) * cos - twoSqrtA * alpha);
var a0 = (a + 1f) - (a - 1f) * cos + twoSqrtA * alpha;
var a1 = 2f * ((a - 1f) - (a + 1f) * cos);
var a2 = (a + 1f) - (a - 1f) * cos - twoSqrtA * alpha;
_b0 = b0 / a0;
_b1 = b1 / a0;
_b2 = b2 / a0;
_a1 = a1 / a0;
_a2 = a2 / a0;
}
public float Process(float x)
{
var y = _b0 * x + _z1;
_z1 = _b1 * x - _a1 * y + _z2;
_z2 = _b2 * x - _a2 * y;
return y;
}
public void Reset()
{
_z1 = 0;
_z2 = 0;
}
}
/// <summary>
/// A simple noise gate with open/release hysteresis (pure C#, no external DSP
/// dependency). The envelope follows the input peak fast and decays slowly;
/// the gate opens above <see cref="Threshold"/> and closes below half of it, so
/// quiet background hum stays silent and borderline signals don't chatter.
/// </summary>
public sealed class NoiseGate
{
private const float HysteresisRatio = 0.5f;
private const float Attack = 0.5f;
private const float Release = 0.0005f;
private float _envelope;
private bool _open;
public NoiseGate(float threshold = DefaultThreshold)
{
Threshold = threshold;
}
public const float DefaultThreshold = 0.005f;
public float Threshold { get; }
public bool IsOpen => _open;
public float Process(float sample)
{
var abs = MathF.Abs(sample);
_envelope = abs > _envelope
? _envelope + (abs - _envelope) * Attack
: _envelope * (1f - Release);
if (!_open && _envelope > Threshold)
_open = true;
else if (_open && _envelope < Threshold * HysteresisRatio)
_open = false;
return _open ? sample : 0f;
}
public void Reset()
{
_envelope = 0;
_open = false;
}
}
/// <summary>
/// A static soft-limit compressor: samples at or under the threshold pass
/// through; louder samples fold down at <c>Ratio:1</c> so hot mic peaks can't
/// slam the stream. State-free.
/// </summary>
public sealed class Compressor
{
public const float Threshold = 0.5f;
public const float Ratio = 4f;
public float Process(float sample)
{
var abs = MathF.Abs(sample);
if (abs <= Threshold)
return sample;
return MathF.CopySign(Threshold + (abs - Threshold) / Ratio, sample);
}
public void Reset()
{
}
}