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,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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user