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? 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();
|
|
}
|
|
}
|