using System.IO; using NAudio.Wave; namespace ytLive.Services.Audio; /// /// 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. /// The desktop/game bar's gain and mute scale , so the /// creator hears the game-audio controls work on the music too. /// public sealed class MusicPlayer : IDisposable { /// 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 . public const float MusicVolume = 0.20f; private MediaFoundationReader? _reader; private VolumeWaveProvider16? _volume; private WaveOutEvent? _output; private bool _disposed; private bool _stopping; private float _localGain = 1f; /// Raised when playback ends or is stopped (not on natural-end loop). 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; /// Local gain multiplier from the desktop/game audio bar (0 = mute, /// 1 = full bed). Applied live on top of the fixed . public float LocalGain { get => _localGain; set { _localGain = Math.Clamp(value, 0f, 1f); if (_volume != null) _volume.Volume = MusicVolume * _localGain; } } /// Loads a track and prepares playback (does not start it). 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 * _localGain }; _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; // 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(); return; } PlaybackEnded?.Invoke(); } }