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
+119 -10
View File
@@ -50,7 +50,14 @@ public class MainViewModel : ViewModelBase
private double _gameVolume = 1.0;
private bool _gameMuted;
private double? _gameVolumeBeforeMute;
private bool _isGameAudioBarVisible;
private bool _gameAudioBarActive;
// TRAX (TASK 9): free background music at a fixed quiet volume, playing to
// the default device so it rides the loopback into the stream. The dot is
// red (no track) / yellow (loaded, stopped) / green (playing).
private readonly MusicPlayer _musicPlayer = new();
private Music? _music;
private bool _musicPlaying;
private StreamStatus _streamStatus = StreamStatus.Offline;
private StreamHealth _currentHealth = new();
private string _streamTitle = string.Empty;
@@ -486,12 +493,102 @@ public class MainViewModel : ViewModelBase
// ─── is producing sound (IGameAudioDetector). Meter + mute + volume mirror
// ─── the mic bar. ───
/// <summary>True while the game audio bar should be shown (driven by
/// IGameAudioDetector via the poll timer).</summary>
public bool IsGameAudioBarVisible
/// <summary>True while the game audio bar should be shown: a full-screen game
/// producing sound (IGameAudioDetector) OR background music playing (TASK 9)
/// — the meter bounces during scene setup even with no game up.</summary>
public bool IsGameAudioBarVisible => _gameAudioBarActive || IsMusicPlaying;
/// <summary>True while the TRAX background track is playing.</summary>
public bool IsMusicPlaying
{
get => _isGameAudioBarVisible;
private set => SetProperty(ref _isGameAudioBarVisible, value);
get => _musicPlaying;
private set
{
if (SetProperty(ref _musicPlaying, value))
{
OnPropertyChanged(nameof(TraxDotBrush));
OnPropertyChanged(nameof(TraxToolTip));
OnPropertyChanged(nameof(IsGameAudioBarVisible));
}
}
}
/// <summary>TRAX status dot: green = playing, yellow = loaded/stopped, red = no track.</summary>
public SolidColorBrush TraxDotBrush => _musicPlayer.IsPlaying
? BarOnBrush
: _music != null && !string.IsNullOrWhiteSpace(_music.TrackPath)
? MicProblemBrush
: BarOffBrush;
public string TraxToolTip => _musicPlayer.TrackName != null
? $"TRAX: {_musicPlayer.TrackName} — click to {(IsMusicPlaying ? "pause" : "play")}"
: "No track — right-click to choose background music";
/// <summary>Left-click on TRAX: no track → pick one; otherwise toggle play/pause.</summary>
public void TraxLeftClick()
{
if (_music == null || string.IsNullOrWhiteSpace(_music.TrackPath))
PickTraxTrack();
else if (IsMusicPlaying)
PauseTrax();
else
PlayTrax();
}
/// <summary>Right-click on TRAX: always open the track picker (never the OS viewer).</summary>
public void TraxRightClick() => PickTraxTrack();
private void PickTraxTrack()
{
var dialog = new OpenFileDialog
{
Title = "Choose background music",
Filter = "Music (*.mp3;*.wav;*.m4a;*.aac;*.flac;*.ogg)|*.mp3;*.wav;*.m4a;*.aac;*.flac;*.ogg|All files (*.*)|*.*",
};
if (dialog.ShowDialog() != true)
return;
_music = new Music { TrackPath = dialog.FileName, IsEnabled = true };
LoadTrax(play: true);
}
private void LoadTrax(bool play)
{
if (_music == null || string.IsNullOrWhiteSpace(_music.TrackPath))
return;
try
{
_musicPlayer.Load(_music.TrackPath);
_music.IsEnabled = true;
if (play)
_musicPlayer.Play();
}
catch (Exception ex)
{
AppLog.Write($"TRAX: failed to load '{_music.TrackPath}': {ex.Message}");
_music = null;
}
RefreshTraxUi();
ScheduleSave();
}
private void PlayTrax() => _musicPlayer.Play();
private void PauseTrax() => _musicPlayer.Pause();
private void OnTraxPlaybackEnded()
{
// Natural end loops inside the player; here only an explicit stop or a
// device failure lands — flip the dot back to loaded/stopped.
RefreshTraxUi();
}
private void RefreshTraxUi()
{
IsMusicPlaying = _musicPlayer.IsPlaying;
OnPropertyChanged(nameof(TraxDotBrush));
OnPropertyChanged(nameof(TraxToolTip));
}
/// <summary>Live desktop/game input level (0..1), fed by the mixer's loopback
@@ -1069,6 +1166,7 @@ public class MainViewModel : ViewModelBase
_audioMixer.LoopbackLevelChanged += OnLoopbackLevelChanged;
_audioMixer.MicConnected += OnMicConnected;
_audioMixer.MicFailed += OnMicFailed;
_musicPlayer.PlaybackEnded += OnTraxPlaybackEnded;
_ = StartMicCaptureAsync();
_fullScreenDetector = new Win32FullScreenDetector();
@@ -1199,6 +1297,8 @@ public class MainViewModel : ViewModelBase
OnPropertyChanged(nameof(HasSocials));
OnPropertyChanged(nameof(SocialBarVisible));
OnPropertyChanged(nameof(SocialBarDotBrush));
_music = _layoutStore.Music;
LoadTrax(play: false);
ScheduleSave();
AppLog.Write("LoadLayout end");
}
@@ -1423,6 +1523,7 @@ public class MainViewModel : ViewModelBase
_cameraManager.Dispose();
_screenCaptureManager.Dispose();
_layoutStore.Dispose();
_musicPlayer.Dispose();
}
public void SaveLayoutNow()
@@ -1430,7 +1531,7 @@ public class MainViewModel : ViewModelBase
_saveDebounce?.Stop();
try
{
_layoutStore.Save(Scenes, _webcam, _socials);
_layoutStore.Save(Scenes, _webcam, _socials, _music);
}
catch (Exception ex)
{
@@ -2016,6 +2117,7 @@ public class MainViewModel : ViewModelBase
: $"{dialog.StreamTitle} — ytLlive";
StreamStatus = StreamStatus.Streaming;
ResetHealth(StreamStatus.Streaming);
_audioMixer.StartLive(EncoderOptions.DefaultAudioPipeName);
_ = CreateBroadcastAsync();
_ = _framePump.StartAsync(); // never throws; failures log + surface via Failed
}
@@ -2052,8 +2154,11 @@ public class MainViewModel : ViewModelBase
StreamStatus = StreamStatus.Offline;
WindowTitle = "ytLlive";
ResetHealth(StreamStatus.Offline);
// Audio capture is always-on (preview monitoring); only the frame pump
// and the session stop here.
// Close the audio pipe FIRST so ffmpeg sees EOF on the audio input,
// then stop the pump (stdin EOF) — both inputs end and the encoder
// finalizes the FLV. Audio capture itself is always-on (preview
// monitoring); only the live pipe, the pump, and the session stop here.
_audioMixer.StopLive();
_ = _framePump.StopAsync();
_currentBroadcastId = null;
// Graceful end completes the session = signs out (the DPAPI token is
@@ -2127,7 +2232,11 @@ public class MainViewModel : ViewModelBase
}
}
private void OnGameAudioActiveChanged(bool active) => IsGameAudioBarVisible = active;
private void OnGameAudioActiveChanged(bool active)
{
_gameAudioBarActive = active;
OnPropertyChanged(nameof(IsGameAudioBarVisible));
}
private void OnGameAudioPollTick(object? sender, EventArgs e)
{