feat(dock): the GAME bay — dB fader, measured ladder, mute, real duck badge
Unit 6. Mirrors the mic bay for the desktop/game bed, plus the badge that matters to a creator who cannot hear the duck: it shows while the ducker is actually attenuating, and it prints the gain the mixer applied (−12.0 DUCK by design). - `AudioMixer.DuckGain` exposes `AutoDucker.CurrentGain` instead of leaving it internal. - VM: `GameVolumeDb` / `GameVolumeDbLabel` (fader face), `GameMeterFraction` (measured level), `IsGameDucking` + `GameDuckLabel` refreshed on the level tick — the same moment the ducker runs, so the badge cannot lag behind the mix. - The bay: header chip + source, ladder, mute dot (uses the meter band, so mute reads at a glance), dB readout, fader. Guard: GameBayTests — the dock binds all four, `AutoDucker.DuckGain` is −12.04 dB and the label is produced through the same dB scale as the faders, and the mixer property exists so the badge cannot become a hardcoded number.
This commit is contained in:
+46
-4
@@ -66,10 +66,52 @@
|
||||
</Grid>
|
||||
</Border>
|
||||
<Border Grid.Column="1" Background="{StaticResource Brush.SurfaceContainerLow}" CornerRadius="{StaticResource Radius.Sm}" Margin="4,0" Padding="10,6">
|
||||
<StackPanel>
|
||||
<TextBlock Text="GAME / PC" FontSize="10" FontWeight="SemiBold" Foreground="{StaticResource Brush.OnSurfaceVariant}"/>
|
||||
<TextBlock x:Name="GameBayBody" Text="—" FontSize="11" Foreground="{StaticResource Brush.OnSurfaceDisabled}"/>
|
||||
</StackPanel>
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<Grid Grid.Row="0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Border Background="{StaticResource Brush.SurfaceContainerHighest}" CornerRadius="{StaticResource Radius.Sm}" Padding="5,1">
|
||||
<TextBlock Text="GAME / PC" FontSize="9" FontWeight="Bold" Foreground="{StaticResource Brush.OnSurfaceVariant}"/>
|
||||
</Border>
|
||||
<TextBlock Grid.Column="1" Margin="6,0,0,0" FontSize="11" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" Foreground="{StaticResource Brush.OnSurfaceVariant}"
|
||||
Text="Desktop loopback"/>
|
||||
<!-- The duck badge only exists while the duck is happening, and it carries
|
||||
the gain the mixer actually applied. -->
|
||||
<Border Grid.Column="2" Background="{StaticResource Brush.SurfaceContainerHighest}"
|
||||
CornerRadius="{StaticResource Radius.Sm}" Padding="5,1"
|
||||
Visibility="{Binding IsGameDucking, Converter={StaticResource BoolToVis}}">
|
||||
<TextBlock FontSize="9" FontWeight="Bold" FontFamily="{StaticResource FontFamily.Mono}"
|
||||
Foreground="{StaticResource Brush.Secondary}" Text="{Binding GameDuckLabel}"/>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<controls:SegmentMeter Grid.Row="1" Height="10" Margin="0,6,0,0" Orientation="Horizontal"
|
||||
Level="{Binding GameMeterFraction}" Segments="24"/>
|
||||
|
||||
<DockPanel Grid.Row="2" Margin="0,6,0,0" VerticalAlignment="Bottom">
|
||||
<Button DockPanel.Dock="Left" Width="24" Height="20" Margin="0,0,6,0"
|
||||
Style="{StaticResource YtButtonSecondary}" Padding="0"
|
||||
Command="{Binding ToggleGameMuteCommand}" ToolTip="Mute desktop audio">
|
||||
<Ellipse Width="9" Height="9" Fill="{Binding GameMeterBrush}"/>
|
||||
</Button>
|
||||
<TextBlock DockPanel.Dock="Right" Width="56" TextAlignment="Right" FontSize="10"
|
||||
FontFamily="{StaticResource FontFamily.Mono}" VerticalAlignment="Center"
|
||||
Foreground="{StaticResource Brush.OnSurfaceVariant}"
|
||||
Text="{Binding GameVolumeDbLabel}"/>
|
||||
<Slider Minimum="-60" Maximum="6" SmallChange="0.5" LargeChange="3"
|
||||
Value="{Binding GameVolumeDb, Mode=TwoWay}" VerticalAlignment="Center"/>
|
||||
</DockPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
<Border Grid.Column="2" Background="{StaticResource Brush.SurfaceContainerLow}" CornerRadius="{StaticResource Radius.Sm}" Margin="4,0" Padding="10,6">
|
||||
<StackPanel>
|
||||
|
||||
@@ -44,6 +44,10 @@ public sealed class AudioMixer : IDisposable
|
||||
private readonly AudioRingBuffer _alertBuffer;
|
||||
private readonly VoiceFilterChain _voiceChain;
|
||||
private readonly AutoDucker _ducker;
|
||||
|
||||
/// <summary>The ducker's CURRENT gain (1 = not ducking, 0.25 = the −12 dB duck). Read by the
|
||||
/// dock's badge: a duck indicator that is not the real value is decoration.</summary>
|
||||
public float DuckGain => _ducker.CurrentGain;
|
||||
private readonly MasterLimiter _masterLimiter = new();
|
||||
private TinyResampler? _micResampler;
|
||||
private TinyResampler? _loopbackResampler;
|
||||
|
||||
@@ -54,6 +54,8 @@ public partial class MainViewModel
|
||||
OnPropertyChanged(nameof(MeterFillWidth));
|
||||
OnPropertyChanged(nameof(MeterBrush));
|
||||
OnPropertyChanged(nameof(MicMeterFraction));
|
||||
UpdateDuckState();
|
||||
OnPropertyChanged(nameof(MicMeterFraction));
|
||||
OnPropertyChanged(nameof(MicVolumeDb));
|
||||
OnPropertyChanged(nameof(MicVolumeDbLabel));
|
||||
}
|
||||
@@ -307,6 +309,32 @@ public partial class MainViewModel
|
||||
|
||||
public double GameMeterFillWidth => GameMeterLevel * 288;
|
||||
|
||||
/// <summary>Ladder input: a 0…1 display fraction of what was measured.</summary>
|
||||
public double GameMeterFraction => GameMeterLevel;
|
||||
|
||||
/// <summary>The game fader's dB face (−60 … +6). Same rule as the mic: the model keeps linear
|
||||
/// gain; the dock shows decibels.</summary>
|
||||
public double GameVolumeDb
|
||||
{
|
||||
get => Helpers.GainDb.ToDb(GameAudioVolume);
|
||||
set
|
||||
{
|
||||
var gain = Helpers.GainDb.FromDb(value);
|
||||
if (Math.Abs(gain - GameAudioVolume) < 1e-6) return;
|
||||
GameAudioVolume = gain;
|
||||
OnPropertyChanged(nameof(GameVolumeDb));
|
||||
OnPropertyChanged(nameof(GameVolumeDbLabel));
|
||||
}
|
||||
}
|
||||
|
||||
public string GameVolumeDbLabel => Helpers.GainDb.Label(GameVolumeDb) + " dB";
|
||||
|
||||
/// <summary>True while the mic is ducking the game/desktop bed (the mixer's own value).</summary>
|
||||
public bool IsGameDucking { get; private set; }
|
||||
|
||||
/// <summary>"−12.0 dB" — the badge text, computed from the same gain the mixer applied.</summary>
|
||||
public string GameDuckLabel => Helpers.GainDb.Label(Helpers.GainDb.ToDb(_audioMixer.DuckGain)) + " DUCK";
|
||||
|
||||
public string GameMeterBrush => GameMeterLevel switch
|
||||
{
|
||||
< 0.6 => ThemeBrush.Hex("Brush.Meter.Nominal"),
|
||||
@@ -386,6 +414,17 @@ public partial class MainViewModel
|
||||
|
||||
public string GameMuteText => GameMuted ? "Unmute" : "Mute";
|
||||
|
||||
/// <summary>Badge state follows the mix, not a timer of its own: the level tick is already the
|
||||
/// moment the ducker has just run.</summary>
|
||||
private void UpdateDuckState()
|
||||
{
|
||||
var ducking = _audioMixer.DuckGain < 0.99f;
|
||||
if (ducking == IsGameDucking) return;
|
||||
IsGameDucking = ducking;
|
||||
OnPropertyChanged(nameof(IsGameDucking));
|
||||
OnPropertyChanged(nameof(GameDuckLabel));
|
||||
}
|
||||
|
||||
private void ToggleGameMute()
|
||||
{
|
||||
if (GameMuted)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using Xunit;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Services.Audio;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>The GAME bay's badge must carry the gain the mixer actually applied — a duck indicator
|
||||
/// that is not the real value is decoration, and this creator cannot hear whether it happened.
|
||||
/// </summary>
|
||||
public class GameBayTests
|
||||
{
|
||||
[Fact]
|
||||
public void The_game_bay_binds_the_db_fader_and_a_real_duck_readout()
|
||||
{
|
||||
var dock = SourceAssert.Text("Controls", "AudioDock.xaml");
|
||||
Assert.Contains("Value=\"{Binding GameVolumeDb, Mode=TwoWay}\"", dock);
|
||||
Assert.Contains("Level=\"{Binding GameMeterFraction}\"", dock);
|
||||
Assert.Contains("Text=\"{Binding GameDuckLabel}\"", dock);
|
||||
Assert.Contains("Visibility=\"{Binding IsGameDucking, Converter={StaticResource BoolToVis}}\"", dock);
|
||||
|
||||
foreach (var name in new[] { "GameVolumeDb", "GameVolumeDbLabel", "GameMeterFraction", "IsGameDucking", "GameDuckLabel" })
|
||||
Assert.NotNull(typeof(MainViewModel).GetProperty(name));
|
||||
|
||||
// The badge's number is the mixer's ducker, not a copy: the duck is −12.04 dB by design,
|
||||
// and the label is produced from the live gain through the same dB scale as the faders.
|
||||
Assert.Equal(-12.04, GainDb.ToDb(AutoDucker.DuckGain), 1);
|
||||
Assert.Equal("-12.0 DUCK", GainDb.Label(GainDb.ToDb(AutoDucker.DuckGain)) + " DUCK");
|
||||
|
||||
// And the mixer exposes that gain rather than hiding it.
|
||||
Assert.NotNull(typeof(AudioMixer).GetProperty("DuckGain"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user