Files
RemSound/src/RemSound.Core/RemSoundSettingsStore.cs
T

676 lines
32 KiB
C#
Raw Normal View History

2026-05-13 15:08:31 +01:00
using System.Windows.Forms;
namespace RemSound.Core;
/// <summary>
/// In-memory cache of UI/runtime preferences. As of 2026-05-02 this no longer persists to
/// disk — RemSound's persistence layer is the profile system (<see cref="Profile"/> /
/// <see cref="ProfileStore"/>), and this class is just an intra-process holding area that
/// the active profile populates on app startup and reads back from when the user saves a
/// profile. Old <c>configs/</c> folders from prior builds are ignored. Constructor still
/// takes an <c>appName</c> for backwards compatibility but it's unused.
/// </summary>
public sealed class RemSoundSettingsStore
{
public RemSoundSettingsStore(string appName) { }
public HotkeyInfo LoadReceiveMuteHotkey() =>
Try(() => Load()?.ReceiveMuteHotkey?.ToHotkeyInfo()) ?? new HotkeyInfo(Keys.R, true, true, true);
public void SaveReceiveMuteHotkey(HotkeyInfo hotkey)
{
var s = Load() ?? new Settings();
s.ReceiveMuteHotkey = HotkeySetting.From(hotkey);
Save(s);
}
public HotkeyInfo LoadSendMuteHotkey() =>
Try(() => Load()?.SendMuteHotkey?.ToHotkeyInfo()) ?? new HotkeyInfo(Keys.S, true, true, true);
public void SaveSendMuteHotkey(HotkeyInfo hotkey)
{
var s = Load() ?? new Settings();
s.SendMuteHotkey = HotkeySetting.From(hotkey);
Save(s);
}
public HotkeyInfo LoadTrayHotkey() =>
Try(() => Load()?.TrayHotkey?.ToHotkeyInfo()) ?? new HotkeyInfo(Keys.F10, true, true, false);
public void SaveTrayHotkey(HotkeyInfo hotkey)
{
var s = Load() ?? new Settings();
s.TrayHotkey = HotkeySetting.From(hotkey);
Save(s);
}
public HotkeyInfo LoadVolumeUpHotkey() =>
Try(() => Load()?.VolumeUpHotkey?.ToHotkeyInfo()) ?? HotkeyInfo.Unset;
public void SaveVolumeUpHotkey(HotkeyInfo hotkey)
{
var s = Load() ?? new Settings();
s.VolumeUpHotkey = HotkeySetting.From(hotkey);
Save(s);
}
public HotkeyInfo LoadVolumeDownHotkey() =>
Try(() => Load()?.VolumeDownHotkey?.ToHotkeyInfo()) ?? HotkeyInfo.Unset;
public void SaveVolumeDownHotkey(HotkeyInfo hotkey)
{
var s = Load() ?? new Settings();
s.VolumeDownHotkey = HotkeySetting.From(hotkey);
Save(s);
}
public HotkeyInfo LoadToggleRecordingHotkey() =>
Try(() => Load()?.ToggleRecordingHotkey?.ToHotkeyInfo()) ?? HotkeyInfo.Unset;
public void SaveToggleRecordingHotkey(HotkeyInfo hotkey)
{
var s = Load() ?? new Settings();
s.ToggleRecordingHotkey = HotkeySetting.From(hotkey);
Save(s);
}
2026-05-13 15:08:31 +01:00
public HotkeyInfo LoadRemoteVolumeUpHotkey() =>
Try(() => Load()?.RemoteVolumeUpHotkey?.ToHotkeyInfo()) ?? HotkeyInfo.Unset;
public void SaveRemoteVolumeUpHotkey(HotkeyInfo hotkey)
{
var s = Load() ?? new Settings();
s.RemoteVolumeUpHotkey = HotkeySetting.From(hotkey);
Save(s);
}
public HotkeyInfo LoadRemoteVolumeDownHotkey() =>
Try(() => Load()?.RemoteVolumeDownHotkey?.ToHotkeyInfo()) ?? HotkeyInfo.Unset;
public void SaveRemoteVolumeDownHotkey(HotkeyInfo hotkey)
{
var s = Load() ?? new Settings();
s.RemoteVolumeDownHotkey = HotkeySetting.From(hotkey);
Save(s);
}
public HotkeyInfo LoadRemoteMuteToggleHotkey() =>
Try(() => Load()?.RemoteMuteToggleHotkey?.ToHotkeyInfo()) ?? HotkeyInfo.Unset;
public void SaveRemoteMuteToggleHotkey(HotkeyInfo hotkey)
{
var s = Load() ?? new Settings();
s.RemoteMuteToggleHotkey = HotkeySetting.From(hotkey);
Save(s);
}
public HotkeyInfo LoadSystemVolumeUpHotkey() =>
Try(() => Load()?.SystemVolumeUpHotkey?.ToHotkeyInfo()) ?? HotkeyInfo.Unset;
public void SaveSystemVolumeUpHotkey(HotkeyInfo hotkey)
{
var s = Load() ?? new Settings();
s.SystemVolumeUpHotkey = HotkeySetting.From(hotkey);
Save(s);
}
public HotkeyInfo LoadSystemVolumeDownHotkey() =>
Try(() => Load()?.SystemVolumeDownHotkey?.ToHotkeyInfo()) ?? HotkeyInfo.Unset;
public void SaveSystemVolumeDownHotkey(HotkeyInfo hotkey)
{
var s = Load() ?? new Settings();
s.SystemVolumeDownHotkey = HotkeySetting.From(hotkey);
Save(s);
}
public HotkeyInfo LoadSystemMuteToggleHotkey() =>
Try(() => Load()?.SystemMuteToggleHotkey?.ToHotkeyInfo()) ?? HotkeyInfo.Unset;
public void SaveSystemMuteToggleHotkey(HotkeyInfo hotkey)
{
var s = Load() ?? new Settings();
s.SystemMuteToggleHotkey = HotkeySetting.From(hotkey);
Save(s);
}
public bool LoadAcceptRemoteVolumeCommands(bool defaultValue = false) =>
Try(() => Load()?.AcceptRemoteVolumeCommands) ?? defaultValue;
public void SaveAcceptRemoteVolumeCommands(bool value)
{
var s = Load() ?? new Settings();
s.AcceptRemoteVolumeCommands = value;
Save(s);
}
public int LoadMaxLatencyMs(int defaultValue = 80) =>
Try(() => Load()?.MaxLatencyMs is int v ? Math.Clamp(v, 5, 500) : (int?)null) ?? defaultValue;
public void SaveMaxLatencyMs(int value)
{
var s = Load() ?? new Settings();
s.MaxLatencyMs = Math.Clamp(value, 1, 500);
Save(s);
}
/// <summary>
/// Per-route latency settings used only in BothIndependent audio mode. The existing
/// <see cref="LoadMaxLatencyMs"/> / <see cref="SaveMaxLatencyMs"/> govern the WASAPI lane
/// (which is what the existing slider has always controlled — every classic mode reads
/// it the same way pre-Stage-4.5). The ASIO companion below stores the ASIO lane's
/// target. Default 10 ms because the whole point of the new mode is to let ASIO run at
/// its native low latency; if the user has picked BothIndependent they almost certainly
/// want ASIO closer to 10 than to 80.
/// </summary>
public int LoadMaxLatencyMsAsio(int defaultValue = 10) =>
Try(() => Load()?.MaxLatencyMsAsio is int v ? Math.Clamp(v, 5, 500) : (int?)null) ?? defaultValue;
public void SaveMaxLatencyMsAsio(int value)
{
var s = Load() ?? new Settings();
s.MaxLatencyMsAsio = Math.Clamp(value, 1, 500);
Save(s);
}
/// <summary>Continuous auto-tune enabled for the ASIO lane. Defaults false to match the
/// WASAPI-lane default — having one lane auto-adjusting and the other fixed produces
/// confusingly asymmetric latency where the auto-tuning lane sits noticeably higher
/// because it's reacting to network jitter the fixed lane just rides through. User can
/// enable per lane explicitly; in BothIndependent both lanes' enable checkboxes are
/// visible side-by-side.</summary>
public bool LoadContinuousAutoTuneAsioEnabled(bool defaultValue = false) =>
Try(() => Load()?.ContinuousAutoTuneAsioEnabled) ?? defaultValue;
public void SaveContinuousAutoTuneAsioEnabled(bool value)
{
var s = Load() ?? new Settings();
s.ContinuousAutoTuneAsioEnabled = value;
Save(s);
}
public AudioTransportCodec LoadCodec(AudioTransportCodec defaultValue = AudioTransportCodec.Pcm) =>
Try(() => Load()?.Codec) ?? defaultValue;
public void SaveCodec(AudioTransportCodec value)
{
var s = Load() ?? new Settings();
s.Codec = value;
Save(s);
}
/// <summary>Loads the Opus frame size in samples-per-channel at 48 kHz. Default 480 = 10 ms.
/// Migration path: profiles written by v2.x stored milliseconds (5/10/20) in the same JSON
/// field; values &lt; 120 are interpreted as legacy ms and converted (×48 → samples). The
/// ranges don't overlap (max legitimate ms = 60, min legitimate samples = 120), so the
/// disambiguation is unambiguous.</summary>
public int LoadOpusFrameSamplesPerChannel(int defaultValue = 480) =>
Try(() => Load()?.OpusFrameSamplesPerChannel is int v ? NormalizeOpusFrameSamples(v) : (int?)null) ?? defaultValue;
2026-05-13 15:08:31 +01:00
/// <summary>Saves the Opus frame size in samples-per-channel at 48 kHz. Accepts the four
/// standard-Opus RESTRICTED_LOWDELAY values (120/240/480/960); anything else collapses to
/// 480 (= 10 ms), the safe default.</summary>
public void SaveOpusFrameSamplesPerChannel(int value)
2026-05-13 15:08:31 +01:00
{
var s = Load() ?? new Settings();
s.OpusFrameSamplesPerChannel = value switch
{
960 => 960, // 20 ms
480 => 480, // 10 ms
240 => 240, // 5 ms — not exposed in the dropdown but reachable via Tight rate
120 => 120, // 2.5 ms experimental
_ => 480,
};
2026-05-13 15:08:31 +01:00
Save(s);
}
/// <summary>Disambiguates a persisted Opus frame-size value between the legacy v2.x
/// integer-milliseconds storage (5/10/20) and the v3.x samples-per-channel storage
/// (120/240/480/960). Values &lt; 120 are legacy ms; ≥ 120 are samples. See
/// <see cref="LoadOpusFrameSamplesPerChannel"/>.</summary>
private static int NormalizeOpusFrameSamples(int persisted)
{
if (persisted < 120) return persisted * 48; // legacy ms → samples at 48 kHz
return persisted;
}
2026-05-13 15:08:31 +01:00
public bool LoadContinuousAutoTuneEnabled(bool defaultValue = false) =>
Try(() => Load()?.ContinuousAutoTuneEnabled) ?? defaultValue;
public void SaveContinuousAutoTuneEnabled(bool value)
{
var s = Load() ?? new Settings();
s.ContinuousAutoTuneEnabled = value;
Save(s);
}
public int LoadContinuousAutoTuneIntervalSec(int defaultValue = 5) =>
Try(() => Load()?.ContinuousAutoTuneIntervalSec is int v && v >= 5 && v <= 60 ? v : (int?)null) ?? defaultValue;
public void SaveContinuousAutoTuneIntervalSec(int value)
{
var s = Load() ?? new Settings();
s.ContinuousAutoTuneIntervalSec = Math.Clamp(value, 5, 60);
Save(s);
}
public IReadOnlyList<string> LoadRememberedPeers() =>
Try(() => Load()?.RememberedPeers?
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.OrdinalIgnoreCase).ToList())
?? [];
public void SaveRememberedPeers(IEnumerable<string> peers)
{
var s = Load() ?? new Settings();
s.RememberedPeers = peers
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Select(static value => value.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
Save(s);
}
// LoggingEnabled lives in AppConfig now — it's a machine-local debug knob, not a
// per-profile setting. LoadLoggingEnabled / SaveLoggingEnabled were retired here;
// callers go to AppConfig.LoggingEnabled directly.
/// <summary>
/// Audio mode is derived from whether an ASIO driver is selected. Pre-2026-05-11 this was
/// a user-facing setting with its own listbox; now the UI is simpler — the user just picks
/// an ASIO driver (or "(none)" to disable ASIO) and the mode follows. A real driver chosen
/// means BothIndependent (WASAPI + ASIO running side by side, each at its own latency);
/// no driver means WasapiOnly. The AudioMode field still exists on the persisted Settings
/// JSON purely for backward compat with old profiles — its value is ignored on load. The
/// matching SaveAudioMode setter was deleted with the listbox in the 2026-05-11 cleanup;
/// callers that used to invoke it have been removed.
/// </summary>
public AudioMode LoadAudioMode(AudioMode defaultValue = AudioMode.WasapiOnly) =>
string.IsNullOrWhiteSpace(LoadAsioDriverName()) ? AudioMode.WasapiOnly : AudioMode.BothIndependent;
// BothModeWarningSuppressed used to live here. Moved to AppConfig (remsound.config.json,
// machine-local) on 2026-05-07 — a "do not show me this again" decision shouldn't be
// tied to which profile is active. The accessors were removed; callers go to AppConfig
// directly. Profile.BothModeWarningSuppressed is left in place to deserialise old JSONs
// (one-shot migrated to AppConfig in MainForm's constructor).
public SendRate LoadSendRate(SendRate defaultValue = SendRate.Standard) =>
Try(() => Load()?.SendRate is SendRate v ? v : (SendRate?)null) ?? defaultValue;
public void SaveSendRate(SendRate value)
{
var s = Load() ?? new Settings();
s.SendRate = value;
Save(s);
}
/// <summary>Tight-latency mode toggle. Sender-side only as of 2026-05-06 (the receiver no
/// longer has a resampler to bypass). In WasapiOnly + single source mode the sender swaps
/// from the timer-driven MixingEngine to the audio-clock-locked PushModeWasapiBackend; in
/// AsioOnly + PCM mode the sender emits one packet per ASIO callback instead of accumulating
/// to the chosen frame size. Saves a few ms of send-side latency at the cost of brief
/// clicks if the link can't keep up. Off by default.</summary>
public bool LoadTightLatencyMode(bool defaultValue = false) =>
Try(() => Load()?.TightLatencyMode) ?? defaultValue;
public void SaveTightLatencyMode(bool value)
{
var s = Load() ?? new Settings();
s.TightLatencyMode = value;
Save(s);
}
/// <summary>Priority mode for the current profile. When true, the App's
/// <c>PerformanceMode</c> helper drives every Win32 lever that elevates a process's
/// scheduling and memory behaviour: EcoQoS opt-out, kernel power request, High
/// priority class, 1&nbsp;ms scheduler quantum, memory priority, working-set lock,
/// and MMCSS thread priority. Off by default; the user opts in per profile.</summary>
public bool LoadPriorityMode(bool defaultValue = false) =>
Try(() => Load()?.PriorityMode) ?? defaultValue;
public void SavePriorityMode(bool value)
{
var s = Load() ?? new Settings();
s.PriorityMode = value;
Save(s);
}
2026-05-13 15:08:31 +01:00
/// <summary>Suppresses the connect/disconnect sound cues that play when a peer's health
/// transitions to/from Healthy. Off by default — cues are on. Saved per-profile so users
/// who don't want them in a given setup don't have to remember to mute every session.
/// 2026-05-06.</summary>
public bool LoadMuteConnectionCues(bool defaultValue = false) =>
Try(() => Load()?.MuteConnectionCues) ?? defaultValue;
public void SaveMuteConnectionCues(bool value)
{
var s = Load() ?? new Settings();
s.MuteConnectionCues = value;
Save(s);
}
// === Per-cue enable flags (2026-05-15) ===
// Each cue sound has its own enable toggle, surfaced in the Preferences dialog as a
// CheckedListBox. The legacy MuteConnectionCues above used to gate both connect AND
// disconnect — when the new flags are absent (null cache + null profile), the load
// helpers fall back to the legacy value as a migration step. Once the user touches
// any per-cue toggle, that flag's load returns the explicit value directly and the
// legacy field becomes irrelevant for that cue.
public bool LoadEnableConnectCue()
{
var s = Load();
if (s?.EnableConnectCue is bool v) return v;
// Legacy fallback: an older profile with MuteConnectionCues=true was muting both
// connect AND disconnect at once. Honour that intent on first load.
if (s?.MuteConnectionCues == true) return false;
return true;
}
public void SaveEnableConnectCue(bool value)
{
var s = Load() ?? new Settings();
s.EnableConnectCue = value;
Save(s);
}
public bool LoadEnableDisconnectCue()
{
var s = Load();
if (s?.EnableDisconnectCue is bool v) return v;
if (s?.MuteConnectionCues == true) return false;
return true;
}
public void SaveEnableDisconnectCue(bool value)
{
var s = Load() ?? new Settings();
s.EnableDisconnectCue = value;
Save(s);
}
public bool LoadEnableRecordStartCue() =>
Try(() => Load()?.EnableRecordStartCue) ?? true;
public void SaveEnableRecordStartCue(bool value)
{
var s = Load() ?? new Settings();
s.EnableRecordStartCue = value;
Save(s);
}
public bool LoadEnableRecordStopCue() =>
Try(() => Load()?.EnableRecordStopCue) ?? true;
public void SaveEnableRecordStopCue(bool value)
{
var s = Load() ?? new Settings();
s.EnableRecordStopCue = value;
Save(s);
}
/// <summary>The whole recording-settings bag for the current profile. Loaded as a
/// CLONE so callers can mutate the returned object without inadvertently writing
/// back to the cache. Save flushes the object atomically.</summary>
public RecordingSettings LoadRecordingSettings() =>
(Try(() => Load()?.RecordingSettings) ?? new RecordingSettings()).Clone();
public void SaveRecordingSettings(RecordingSettings value)
{
var s = Load() ?? new Settings();
s.RecordingSettings = value?.Clone() ?? new RecordingSettings();
Save(s);
}
2026-05-13 15:08:31 +01:00
/// <summary>How aggressively the receiver pulls the playout queue back to the user's
/// target latency under network jitter. 1 = stupid aggressive (~10 % playback rate change,
/// audible pitch shift on drift, sub-second recovery). 10 = perfectly smooth (gentle
/// controller, no audible artefacts, slow recovery — buffer can creep up over a long
/// session). Lower is faster-but-less-stable, like the latency slider. Default is 3 —
/// quite aggressive but not the extreme; user dials down for tighter, up for smoother.</summary>
public int LoadSmoothness(int defaultValue = 3) =>
Try(() => Load()?.Smoothness is int v ? Math.Clamp(v, 1, 10) : (int?)null) ?? defaultValue;
public void SaveSmoothness(int value)
{
var s = Load() ?? new Settings();
s.Smoothness = Math.Clamp(value, 1, 10);
Save(s);
}
/// <summary>Receiver-side concealment artifact pick. See <see cref="ConcealmentArtifact"/>
/// for what each value sounds like. Default is <see cref="ConcealmentArtifact.NoiseBurst"/>
/// — the cosine-tone defaults were removed in Phase 3 cleanup (they sounded harsh on
/// orchestral content). Old profiles holding a CosineTone* enum value still load fine;
/// the dialog dropdown coerces them to NoiseBurst on display.</summary>
public ConcealmentArtifact LoadConcealmentArtifact(ConcealmentArtifact defaultValue = ConcealmentArtifact.NoiseBurst) =>
Try(() => Load()?.ConcealmentArtifact is ConcealmentArtifact v ? v : (ConcealmentArtifact?)null) ?? defaultValue;
public void SaveConcealmentArtifact(ConcealmentArtifact value)
{
var s = Load() ?? new Settings();
s.ConcealmentArtifact = value;
Save(s);
}
// ResamplerBypassWhenTight (load/save + Settings field) removed 2026-05-06 in Phase 3
// cleanup. The receiver no longer has a resampler in the steady-state path, so the
// bypass switch had nothing left to toggle. Existing profile JSON with the old key
// is silently ignored by the deserialiser.
public string? LoadAsioDriverName() => Try(() => Load()?.AsioDriverName);
public void SaveAsioDriverName(string? value)
{
var s = Load() ?? new Settings();
s.AsioDriverName = string.IsNullOrWhiteSpace(value) ? null : value;
Save(s);
}
private static T? Try<T>(Func<T?> action) where T : class
{
try { return action(); } catch { return null; }
}
private static T? Try<T>(Func<T?> action, T? unused = null) where T : struct
{
try { return action(); } catch { return null; }
}
// 2026-05-02: persistence moved out of this class. RemSound now manages settings via the
// profile system (RemSound.Core.Profile / ProfileStore), and the settings store has become
// a per-process in-memory cache that the active profile populates on load and reads back
// from on save. Disk IO from this class is intentionally a no-op now: the old configs/
// folder is no longer written to. If a configs/ folder exists from a previous build, it's
// ignored — users are expected to re-create their setup as a Profile via the new dialog.
private Settings cache = new();
private Settings? Load() => cache;
private void Save(Settings settings) => cache = settings;
/// <summary>Replace the in-memory settings cache from a loaded <see cref="Profile"/>.
/// Called once at app startup after the user picks a profile (or never, if they pick
/// the blank template — in which case defaults remain).</summary>
public void ApplyProfile(Profile profile)
{
if (profile is null) throw new ArgumentNullException(nameof(profile));
cache = new Settings
{
ReceiveMuteHotkey = profile.ReceiveMuteHotkey is null ? null : HotkeySettingFromRecord(profile.ReceiveMuteHotkey),
SendMuteHotkey = profile.SendMuteHotkey is null ? null : HotkeySettingFromRecord(profile.SendMuteHotkey),
TrayHotkey = profile.TrayHotkey is null ? null : HotkeySettingFromRecord(profile.TrayHotkey),
VolumeUpHotkey = profile.VolumeUpHotkey is null ? null : HotkeySettingFromRecord(profile.VolumeUpHotkey),
VolumeDownHotkey = profile.VolumeDownHotkey is null ? null : HotkeySettingFromRecord(profile.VolumeDownHotkey),
ToggleRecordingHotkey = profile.ToggleRecordingHotkey is null ? null : HotkeySettingFromRecord(profile.ToggleRecordingHotkey),
2026-05-13 15:08:31 +01:00
RemoteVolumeUpHotkey = profile.RemoteVolumeUpHotkey is null ? null : HotkeySettingFromRecord(profile.RemoteVolumeUpHotkey),
RemoteVolumeDownHotkey = profile.RemoteVolumeDownHotkey is null ? null : HotkeySettingFromRecord(profile.RemoteVolumeDownHotkey),
RemoteMuteToggleHotkey = profile.RemoteMuteToggleHotkey is null ? null : HotkeySettingFromRecord(profile.RemoteMuteToggleHotkey),
SystemVolumeUpHotkey = profile.SystemVolumeUpHotkey is null ? null : HotkeySettingFromRecord(profile.SystemVolumeUpHotkey),
SystemVolumeDownHotkey = profile.SystemVolumeDownHotkey is null ? null : HotkeySettingFromRecord(profile.SystemVolumeDownHotkey),
SystemMuteToggleHotkey = profile.SystemMuteToggleHotkey is null ? null : HotkeySettingFromRecord(profile.SystemMuteToggleHotkey),
AcceptRemoteVolumeCommands = profile.AcceptRemoteVolumeCommands,
MaxLatencyMs = profile.MaxLatencyMs,
Codec = profile.Codec,
OpusFrameSamplesPerChannel = profile.OpusFrameSamplesPerChannel,
2026-05-13 15:08:31 +01:00
ContinuousAutoTuneEnabled = profile.ContinuousAutoTuneEnabled,
ContinuousAutoTuneIntervalSec = profile.ContinuousAutoTuneIntervalSec,
MaxLatencyMsAsio = profile.MaxLatencyMsAsio,
ContinuousAutoTuneAsioEnabled = profile.ContinuousAutoTuneAsioEnabled,
RememberedPeers = profile.RememberedPeers is null ? null : new List<string>(profile.RememberedPeers),
AsioDriverName = profile.AsioDriverName,
// Profile.AudioModeRaw and Profile.BothModeWarningSuppressed are no longer carried
// through the settings cache. Both fields are retired (2026-05-07 / 2026-05-11);
// mode is derived from AsioDriverName and the Both-mode warning popup is gone.
SendRate = profile.SendRate,
TightLatencyMode = profile.TightLatencyMode,
PriorityMode = profile.PriorityMode,
2026-05-13 15:08:31 +01:00
Smoothness = profile.Smoothness,
ConcealmentArtifact = (ConcealmentArtifact)profile.ConcealmentArtifactRaw,
MuteConnectionCues = profile.MuteConnectionCues,
EnableConnectCue = profile.EnableConnectCue,
EnableDisconnectCue = profile.EnableDisconnectCue,
EnableRecordStartCue = profile.EnableRecordStartCue,
EnableRecordStopCue = profile.EnableRecordStopCue,
RecordingSettings = profile.RecordingSettings?.Clone() ?? new RecordingSettings(),
2026-05-13 15:08:31 +01:00
};
}
/// <summary>Copies the current in-memory settings cache into a Profile. Note: this only
/// covers the fields the settings store has historically known about — the device-tick
/// state, send/receive checkbox state, audio port, volume slider, and selected-peer
/// state live on the form itself and are gathered by the form when saving a profile.</summary>
public void CopyTo(Profile profile)
{
if (profile is null) throw new ArgumentNullException(nameof(profile));
var s = cache;
profile.ReceiveMuteHotkey = s.ReceiveMuteHotkey is null ? null : HotkeyRecordFromSetting(s.ReceiveMuteHotkey);
profile.SendMuteHotkey = s.SendMuteHotkey is null ? null : HotkeyRecordFromSetting(s.SendMuteHotkey);
profile.TrayHotkey = s.TrayHotkey is null ? null : HotkeyRecordFromSetting(s.TrayHotkey);
profile.VolumeUpHotkey = s.VolumeUpHotkey is null ? null : HotkeyRecordFromSetting(s.VolumeUpHotkey);
profile.VolumeDownHotkey = s.VolumeDownHotkey is null ? null : HotkeyRecordFromSetting(s.VolumeDownHotkey);
profile.ToggleRecordingHotkey = s.ToggleRecordingHotkey is null ? null : HotkeyRecordFromSetting(s.ToggleRecordingHotkey);
2026-05-13 15:08:31 +01:00
profile.RemoteVolumeUpHotkey = s.RemoteVolumeUpHotkey is null ? null : HotkeyRecordFromSetting(s.RemoteVolumeUpHotkey);
profile.RemoteVolumeDownHotkey = s.RemoteVolumeDownHotkey is null ? null : HotkeyRecordFromSetting(s.RemoteVolumeDownHotkey);
profile.RemoteMuteToggleHotkey = s.RemoteMuteToggleHotkey is null ? null : HotkeyRecordFromSetting(s.RemoteMuteToggleHotkey);
profile.SystemVolumeUpHotkey = s.SystemVolumeUpHotkey is null ? null : HotkeyRecordFromSetting(s.SystemVolumeUpHotkey);
profile.SystemVolumeDownHotkey = s.SystemVolumeDownHotkey is null ? null : HotkeyRecordFromSetting(s.SystemVolumeDownHotkey);
profile.SystemMuteToggleHotkey = s.SystemMuteToggleHotkey is null ? null : HotkeyRecordFromSetting(s.SystemMuteToggleHotkey);
if (s.AcceptRemoteVolumeCommands is bool arvc) profile.AcceptRemoteVolumeCommands = arvc;
if (s.MaxLatencyMs is int ml) profile.MaxLatencyMs = ml;
if (s.Codec is AudioTransportCodec c) profile.Codec = c;
if (s.OpusFrameSamplesPerChannel is int op) profile.OpusFrameSamplesPerChannel = op;
2026-05-13 15:08:31 +01:00
if (s.ContinuousAutoTuneEnabled is bool cae) profile.ContinuousAutoTuneEnabled = cae;
if (s.ContinuousAutoTuneIntervalSec is int cai) profile.ContinuousAutoTuneIntervalSec = cai;
if (s.MaxLatencyMsAsio is int mla) profile.MaxLatencyMsAsio = mla;
if (s.ContinuousAutoTuneAsioEnabled is bool cata) profile.ContinuousAutoTuneAsioEnabled = cata;
if (s.RememberedPeers is { } rp) profile.RememberedPeers = new List<string>(rp);
profile.AsioDriverName = s.AsioDriverName;
// AudioMode and BothModeWarningSuppressed are not copied — both Profile fields were
// retired in the 2026-05-11 cleanup. Mode is derived from AsioDriverName and the
// popup that owned the suppression flag is gone.
if (s.SendRate is SendRate sr) profile.SendRate = sr;
if (s.TightLatencyMode is bool tl) profile.TightLatencyMode = tl;
if (s.PriorityMode is bool pm) profile.PriorityMode = pm;
2026-05-13 15:08:31 +01:00
if (s.Smoothness is int sm) profile.Smoothness = sm;
if (s.ConcealmentArtifact is ConcealmentArtifact ca) profile.ConcealmentArtifactRaw = (int)ca;
if (s.MuteConnectionCues is bool mc) profile.MuteConnectionCues = mc;
// Per-cue enable flags — copy through verbatim (nullable on both sides, so an
// unset flag in the cache stays unset on the profile, letting the legacy
// MuteConnectionCues path govern that cue on the next load).
profile.EnableConnectCue = s.EnableConnectCue;
profile.EnableDisconnectCue = s.EnableDisconnectCue;
profile.EnableRecordStartCue = s.EnableRecordStartCue;
profile.EnableRecordStopCue = s.EnableRecordStopCue;
if (s.RecordingSettings is RecordingSettings rs) profile.RecordingSettings = rs.Clone();
2026-05-13 15:08:31 +01:00
}
private static HotkeySetting HotkeySettingFromRecord(HotkeyRecord r) => new()
{
Key = r.Key,
Control = r.Control,
Shift = r.Shift,
Alt = r.Alt,
};
private static HotkeyRecord HotkeyRecordFromSetting(HotkeySetting s) => new()
{
Key = s.Key,
Control = s.Control,
Shift = s.Shift,
Alt = s.Alt,
};
private sealed class Settings
{
public HotkeySetting? ReceiveMuteHotkey { get; set; }
public HotkeySetting? SendMuteHotkey { get; set; }
public HotkeySetting? TrayHotkey { get; set; }
public HotkeySetting? VolumeUpHotkey { get; set; }
public HotkeySetting? VolumeDownHotkey { get; set; }
public HotkeySetting? ToggleRecordingHotkey { get; set; }
2026-05-13 15:08:31 +01:00
public HotkeySetting? RemoteVolumeUpHotkey { get; set; }
public HotkeySetting? RemoteVolumeDownHotkey { get; set; }
public HotkeySetting? RemoteMuteToggleHotkey { get; set; }
public HotkeySetting? SystemVolumeUpHotkey { get; set; }
public HotkeySetting? SystemVolumeDownHotkey { get; set; }
public HotkeySetting? SystemMuteToggleHotkey { get; set; }
public bool? AcceptRemoteVolumeCommands { get; set; }
public int? MaxLatencyMs { get; set; }
public AudioTransportCodec? Codec { get; set; }
// Renamed 2026-05-23 (v3.0). Was OpusFrameMilliseconds; value semantic shifted to
// samples-per-channel at 48 kHz. The cache is in-memory only — no JSON migration is
// needed here; on-disk migration happens in Profile via [JsonPropertyName].
public int? OpusFrameSamplesPerChannel { get; set; }
2026-05-13 15:08:31 +01:00
public bool? ContinuousAutoTuneEnabled { get; set; }
public int? ContinuousAutoTuneIntervalSec { get; set; }
// Per-route latency settings used in AudioMode.BothIndependent only. MaxLatencyMs
// above continues to govern the WASAPI lane (= the only lane in classic modes), so
// existing profiles keep their current slider value untouched on upgrade. Asio
// companion below holds the ASIO lane's slider; the auto-tune enable companion lets
// the user opt either lane in or out independently.
public int? MaxLatencyMsAsio { get; set; }
public bool? ContinuousAutoTuneAsioEnabled { get; set; }
public List<string>? RememberedPeers { get; set; }
public string? AsioDriverName { get; set; }
// AudioMode and BothModeWarningSuppressed both retired from this cache. Mode is
// derived from AsioDriverName via LoadAudioMode; the Both-mode warning popup is gone.
public SendRate? SendRate { get; set; }
public bool? TightLatencyMode { get; set; }
public bool? PriorityMode { get; set; }
2026-05-13 15:08:31 +01:00
public int? Smoothness { get; set; }
public ConcealmentArtifact? ConcealmentArtifact { get; set; }
public bool? MuteConnectionCues { get; set; }
// Per-cue enable flags (2026-05-15). Nullable so an absent value in the loaded
// profile falls back to the legacy MuteConnectionCues migration path.
public bool? EnableConnectCue { get; set; }
public bool? EnableDisconnectCue { get; set; }
public bool? EnableRecordStartCue { get; set; }
public bool? EnableRecordStopCue { get; set; }
public RecordingSettings? RecordingSettings { get; set; }
2026-05-13 15:08:31 +01:00
}
private sealed class HotkeySetting
{
public string Key { get; set; } = "M";
public bool Control { get; set; }
public bool Shift { get; set; }
public bool Alt { get; set; }
public static HotkeySetting From(HotkeyInfo hotkey) => new()
{
Key = hotkey.Key.ToString(),
Control = hotkey.Control,
Shift = hotkey.Shift,
Alt = hotkey.Alt,
};
public HotkeyInfo ToHotkeyInfo()
{
if (!Enum.TryParse<Keys>(Key, out var parsedKey)) return HotkeyInfo.Default;
var hotkey = new HotkeyInfo(parsedKey, Control, Shift, Alt);
if (hotkey.IsUnset) return HotkeyInfo.Unset;
return hotkey.IsValid ? hotkey : HotkeyInfo.Default;
}
}
}