using System.Text.Json.Serialization; using System.Windows.Forms; namespace RemSound.Core; /// /// A saved snapshot of every user-controllable RemSound setting. Replaces the old /// machine-wide "settings" file. Profiles live as one JSON file per profile under /// <exe>\profiles\<machine name>\<title>.json and are portable — /// copying a profile JSON to another machine's profiles folder makes it appear in that /// machine's selection list. Device IDs stored in a profile (sound cards, ASIO drivers) /// that don't exist on the loading machine are silently ignored on apply, so a profile /// can roam between machines with different hardware without erroring out. /// /// Design point: profiles capture EVERY UI control state, including device ticks. The /// previous design rule was to NOT persist device selections (start unticked every /// session). Profiles deliberately override that — the whole point is one-click /// restoration. If a user wants the old "start fresh" behaviour, they pick the blank /// template at startup. /// public sealed class Profile { /// Display title and filename stem (sanitised). Required. public string Title { get; set; } = ""; /// If true, this profile is loaded for use but the app never writes the user's /// in-session changes back to disk: Ctrl+S / File → Save politely refuses (with a "use /// Save As instead" message), and FormClosing skips its usual "save changes?" prompt /// entirely. Whatever the user fiddled with this session is kept in memory until the /// app closes and then discarded; the file on disk stays exactly as it was. Off by /// default. Toggled per-profile via File → Lock profile (read-only). Use case: a /// "default" profile you want to live in and toggle send/receive on without the close /// prompt blocking shutdown — important for users who can't reach the prompt because /// they're remote, or because the screen reader has crashed, or because the laptop is /// hibernating. The flag is the *only* property the lock-toggle writes back to disk; /// any other in-session edits stay session-only. 2026-05-22. public bool ReadOnly { get; set; } // === Main form: send / receive === public bool ReceiveAudioOn { get; set; } public bool SendAudioOn { get; set; } public int Volume { get; set; } = 100; public bool Muted { get; set; } // === Audio backend === /// The ASIO driver this profile uses. null or empty means "no ASIO" — /// the form runs in WASAPI-only mode. Any other value selects an ASIO driver and puts /// the form into the WASAPI + ASIO independent-lane mode. There is no separate audio-mode /// field on the profile any more: the mode is derived from this name alone (2026-05-11 /// cleanup retired the old AudioMode listbox and its persisted enum). Old profile JSONs /// that still contain "AudioModeRaw" or "BothModeWarningSuppressed" simply /// have those keys ignored on deserialisation. public string? AsioDriverName { get; set; } // === Selected devices (raw device IDs, not display names) === public List SelectedWasapiReceiveOutputs { get; set; } = []; public List SelectedAsioReceiveOutputs { get; set; } = []; public List SelectedWasapiSendOutputs { get; set; } = []; // loopback (system audio) public List SelectedWasapiSendInputs { get; set; } = []; // microphones / line-ins public List SelectedAsioSendInputs { get; set; } = []; // === Connectivity & transport === public int AudioPort { get; set; } = 47830; public int CodecRaw { get; set; } = (int)AudioTransportCodec.Pcm; /// Opus frame size in samples-per-channel at 48 kHz. 120 = 2.5 ms, 240 = 5 ms, /// 480 = 10 ms (default), 960 = 20 ms. Renamed from OpusFrameMilliseconds in the /// v3.0 wire-format refactor (2026-05-23). The JSON key is kept as /// OpusFrameMilliseconds for back-compat with v2.x profile files; on read, /// disambiguates the /// legacy integer-ms encoding (5/10/20) from the new sample-count encoding /// (120/240/480/960) using a sentinel: any persisted value < 120 is treated as ms and /// multiplied by 48. v2.x readers loading a v3.x profile see e.g. 480 and clamp it to /// their accept-list (which only knows 10 or 20), defaulting silently to 10 ms. [JsonPropertyName("OpusFrameMilliseconds")] public int OpusFrameSamplesPerChannel { get; set; } = 480; public int SendRateRaw { get; set; } = (int)SendRate.Standard; public bool TightLatencyMode { get; set; } /// True if this profile asks Windows to keep the RemSound process in /// high-priority mode while it's running — CPU scheduling, power management, memory /// priority, working-set lock, and MMCSS thread priority all elevated. Off by default; /// the user opts in per profile when they want a "live session" feel where the /// cold-start CPU ramp doesn't audibly hurt latency. On laptops that drains the /// battery faster; on desktops it costs a couple of extra watts. See /// PerformanceMode in the App project for the full lever list. Saved per /// profile (not in AppConfig) because the right answer genuinely differs between /// profiles. public bool PriorityMode { get; set; } /// Legacy combined "mute connect/disconnect sounds" toggle. True suppresses /// both connect AND disconnect cues. Superseded 2026-05-15 by the four individual /// Enable*Cue flags below — the new flags take precedence when set. This field /// is preserved on the profile for backward compatibility with older builds that don't /// know about the per-cue flags; on first load the per-cue flags inherit from this /// (true → connect+disconnect cues disabled). public bool MuteConnectionCues { get; set; } /// Per-cue enable flags. Nullable so a missing entry in an older profile JSON /// falls back to the legacy migration path; once the /// user touches the new UI we write a concrete true/false and the legacy /// field stops mattering. Defaults to "play the sound" (true) for both cases — the /// audio cues are part of the normal user feedback loop, not opt-in. 2026-05-15. public bool? EnableConnectCue { get; set; } public bool? EnableDisconnectCue { get; set; } public bool? EnableRecordStartCue { get; set; } public bool? EnableRecordStopCue { get; set; } public int MaxLatencyMs { get; set; } = 80; public int Smoothness { get; set; } = 3; public bool ContinuousAutoTuneEnabled { get; set; } public int ContinuousAutoTuneIntervalSec { get; set; } = 5; /// Per-route latency for the ASIO lane in AudioMode.BothIndependent. Default /// 10 ms because BothIndependent's value proposition is letting ASIO run at its native /// low latency; users who pick that mode almost always want ASIO closer to 10 than 80. /// Ignored in every classic mode. public int MaxLatencyMsAsio { get; set; } = 10; /// Continuous auto-tune toggle for the ASIO lane (BothIndependent only). /// Defaults false to match the WASAPI-lane default — symmetric off-by-default avoids /// the trap where the ASIO lane auto-inflates its target while WASAPI sits fixed at /// its slider, producing higher ASIO latency than WASAPI in the typical session. public bool ContinuousAutoTuneAsioEnabled { get; set; } // LoggingEnabled was retired from Profile — logging is a machine-local debug knob // (AppConfig.LoggingEnabled), not a per-profile setting. Old profile JSONs that still // contain "LoggingEnabled" just have the key ignored on load. /// Receiver-side concealment artifact, stored as raw int for JSON-stability /// across enum-reorderings. Defaults to /// (the cosine-tone variants were removed from the dropdown in Phase 3 cleanup — /// 2026-05-06 — but the enum values stay around so old profile JSONs still parse; /// the dialog coerces any cosine-tone value to NoiseBurst at load time). public int ConcealmentArtifactRaw { get; set; } = (int)ConcealmentArtifact.NoiseBurst; [JsonIgnore] public ConcealmentArtifact ConcealmentArtifact { get => (ConcealmentArtifact)ConcealmentArtifactRaw; set => ConcealmentArtifactRaw = (int)value; } // === Recording === /// Recording source / format / attributes. The whole settings object is saved /// per profile so different profiles can record different things (a "long session" /// profile might record everything to MP3, a "monitoring" profile might not record at /// all but keep the dialog defaults sensible). The recording isn't running until the /// user explicitly triggers it via the Record menu; this just holds the configuration /// the recorder picks up when it starts. public RecordingSettings RecordingSettings { get; set; } = new(); // === Peers === public List RememberedPeers { get; set; } = []; /// Peer addresses (IP or host[:port]) the user had ticked in the connected /// list at save time. On load, RemSound auto-connects to any of these that resolve. public List SelectedConnectedPeers { get; set; } = []; // === Hotkeys === public HotkeyRecord? ReceiveMuteHotkey { get; set; } public HotkeyRecord? SendMuteHotkey { get; set; } public HotkeyRecord? TrayHotkey { get; set; } public HotkeyRecord? VolumeUpHotkey { get; set; } public HotkeyRecord? VolumeDownHotkey { get; set; } /// Global hotkey for start / stop recording. Toggles the same action as the /// Record menu's "Start recording / Stop recording" item and the in-app Ctrl+R, but /// works system-wide (RemSound doesn't need keyboard focus). Default unset — recording /// is uncommon enough that we don't claim a default chord that might clash with the /// user's other tools. public HotkeyRecord? ToggleRecordingHotkey { get; set; } /// Hotkey that sends a "raise volume" command to every connected peer that has /// "Accept remote volume commands" enabled. The local volume slider on this machine is /// NOT touched. Use case: I'm NVDA-Remote'd into another machine and want to nudge the /// listening volume on the laptop I'm physically at without breaking out of the session. public HotkeyRecord? RemoteVolumeUpHotkey { get; set; } /// Mirror of RemoteVolumeUpHotkey for "lower volume" commands. public HotkeyRecord? RemoteVolumeDownHotkey { get; set; } /// Hotkey that sends a "toggle receive mute" command to every connected peer. public HotkeyRecord? RemoteMuteToggleHotkey { get; set; } /// Hotkey that sends a "raise Windows default-output-device volume by one step" /// command to every connected peer that has Accept remote volume commands enabled. Each /// press bumps the receiving peer's Windows master volume by the OS native step (~2%) — /// same as pressing the keyboard volume key on the receiver. System-wide on the receiver: /// affects every app on that machine including its screen reader. public HotkeyRecord? SystemVolumeUpHotkey { get; set; } /// Mirror of SystemVolumeUpHotkey for the down direction. public HotkeyRecord? SystemVolumeDownHotkey { get; set; } /// Hotkey that sends a "toggle Windows default-output-device mute" command to /// every connected peer. public HotkeyRecord? SystemMuteToggleHotkey { get; set; } /// When true, this machine honours incoming Control packets from connected /// peers — adjusts the local volume slider or toggles mute. Default false: receiving /// remote control is opt-in even though the audio allow-list already gates who's /// connected. Lets a user have one profile that's controllable (home setup, single /// trusted peer) and another that's not (one-off jam session, public-ish peer). public bool AcceptRemoteVolumeCommands { get; set; } // === JSON-friendly accessors (so callers don't deal with the raw int casts) === // AudioMode accessor + AudioModeRaw backing field retired 2026-05-11. The runtime mode is // now derived from AsioDriverName; there is no separate persisted enum. [JsonIgnore] public AudioTransportCodec Codec { get => (AudioTransportCodec)CodecRaw; set => CodecRaw = (int)value; } [JsonIgnore] public SendRate SendRate { get => (SendRate)SendRateRaw; set => SendRateRaw = (int)value; } /// Returns a defaults-only profile — same shape as the "blank template" /// the user picks at startup. Title is empty (caller assigns when saving). public static Profile NewBlank() => new(); } /// JSON-serialisable hotkey representation. Mirrors /// but stores Key as a string to keep the JSON robust to enum reorganisations. public sealed class HotkeyRecord { public string Key { get; set; } = "M"; public bool Control { get; set; } public bool Shift { get; set; } public bool Alt { get; set; } public static HotkeyRecord From(HotkeyInfo hotkey) => new() { Key = hotkey.Key.ToString(), Control = hotkey.Control, Shift = hotkey.Shift, Alt = hotkey.Alt, }; public HotkeyInfo ToHotkeyInfo() { if (!Enum.TryParse(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; } }