205 lines
5.8 KiB
C#
205 lines
5.8 KiB
C#
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.
|
|
/// </summary>
|
|
public sealed class VoiceFilterChain
|
|
{
|
|
private readonly LowShelfFilter _bass;
|
|
private readonly HighShelfFilter _treble;
|
|
private readonly NoiseGate _gate;
|
|
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();
|
|
_compressor = new Compressor();
|
|
}
|
|
|
|
public float Process(float sample)
|
|
{
|
|
var bass = _bass.Process(sample);
|
|
var treble = _treble.Process(bass);
|
|
var gated = _gate.Process(treble);
|
|
return _compressor.Process(gated);
|
|
}
|
|
|
|
public void Reset()
|
|
{
|
|
_bass.Reset();
|
|
_treble.Reset();
|
|
_gate.Reset();
|
|
_compressor.Reset();
|
|
}
|
|
}
|
|
|
|
/// <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()
|
|
{
|
|
}
|
|
}
|