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:
2026-08-14 18:09:26 -07:00
parent 3d92bd0225
commit 6d71acef8c
22 changed files with 1605 additions and 123 deletions
+212 -10
View File
@@ -4,28 +4,72 @@ namespace ytLive.Services.Audio;
/// <summary>
/// Owns the mic + desktop/game capture sources (TASK 4 ship step 4) and drives
/// the footer meters. Capture runs for the app's lifetime (started once at
/// startup, stopped on shutdown) so both bars stay live in preview: mic samples
/// are level-metered and forwarded, loopback samples feed the game bar's meter.
/// The mixer surfaces mic connection state (Connected/Failed) for the status
/// dot and can restart the mic source mid-session when a device is re-picked.
/// the footer meters; since TASK 9 it is also the live audio path into the
/// encoder. Capture runs for the app's lifetime so both bars stay live in
/// preview: mic samples are downmixed to mono, resampled to 48 kHz, put
/// through the voice chain (bass → treble → noise gate → compressor), THEN
/// level-metered and queued; loopback samples are resampled to 48 kHz stereo
/// and queued. While live (<see cref="StartLive"/>), a 10 ms loop drains both
/// queues, applies the honest gains (mic = MicVolume, loopback = GameAudioVolume
/// × auto-duck when the mic is hot), mixes them to stereo float and writes the
/// chunk to the encoder's audio pipe.
/// </summary>
public sealed class AudioMixer : IDisposable
{
public const int OutputSampleRate = 48000;
private static readonly TimeSpan DefaultMixInterval = TimeSpan.FromMilliseconds(10);
private readonly IAudioSource _mic;
private readonly IAudioSource _loopback;
private readonly AudioLevelMeter _meter;
private readonly AudioLevelMeter _loopbackMeter;
private readonly Action<string>? _log;
private readonly Func<double>? _micGain;
private readonly Func<double>? _loopbackGain;
private readonly TimeSpan _mixInterval;
private bool _started;
public AudioMixer(IAudioSource mic, IAudioSource loopback, Action<string>? log = null)
// Live path (TASK 9): per-source resampling → voice chain on the mic →
// thread-safe queues → the ducker + gain/mix loop → the encoder's pipe.
private readonly AudioRingBuffer _micBuffer;
private readonly AudioRingBuffer _loopbackBuffer;
private readonly VoiceFilterChain _voiceChain;
private readonly AutoDucker _ducker;
private TinyResampler? _micResampler;
private TinyResampler? _loopbackResampler;
private CancellationTokenSource? _liveCts;
private NamedPipeAudioWriter _pipe = new();
private float[]? _micChunk;
private float[]? _loopbackChunk;
private float[]? _mixBuffer;
public AudioMixer(
IAudioSource mic,
IAudioSource loopback,
Action<string>? log = null,
Func<double>? micGain = null,
Func<double>? loopbackGain = null,
TimeSpan? mixInterval = null)
{
_mic = mic;
_loopback = loopback;
_meter = new AudioLevelMeter();
_loopbackMeter = new AudioLevelMeter();
_log = log;
_micGain = micGain;
_loopbackGain = loopbackGain;
_mixInterval = mixInterval ?? DefaultMixInterval;
var seconds = _mixInterval.TotalSeconds;
var framesPerTick = Math.Max(1, (int)Math.Round(OutputSampleRate * seconds));
_micBuffer = new AudioRingBuffer(OutputSampleRate * 2);
_loopbackBuffer = new AudioRingBuffer(OutputSampleRate * 2 * 2);
_voiceChain = new VoiceFilterChain(OutputSampleRate);
_ducker = new AutoDucker();
_micChunk = new float[framesPerTick];
_loopbackChunk = new float[framesPerTick * 2];
_mixBuffer = new float[framesPerTick * 2];
_mic.Started += OnMicStarted;
_mic.SampleReady += OnMicSample;
@@ -34,7 +78,7 @@ public sealed class AudioMixer : IDisposable
_loopback.Failed += OnLoopbackFailed;
}
/// <summary>Current smoothed mic level (0..1).</summary>
/// <summary>Current smoothed mic level (0..1), post voice chain.</summary>
public float MicLevel => _meter.Level;
/// <summary>Current smoothed desktop/game level (0..1).</summary>
@@ -70,6 +114,9 @@ public sealed class AudioMixer : IDisposable
{
_mic.Stop();
_meter.Reset();
_micBuffer.Clear();
_micResampler?.Reset();
_voiceChain.Reset();
MicLevelChanged?.Invoke(0);
_mic.Start();
}
@@ -80,6 +127,7 @@ public sealed class AudioMixer : IDisposable
return;
_started = false;
StopLive();
_mic.Stop();
_loopback.Stop();
_meter.Reset();
@@ -88,6 +136,30 @@ public sealed class AudioMixer : IDisposable
LoopbackLevelChanged?.Invoke(0);
}
/// <summary>Begins the live mix loop: drains the capture queues, applies the
/// honest gains + auto-duck, and streams stereo float into the named audio
/// pipe. Idempotent — safe to call once per go-live.</summary>
public void StartLive(string pipeName)
{
if (_liveCts != null)
return;
var cts = new CancellationTokenSource();
_liveCts = cts;
_pipe = new NamedPipeAudioWriter();
_pipe.Start(pipeName);
_ = Task.Run(() => LiveLoopAsync(_pipe, cts.Token));
}
/// <summary>Ends the live mix loop and closes the audio pipe — ffmpeg sees
/// EOF on the audio input. Safe when not live.</summary>
public void StopLive()
{
_liveCts?.Cancel();
_liveCts = null;
_pipe.Stop();
}
public void Dispose()
{
Stop();
@@ -107,15 +179,26 @@ public sealed class AudioMixer : IDisposable
private void OnMicSample(AudioSample sample)
{
var mono = DownmixToMono(sample);
mono = ResampleMic(mono, sample.SampleRate);
for (var i = 0; i < mono.Length; i++)
mono[i] = _voiceChain.Process(mono[i]);
_micBuffer.Write(mono);
// Push unconditionally: the ?. on the event would otherwise skip the
// argument (and the meter update) when nothing is subscribed yet.
var level = _meter.Push(sample);
// argument (and the meter update) when nothing is subscribed yet. The
// level is the POST-filtered signal — the meter shows what the stream
// will carry.
var level = _meter.Push(new AudioSample(mono, OutputSampleRate, 1));
MicLevelChanged?.Invoke(level);
}
private void OnLoopbackSample(AudioSample sample)
{
var level = _loopbackMeter.Push(sample);
var data = ResampleLoopback(sample.Samples, sample.SampleRate);
_loopbackBuffer.Write(data);
var level = _loopbackMeter.Push(new AudioSample(data, OutputSampleRate, sample.Channels));
LoopbackLevelChanged?.Invoke(level);
}
@@ -130,4 +213,123 @@ public sealed class AudioMixer : IDisposable
{
_log?.Invoke($"Desktop audio capture failed: {ex.Message}");
}
private static float[] DownmixToMono(AudioSample sample)
{
if (sample.Channels <= 1)
return sample.Samples;
var frames = sample.Samples.Length / sample.Channels;
var mono = new float[frames];
for (var i = 0; i < frames; i++)
{
var sum = 0f;
for (var c = 0; c < sample.Channels; c++)
sum += sample.Samples[i * sample.Channels + c];
mono[i] = sum / sample.Channels;
}
return mono;
}
private float[] ResampleMic(float[] mono, int inputRate)
{
if (inputRate == OutputSampleRate)
return mono;
_micResampler ??= new TinyResampler(inputRate, OutputSampleRate);
if (_micResampler.NeedsResampling)
return _micResampler.Process(mono);
return mono;
}
private float[] ResampleLoopback(float[] interleaved, int inputRate)
{
if (inputRate == OutputSampleRate)
return interleaved;
_loopbackResampler ??= new TinyResampler(inputRate, OutputSampleRate);
if (_loopbackResampler.NeedsResampling)
return _loopbackResampler.Process(interleaved);
return interleaved;
}
private async Task LiveLoopAsync(IAudioPipeWriter pipe, CancellationToken cancellationToken)
{
var mixBuffer = _mixBuffer!;
var micChunk = _micChunk!;
var loopbackChunk = _loopbackChunk!;
while (!cancellationToken.IsCancellationRequested)
{
var nextTick = DateTime.UtcNow + _mixInterval;
try
{
FillAndMix(micChunk, loopbackChunk, mixBuffer);
await pipe.WriteAsync(mixBuffer, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
_log?.Invoke($"Audio live loop error: {ex.Message}");
}
var delay = nextTick - DateTime.UtcNow;
if (delay > TimeSpan.Zero)
{
try
{
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
}
}
}
/// <summary>Drains one tick's worth of mic + loopback, silence-fills any
/// underrun, applies the ducker and the honest gains, and mixes to stereo.</summary>
private float FillAndMix(float[] micChunk, float[] loopbackChunk, float[] mix)
{
var micCount = _micBuffer.Read(micChunk, micChunk.Length);
var loopCount = _loopbackBuffer.Read(loopbackChunk, loopbackChunk.Length);
float micRms = 0;
if (micCount == 0)
{
Array.Clear(micChunk, 0, micChunk.Length);
}
else
{
double sumSquares = 0;
for (var i = 0; i < micCount; i++)
sumSquares += micChunk[i] * micChunk[i];
micRms = (float)Math.Sqrt(sumSquares / micCount);
if (micCount < micChunk.Length)
Array.Clear(micChunk, micCount, micChunk.Length - micCount);
}
if (loopCount == 0)
{
Array.Clear(loopbackChunk, 0, loopbackChunk.Length);
}
else if (loopCount < loopbackChunk.Length)
{
Array.Clear(loopbackChunk, loopCount, loopbackChunk.Length - loopCount);
}
var duck = _ducker.Update(micRms);
var micGain = (float)(_micGain?.Invoke() ?? 1.0);
var loopGain = (float)(_loopbackGain?.Invoke() ?? 1.0) * duck;
var frames = micChunk.Length;
for (var i = 0; i < frames; i++)
{
var m = micChunk[i] * micGain;
mix[i * 2] = m + loopbackChunk[i * 2] * loopGain;
mix[i * 2 + 1] = m + loopbackChunk[i * 2 + 1] * loopGain;
}
return micRms;
}
}
+95
View File
@@ -0,0 +1,95 @@
namespace ytLive.Services.Audio;
/// <summary>
/// A thread-safe float ring buffer for the live audio path (TASK 9): NAudio
/// raises capture chunks on its own threads, the mixer's live loop drains on
/// its own. Writes overwrite the OLDEST samples when full so the buffer never
/// grows unbounded — the stream can't stall on capture jitter, it just skips
/// the stale tail.
/// </summary>
public sealed class AudioRingBuffer
{
private readonly object _sync = new();
private readonly float[] _buffer;
private int _head;
private int _count;
public AudioRingBuffer(int capacity)
{
if (capacity <= 0)
throw new ArgumentOutOfRangeException(nameof(capacity));
_buffer = new float[capacity];
}
public int Count
{
get
{
lock (_sync)
{
return _count;
}
}
}
/// <summary>Appends a chunk, dropping the oldest samples if the buffer is full.</summary>
public void Write(float[] samples)
{
if (samples.Length == 0)
return;
lock (_sync)
{
if (samples.Length >= _buffer.Length)
{
Array.Copy(samples, samples.Length - _buffer.Length, _buffer, 0, _buffer.Length);
_head = 0;
_count = _buffer.Length;
return;
}
if (_count + samples.Length > _buffer.Length)
{
// Evict the oldest samples to make room; the incoming write then
// overwrites them as it wraps. Head stays put — only writing
// advances it.
var drop = _count + samples.Length - _buffer.Length;
_count -= drop;
}
var writeAt = _head;
var first = Math.Min(samples.Length, _buffer.Length - writeAt);
Array.Copy(samples, 0, _buffer, writeAt, first);
if (first < samples.Length)
Array.Copy(samples, first, _buffer, 0, samples.Length - first);
_head = (writeAt + samples.Length) % _buffer.Length;
_count += samples.Length;
}
}
/// <summary>Reads up to <paramref name="count"/> samples, removing them;
/// returns the number actually read (fewer on underrun).</summary>
public int Read(float[] destination, int count)
{
lock (_sync)
{
var n = Math.Min(count, _count);
var tail = (_head - _count + _buffer.Length) % _buffer.Length;
var first = Math.Min(n, _buffer.Length - tail);
Array.Copy(_buffer, tail, destination, 0, first);
if (first < n)
Array.Copy(_buffer, 0, destination, first, n - first);
_count -= n;
return n;
}
}
public void Clear()
{
lock (_sync)
{
_head = 0;
_count = 0;
}
}
}
+36
View File
@@ -0,0 +1,36 @@
namespace ytLive.Services.Audio;
/// <summary>
/// Auto-duck (TASK 9): while the mic is hot the loopback (game + music) rides
/// its volume down ~12 dB so the voice stays on top of the mix, then recovers
/// when the creator stops talking. Smooth attack/release, always-on, no knobs.
/// Pure — unit-tested.
/// </summary>
public sealed class AutoDucker
{
public const float Threshold = 0.02f;
/// <summary>-12 dB: the loopback's volume while the mic is active.</summary>
public const float DuckGain = 0.25f;
private const float Attack = 0.05f;
private const float Release = 0.005f;
private float _gain = 1f;
/// <summary>The current loopback gain to apply (1 = no duck).</summary>
public float CurrentGain => _gain;
/// <summary>Feeds one mic level sample (0..1, post-filter RMS) and returns
/// the gain to apply to the loopback for that tick.</summary>
public float Update(float micLevel)
{
var target = micLevel > Threshold ? DuckGain : 1f;
_gain += (target - _gain) * (target < _gain ? Attack : Release);
if (MathF.Abs(_gain - target) < 0.001f)
_gain = target;
return _gain;
}
public void Reset() => _gain = 1f;
}
+109
View File
@@ -0,0 +1,109 @@
using System.IO;
using System.Runtime.InteropServices;
using NAudio.Wave;
namespace ytLive.Services.Audio;
/// <summary>
/// Streams background music (TASK 9 TRAX) to the DEFAULT render device at a
/// fixed quiet volume so it rides the WASAPI loopback into the mix — the same
/// physical path game audio takes, so music is ducked with the game, heard in
/// headphones, and bounced on the desktop audio meter. Loops on natural end.
/// </summary>
public sealed class MusicPlayer : IDisposable
{
/// <summary>The one and only music level — quiet bed, no slider (the
/// creator's voice must stay on top; game/music loudness is the ducker's job).</summary>
public const float MusicVolume = 0.20f;
private MediaFoundationReader? _reader;
private VolumeWaveProvider16? _volume;
private WaveOutEvent? _output;
private bool _disposed;
private bool _stopping;
/// <summary>Raised when playback ends or is stopped (not on natural-end loop).</summary>
public event Action? PlaybackEnded;
public string? TrackPath { get; private set; }
public string? TrackName => TrackPath == null ? null : Path.GetFileNameWithoutExtension(TrackPath);
public bool IsPlaying => _output?.PlaybackState == PlaybackState.Playing;
/// <summary>Loads a track and prepares playback (does not start it).</summary>
public void Load(string path)
{
if (string.IsNullOrWhiteSpace(path))
throw new ArgumentException("A music file path is required.", nameof(path));
Stop();
TrackPath = path;
_reader = new MediaFoundationReader(path);
_volume = new VolumeWaveProvider16(_reader) { Volume = MusicVolume };
_output = new WaveOutEvent();
_output.PlaybackStopped += OnPlaybackStopped;
_output.Init(_volume);
}
public void Play()
{
if (_output == null)
return;
_output.Play();
}
public void Pause()
{
if (_output == null)
return;
_output.Pause();
}
public void Stop()
{
if (_output == null)
return;
_stopping = true;
try
{
_output.Stop();
}
catch
{
}
_output.PlaybackStopped -= OnPlaybackStopped;
_output.Dispose();
_output = null;
_volume = null;
_reader?.Dispose();
_reader = null;
_stopping = false;
TrackPath = null;
}
public void Dispose()
{
_disposed = true;
Stop();
_disposed = false;
}
private void OnPlaybackStopped(object? sender, StoppedEventArgs e)
{
if (_disposed || _stopping || _output == null || _reader == null)
return;
// Natural end → loop the track; anything else (an explicit stop or a
// device failure) surfaces via PlaybackEnded so the UI can reset the dot.
if (_reader.Position >= _reader.Length)
{
_reader.Position = 0;
_output.Play();
return;
}
PlaybackEnded?.Invoke();
}
}
+105
View File
@@ -0,0 +1,105 @@
using System.IO.Pipes;
using System.Runtime.InteropServices;
namespace ytLive.Services.Audio;
/// <summary>
/// The mixer's sink for the live loop: interleaved stereo PCM float at 48 kHz
/// written to a named pipe, byte-identical to what ffmpeg expects from
/// <c>-f f32le -ar 48000 -ac 2 -i \\.\pipe\&lt;name&gt;</c>. The encoder side is
/// handled by FfmpegArgs/EncoderOptions; this side just owns the server end.
/// </summary>
public interface IAudioPipeWriter : IDisposable
{
/// <summary>Creates the named pipe server and waits (async) for the client.</summary>
void Start(string pipeName);
/// <summary>True once ffmpeg has connected and the pipe is writable.</summary>
bool IsConnected { get; }
/// <summary>Writes interleaved float samples; dropped until the client connects.</summary>
Task WriteAsync(ReadOnlyMemory<float> samples, CancellationToken cancellationToken = default);
/// <summary>Closes the server end — ffmpeg sees EOF and ends the audio input.</summary>
void Stop();
}
public sealed class NamedPipeAudioWriter : IAudioPipeWriter
{
private readonly object _sync = new();
private NamedPipeServerStream? _pipe;
public void Start(string pipeName)
{
lock (_sync)
{
if (_pipe != null)
throw new InvalidOperationException("Already started.");
_pipe = new NamedPipeServerStream(
pipeName,
PipeDirection.Out,
1,
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous);
}
_ = Task.Run(WaitForConnectionAsync);
}
public bool IsConnected
{
get
{
lock (_sync)
{
return _pipe is { IsConnected: true };
}
}
}
public async Task WriteAsync(ReadOnlyMemory<float> samples, CancellationToken cancellationToken = default)
{
NamedPipeServerStream? pipe;
lock (_sync)
{
pipe = _pipe;
}
if (pipe == null || !pipe.IsConnected || samples.IsEmpty)
return;
var bytes = MemoryMarshal.AsBytes(samples.Span).ToArray();
await pipe.WriteAsync(bytes, cancellationToken).ConfigureAwait(false);
}
public void Stop()
{
NamedPipeServerStream? pipe;
lock (_sync)
{
pipe = _pipe;
_pipe = null;
}
pipe?.Dispose();
}
public void Dispose() => Stop();
private async Task WaitForConnectionAsync()
{
NamedPipeServerStream? pipe;
lock (_sync)
{
pipe = _pipe;
}
if (pipe == null)
return;
try
{
await pipe.WaitForConnectionAsync().ConfigureAwait(false);
}
catch
{
Stop();
}
}
}
+54
View File
@@ -0,0 +1,54 @@
namespace ytLive.Services.Audio;
/// <summary>
/// A tiny stateful linear-interpolation sample-rate converter (TASK 9). NAudio's
/// WASAPI capture rate follows the device mix format; the stream needs a fixed
/// 48 kHz, so each source normalizes through one of these. Purely a fallback —
/// the mic requests 48 kHz float up front, and most loopback mix formats are
/// already 48 kHz, in which case <see cref="NeedsResampling"/> is false and the
/// chunk passes through untouched. Pure — unit-tested.
/// </summary>
public sealed class TinyResampler
{
private readonly double _ratio;
private double _pos;
/// <param name="inputRate">The source sample rate.</param>
/// <param name="outputRate">The target sample rate (48 kHz for the stream).</param>
public TinyResampler(int inputRate, int outputRate)
{
if (inputRate <= 0 || outputRate <= 0)
throw new ArgumentOutOfRangeException();
_ratio = (double)inputRate / outputRate;
}
public bool NeedsResampling => Math.Abs(_ratio - 1.0) > 1e-9;
/// <summary>Converts one interleaved chunk, keeping the phase across calls.</summary>
public float[] Process(float[] input)
{
if (!NeedsResampling)
return input;
var outLen = (int)((_pos + input.Length) / _ratio);
var output = new float[outLen];
for (var i = 0; i < outLen; i++)
{
var inPos = _pos + i * _ratio;
var idx = (int)inPos;
if (idx >= input.Length)
{
output[i] = input[input.Length - 1];
continue;
}
var frac = (float)(inPos - idx);
var a = input[idx];
var b = idx + 1 < input.Length ? input[idx + 1] : a;
output[i] = a + (b - a) * frac;
}
_pos += outLen * _ratio - input.Length;
return output;
}
public void Reset() => _pos = 0;
}
+204
View File
@@ -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()
{
}
}
+4 -2
View File
@@ -6,7 +6,9 @@ namespace ytLive.Services.Audio;
/// <summary>
/// Captures the selected microphone (TASK 4 ship step 4) via WASAPI. Resolves
/// the NAudio device by FriendlyName matching <c>MicSourceName</c> (the app only
/// persists DisplayName), falling back to the default capture endpoint.
/// persists DisplayName), falling back to the default capture endpoint. The
/// device's own mix format is used — the mixer normalizes any rate to 48 kHz
/// (TASK 9) so no format forcing is needed here.
/// </summary>
public sealed class WasapiMicAudioSource : IAudioSource
{
@@ -91,7 +93,7 @@ public sealed class WasapiMicAudioSource : IAudioSource
if (e.BytesRecorded <= 0)
return;
var format = _capture?.WaveFormat ?? WaveFormat.CreateIeeeFloatWaveFormat(48000, 1);
var format = _capture?.WaveFormat ?? WaveFormat.CreateIeeeFloatWaveFormat(48000, 2);
var samples = WaveToFloat.Convert(e.Buffer, e.BytesRecorded, format);
if (samples.Length > 0)
SampleReady?.Invoke(new AudioSample(samples, format.SampleRate, format.Channels));
+9 -2
View File
@@ -4,11 +4,15 @@ namespace ytLive.Services.Encoder;
/// Everything the FFmpeg subprocess encoder needs for one go-live (TASK 4 ship
/// step 3). <see cref="RtmpUrl"/> is the FULL ingestion URL — the reusable
/// stream's ingest address plus its stream key (<c>rtmp://a.rtmp.youtube.com/live2/&lt;key&gt;</c>).
/// Resolution/FPS/bitrate come from the active quality tier; audio is a silent
/// placeholder track until the WASAPI capture step replaces the input.
/// Resolution/FPS/bitrate come from the active quality tier; audio (TASK 9)
/// streams from the mixer through the named pipe <see cref="AudioPipeName"/>.
/// </summary>
public sealed class EncoderOptions
{
/// <summary>The audio pipe the mixer streams into; the args reference the
/// same name for ffmpeg's <c>-i</c>.</summary>
public const string DefaultAudioPipeName = "ytllive_audio";
public string RtmpUrl { get; init; } = string.Empty;
public int Width { get; init; } = 1920;
public int Height { get; init; } = 1080;
@@ -22,6 +26,9 @@ public sealed class EncoderOptions
public int AudioSampleRate { get; init; } = 48000;
public int AudioChannels { get; init; } = 2;
/// <summary>Name of the mixer's audio pipe (default <see cref="DefaultAudioPipeName"/>).</summary>
public string AudioPipeName { get; init; } = DefaultAudioPipeName;
/// <summary>GOP in frames = Fps × 4s — the YouTube keyframe ≤ 4s compliance bound.</summary>
public int GopSize => Fps * 4;
}
+11 -6
View File
@@ -2,10 +2,11 @@ namespace ytLive.Services.Encoder;
/// <summary>
/// Builds the FFmpeg command line for a live RTMP push (TASK 4 ship step 3):
/// raw BGRA frames via stdin (paced <c>-re</c>), silent placeholder audio via lavfi
/// <c>anullsrc</c> (the WASAPI step replaces this input), H.264 + AAC encoding, FLV
/// muxing to the ingestion URL. Pure — the encoder just starts
/// <c>ffmpeg.exe [Build(...)]</c>.
/// raw BGRA frames via stdin (paced <c>-re</c>), real audio via the named pipe
/// (TASK 9 — the mixer streams f32le at 48 kHz stereo into <c>\\.\pipe\&lt;name&gt;</c>;
/// WASAPI loopback + the mic ride the pipe instead of the old anullsrc silence),
/// H.264 + AAC encoding, FLV muxing to the ingestion URL. Pure — the encoder
/// just starts <c>ffmpeg.exe [Build(...)]</c>.
/// </summary>
public static class FfmpegArgs
{
@@ -24,8 +25,12 @@ public static class FfmpegArgs
"-video_size", $"{options.Width}x{options.Height}",
"-framerate", options.Fps.ToString(),
"-i", "pipe:0",
"-f", "lavfi",
"-i", $"anullsrc=channel_layout=stereo:sample_rate={options.AudioSampleRate}",
"-f", "f32le",
"-ar", options.AudioSampleRate.ToString(),
"-ac", options.AudioChannels.ToString(),
"-i", $@"\\.\pipe\{options.AudioPipeName}",
"-map", "0:v",
"-map", "1:a",
"-c:v", videoEncoder,
"-b:v", $"{options.BitrateKbps}k",
"-maxrate", $"{options.BitrateKbps}k",
+45 -2
View File
@@ -18,6 +18,10 @@ public class LayoutStore : IDisposable
/// <summary>The app-wide social bar config loaded with the last Load() (null = never defined).</summary>
public SocialsConfig? Socials { get; private set; }
/// <summary>The app-wide background music (TASK 9 TRAX) loaded with the last
/// Load() (null = no track chosen).</summary>
public Music? Music { get; private set; }
public LayoutStore(string path)
{
ActivePath = path;
@@ -124,6 +128,13 @@ public class LayoutStore : IDisposable
SortOrder INTEGER NOT NULL DEFAULT 0
);
""",
"""
CREATE TABLE IF NOT EXISTS Music (
Id TEXT PRIMARY KEY,
TrackPath TEXT NOT NULL,
IsEnabled INTEGER NOT NULL DEFAULT 0
);
""",
};
foreach (var sql in statements)
{
@@ -141,7 +152,7 @@ public class LayoutStore : IDisposable
MigrateToV3();
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "PRAGMA user_version = 8;";
cmd.CommandText = "PRAGMA user_version = 9;";
cmd.ExecuteNonQuery();
}
}
@@ -406,6 +417,7 @@ public class LayoutStore : IDisposable
{
Webcam = null;
Socials = null;
Music = null;
var scenes = new List<Scene>();
var sourcesByScene = new Dictionary<string, List<Source>>();
var configsByScene = new Dictionary<string, List<WebcamSceneConfig>>();
@@ -473,6 +485,20 @@ public class LayoutStore : IDisposable
}
}
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "SELECT TrackPath, IsEnabled FROM Music LIMIT 1;";
using var reader = cmd.ExecuteReader();
if (reader.Read())
{
Music = new Music
{
TrackPath = reader.GetString(0),
IsEnabled = reader.GetInt32(1) != 0,
};
}
}
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = """
@@ -559,7 +585,7 @@ public class LayoutStore : IDisposable
return scenes;
}
public void Save(IEnumerable<Scene> scenes, Webcam? webcam, SocialsConfig? socials)
public void Save(IEnumerable<Scene> scenes, Webcam? webcam, SocialsConfig? socials, Music? music = null)
{
using var tx = _connection.BeginTransaction();
using (var cmd = _connection.CreateCommand())
@@ -593,6 +619,12 @@ public class LayoutStore : IDisposable
cmd.ExecuteNonQuery();
}
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "DELETE FROM Music;";
cmd.Transaction = tx;
cmd.ExecuteNonQuery();
}
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "DELETE FROM Scene;";
cmd.Transaction = tx;
@@ -796,6 +828,17 @@ public class LayoutStore : IDisposable
}
}
if (music != null && !string.IsNullOrWhiteSpace(music.TrackPath))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "INSERT INTO Music (Id, TrackPath, IsEnabled) VALUES ($id, $path, $enabled);";
cmd.Transaction = tx;
cmd.Parameters.AddWithValue("$id", Guid.NewGuid().ToString());
cmd.Parameters.AddWithValue("$path", music.TrackPath);
cmd.Parameters.AddWithValue("$enabled", music.IsEnabled ? 1 : 0);
cmd.ExecuteNonQuery();
}
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "DELETE FROM Asset WHERE Id NOT IN (SELECT AssetId FROM Source WHERE AssetId IS NOT NULL);";