namespace ytLive.Services.Audio; /// /// The always-on voice processing chain for the mic (TASK 9 audio milestone). /// Pure, per-sample, unit-tested: bass boost → treble lift → noise gate → /// speech leveler → 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. /// 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. /// public sealed class VoiceFilterChain { private readonly LowShelfFilter _bass; private readonly HighShelfFilter _treble; private readonly NoiseGate _gate; private readonly SpeechLeveler _leveler; private readonly Compressor _compressor; /// The (post-resample) mic rate — the RBJ shelf /// coefficients are rate-dependent, so the chain is built once at 48 kHz. 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(); } /// The leveler's current gain (linear) — for diagnostics and tests. 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(); } } /// /// Automatic gain for speech: tracks the voice's RMS over ~0.3 s and slowly steers a /// gain so speech sits at (−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. /// /// Only learns while the noise gate is open and the voice is above a silence floor — /// pauses and background hum never get pumped up. /// Gain rises at most and falls at most /// (faster down than up, so a sudden shout is tamed quickly /// and a quiet stretch isn't chased), within ... /// Peaks it pushes up are caught by the after it and the /// master limiter at the end of the mix. /// /// 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; } } /// /// RBJ audio EQ cookbook low-shelf biquad (bass boost). Transposed direct /// form II — stable, cheap, standard. /// 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; } } /// RBJ audio EQ cookbook high-shelf biquad (treble lift). 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; } } /// /// 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 and closes below half of it, so /// quiet background hum stays silent and borderline signals don't chatter. /// 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; } } /// /// A static soft-limit compressor: samples at or under the threshold pass /// through; louder samples fold down at Ratio:1 so hot mic peaks can't /// slam the stream. State-free. /// 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() { } }