diff --git a/Controls/AudioDock.xaml b/Controls/AudioDock.xaml
index e36c80a..049adb2 100644
--- a/Controls/AudioDock.xaml
+++ b/Controls/AudioDock.xaml
@@ -114,10 +114,60 @@
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Controls/AudioDock.xaml.cs b/Controls/AudioDock.xaml.cs
index 2b12e86..1fcd815 100644
--- a/Controls/AudioDock.xaml.cs
+++ b/Controls/AudioDock.xaml.cs
@@ -39,6 +39,21 @@ public partial class AudioDock : UserControl
vm.OpenMicPickerCommand.Execute(null);
}
+ private void TraxPlayPause_Click(object sender, RoutedEventArgs e)
+ {
+ if (DataContext is ViewModels.MainViewModel vm) vm.TraxLeftClick();
+ }
+
+ private void TraxLabel_MouseLeftButtonUp(object sender, System.Windows.Input.MouseButtonEventArgs e)
+ {
+ if (DataContext is ViewModels.MainViewModel vm) vm.TraxLeftClick();
+ }
+
+ private void TraxLabel_MouseRightButtonUp(object sender, System.Windows.Input.MouseButtonEventArgs e)
+ {
+ if (DataContext is ViewModels.MainViewModel vm) vm.ChooseTrack();
+ }
+
private void OnLoaded(object sender, RoutedEventArgs e)
{
_window = Window.GetWindow(this);
diff --git a/Services/Audio/ITraxPlayer.cs b/Services/Audio/ITraxPlayer.cs
index 7df17a8..5031a2a 100644
--- a/Services/Audio/ITraxPlayer.cs
+++ b/Services/Audio/ITraxPlayer.cs
@@ -8,6 +8,10 @@ public interface ITraxPlayer : IDisposable
string? TrackName { get; }
bool IsPlaying { get; }
float LocalGain { get; set; }
+
+ /// The creator's TRAX level (1 = the bed's fixed design level). Separate from
+ /// , which follows the desktop mute; the dock's fader moves this.
+ float UserGain { get; set; }
void Load(string path);
void Play();
void Pause();
diff --git a/Services/Audio/MusicPlayer.cs b/Services/Audio/MusicPlayer.cs
index 1be0cef..0a55e12 100644
--- a/Services/Audio/MusicPlayer.cs
+++ b/Services/Audio/MusicPlayer.cs
@@ -41,6 +41,19 @@ public sealed class MusicPlayer : ITraxPlayer
/// 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;
+
+ /// Creator-set level, multiplied onto the fixed design volume.
+ 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;
@@ -61,7 +74,7 @@ public sealed class MusicPlayer : ITraxPlayer
Stop();
TrackPath = path;
_reader = new MediaFoundationReader(path);
- _volume = new VolumeWaveProvider16(_reader) { Volume = MusicVolume * _localGain };
+ _volume = new VolumeWaveProvider16(_reader) { Volume = MusicVolume * _userGain * _localGain };
_output = new WaveOutEvent();
_output.PlaybackStopped += OnPlaybackStopped;
_output.Init(_volume);
diff --git a/ViewModels/MainViewModel.Trax.cs b/ViewModels/MainViewModel.Trax.cs
index e21f544..dbc147b 100644
--- a/ViewModels/MainViewModel.Trax.cs
+++ b/ViewModels/MainViewModel.Trax.cs
@@ -63,6 +63,28 @@ public partial class MainViewModel
? BarOnBrush
: TraxError != null ? BarOffBrush : TraxIdleBrush;
+ /// The dock's TRAX fader, in dB. Unity means the bed's design level, not the raw file.
+ public double TraxVolumeDb
+ {
+ get => Helpers.GainDb.ToDb(_musicPlayer.UserGain);
+ set
+ {
+ var gain = Helpers.GainDb.FromDb(value);
+ if (Math.Abs(gain - _musicPlayer.UserGain) < 1e-6) return;
+ _musicPlayer.UserGain = (float)gain;
+ OnPropertyChanged(nameof(TraxVolumeDb));
+ OnPropertyChanged(nameof(TraxVolumeDbLabel));
+ }
+ }
+
+ public string TraxVolumeDbLabel => Helpers.GainDb.Label(TraxVolumeDb) + " dB";
+
+ /// Track name, or an honest "No track".
+ public string TraxTrackLabel => _musicPlayer.TrackName ?? "No track";
+
+ /// Same ducker signal as the game bay — the bed ducks with it.
+ public bool IsTraxDucking => IsGameDucking;
+
public string TraxToolTip => _musicPlayer.TrackName != null
? $"TRAX: {_musicPlayer.TrackName} — left-click {(IsMusicPlaying ? "pauses" : "plays")}, right-click chooses a track"
: "No track — left-click or right-click to choose background music";
@@ -81,6 +103,9 @@ public partial class MainViewModel
/// Right-click on TRAX: always open the track picker (never the OS viewer).
public void TraxRightClick() => PickTraxTrack();
+ /// Open the track picker (the dock's right-click on the TRAX name).
+ internal void ChooseTrack() => PickTraxTrack();
+
private void PickTraxTrack()
{
var dialog = new OpenFileDialog
diff --git a/ytLive.Tests/TraxBayTests.cs b/ytLive.Tests/TraxBayTests.cs
new file mode 100644
index 0000000..d44b2e4
--- /dev/null
+++ b/ytLive.Tests/TraxBayTests.cs
@@ -0,0 +1,41 @@
+using Xunit;
+using ytLive.Helpers;
+using ytLive.Services.Audio;
+using ytLive.ViewModels;
+
+namespace ytLive.Tests;
+
+/// The TRAX bay shows the track, plays/pauses it, and carries a level — but no meter:
+/// nothing measures the bed, and a decorative ladder would be a lie. Its fader moves a gain the
+/// player actually applies, separate from the desktop-mute follow.
+///
+public class TraxBayTests
+{
+ [Fact]
+ public void The_trax_bay_binds_the_track_level_and_play_state_without_inventing_a_meter()
+ {
+ var dock = SourceAssert.Text("Controls", "AudioDock.xaml");
+ Assert.Contains("Text=\"{Binding TraxTrackLabel}\"", dock);
+ Assert.Contains("Value=\"{Binding TraxVolumeDb, Mode=TwoWay}\"", dock);
+ Assert.Contains("Text=\"{Binding TraxVolumeDbLabel}\"", dock);
+ 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.
+ 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);
+
+ foreach (var name in new[] { "TraxVolumeDb", "TraxVolumeDbLabel", "TraxTrackLabel", "IsTraxDucking" })
+ 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(MusicPlayer).GetProperty("UserGain"));
+
+ // Unity means the bed's design level, and the fader's scale is the same one as the others.
+ Assert.Equal(0, GainDb.ToDb(1f), 6);
+ Assert.Contains("∞", GainDb.Label(GainDb.FloorDb)); // true silence, not "-60.0"
+ }
+}
diff --git a/ytLive.Tests/TraxToggleTests.cs b/ytLive.Tests/TraxToggleTests.cs
index d770006..64c10e6 100644
--- a/ytLive.Tests/TraxToggleTests.cs
+++ b/ytLive.Tests/TraxToggleTests.cs
@@ -21,6 +21,7 @@ public sealed class TraxToggleTests
public string? TrackName { get; private set; }
public bool IsPlaying { get; private set; }
public float LocalGain { get; set; } = 1f;
+ public float UserGain { get; set; } = 1f;
public void Load(string path) => TrackName = Path.GetFileNameWithoutExtension(path);
public void Play() => IsPlaying = Starts;
public void Pause() => IsPlaying = false;