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:
+119
-10
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user