[GUI] Added cross-platform playback menu music and title music volume control

This commit is contained in:
ParantezTech 2026-10-13 18:11:24 +03:00
parent 1f4425bd9c
commit 85393c2ef8
23 changed files with 305 additions and 73 deletions

View File

@ -40,6 +40,8 @@ public sealed class GuiSettings
/// <summary>Loop the selected game's sce_sys/snd0.at9 preview music.</summary>
public bool PlayTitleMusic { get; set; } = true;
public double TitleMusicVolume { get; set; } = 1.0;
public string LibraryLayout { get; set; } = "Carousel";
public double EmbeddedConsoleHeight { get; set; } = 240;
@ -148,6 +150,9 @@ public sealed class GuiSettings
legacyProfile is { Length: 2 } ? legacyProfile[1] : settings.DefaultProfile);
settings.DiscordClientId ??= "1525606762248540221";
settings.LibraryLayout = NormalizeChoice(settings.LibraryLayout, "Carousel", "Grid");
settings.TitleMusicVolume = double.IsFinite(settings.TitleMusicVolume)
? Math.Clamp(settings.TitleMusicVolume, 0.0, 1.0)
: 1.0;
settings.ConsoleType = NormalizeChoice(settings.ConsoleType, "PS5");
settings.ConsoleLanguage = NormalizeChoice(
settings.ConsoleLanguage,

View File

@ -44,6 +44,10 @@
"Library.Stat.TitleId": "معرّف اللعبة",
"Common.Back": "رجوع",
"Library.Music.Volume": "مستوى صوت موسيقى اللعبة",
"Library.Music.Mute": "كتم موسيقى اللعبة",
"Library.Music.Unmute": "إلغاء كتم موسيقى اللعبة",
"Options.General": "عام",
"Options.Logging": "التسجيل",
"Options.Section.Emulation": "المحاكاة",

View File

@ -31,6 +31,10 @@
"Library.Stat.TitleId": "Oyunun ID-si",
"Common.Back": "Geri qayıt",
"Library.Music.Volume": "Oyun musiqisinin səs səviyyəsi",
"Library.Music.Mute": "Oyun musiqisini susdur",
"Library.Music.Unmute": "Oyun musiqisinin səsini aç",
"Options.General": "Ümumi",
"Options.Logging": "Jurnala yazma",
"Options.Env.Tab": "Mühit",

View File

@ -44,6 +44,10 @@
"Library.Stat.TitleId": "ID do título",
"Common.Back": "Voltar",
"Library.Music.Volume": "Volume da música do jogo",
"Library.Music.Mute": "Silenciar música do jogo",
"Library.Music.Unmute": "Ativar som da música do jogo",
"Options.General": "Opções Gerais",
"Options.Logging": "Logs",
"Options.Env.Tab": "Ambiente",

View File

@ -44,6 +44,10 @@
"Library.Stat.TitleId": "Titel-ID",
"Common.Back": "Zurück",
"Library.Music.Volume": "Lautstärke der Spielmusik",
"Library.Music.Mute": "Spielmusik stummschalten",
"Library.Music.Unmute": "Stummschaltung der Spielmusik aufheben",
"Options.General": "Allgemein",
"Options.Logging": "Protokollierung",
"Options.Section.Emulation": "EMULATION",

View File

@ -44,6 +44,10 @@
"Library.Stat.TitleId": "Titel-id",
"Common.Back": "Tilbage",
"Library.Music.Volume": "Lydstyrke for spilmusik",
"Library.Music.Mute": "Slå spilmusik fra",
"Library.Music.Unmute": "Slå spilmusik til",
"Options.General": "Generelt",
"Options.Logging": "Logging",
"Options.Section.Emulation": "EMULERING",

View File

@ -47,6 +47,10 @@
"Library.Stat.TitleId": "Title ID",
"Common.Back": "Tagasi",
"Library.Music.Volume": "Mängumuusika helitugevus",
"Library.Music.Mute": "Vaigista mängumuusika",
"Library.Music.Unmute": "Lülita mängumuusika heli sisse",
"Options.General": "Üldine",
"Options.Logging": "Logimine",
"Options.Env.Tab": "Keskkond",

View File

@ -66,6 +66,10 @@
"Library.Stat.TitleId": "Title ID",
"Common.Back": "Back",
"Library.Music.Volume": "Title music volume",
"Library.Music.Mute": "Mute title music",
"Library.Music.Unmute": "Unmute title music",
"Options.General": "General",
"Options.Logging": "Logging",
"Options.Env.Tab": "Environment",

View File

@ -44,6 +44,10 @@
"Library.Stat.TitleId": "ID del título",
"Common.Back": "Volver",
"Library.Music.Volume": "Volumen de la música del juego",
"Library.Music.Mute": "Silenciar la música del juego",
"Library.Music.Unmute": "Activar el sonido de la música del juego",
"Options.General": "General",
"Options.Logging": "Logs",
"Options.Section.Emulation": "EMULACIÓN",

View File

@ -46,6 +46,10 @@
"Library.Stat.TitleId": "ID du titre",
"Common.Back": "Retour",
"Library.Music.Volume": "Volume de la musique du jeu",
"Library.Music.Mute": "Couper la musique du jeu",
"Library.Music.Unmute": "Rétablir la musique du jeu",
"Options.General": "Général",
"Options.Logging": "Journalisation",
"Options.Env.Tab": "Environnement",

View File

@ -44,6 +44,10 @@
"Library.Stat.TitleId": "Címazonosító",
"Common.Back": "Vissza",
"Library.Music.Volume": "Játékzene hangereje",
"Library.Music.Mute": "Játékzene némítása",
"Library.Music.Unmute": "Játékzene visszakapcsolása",
"Options.General": "Általános",
"Options.Logging": "Logolás",
"Options.Env.Tab": "Környezet",

View File

@ -44,6 +44,10 @@
"Library.Stat.TitleId": "ID titolo",
"Common.Back": "Indietro",
"Library.Music.Volume": "Volume della musica del gioco",
"Library.Music.Mute": "Disattiva audio della musica del gioco",
"Library.Music.Unmute": "Riattiva audio della musica del gioco",
"Options.General": "Generale",
"Options.Logging": "Log",
"Options.Section.Emulation": "EMULAZIONE",

View File

@ -44,6 +44,10 @@
"Library.Stat.TitleId": "タイトルID",
"Common.Back": "戻る",
"Library.Music.Volume": "ゲーム音楽の音量",
"Library.Music.Mute": "ゲーム音楽をミュート",
"Library.Music.Unmute": "ゲーム音楽のミュートを解除",
"Options.General": "一般",
"Options.Logging": "ロギング",
"Options.Section.Emulation": "エミュレーション",

View File

@ -44,6 +44,10 @@
"Library.Stat.TitleId": "타이틀 ID",
"Common.Back": "뒤로",
"Library.Music.Volume": "게임 음악 볼륨",
"Library.Music.Mute": "게임 음악 음소거",
"Library.Music.Unmute": "게임 음악 음소거 해제",
"Options.General": "일반",
"Options.Logging": "로깅",
"Options.Section.Emulation": "에뮬레이션",

View File

@ -44,6 +44,10 @@
"Library.Stat.TitleId": "Titel-ID",
"Common.Back": "Terug",
"Library.Music.Volume": "Volume van gamemuziek",
"Library.Music.Mute": "Gamemuziek dempen",
"Library.Music.Unmute": "Dempen van gamemuziek opheffen",
"Options.General": "Algemeen",
"Options.Logging": "Logging",
"Options.Section.Emulation": "EMULATIE",

View File

@ -47,6 +47,10 @@
"Library.Stat.TitleId": "Numer seryjny",
"Common.Back": "Wstecz",
"Library.Music.Volume": "Głośność muzyki w grze",
"Library.Music.Mute": "Wycisz muzykę z gry",
"Library.Music.Unmute": "Włącz dźwięk muzyki z gry",
"Options.General": "Ogólne",
"Options.Logging": "Dziennik zdarzeń",
"Options.Env.Tab": "Środowisko",

View File

@ -44,6 +44,10 @@
"Library.Stat.TitleId": "ID do título",
"Common.Back": "Voltar",
"Library.Music.Volume": "Volume da música do jogo",
"Library.Music.Mute": "Silenciar música do jogo",
"Library.Music.Unmute": "Reativar som da música do jogo",
"Options.General": "Geral",
"Options.Logging": "Registos",
"Options.Env.Tab": "Ambiente",

View File

@ -45,6 +45,10 @@
"Library.Stat.TitleId": "ID игры",
"Common.Back": "Назад",
"Library.Music.Volume": "Громкость музыки игры",
"Library.Music.Mute": "Выключить музыку игры",
"Library.Music.Unmute": "Включить музыку игры",
"Options.General": "Основные",
"Options.Logging": "Логгирование",
"Options.Env.Tab": "Окружение",

View File

@ -46,6 +46,10 @@
"Library.Stat.TitleId": "Başlık kimliği",
"Common.Back": "Geri",
"Library.Music.Volume": "Oyun müziği ses seviyesi",
"Library.Music.Mute": "Oyun müziğini kapat",
"Library.Music.Unmute": "Oyun müziğini aç",
"Options.General": "Genel",
"Options.Logging": "Günlükleme",
"Options.Section.Emulation": "EMÜLASYON",

View File

@ -146,6 +146,36 @@ SPDX-License-Identifier: GPL-2.0-or-later
<StackPanel Grid.Column="2" x:Name="LibraryToolbar" Orientation="Horizontal" Spacing="8"
VerticalAlignment="Center">
<Border Background="Transparent"
BorderBrush="{StaticResource CardBorderBrush}"
BorderThickness="1" CornerRadius="8"
Height="32" Padding="2,0,6,0" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal" Spacing="2" VerticalAlignment="Center">
<Button x:Name="TitleMusicMuteButton"
Classes="iconGhost"
Width="28" Height="28" BorderThickness="0"
VerticalAlignment="Center">
<TextBlock x:Name="TitleMusicMuteGlyph"
Classes="materialSymbol compact"
Text="volume_up" />
</Button>
<Slider x:Name="TitleMusicVolumeSlider"
Width="76" Height="28"
Minimum="0" Maximum="100"
SmallChange="1" LargeChange="10"
VerticalAlignment="Center"
ToolTip.Tip="{Binding [Library.Music.Volume], Source={x:Static local:Localization.Instance}}">
<Slider.Resources>
<GridLength x:Key="SliderPreContentMargin">7</GridLength>
<GridLength x:Key="SliderPostContentMargin">7</GridLength>
<x:Double x:Key="SliderHorizontalHeight">24</x:Double>
<x:Double x:Key="SliderHorizontalThumbWidth">14</x:Double>
<x:Double x:Key="SliderHorizontalThumbHeight">14</x:Double>
<CornerRadius x:Key="SliderThumbCornerRadius">7</CornerRadius>
</Slider.Resources>
</Slider>
</StackPanel>
</Border>
<Button x:Name="LibraryLayoutButton"
Classes="iconGhost"
VerticalAlignment="Center">

View File

@ -143,6 +143,7 @@ public partial class MainWindow : Window
private readonly ConcurrentQueue<(string Line, bool IsError)> _pendingLines = new();
private readonly DispatcherTimer _consoleFlushTimer;
private readonly DispatcherTimer _libraryLayoutTimer;
private readonly DispatcherTimer _titleMusicVolumeTimer;
private GuiSettings _settings = new();
private IReadOnlyList<HostDisplayOption> _hostDisplays = [];
@ -260,6 +261,15 @@ public partial class MainWindow : Window
_libraryLayoutTimer.Stop();
UpdateLibraryLayoutMetrics();
};
_titleMusicVolumeTimer = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(180),
};
_titleMusicVolumeTimer.Tick += (_, _) =>
{
_titleMusicVolumeTimer.Stop();
_settings.Save();
};
CarouselAddFolderButton.Click += (_, _) => StartAddFolderFromTile();
GameList.AddHandler(PointerPressedEvent, OnCarouselPointerPressed, RoutingStrategies.Tunnel, handledEventsToo: true);
GameList.AddHandler(PointerMovedEvent, OnCarouselPointerMoved, RoutingStrategies.Tunnel, handledEventsToo: true);
@ -323,6 +333,15 @@ public partial class MainWindow : Window
LibraryTabButton.Click += (_, _) => SetActivePage(0);
OptionsTabButton.Click += (_, _) => SetActivePage(1);
LibraryLayoutButton.Click += (_, _) => ToggleLibraryLayout();
TitleMusicMuteButton.Click += (_, _) => TitleMusicToggle.IsChecked = !_settings.PlayTitleMusic;
TitleMusicVolumeSlider.ValueChanged += (_, _) =>
{
_settings.TitleMusicVolume = TitleMusicVolumeSlider.Value / 100.0;
_sndPreview.Volume = _settings.TitleMusicVolume;
UpdateTitleMusicMuteButton();
_titleMusicVolumeTimer.Stop();
_titleMusicVolumeTimer.Start();
};
LibraryPage.SizeChanged += (_, _) => ScheduleLibraryLayoutMetricsUpdate();
LibrarySelectedDetails.SizeChanged += (_, _) => UpdateLibraryMinimumHeight();
MainContent.SizeChanged += (_, _) => ClampEmbeddedConsoleHeight();
@ -342,6 +361,7 @@ public partial class MainWindow : Window
{
_settings.PlayTitleMusic = TitleMusicToggle.IsChecked == true;
OnTitleMusicSettingChanged();
_settings.Save();
};
DiscordToggle.IsCheckedChanged += (_, _) =>
{
@ -1133,6 +1153,8 @@ public partial class MainWindow : Window
RefreshUpdateText();
UpdateEmptyStateTexts();
UpdateLibraryLayoutButton();
UpdateTitleMusicMuteButton();
AutomationProperties.SetName(TitleMusicVolumeSlider, Localization.Instance.Get("Library.Music.Volume"));
UpdateRunButtons();
}
@ -1259,6 +1281,7 @@ public partial class MainWindow : Window
Interlocked.Increment(ref _detailLoadGeneration);
_consoleFlushTimer.Stop();
_libraryLayoutTimer.Stop();
_titleMusicVolumeTimer.Stop();
_gamepadTimer.Stop();
}
@ -1426,6 +1449,10 @@ public partial class MainWindow : Window
CrashDumpToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_CRASH_CAPTURE");
OverrideLogFileToggle.IsChecked = _settings.OverrideLogFile;
TitleMusicToggle.IsChecked = _settings.PlayTitleMusic;
TitleMusicVolumeSlider.Value = _settings.TitleMusicVolume * 100.0;
_titleMusicVolumeTimer.Stop();
_sndPreview.Volume = _settings.TitleMusicVolume;
UpdateTitleMusicMuteButton();
SetLibraryLayout(string.Equals(_settings.LibraryLayout, "Grid", StringComparison.OrdinalIgnoreCase));
DiscordToggle.IsChecked = _settings.DiscordRichPresence;
AutoUpdateToggle.IsChecked = _settings.CheckForUpdatesOnStartup;
@ -2788,6 +2815,7 @@ public partial class MainWindow : Window
private void OnTitleMusicSettingChanged()
{
UpdateTitleMusicMuteButton();
if (!_settings.PlayTitleMusic)
{
_sndPreview.Stop();
@ -2798,6 +2826,17 @@ public partial class MainWindow : Window
}
}
private void UpdateTitleMusicMuteButton()
{
TitleMusicMuteGlyph.Text = !_settings.PlayTitleMusic || _settings.TitleMusicVolume == 0
? "volume_off"
: _settings.TitleMusicVolume < 0.5 ? "volume_down" : "volume_up";
var label = Localization.Instance.Get(
_settings.PlayTitleMusic ? "Library.Music.Mute" : "Library.Music.Unmute");
ToolTip.SetTip(TitleMusicMuteButton, label);
AutomationProperties.SetName(TitleMusicMuteButton, label);
}
/// <summary>Pauses the preview music while the window is minimized.</summary>
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{

View File

@ -2,40 +2,52 @@
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using System.Runtime.InteropServices;
using System.Text;
using LibAtrac9;
using SDL;
using static SDL.SDL3;
namespace SharpEmu.GUI;
/// <summary>
/// Loops a game's sce_sys/snd0.at9 preview music while the game is selected
/// in the library, like the console home screen. The ATRAC9 stream is decoded
/// to WAV on a background task (vendored LibAtrac9); playback uses winmm's
/// PlaySound, so this is Windows-only and a no-op elsewhere.
/// to WAV on a background task (vendored LibAtrac9); SDL3 plays the PCM on
/// Windows, Linux and macOS.
/// </summary>
internal sealed class SndPreviewPlayer
{
private const uint SND_ASYNC = 0x0001;
private const uint SND_NODEFAULT = 0x0002;
private const uint SND_MEMORY = 0x0004;
private const uint SND_LOOP = 0x0008;
private readonly object _sync = new();
private int _generation;
private GCHandle _pinnedWav;
private bool _playing;
private unsafe SDL_AudioStream* _stream;
private int _pcmOffset;
private int _targetQueuedBytes;
private bool _paused;
private bool _muted;
private double _volume = 1.0;
private string? _cachedPath;
private byte[]? _cachedWav;
public unsafe double Volume
{
set
{
lock (_sync)
{
_volume = double.IsFinite(value) ? Math.Clamp(value, 0.0, 1.0) : 1.0;
if (_stream is not null)
{
SDL_SetAudioStreamGain(_stream, (float)_volume);
}
}
}
}
/// <summary>
/// Keeps the music silent while set, even across <see cref="Play"/> and
/// <see cref="Resume"/>; the track stays ready so clearing it and
/// resuming restarts the music.
/// <see cref="Resume"/>; the track stays ready to continue after unmuting.
/// </summary>
public bool Muted
public unsafe bool Muted
{
get
{
@ -46,18 +58,12 @@ internal sealed class SndPreviewPlayer
}
set
{
if (!OperatingSystem.IsWindows())
{
return;
}
lock (_sync)
{
_muted = value;
if (value && _playing)
if (value && _stream is not null)
{
_ = PlaySound(0, 0, 0);
_playing = false;
SDL_PauseAudioStreamDevice(_stream);
_paused = true;
}
}
@ -67,11 +73,6 @@ internal sealed class SndPreviewPlayer
/// <summary>Starts looping the given snd0.at9 after a short debounce.</summary>
public void Play(string at9Path)
{
if (!OperatingSystem.IsWindows())
{
return;
}
int generation;
lock (_sync)
{
@ -119,19 +120,14 @@ internal sealed class SndPreviewPlayer
_cachedPath = at9Path;
_cachedWav = wav;
StopLocked();
// The WAV image must stay pinned while winmm plays from it.
_pinnedWav = GCHandle.Alloc(wav, GCHandleType.Pinned);
if (_muted)
try
{
_paused = true;
return;
StartLocked(wav);
}
_playing = PlaySound(_pinnedWav.AddrOfPinnedObject(), 0, SND_MEMORY | SND_ASYNC | SND_LOOP | SND_NODEFAULT);
if (!_playing)
catch (Exception error) when (error is DllNotFoundException or EntryPointNotFoundException or InvalidOperationException)
{
_pinnedWav.Free();
Console.Error.WriteLine($"[GUI][WARN] Title music playback unavailable: {error.Message}");
StopLocked();
}
}
});
@ -139,11 +135,6 @@ internal sealed class SndPreviewPlayer
public void Stop()
{
if (!OperatingSystem.IsWindows())
{
return;
}
lock (_sync)
{
_generation++;
@ -152,60 +143,139 @@ internal sealed class SndPreviewPlayer
}
/// <summary>
/// Silences playback but keeps the decoded track ready, so
/// <see cref="Resume"/> can restart it (winmm cannot truly pause).
/// Pauses playback while preserving the current position and queued audio.
/// </summary>
public void Pause()
public unsafe void Pause()
{
if (!OperatingSystem.IsWindows())
{
return;
}
lock (_sync)
{
if (!_playing)
if (_stream is null || _paused)
{
return;
}
_ = PlaySound(0, 0, 0);
_playing = false;
SDL_PauseAudioStreamDevice(_stream);
_paused = true;
}
}
/// <summary>Restarts the track silenced by <see cref="Pause"/>.</summary>
public void Resume()
/// <summary>Continues the track silenced by <see cref="Pause"/>.</summary>
public unsafe void Resume()
{
if (!OperatingSystem.IsWindows())
lock (_sync)
{
if (!_paused || _muted || _stream is null)
{
return;
}
SDL_ResumeAudioStreamDevice(_stream);
_paused = false;
}
}
private unsafe void StartLocked(byte[] wav)
{
if (wav.Length <= 44)
{
return;
}
lock (_sync)
if ((SDL_WasInit(SDL_InitFlags.SDL_INIT_AUDIO) & SDL_InitFlags.SDL_INIT_AUDIO) == 0 &&
!SDL_InitSubSystem(SDL_InitFlags.SDL_INIT_AUDIO))
{
if (!_paused || _muted || !_pinnedWav.IsAllocated)
{
return;
}
throw new InvalidOperationException("SDL audio initialization failed.");
}
_paused = false;
_playing = PlaySound(_pinnedWav.AddrOfPinnedObject(), 0, SND_MEMORY | SND_ASYNC | SND_LOOP | SND_NODEFAULT);
var spec = new SDL_AudioSpec
{
format = SDL_AudioFormat.SDL_AUDIO_S16LE,
channels = checked((byte)BinaryPrimitives.ReadInt16LittleEndian(wav.AsSpan(22))),
freq = BinaryPrimitives.ReadInt32LittleEndian(wav.AsSpan(24)),
};
_stream = SDL_OpenAudioDeviceStream(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, &spec, null, IntPtr.Zero);
if (_stream is null)
{
throw new InvalidOperationException("SDL audio stream creation failed.");
}
SDL_SetAudioStreamGain(_stream, (float)_volume);
_targetQueuedBytes = spec.freq * spec.channels * sizeof(short) / 4; // 250 ms of buffered audio
_pcmOffset = 44;
FillStreamLocked(wav);
_paused = _muted;
if (!_muted && !SDL_ResumeAudioStreamDevice(_stream))
{
throw new InvalidOperationException("SDL audio stream start failed.");
}
_ = PumpAsync(_generation, wav);
}
private async Task PumpAsync(int generation, byte[] wav)
{
while (true)
{
await Task.Delay(40).ConfigureAwait(false);
lock (_sync)
{
if (generation != _generation)
{
return;
}
FillStreamIfActiveLocked(wav);
}
}
}
private void StopLocked()
private unsafe void FillStreamIfActiveLocked(byte[] wav)
{
_ = PlaySound(0, 0, 0);
_playing = false;
_paused = false;
if (_pinnedWav.IsAllocated)
if (_stream is not null && !_paused)
{
_pinnedWav.Free();
FillStreamLocked(wav);
}
}
private unsafe void FillStreamLocked(byte[] wav)
{
var remaining = _targetQueuedBytes - SDL_GetAudioStreamQueued(_stream);
while (remaining > 0)
{
if (_pcmOffset == wav.Length)
{
_pcmOffset = 44;
}
var bytes = Math.Min(remaining, wav.Length - _pcmOffset);
bytes -= bytes % (BinaryPrimitives.ReadInt16LittleEndian(wav.AsSpan(22)) * sizeof(short));
if (bytes == 0)
{
break;
}
fixed (byte* data = &wav[_pcmOffset])
{
if (!SDL_PutAudioStreamData(_stream, (nint)data, bytes))
{
break;
}
}
_pcmOffset += bytes;
remaining -= bytes;
}
}
private unsafe void StopLocked()
{
if (_stream is not null)
{
SDL_DestroyAudioStream(_stream);
_stream = null;
}
_paused = false;
}
private static readonly Guid Atrac9SubFormat = new("47E142D2-36BA-4D8D-88FC-61654F8C836C");
/// <summary>
@ -335,8 +405,4 @@ internal sealed class SndPreviewPlayer
Encoding.ASCII.GetBytes("data").CopyTo(span[36..]);
BinaryPrimitives.WriteInt32LittleEndian(span[40..], sampleCount * channels * 2);
}
[DllImport("winmm.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool PlaySound(nint sound, nint module, uint flags);
}

View File

@ -0,0 +1,20 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.GUI;
using Xunit;
namespace SharpEmu.Libs.Tests.GUI;
public sealed class TitleMusicVolumeTests
{
[Theory]
[InlineData("{}", 1.0)]
[InlineData("{\"TitleMusicVolume\":0.35}", 0.35)]
[InlineData("{\"TitleMusicVolume\":-1}", 0.0)]
[InlineData("{\"TitleMusicVolume\":4}", 1.0)]
public void SavedVolumeIsRestoredAndClamped(string json, double expected)
{
Assert.Equal(expected, GuiSettings.NormalizeFromJson(json).TitleMusicVolume);
}
}