Files
LlamaCasty/Services/Audio/MusicPlayer.cs
T
gramps 8d23502220 fix(trax): the fader now actually changes the level — and the meter follows it
Reported: pegging the TRAX fader changes nothing audible, the ladder never reaches red, and the
ladder does not scale with the fader.

Cause (two defects in the same setters):
- UserGain wrote the OUTPUT DEVICE's volume. NAudio's WaveOut ignores that, so the fader was inert.
- LocalGain (the desktop-mute follow) recomputed the sample-chain volume WITHOUT the user gain, so
  every duck/mute silently discarded whatever the creator had set.

Fix: one `EffectiveVolume(user, local)` = MusicVolume × user × local, applied to the sample chain by
both setters. The meter sits AFTER that stage, so the ladder now scales with the fader and can reach
the peak band on a loud track — exactly what pegging should look like. Max user gain is +6 dB, so
"pegged" means the file at unity, not a magic boost.

Guard: MusicLevelTests pins the arithmetic (default half, pegged double, mute multiplies to zero),
that the user gain survives the mute follow, and that nothing may drive the output device's volume
again — the no-op that made this invisible.
2026-10-30 13:32:24 -07:00

175 lines
6.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;
/// <summary>+6 dB of headroom on the bed, matching the dock's fader range.</summary>
public const float MaxUserGain = 2f;
/// <summary>The effective multiplier for a (user, local) pair, computed in ONE place.
///
/// <para>The fader used to set the OUTPUT DEVICE's volume, which NAudio's WaveOut ignores, while
/// the mute-follow rewrote the sample-chain volume without the user gain — so pegging the fader
/// changed nothing and then lost the setting at the next duck.</para></summary>
public static float EffectiveVolume(float userGain, float localGain)
=> MusicVolume * Math.Clamp(userGain, 0f, MaxUserGain) * Math.Clamp(localGain, 0f, 1f);
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, MaxUserGain);
ApplyVolume();
}
}
public float LocalGain
{
get => _localGain;
set
{
_localGain = Math.Clamp(value, 0f, 1f);
ApplyVolume();
}
}
/// <summary>Pushes the current gains onto the sample chain. The meter sits AFTER this stage, so
/// the ladder follows the fader — that is the point of both controls being on the same bay.</summary>
private void ApplyVolume()
{
if (_volume != null)
_volume.Volume = EffectiveVolume(_userGain, _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 = EffectiveVolume(_userGain, _localGain) };
_metering = new MeteringSampleProvider(_volume.ToSampleProvider());
_metering.StreamVolume += (_, e) =>
Level = Helpers.MeterScale.NextLevel(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);
}
}