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).
This commit is contained in:
2026-10-30 13:22:13 -07:00
parent 8aa483f933
commit a54149bd7c
7 changed files with 32 additions and 5 deletions
+4 -1
View File
@@ -159,7 +159,10 @@
</Border>
</Grid>
<DockPanel Grid.Row="1" Margin="0,6,0,0" VerticalAlignment="Bottom">
<controls:SegmentMeter Grid.Row="1" Height="10" Margin="0,6,0,6" Orientation="Horizontal"
Level="{Binding TraxMeterFraction}" Segments="24"/>
<DockPanel Grid.Row="1" Margin="0,22,0,0" VerticalAlignment="Bottom">
<TextBlock DockPanel.Dock="Right" Width="56" TextAlignment="Right" FontSize="10"
FontFamily="{StaticResource FontFamily.Mono}" VerticalAlignment="Center"
Foreground="{StaticResource Brush.OnSurfaceVariant}"
+4
View File
@@ -7,6 +7,10 @@ public interface ITraxPlayer : IDisposable
event Action<Exception?>? PlaybackEnded;
string? TrackName { get; }
bool IsPlaying { get; }
/// <summary>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.</summary>
float Level { get; }
float LocalGain { get; set; }
/// <summary>The creator's TRAX level (1 = the bed's fixed design level). Separate from
+11 -1
View File
@@ -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;
/// <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;
@@ -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()
+1
View File
@@ -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));
+5
View File
@@ -79,6 +79,11 @@ public partial class MainViewModel
public string TraxVolumeDbLabel => Helpers.GainDb.Label(TraxVolumeDb) + " dB";
/// <summary>The TRAX ladder: the player's own measured output, so "is music actually making
/// sound" is answerable without hearing it.</summary>
public double TraxMeterFraction =>
IsMusicPlaying ? Services.Audio.AudioLevelMeter.ToDisplay(_musicPlayer.Level) : 0;
/// <summary>Track name, or an honest "No track".</summary>
public string TraxTrackLabel => _musicPlayer.TrackName ?? "No track";
+6 -3
View File
@@ -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.
+1
View File
@@ -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;