diff --git a/Controls/AudioDock.xaml b/Controls/AudioDock.xaml index efabd9d..7943ddb 100644 --- a/Controls/AudioDock.xaml +++ b/Controls/AudioDock.xaml @@ -159,7 +159,10 @@ - + + + ? PlaybackEnded; string? TrackName { get; } bool IsPlaying { get; } + + /// The player's OWN measured output level (0…1). A play glyph can lie — an open file + /// with a dead decoder still says "playing" — so the dock's TRAX ladder shows sound, not state. + float Level { get; } float LocalGain { get; set; } /// The creator's TRAX level (1 = the bed's fixed design level). Separate from diff --git a/Services/Audio/MusicPlayer.cs b/Services/Audio/MusicPlayer.cs index 0a55e12..6c70e4d 100644 --- a/Services/Audio/MusicPlayer.cs +++ b/Services/Audio/MusicPlayer.cs @@ -1,5 +1,7 @@ using System.IO; +using System.Linq; using NAudio.Wave; +using NAudio.Wave.SampleProviders; namespace ytLive.Services.Audio; @@ -39,6 +41,11 @@ public sealed class MusicPlayer : ITraxPlayer public bool IsPlaying => _output?.PlaybackState == PlaybackState.Playing; + /// Measured peak of what the player is actually sending to the device. + public float Level { get; private set; } + + private MeteringSampleProvider? _metering; + /// Local gain multiplier from the desktop/game audio bar (0 = mute, /// 1 = full bed). Applied live on top of the fixed . private float _userGain = 1f; @@ -75,9 +82,12 @@ public sealed class MusicPlayer : ITraxPlayer 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(_volume); + _output.Init(_metering); } public void Play() diff --git a/ViewModels/MainViewModel.Audio.cs b/ViewModels/MainViewModel.Audio.cs index 8763a37..ba347a4 100644 --- a/ViewModels/MainViewModel.Audio.cs +++ b/ViewModels/MainViewModel.Audio.cs @@ -56,6 +56,7 @@ public partial class MainViewModel OnPropertyChanged(nameof(MicMeterFraction)); UpdateDuckState(); RaiseMasterReadouts(); + OnPropertyChanged(nameof(TraxMeterFraction)); OnPropertyChanged(nameof(MicMeterFraction)); OnPropertyChanged(nameof(MicVolumeDb)); OnPropertyChanged(nameof(MicVolumeDbLabel)); diff --git a/ViewModels/MainViewModel.Trax.cs b/ViewModels/MainViewModel.Trax.cs index dbc147b..cf24c13 100644 --- a/ViewModels/MainViewModel.Trax.cs +++ b/ViewModels/MainViewModel.Trax.cs @@ -79,6 +79,11 @@ public partial class MainViewModel public string TraxVolumeDbLabel => Helpers.GainDb.Label(TraxVolumeDb) + " dB"; + /// The TRAX ladder: the player's own measured output, so "is music actually making + /// sound" is answerable without hearing it. + public double TraxMeterFraction => + IsMusicPlaying ? Services.Audio.AudioLevelMeter.ToDisplay(_musicPlayer.Level) : 0; + /// Track name, or an honest "No track". public string TraxTrackLabel => _musicPlayer.TrackName ?? "No track"; diff --git a/ytLive.Tests/TraxBayTests.cs b/ytLive.Tests/TraxBayTests.cs index d44b2e4..517098e 100644 --- a/ytLive.Tests/TraxBayTests.cs +++ b/ytLive.Tests/TraxBayTests.cs @@ -21,17 +21,20 @@ public class TraxBayTests Assert.Contains("Binding=\"{Binding IsMusicPlaying}\" Value=\"True\"", dock); Assert.Contains("Click=\"TraxPlayPause_Click\"", dock); - // No ladder in the TRAX bay: nothing measures the bed, so there is nothing honest to show. + // The bay DOES carry a ladder now — the player measures its own output, so "is music making + // sound" is answerable without hearing it (a play glyph cannot answer that). var traxBay = dock[dock.IndexOf("Text=\"TRAX\"", System.StringComparison.Ordinal) ..dock.IndexOf("Text=\"LIP-SYNC\"", System.StringComparison.Ordinal)]; Assert.Contains("TraxTrackLabel", traxBay); - Assert.DoesNotContain("SegmentMeter", traxBay); + Assert.Contains("SegmentMeter", traxBay); + Assert.Contains("TraxMeterFraction", traxBay); - foreach (var name in new[] { "TraxVolumeDb", "TraxVolumeDbLabel", "TraxTrackLabel", "IsTraxDucking" }) + foreach (var name in new[] { "TraxVolumeDb", "TraxVolumeDbLabel", "TraxTrackLabel", "IsTraxDucking", "TraxMeterFraction" }) Assert.NotNull(typeof(MainViewModel).GetProperty(name)); // The player gained a real user level, separate from the mute-follow LocalGain. Assert.NotNull(typeof(ITraxPlayer).GetProperty("UserGain")); + Assert.NotNull(typeof(ITraxPlayer).GetProperty("Level")); Assert.NotNull(typeof(MusicPlayer).GetProperty("UserGain")); // Unity means the bed's design level, and the fader's scale is the same one as the others. diff --git a/ytLive.Tests/TraxToggleTests.cs b/ytLive.Tests/TraxToggleTests.cs index 64c10e6..00d3709 100644 --- a/ytLive.Tests/TraxToggleTests.cs +++ b/ytLive.Tests/TraxToggleTests.cs @@ -22,6 +22,7 @@ public sealed class TraxToggleTests public bool IsPlaying { get; private set; } public float LocalGain { get; set; } = 1f; public float UserGain { get; set; } = 1f; + public float Level { get; set; } public void Load(string path) => TrackName = Path.GetFileNameWithoutExtension(path); public void Play() => IsPlaying = Starts; public void Pause() => IsPlaying = false;