TASK 24 post-pause polish batch (creator's 8 review issues, 2026-08-15): (1) desktop/game audio volume slider + (2) mute were no-ops — the AudioMixer's gain Func seams defaulted to unity because the VM never passed them; new AudioGainProvider (Services/Audio/) wires MicMuted/MicVolume/GameMuted/GameAudioVolume into the mixer at construction, read live each mix tick, so sliders and mutes are stream-honest; the game bar also scales TRAX locally via new MusicPlayer.LocalGain (UpdateTraxLocalGain on every game volume/mute change + on load) so the creator HEARS the controls work on the music in the headphones. (3) TRAX played once then stopped — OnPlaybackStopped only looped on Position>=Length (unreliable for MediaFoundationReader); now any clean stop rewinds + replays, errors/explicit stops surface via PlaybackEnded; unused using dropped. (4+6) TRAX moved right of Socials in the footer's left cluster (centered under scenes/sources); the mic cluster is now MIC + meter + mute + volume. (5) mic source persists — LayoutStore gains a Settings key/value table (SaveMicSourceName/LoadMicSourceName); the VM saves on pick and restores before the mixer's first Start, so a restart reconnects the same vetted device (green) or reports it missing (yellow). (7) Backdrop's missing trashcan shifted the edit/eye icons right — new HiddenBoolToVisibilityConverter keeps the trash column reserved (Hidden, not Collapsed) so every source row's icons stay in fixed columns. (8) a 1px hairline with top/bottom padding separates scenes from sources in the left panel. Docs in the same commit: ai.md (gains honest + drive TRAX locally, TRAX loops, mic persistence, footer layout), TASKS.md TASK 24, HANDOFF.md shipped state, ViewModels/index.md. ONE integration test: AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback (real mixer + provider gains through the pipe harness: scaled loopback audible, silence after mute). Build 0 warnings, 197 tests passing
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
namespace ytLive.Services.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// Supplies the live-mix gain values (mic + loopback) as Func seams for
|
||||
/// <see cref="AudioMixer"/>, sourced from the VM's volume/mute state. Read live
|
||||
/// on every mix tick so slider drags and mutes take effect immediately on the
|
||||
/// stream. Mute and zero volume can never disagree: 0 always reads as muted.
|
||||
/// </summary>
|
||||
public sealed class AudioGainProvider
|
||||
{
|
||||
private readonly Func<bool> _micMuted;
|
||||
private readonly Func<double> _micVolume;
|
||||
private readonly Func<bool> _gameMuted;
|
||||
private readonly Func<double> _gameVolume;
|
||||
|
||||
public AudioGainProvider(
|
||||
Func<bool> micMuted,
|
||||
Func<double> micVolume,
|
||||
Func<bool> gameMuted,
|
||||
Func<double> gameVolume)
|
||||
{
|
||||
_micMuted = micMuted;
|
||||
_micVolume = micVolume;
|
||||
_gameMuted = gameMuted;
|
||||
_gameVolume = gameVolume;
|
||||
}
|
||||
|
||||
/// <summary>Mic gain for the live mix (0 = muted).</summary>
|
||||
public double MicGain() => _micMuted() ? 0 : _micVolume();
|
||||
|
||||
/// <summary>Loopback (desktop/game) gain for the live mix (0 = muted).</summary>
|
||||
public double LoopbackGain() => _gameMuted() ? 0 : _gameVolume();
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using NAudio.Wave;
|
||||
|
||||
namespace ytLive.Services.Audio;
|
||||
@@ -9,11 +8,14 @@ namespace ytLive.Services.Audio;
|
||||
/// 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.
|
||||
/// The desktop/game bar's gain and mute scale <see cref="LocalGain"/>, so the
|
||||
/// creator hears the game-audio controls work on the music too.
|
||||
/// </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>
|
||||
/// <summary>The one and only music bed — quiet, no slider (the creator's
|
||||
/// voice must stay on top; game/music loudness is the ducker's job). The
|
||||
/// desktop audio bar scales this via <see cref="LocalGain"/>.</summary>
|
||||
public const float MusicVolume = 0.20f;
|
||||
|
||||
private MediaFoundationReader? _reader;
|
||||
@@ -21,6 +23,7 @@ public sealed class MusicPlayer : IDisposable
|
||||
private WaveOutEvent? _output;
|
||||
private bool _disposed;
|
||||
private bool _stopping;
|
||||
private float _localGain = 1f;
|
||||
|
||||
/// <summary>Raised when playback ends or is stopped (not on natural-end loop).</summary>
|
||||
public event Action? PlaybackEnded;
|
||||
@@ -31,6 +34,19 @@ public sealed class MusicPlayer : IDisposable
|
||||
|
||||
public bool IsPlaying => _output?.PlaybackState == PlaybackState.Playing;
|
||||
|
||||
/// <summary>Local gain multiplier from the desktop/game audio bar (0 = mute,
|
||||
/// 1 = full bed). Applied live on top of the fixed <see cref="MusicVolume"/>.</summary>
|
||||
public float LocalGain
|
||||
{
|
||||
get => _localGain;
|
||||
set
|
||||
{
|
||||
_localGain = Math.Clamp(value, 0f, 1f);
|
||||
if (_volume != null)
|
||||
_volume.Volume = MusicVolume * _localGain;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Loads a track and prepares playback (does not start it).</summary>
|
||||
public void Load(string path)
|
||||
{
|
||||
@@ -40,7 +56,7 @@ public sealed class MusicPlayer : IDisposable
|
||||
Stop();
|
||||
TrackPath = path;
|
||||
_reader = new MediaFoundationReader(path);
|
||||
_volume = new VolumeWaveProvider16(_reader) { Volume = MusicVolume };
|
||||
_volume = new VolumeWaveProvider16(_reader) { Volume = MusicVolume * _localGain };
|
||||
_output = new WaveOutEvent();
|
||||
_output.PlaybackStopped += OnPlaybackStopped;
|
||||
_output.Init(_volume);
|
||||
@@ -95,9 +111,11 @@ public sealed class MusicPlayer : IDisposable
|
||||
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)
|
||||
// Any clean stop is a natural end → loop the track (the Position/Length
|
||||
// comparison is unreliable for MediaFoundationReader, so a track can
|
||||
// otherwise play once and stop). Only an explicit stop or a device
|
||||
// failure surfaces via PlaybackEnded so the UI can reset the dot.
|
||||
if (e.Exception == null)
|
||||
{
|
||||
_reader.Position = 0;
|
||||
_output.Play();
|
||||
|
||||
Reference in New Issue
Block a user