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.
175 lines
6.1 KiB
C#
175 lines
6.1 KiB
C#
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);
|
||
}
|
||
}
|