Files
LlamaCasty/Services/Audio/MusicPlayer.cs
T
gramps a54149bd7c feat(dock): TRAX gets its own ladder — measured at the player's output
Creator: "why is there no meter for when trax is playing?"

Routing answer: TRAX rides the desktop loopback, so it already moved the GAME ladder — there was no
separate TRAX signal in the mix. But that ladder cannot answer the question a deaf creator actually
has: "is the music producing sound at all?" The play glyph cannot either — an open file with a dead
decoder still reports Playing.

So the player now measures its own output: \`MusicPlayer\` wraps the volume stage in an NAudio
\`MeteringSampleProvider\` and exposes \`Level\`; \`ITraxPlayer.Level\` keeps the fake honest. The VM maps
it through the same display scale (\`TraxMeterFraction\`), zeroed when not playing, and the TRAX bay
carries a 24-segment ladder above its fader.

This reverses my earlier "no ladder, nothing honest to show" — the honest thing arrived with a real
measurement. TraxBayTests flipped accordingly (it now asserts the ladder AND its source).
2026-10-30 13:22:13 -07:00

156 lines
5.1 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.IO;
using System.Linq;
using NAudio.Wave;
using NAudio.Wave.SampleProviders;
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>Measured peak of what the player is actually sending to the device.</summary>
public float Level { get; private set; }
private MeteringSampleProvider? _metering;
/// <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>
private float _userGain = 1f;
/// <summary>Creator-set level, multiplied onto the fixed design volume.</summary>
public float UserGain
{
get => _userGain;
set
{
_userGain = Math.Clamp(value, 0f, 2f);
if (_output != null) _output.Volume = MusicVolume * _userGain * LocalGain;
}
}
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 * _userGain * _localGain };
_metering = new MeteringSampleProvider(_volume.ToSampleProvider());
_metering.StreamVolume += (_, e) =>
Level = e.MaxSampleValues.Length == 0 ? 0f : e.MaxSampleValues.Max();
_output = new WaveOutEvent();
_output.PlaybackStopped += OnPlaybackStopped;
_output.Init(_metering);
}
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);
}
}