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
+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();
}
}
}