TASK 9 audio milestone: real stream audio + voice filters + auto-duck + free TRAX music — the mixer now feeds the encoder real audio (ffmpeg reads a Windows named pipe, -f f32le -ar 48000 -ac 2 -i \.\pipe\ytllive_audio, replacing anullsrc silence; explicit -map 0:v -map 1:a via EncoderOptions.AudioPipeName), with honest gains reaching the stream (MicVolume scales mic, GameAudioVolume + mute scale loopback, Func<double> seams on AudioMixer), an always-on pure-C# voice chain on the mic BEFORE the meter and mix (LowShelfFilter 120Hz +4dB → HighShelfFilter 8kHz +3dB → NoiseGate 0.005/hysteresis 0.5 → Compressor 0.5 4:1, all TDF2), AutoDucker (mic RMS>0.02 → loopback ×0.25, attack 0.05/release 0.005), and TRAX free background music (MusicPlayer = MediaFoundationReader → VolumeWaveProvider16 at fixed 0.20 → WaveOutEvent via the new sibling NAudio.WinMM 2.2.1 package; plays to the default device so the existing loopback carries it, ducked with game; footer TRAX button with red/yellow/green status dot, left-click toggles/picks, right-click opens the OpenFileDialog picker, tooltip shows the track name; persisted via schema v9 single-row Music). Build-time deviations from the plan (recorded in ai.md + TASKS.md): WasapiCapture in NAudio 2.2.1 exposes no overridable GetDefaultMixFormat so sources run device mix format and the mixer's TinyResampler normalizes any rate to 48kHz (resampler IS the design); FfmpegEncoder.cs untouched — MainViewModel.StopStream calls _audioMixer.StopLive() (pipe EOF) before the frame pump stops; sound-bar relabelled 'Desktop Audio', IsGameAudioBarVisible = game sound OR music playing. Tests: new AudioPipelineTests (DSP/ring-buffer/ducker/resampler units + the ONE integration test Mix_WithFiltersDuckAndGain_Lands_On_AudioPipe reading real pipe bytes; ring-buffer overwrite bug found + fixed), FfmpegEncoderTests/LayoutStorePersistenceTests updated — 196 tests passing, 0 warnings
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
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()
|
||||
{
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user