30a193b2c9
1. Error text shows in red inside the desktop audio meter bar (scrollable) when TRAX load or playback fails — user gets feedback, not just a silent red dot. 2. LoadTrax no longer nulls _music on error — the track path is retained so the user sees what failed and can retry. 3. OnTraxPlaybackEnded now receives the NAudio exception and surfaces it. 4. TRAX dot is green (playing) or gray (idle/no track) — no more yellow or red states. 207 tests, 0 warnings.
128 lines
3.8 KiB
C#
128 lines
3.8 KiB
C#
using System.IO;
|
|
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.
|
|
/// 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 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;
|
|
private VolumeWaveProvider16? _volume;
|
|
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<Exception?>? PlaybackEnded;
|
|
|
|
public string? TrackPath { get; private set; }
|
|
|
|
public string? TrackName => TrackPath == null ? null : Path.GetFileNameWithoutExtension(TrackPath);
|
|
|
|
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)
|
|
{
|
|
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(e.Exception);
|
|
}
|
|
}
|