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()
{
}
}