133 lines
4.3 KiB
C#
133 lines
4.3 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 : ITraxPlayer
|
||
{
|
||
/// <summary>The one and only music bed — no slider (the creator's voice must stay on
|
||
/// top; that's the ducker's job, −12 dB while they talk). The desktop audio bar scales
|
||
/// this via <see cref="LocalGain"/>.
|
||
/// <para>2026-10-09: was 0.20 (−14 dB) — "even at max volume I can barely discern it".
|
||
/// The creator's track masters at −11.3 LUFS (typical: −9..−14), so 0.20 put the bed at
|
||
/// ~−25 LUFS, and ducked under speech ~−37 LUFS: inaudible on stream too, because the
|
||
/// loopback carries exactly what plays locally. 0.5 (−6 dB) lands a typical master at
|
||
/// ~−15..−20 LUFS — beside the leveled voice (≈ −17 LUFS), and well under it when ducked.</para></summary>
|
||
public const float MusicVolume = 0.5f;
|
||
|
||
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);
|
||
}
|
||
}
|