Initial commit: RemSound v1.0

This commit is contained in:
Ednunp
2026-05-13 15:15:27 +01:00
commit 17259438c6
80 changed files with 17892 additions and 0 deletions
+132
View File
@@ -0,0 +1,132 @@
using System.Text.Json;
namespace RemSound.Core;
/// <summary>How often the self-updater polls GitHub Releases for a newer build. Values are
/// stable: don't reorder; deserialisation reads the underlying int from <c>remsound.config.json</c>.</summary>
public enum UpdateCheckFrequency
{
Never = 0,
EveryHour = 1,
Every6Hours = 2,
Every24Hours = 3,
}
/// <summary>
/// App-level configuration that lives next to the exe as <c>remsound.config.json</c>.
/// Distinct from <see cref="Profile"/>: profiles are user-chosen sets of audio /
/// connectivity / device settings; the app config is the *meta* layer that holds
/// preferences that should be sticky regardless of which profile is loaded. Profiles are
/// per-setup; this file is per-installation.
///
/// What lives here:
/// * <see cref="ProfilesDirectory"/> — where the profile JSONs are read from.
///
/// (Pre-2026-05-11 also held <c>BothModeWarningSuppressed</c> — the "do not show me again"
/// tick on the WASAPI+ASIO latency popup. The popup was retired along with the audio-mode
/// listbox; old config JSONs that still contain the key just have it ignored.)
///
/// Persisted location: <c>&lt;exe&gt;\remsound.config.json</c>. If the file is missing or
/// malformed, defaults are used and the app behaves exactly as it did pre-2026-05-05
/// (per-machine subfolder under the exe). The file is only written when the user
/// explicitly changes a setting.
/// </summary>
public sealed class AppConfig
{
/// <summary>Filesystem path to the directory the app should read profiles from. When
/// null, RemSound uses the legacy default: <c>&lt;exe&gt;\profiles\&lt;machine&gt;\</c>.
/// When set to an explicit folder, that folder IS the profiles folder — no per-machine
/// subfolder is appended (the user picked it, they meant it; that also lets a user point
/// at a Dropbox folder shared between machines).</summary>
public string? ProfilesDirectory { get; set; }
/// <summary>True if the user has ticked "do not show me this message again" on the
/// confirmation popup that fires when Save (Ctrl+S / File → Save) successfully
/// overwrites the currently-loaded profile. Lives here (not in Profile) so the
/// preference sticks across profile switches — once you've decided you don't need
/// the "Profile saved" nag, you don't expect it to come back when you load a
/// different profile. The Save-As path doesn't use this flag: the Save-As dialog
/// itself is the user-visible confirmation, so a follow-up popup is redundant.</summary>
public bool SaveProfileConfirmationSuppressed { get; set; }
/// <summary>If true, RemSound minimises to the system tray immediately after the main
/// window finishes loading. Lets the user "boot up the machine and have RemSound
/// already running quietly". Default false.</summary>
public bool StartMinimised { get; set; }
/// <summary>If true, RemSound writes a tab-separated diagnostic log to
/// <c>&lt;exe&gt;\logs\</c>. Lives here (not in <see cref="Profile"/>) because logging
/// is a debugging affordance for the installation, not a user-facing audio preference —
/// switching profiles shouldn't accidentally re-enable a flood of writes the user had
/// turned off, and a one-machine "yes log everything" decision shouldn't have to ride
/// along on every saved profile. Default false: no log file is created until the user
/// ticks <em>Enable logs</em> in the Preferences dialog.</summary>
public bool LoggingEnabled { get; set; }
/// <summary>If non-null and a profile with this title exists, RemSound skips the
/// startup profile picker and loads this profile directly. Combine with
/// <see cref="StartMinimised"/> + the Windows auto-start registry entry
/// (see <c>StartupAutoStart</c>) to get a fully unattended boot-into-streaming flow.
/// To re-show the picker temporarily, untick "Start with a specific profile" in the
/// Startup behaviour dialog. Null = always show the picker (legacy behaviour).</summary>
public string? StartWithProfileTitle { get; set; }
/// <summary>How often RemSound polls the GitHub Releases API for a newer build. Default
/// <see cref="UpdateCheckFrequency.Every24Hours"/>. Set to <see cref="UpdateCheckFrequency.Never"/>
/// to disable background checks entirely (the user can still trigger a manual check via
/// the Preferences button or the Help menu).</summary>
public UpdateCheckFrequency UpdateCheckFrequency { get; set; } = UpdateCheckFrequency.Every24Hours;
/// <summary>If true, RemSound downloads and applies a new release without prompting:
/// the running instance writes the new files to a staging folder, spawns a small
/// detached helper that waits for the exe to exit, swaps in the new files, and restarts
/// RemSound. Default false — the user gets a confirmation dialog before each install.</summary>
public bool SilentlyInstallUpdates { get; set; }
/// <summary>UTC timestamp of the last successful update check. Used by the background
/// update timer to space out polls across launches — if you set the frequency to
/// "every 24 hours" and re-launch the app three times that day, it still hits the API
/// only once. Null on a fresh install.</summary>
public DateTime? LastUpdateCheckUtc { get; set; }
private static string ConfigPath => Path.Combine(AppContext.BaseDirectory, "remsound.config.json");
/// <summary>Reads the app config from disk. Always returns a non-null instance — a missing
/// or malformed file becomes a defaults-only AppConfig rather than throwing.</summary>
public static AppConfig Load()
{
try
{
if (!File.Exists(ConfigPath)) return new AppConfig();
var json = File.ReadAllText(ConfigPath);
return JsonSerializer.Deserialize<AppConfig>(json) ?? new AppConfig();
}
catch
{
// Corrupt config file shouldn't keep RemSound from launching. Fall back to
// defaults; the user can re-pick a folder via the dialog and we'll overwrite
// the bad file on the next save.
return new AppConfig();
}
}
/// <summary>Writes this config to disk. Throws on filesystem failures (caller should
/// surface a MessageBox — failure to persist a directory choice is user-visible).</summary>
public void Save()
{
var json = JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(ConfigPath, json);
}
/// <summary>Convenience: build the appropriate <see cref="ProfileStore"/> for the
/// current config. Falls back to the default store (per-machine subfolder) if the
/// configured folder is missing, blank, or doesn't exist on disk.</summary>
public ProfileStore CreateStore()
{
if (!string.IsNullOrWhiteSpace(ProfilesDirectory) && Directory.Exists(ProfilesDirectory))
{
return new ProfileStore(ProfilesDirectory);
}
return new ProfileStore();
}
}
+37
View File
@@ -0,0 +1,37 @@
namespace RemSound.Core;
/// <summary>
/// Audio format announcement carried in every Format packet. <see cref="Lane"/> was added
/// 2026-05-11 alongside the BothIndependent audio mode — see <see cref="RenderRoute"/> for
/// the semantics. The field is wire-backward-compatible: old receivers parse the first 32
/// bytes of the format payload and ignore the extra; new receivers reading a 32-byte
/// payload from an old sender default Lane to <see cref="RenderRoute.Mixed"/>.
/// </summary>
public sealed record AudioFormatInfo(
int SampleRate,
int Channels,
int BitsPerSample,
int Encoding,
int BlockAlign,
int AverageBytesPerSecond,
int Codec = (int)AudioTransportCodec.Pcm,
int FrameDurationMilliseconds = 10,
RenderRoute Lane = RenderRoute.Mixed)
{
public override string ToString()
{
var encodingName = Encoding switch
{
1 => "PCM",
3 => "IEEE float",
_ => $"encoding {Encoding}"
};
var codecName = (AudioTransportCodec)Codec switch
{
AudioTransportCodec.Opus => $" over Opus ({FrameDurationMilliseconds} ms)",
_ => ""
};
var laneName = Lane == RenderRoute.Mixed ? "" : $" [{Lane}]";
return $"{SampleRate} Hz, {Channels} channel(s), {BitsPerSample}-bit {encodingName}{codecName}{laneName}";
}
}
+29
View File
@@ -0,0 +1,29 @@
namespace RemSound.Core;
/// <summary>
/// Selects which audio backends RemSound runs. Two values are produced by the UI today:
/// <list type="bullet">
/// <item><b>WasapiOnly</b> — MixingEngine ⇄ AudioSender direct, MultiOutputPlayout reads
/// PlayoutEngine direct. No ASIO code path runs at all. Used when the user has the
/// ASIO driver picker set to "(none)" or no ASIO drivers are installed.</item>
/// <item><b>BothIndependent</b> — WASAPI and ASIO both active, but each runs in its own
/// end-to-end pipeline at its own native latency. The sender emits two UDP streams
/// in parallel: a WASAPI lane carrying WASAPI-captured audio (tagged
/// <see cref="RenderRoute.WasapiLane"/>) and an ASIO lane carrying ASIO audio
/// (<see cref="RenderRoute.AsioLane"/>). The receiver routes each lane to the
/// matching render backend with no cross-backend mix. Used when the user has picked
/// a real ASIO driver in the picker.</item>
/// </list>
/// <b>AsioOnly</b> and <b>Both</b> are legacy values kept for back-compat with code paths
/// that take an <see cref="AudioMode"/> as input. No UI path produces them any more, and the
/// composite backends coerce them into <b>WasapiOnly</b> or <b>BothIndependent</b> on receipt.
/// Old profile JSONs that still contain <c>"AudioModeRaw"</c> simply have the key ignored
/// (the field was removed from <see cref="Profile"/> in the 2026-05-11 cleanup).
/// </summary>
public enum AudioMode
{
WasapiOnly = 0,
AsioOnly = 1, // Legacy, no UI path produces this any more.
Both = 2, // Legacy classic-Both (tee). No UI path produces this any more.
BothIndependent = 3,
}
+160
View File
@@ -0,0 +1,160 @@
namespace RemSound.Core;
/// <summary>
/// Single-producer / single-consumer byte ring buffer for an audio pipeline. Used on both the
/// receive side (network → playout) and the send side (composite mixing across capture backends).
/// Producer thread calls <see cref="Write(ReadOnlySpan{byte})"/>; consumer thread calls
/// <see cref="Read(Span{byte})"/> or <see cref="ReadFloats(Span{float})"/>.
///
/// Design choices for predictability:
/// * Power-of-two capacity for cheap mod via mask.
/// * No locks; head/tail are written by exactly one thread each. Reads of the other side use Volatile.Read
/// to get the latest published value.
/// * On overflow the oldest data is dropped, not silently retained — the playout target is the source of truth.
/// * On underrun the consumer gets silence and an underrun count is incremented.
/// </summary>
public sealed class AudioRingBuffer
{
private readonly byte[] storage;
private readonly int mask;
// head is advanced by the consumer (Read); tail is advanced by the producer (Write).
private int head;
private int tail;
private long underruns;
private long drops;
public AudioRingBuffer(int capacityBytes)
{
// Round up to next power of two.
var capacity = 1;
while (capacity < Math.Max(64, capacityBytes)) capacity <<= 1;
storage = new byte[capacity];
mask = capacity - 1;
}
public int CapacityBytes => storage.Length;
public int BufferedBytes => (Volatile.Read(ref tail) - Volatile.Read(ref head)) & 0x7FFFFFFF;
public long UnderrunCount => Interlocked.Read(ref underruns);
public long DropCount => Interlocked.Read(ref drops);
public void Reset()
{
Volatile.Write(ref head, 0);
Volatile.Write(ref tail, 0);
}
/// <summary>Producer side. Writes the entire span; if the buffer is full, drops the oldest bytes to make room.</summary>
public void Write(ReadOnlySpan<byte> source)
{
var currentTail = tail;
var currentHead = Volatile.Read(ref head);
var available = storage.Length - ((currentTail - currentHead) & 0x7FFFFFFF);
if (source.Length > available)
{
// Drop oldest to make room. Advance head by the deficit.
var deficit = source.Length - available;
Volatile.Write(ref head, (currentHead + deficit) & 0x7FFFFFFF);
Interlocked.Add(ref drops, deficit);
}
var writeIndex = currentTail & mask;
var firstChunk = Math.Min(source.Length, storage.Length - writeIndex);
source[..firstChunk].CopyTo(storage.AsSpan(writeIndex));
if (firstChunk < source.Length)
{
source[firstChunk..].CopyTo(storage.AsSpan(0));
}
Volatile.Write(ref tail, (currentTail + source.Length) & 0x7FFFFFFF);
}
/// <summary>
/// Consumer-side: discard the oldest <paramref name="bytesToDrop"/> bytes (or the whole
/// buffered amount if smaller). Used when the user lowers the delay knob, to bring the
/// buffer down to the new target instantly instead of waiting for adaptive rate to drain it.
/// Must be called only from the consumer thread (advances <c>head</c>, which the SPSC
/// invariant treats as consumer-owned).
/// </summary>
public void DropOldest(int bytesToDrop)
{
if (bytesToDrop <= 0) return;
var currentHead = head;
var currentTail = Volatile.Read(ref tail);
var available = (currentTail - currentHead) & 0x7FFFFFFF;
var actual = Math.Min(bytesToDrop, available);
if (actual <= 0) return;
Volatile.Write(ref head, (currentHead + actual) & 0x7FFFFFFF);
Interlocked.Add(ref drops, actual);
}
/// <summary>
/// Producer-side trim. If the buffer currently holds more than <paramref name="targetBytes"/>,
/// advances head to discard the oldest excess. Returns the number of bytes dropped. Same
/// semantics as the overflow-drop path inside <see cref="Write"/>: producer can advance
/// head, accepting a rare race against the consumer's own head advance — the alternative
/// (an unbounded queue while no consumer exists) is worse.
///
/// Used by <see cref="SessionPlayout.NoteFramesQueued"/> to soft-cap the playout queue when
/// audio piles up faster than it's being consumed (e.g. a delay between "receive on" and
/// "output device selected" — the listener should hear live audio when render starts, not
/// the multi-second backlog that arrived during the gap).
/// </summary>
public int TrimFromProducer(int targetBytes)
{
var currentHead = Volatile.Read(ref head);
var currentTail = Volatile.Read(ref tail);
var available = (currentTail - currentHead) & 0x7FFFFFFF;
if (available <= targetBytes) return 0;
var excess = available - targetBytes;
Volatile.Write(ref head, (currentHead + excess) & 0x7FFFFFFF);
Interlocked.Add(ref drops, excess);
return excess;
}
/// <summary>
/// Float-typed convenience over <see cref="Read(Span{byte})"/>. Returns the count of floats
/// that came from the buffer (silence-fill is included in destination but not counted here).
/// Use this from the playout read path on the render thread.
/// </summary>
public int ReadFloats(Span<float> destination)
{
var bytes = System.Runtime.InteropServices.MemoryMarshal.AsBytes(destination);
var bytesRead = Read(bytes);
return bytesRead / sizeof(float);
}
/// <summary>Consumer side. Reads up to destination.Length bytes. Any shortfall is filled with silence (zero).
/// Returns the number of bytes that came from the buffer (the silence-fill is included in the destination
/// but is reflected in the underrun counter, not the return value).</summary>
public int Read(Span<byte> destination)
{
var currentHead = head;
var currentTail = Volatile.Read(ref tail);
var available = (currentTail - currentHead) & 0x7FFFFFFF;
var toRead = Math.Min(destination.Length, available);
if (toRead > 0)
{
var readIndex = currentHead & mask;
var firstChunk = Math.Min(toRead, storage.Length - readIndex);
storage.AsSpan(readIndex, firstChunk).CopyTo(destination);
if (firstChunk < toRead)
{
storage.AsSpan(0, toRead - firstChunk).CopyTo(destination[firstChunk..]);
}
Volatile.Write(ref head, (currentHead + toRead) & 0x7FFFFFFF);
}
if (toRead < destination.Length)
{
destination[toRead..].Clear();
Interlocked.Increment(ref underruns);
}
return toRead;
}
}
+7
View File
@@ -0,0 +1,7 @@
namespace RemSound.Core;
public enum AudioTransportCodec
{
Pcm = 1,
Opus = 2
}
+38
View File
@@ -0,0 +1,38 @@
namespace RemSound.Core;
/// <summary>
/// Whether a capture source pulls audio via WASAPI loopback (rendering side of an output device,
/// e.g. system audio / a soundcard's playback) or via direct WASAPI capture (microphones,
/// line-ins, USB capture inputs).
/// </summary>
public enum CaptureKind
{
Loopback,
Input,
}
/// <summary>
/// Identifies one source the sender should mix into the outgoing stream. <see cref="Name"/> is
/// purely for diagnostic logging; <see cref="DeviceId"/> is either a WASAPI MMDevice ID or a
/// synthetic ASIO id of the form <c>"asio:&lt;channel-pair-index&gt;"</c>.
/// </summary>
public sealed record CaptureSourceSpec(string DeviceId, CaptureKind Kind, string Name);
/// <summary>
/// Helpers for the synthetic ASIO device-id format used by both sender and receiver backends.
/// Each ASIO channel pair (stereo) is identified by its zero-based pair index — pair 0 is ASIO
/// channels 0+1, pair 1 is 2+3, etc. The driver itself isn't encoded in the id; only one ASIO
/// driver is active per session and it's configured separately.
/// </summary>
public static class AsioDeviceId
{
public static string Format(int channelPair) => $"asio:{channelPair}";
public static bool TryParse(string deviceId, out int channelPair)
{
channelPair = -1;
if (string.IsNullOrEmpty(deviceId)) return false;
if (!deviceId.StartsWith("asio:", StringComparison.OrdinalIgnoreCase)) return false;
return int.TryParse(deviceId.AsSpan("asio:".Length), out channelPair) && channelPair >= 0;
}
}
+38
View File
@@ -0,0 +1,38 @@
namespace RemSound.Core;
/// <summary>
/// What kind of audio the receiver synthesises across an underrun gap. The receiver-side
/// playout buffer can come up empty for a few frames if the network is late or if the local
/// audio thread woke up faster than packets arrived; the original behaviour was a hard zero
/// (audible click). 2026-05-04 introduced a brief cosine fade so the *edges* of the gap are
/// smooth — but a 32-frame cosine creates a spectral peak near 750 Hz, which sounds like a
/// brief F#-ish tone every time it fires. For dense networks this can be a perceptible
/// pattern. The user picks the artifact character here.
///
/// Receiver-side only. The sender has no idea its packets came up late at the listener; each
/// listening machine decides locally what its own underruns sound like. Stored per-profile.
/// </summary>
public enum ConcealmentArtifact
{
/// <summary>Legacy: 32-frame cosine fade-out + fade-in. ~750 Hz spectral peak — brief tone
/// close to F#5. Was the default 2026-05-04 to 2026-05-06; removed from the dropdown after
/// user feedback that it sounded harsh on orchestral content. Kept in the enum so old
/// profile JSONs still parse; the dialog coerces it to NoiseBurst on load.</summary>
CosineToneShort = 0,
/// <summary>Legacy: 96-frame cosine fade. ~250 Hz spectral peak — softer thump than the
/// short variant. Removed from the dropdown 2026-05-06 same as CosineToneShort. Kept for
/// back-compat with old profile JSONs.</summary>
CosineToneLow = 1,
/// <summary>32-frame burst of white noise enveloped at the last sample's amplitude.
/// Energy is broadband (no audible pitch); sounds like a brief shhh and tends to blend
/// into music more than a tone does. The current default since 2026-05-06.</summary>
NoiseBurst = 2,
/// <summary>No concealment. Hard zero-fill across the gap (the pre-2026-05-04 behaviour).
/// You'll hear the raw click at the amplitude transition — useful for direct
/// comparison with the smoothed options, or if the click somehow bothers you less than
/// any of the synthesised artifacts.</summary>
Click = 3,
}
+43
View File
@@ -0,0 +1,43 @@
namespace RemSound.Core;
/// <summary>
/// Single shared on/off switch for the engine's diagnostic instrumentation. The App sets
/// <see cref="Enabled"/> at startup from <c>AppConfig.LoggingEnabled</c> and re-sets it
/// whenever the user toggles the <em>Enable logs</em> checkbox in Preferences. Every probe
/// site in <c>RemSound.Sender</c> and <c>RemSound.Receiver</c> reads this flag as its very
/// first action and bails before doing any measurement, CAS update or per-sample arithmetic
/// when it is false.
///
/// What's behind this gate:
/// <list type="bullet">
/// <item>Sender-side max-time probes — <c>SenderLane.OnMixedSamples</c> emit timing,
/// <c>AudioSender.SendToAll</c> kernel-send timing, capture-callback gap timers in
/// <c>AsioCaptureBackend</c> and <c>MixingEngine</c>.</item>
/// <item>Receiver-side max-time probes — <c>NetworkListener</c> dispatch timing,
/// <c>ReceiverDiagnostics</c> arrival-gap and render-callback-gap recording.</item>
/// <item>The per-sample envelope-spike detector
/// (<c>ReceiverDiagnostics.RecordOutputSampleSteps</c>), which iterates every output
/// sample doing second-derivative arithmetic and is the most expensive probe.</item>
/// </list>
///
/// What's <em>not</em> behind this gate: the running counters that feed the always-visible
/// status footer (packets sent, packets received, bytes, underruns, drops). Those are cheap
/// <c>Interlocked.Add</c> calls and the UI needs them whether logs are on or off.
///
/// Flag is plain <c>volatile</c>: the audio path reads it on every callback; lock-free reads
/// are essential, and the only writer is the UI thread on a checkbox-toggle (effectively
/// once per session). The gate flips on or off cleanly without any inflight write needing to
/// see the new value mid-probe.
/// </summary>
public static class DiagnosticsGate
{
private static volatile bool enabled;
/// <summary>True when the engine should run its diagnostic instrumentation. Set by the
/// App at startup and on every toggle of the Enable-logs checkbox.</summary>
public static bool Enabled
{
get => enabled;
set => enabled = value;
}
}
+73
View File
@@ -0,0 +1,73 @@
using System.Runtime.InteropServices;
using System.Windows.Forms;
namespace RemSound.Core;
public sealed class GlobalHotkey : NativeWindow, IDisposable
{
private const int WmHotkey = 0x0312;
private const uint ModAlt = 0x0001;
private const uint ModControl = 0x0002;
private const uint ModShift = 0x0004;
private const uint ModNoRepeat = 0x4000;
private static int nextId = 0x5253;
private readonly int id = Interlocked.Increment(ref nextId);
private bool registered;
public event Action? Pressed;
public GlobalHotkey(Form owner) => AssignHandle(owner.Handle);
/// <summary>Register the global hotkey. <paramref name="allowRepeat"/> controls whether
/// holding the key down fires <see cref="Pressed"/> repeatedly at the OS keyboard
/// auto-repeat rate. Default <c>false</c> = Windows' MOD_NOREPEAT flag is set, so each
/// physical press fires exactly once (the right semantic for toggle hotkeys — mute,
/// tray show/hide — where re-firing on hold would flip state back and forth). Pass
/// <c>true</c> for step hotkeys where holding the key is meant to ramp a value
/// (volume up/down, both local and remote/system variants).</summary>
public bool Register(HotkeyInfo hotkey, bool allowRepeat = false)
{
Unregister();
uint modifiers = allowRepeat ? 0 : ModNoRepeat;
if (hotkey.Control) modifiers |= ModControl;
if (hotkey.Shift) modifiers |= ModShift;
if (hotkey.Alt) modifiers |= ModAlt;
registered = RegisterHotKey(Handle, id, modifiers, (uint)hotkey.Key);
LastWin32ErrorOnRegister = registered ? 0 : Marshal.GetLastWin32Error();
return registered;
}
/// <summary>The Win32 GetLastError value captured immediately after the most recent
/// failed <see cref="Register"/> call. 0 when the last register call succeeded. Useful
/// for distinguishing "another app already owns this combo" (1409 ERROR_HOTKEY_ALREADY_REGISTERED)
/// from other failure modes.</summary>
public int LastWin32ErrorOnRegister { get; private set; }
public void Unregister()
{
if (!registered) return;
UnregisterHotKey(Handle, id);
registered = false;
}
protected override void WndProc(ref Message m)
{
if (m.Msg == WmHotkey && m.WParam.ToInt32() == id)
{
Pressed?.Invoke();
}
base.WndProc(ref m);
}
public void Dispose()
{
Unregister();
ReleaseHandle();
}
[DllImport("user32.dll", SetLastError = true)]
private static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk);
[DllImport("user32.dll", SetLastError = true)]
private static extern bool UnregisterHotKey(IntPtr hWnd, int id);
}
+344
View File
@@ -0,0 +1,344 @@
using System.Diagnostics;
using System.Net;
namespace RemSound.Core;
/// <summary>
/// Bidirectional UDP heartbeat: every selected peer is pinged once per second; pongs are
/// echoed back; the sender computes RTT against its own monotonic clock and tracks per-peer
/// reachability state.
///
/// SINGLE-PORT MODEL (2026-05-06):
/// This service no longer binds a UDP socket of its own. All heartbeat traffic flows on
/// the audio port (default 47830) — outbound via the audio sender's UDP socket (which is
/// the same NAT pinhole the audio packets use), inbound via the audio receiver's listener
/// (LAN: peer pings our audio port directly) or the audio sender's recv-side (WAN/relay:
/// pings come back through the relay on our sender's ephemeral source port). The App
/// forwards heartbeat packets from both sources into <see cref="HandleInjectedPacket"/>.
///
/// Why we collapsed audioPort+2 into the audio port:
/// * The +2 socket only existed because the audio receiver used to be bound on demand
/// (driven by the user's "Receive audio" tick), and heartbeats need a socket that's
/// bound regardless. Splitting <see cref="AudioReceiver.Start"/> from
/// <see cref="AudioReceiver.SetPlaybackEnabled"/> removed that gap — the listener
/// socket is bound for the duration of a connection.
/// * Asymmetric send-only / receive-only configs broke heartbeat under the old dual-
/// transport scheme (relay path drops the ping when the peer's audio port has no
/// listener). With the listener always bound and the heartbeat travelling on the
/// same port, the asymmetry disappears.
/// * One firewall rule, one router pinhole, one mental model.
///
/// Why 1 Hz cadence instead of the more common 2025 s NAT-keepalive interval:
/// - Tiny packets (21 B), so 21 B/s is irrelevant overhead.
/// - Detects unreachability within ~35 s instead of 30+ s.
/// - 1 s ≪ NAT timeout (30 s+ on virtually all consumer routers), so keepalive role is
/// covered too.
///
/// RTT computation borrows the RTCP DLSR pattern (RFC 3550) in simplified form: the originator
/// stamps the Ping with its own Stopwatch.ElapsedMilliseconds; the responder echoes that value
/// verbatim in the Pong; the originator computes <c>now - pongPayload.originatorTickMs</c>
/// using only its own clock. No peer-clock sync needed.
/// </summary>
public sealed class HeartbeatService : IDisposable
{
/// <summary>How often a Ping is sent to each tracked peer.</summary>
public static readonly TimeSpan PingInterval = TimeSpan.FromSeconds(1);
/// <summary>If the most recent Pong is younger than this, the peer is healthy.</summary>
public static readonly TimeSpan HealthyWindow = TimeSpan.FromSeconds(2);
/// <summary>If the most recent Pong is older than this, the peer is unreachable.</summary>
public static readonly TimeSpan UnreachableWindow = TimeSpan.FromSeconds(5);
private readonly Action<string>? onDiagnostic;
private readonly object gate = new();
private readonly Dictionary<string, PeerState> peers = new(StringComparer.OrdinalIgnoreCase);
private readonly Stopwatch monotonic = Stopwatch.StartNew();
private CancellationTokenSource? cts;
private Task? sendTask;
private uint sequence;
/// <summary>
/// Outbound transport for heartbeat packets. REQUIRED — without it Start() succeeds but
/// no pings are emitted. Wire it to <see cref="RemSound.Sender.AudioSender.SendVia"/>
/// (or any equivalent UDP send delegate) so heartbeats share the audio sender's NAT
/// pinhole. The bool return is the success indicator (true = sent, false = transport
/// error / socket not bound). Pong replies route through the same transport.
/// </summary>
public Func<byte[], int, IPEndPoint, bool>? SendTransport { get; set; }
public HeartbeatService(Action<string>? onDiagnostic = null)
{
this.onDiagnostic = onDiagnostic;
}
public bool IsRunning => sendTask is not null;
public void Start()
{
lock (gate)
{
if (IsRunning) return;
cts = new CancellationTokenSource();
sendTask = Task.Run(() => SendLoop(cts.Token));
onDiagnostic?.Invoke("started (single-port)");
}
}
public void Stop()
{
lock (gate) StopInternal();
}
private void StopInternal()
{
try { cts?.Cancel(); } catch { /* ignore */ }
try { sendTask?.Wait(TimeSpan.FromMilliseconds(500)); } catch { /* ignore */ }
cts?.Dispose();
cts = null;
sendTask = null;
}
public void Dispose() => Stop();
/// <summary>
/// Replaces the tracked peer set. Each endpoint is the peer's audio port — heartbeat
/// targets the same port (single-port model). Removing a peer wipes its tracked state
/// immediately; adding a new one starts in the Unknown state until the first Pong arrives.
/// </summary>
public void SetTrackedPeers(IEnumerable<IPEndPoint> audioEndpoints)
{
lock (gate)
{
var desired = new Dictionary<string, IPEndPoint>(StringComparer.OrdinalIgnoreCase);
foreach (var ep in audioEndpoints)
{
desired[KeyFor(ep)] = ep;
}
// Remove peers that are no longer selected.
foreach (var key in peers.Keys.Where(k => !desired.ContainsKey(k)).ToList())
{
peers.Remove(key);
}
// Add or update peers.
foreach (var (key, ep) in desired)
{
if (!peers.TryGetValue(key, out var p))
{
peers[key] = new PeerState { AudioEndpoint = ep };
}
else
{
p.AudioEndpoint = ep;
}
}
}
}
/// <summary>
/// Snapshot of the current health state of every tracked peer. Safe to call from any thread.
/// </summary>
public IReadOnlyList<PeerHealth> GetAllPeerHealth()
{
lock (gate)
{
var nowUtc = DateTime.UtcNow;
var result = new List<PeerHealth>(peers.Count);
foreach (var p in peers.Values)
{
result.Add(SnapshotHealthLocked(p, nowUtc));
}
return result;
}
}
/// <summary>
/// One-line summary suitable for the snapshot log column or status label.
/// "no peers" / "192.168.1.5: 24ms" / "192.168.1.5: 24ms, 192.168.1.6: unreachable 7s".
/// </summary>
public string GetHealthSummary()
{
var entries = GetAllPeerHealth();
if (entries.Count == 0) return "no peers";
return string.Join(", ", entries.Select(FormatPeer));
static string FormatPeer(PeerHealth p) => p.State switch
{
PeerHealthState.Healthy when p.RttMs is { } rtt => $"{p.AudioEndpoint.Address}: {rtt}ms",
PeerHealthState.Stale when p.AgeOfLastPong is { } age => $"{p.AudioEndpoint.Address}: stale {age.TotalSeconds:0.0}s",
PeerHealthState.Unreachable when p.AgeOfLastPong is { } age => $"{p.AudioEndpoint.Address}: unreachable {age.TotalSeconds:0.0}s",
_ => $"{p.AudioEndpoint.Address}: pending",
};
}
private static string KeyFor(IPEndPoint ep) => $"{ep.Address}:{ep.Port}";
private PeerHealth SnapshotHealthLocked(PeerState p, DateTime nowUtc)
{
if (p.LastPongUtc is null)
{
// Never heard from. If we've been pinging for a while with no response, that's
// "unreachable"; otherwise still "unknown / pending".
if (p.FirstPingSentUtc is { } firstPing && nowUtc - firstPing > UnreachableWindow)
{
return new PeerHealth(p.AudioEndpoint, PeerHealthState.Unreachable, null, nowUtc - firstPing);
}
return new PeerHealth(p.AudioEndpoint, PeerHealthState.Unknown, null, null);
}
var age = nowUtc - p.LastPongUtc.Value;
var state = age <= HealthyWindow
? PeerHealthState.Healthy
: (age <= UnreachableWindow ? PeerHealthState.Stale : PeerHealthState.Unreachable);
return new PeerHealth(p.AudioEndpoint, state, p.RttEwmaMs, age);
}
private async Task SendLoop(CancellationToken ct)
{
try
{
while (!ct.IsCancellationRequested)
{
await Task.Delay(PingInterval, ct).ConfigureAwait(false);
SendPings();
}
}
catch (OperationCanceledException) { /* expected on shutdown */ }
catch (Exception ex)
{
onDiagnostic?.Invoke($"send loop ended: {ex.GetType().Name}: {ex.Message}");
}
}
private void SendPings()
{
var transport = SendTransport;
if (transport is null) return;
List<PeerState> targets;
lock (gate)
{
targets = peers.Values.ToList();
var nowUtc = DateTime.UtcNow;
foreach (var p in targets) p.FirstPingSentUtc ??= nowUtc;
}
// Build packet. streamId is fixed at 0xFFFF for heartbeats so it's distinguishable
// in any future stream-aware filter; sequence increments locally per send.
Span<byte> packet = stackalloc byte[RemPacket.HeaderSize + RemPacket.HeartbeatPayloadSize];
var seq = Interlocked.Increment(ref sequence);
var tickMs = monotonic.ElapsedMilliseconds;
RemPacket.WriteHeader(packet, RemPacketType.Heartbeat, 0xFFFF, seq);
RemPacket.WriteHeartbeatPayload(packet[RemPacket.HeaderSize..], HeartbeatKind.Ping, tickMs);
var bytes = packet.ToArray();
foreach (var p in targets)
{
try
{
var ok = transport(bytes, bytes.Length, p.AudioEndpoint);
onDiagnostic?.Invoke($"send seq={seq} to={p.AudioEndpoint} {(ok ? "ok" : "FAILED")}");
}
catch (Exception ex)
{
onDiagnostic?.Invoke($"send to {p.AudioEndpoint} failed: {ex.GetType().Name}: {ex.Message}");
}
}
}
/// <summary>
/// Inject a heartbeat packet that arrived on one of the App's other sockets (the audio
/// receiver's listener for LAN, or the audio sender's recv-side for relay-return). This
/// is the ONLY inbound path in single-port mode — the service no longer binds a socket
/// of its own. Same processing as the old local-socket receive: parse, echo Pongs back
/// via <see cref="SendTransport"/>, update RTT state on Pong arrival.
/// </summary>
public void HandleInjectedPacket(byte[] buffer, int length, IPEndPoint remote)
{
// Tighten the buffer to `length` so HandlePacket's spans don't read trailing bytes.
if (length < buffer.Length)
{
var trimmed = new byte[length];
Array.Copy(buffer, trimmed, length);
HandlePacket(trimmed, remote);
}
else
{
HandlePacket(buffer, remote);
}
}
private void HandlePacket(byte[] buffer, IPEndPoint remote)
{
if (!RemPacket.TryReadHeader(buffer, out var type, out _, out _)) return;
if (type != RemPacketType.Heartbeat) return;
var payload = buffer.AsSpan(RemPacket.HeaderSize);
if (!RemPacket.TryReadHeartbeat(payload, out var kind, out var originatorTickMs)) return;
if (kind == HeartbeatKind.Ping)
{
onDiagnostic?.Invoke($"recv ping from={remote}");
// Echo the originator's timestamp back to them as a Pong. Reply target is the
// remote source endpoint (whatever socket the ping came in on, that's where to
// send the pong) — this works for both LAN-direct (peer's audio port) and
// relay-return (relay's source port) without us needing to know which.
Span<byte> reply = stackalloc byte[RemPacket.HeaderSize + RemPacket.HeartbeatPayloadSize];
var seq = Interlocked.Increment(ref sequence);
RemPacket.WriteHeader(reply, RemPacketType.Heartbeat, 0xFFFF, seq);
RemPacket.WriteHeartbeatPayload(reply[RemPacket.HeaderSize..], HeartbeatKind.Pong, originatorTickMs);
var bytes = reply.ToArray();
try { SendTransport?.Invoke(bytes, bytes.Length, remote); }
catch { /* UDP, ignore */ }
return;
}
// Pong: compute RTT vs our own clock, update peer state. We expect this peer to be
// tracked (we sent a ping that produced this pong) — but we match by IP only since
// the source port of an incoming pong is the peer's outbound source port (NAT can
// rewrite, and on LAN it's the peer's ephemeral sender port, not the audio port).
var nowMs = monotonic.ElapsedMilliseconds;
var rttMs = (int)Math.Max(0, nowMs - originatorTickMs);
var nowUtc = DateTime.UtcNow;
var matchedCount = 0;
lock (gate)
{
foreach (var p in peers.Values)
{
if (!p.AudioEndpoint.Address.Equals(remote.Address)) continue;
p.LastRttMs = rttMs;
p.RttEwmaMs = p.RttEwmaMs is null ? rttMs : (int)(p.RttEwmaMs.Value * 0.7 + rttMs * 0.3);
p.LastPongUtc = nowUtc;
matchedCount++;
}
}
// Diagnostic for the Pong path. matched=0 means we got a pong from an IP we don't
// track (suspicious — possible loopback / echo), >0 is the normal case.
onDiagnostic?.Invoke($"recv pong from={remote} rtt={rttMs}ms matched={matchedCount} origTickMs={originatorTickMs} nowMs={nowMs}");
}
private sealed class PeerState
{
public IPEndPoint AudioEndpoint { get; set; } = null!;
public DateTime? FirstPingSentUtc { get; set; }
public DateTime? LastPongUtc { get; set; }
public int? LastRttMs { get; set; }
public int? RttEwmaMs { get; set; }
}
}
public enum PeerHealthState
{
Unknown,
Healthy,
Stale,
Unreachable,
}
public sealed record PeerHealth(
IPEndPoint AudioEndpoint,
PeerHealthState State,
int? RttMs,
TimeSpan? AgeOfLastPong);
+140
View File
@@ -0,0 +1,140 @@
using System.Windows.Forms;
namespace RemSound.Core;
public sealed class HotkeyCaptureForm : Form
{
private readonly Label instructionLabel = new() { AutoSize = true };
private readonly TextBox hotkeyTextBox = new() { ReadOnly = true, Width = 360 };
private readonly Button cancelButton = new() { Text = "Cancel", AutoSize = true };
private HotkeyInfo? pendingHotkey;
private bool capturingCombination;
public HotkeyCaptureForm()
{
Text = "Change hotkey";
Width = 420;
Height = 180;
KeyPreview = true;
AccessibleName = "Change hotkey";
instructionLabel.Text = "Hold the full key combination, then release it to save it automatically.";
instructionLabel.MaximumSize = new Size(360, 0);
hotkeyTextBox.AccessibleName = "Current hotkey";
hotkeyTextBox.Text = "Press a hotkey combination.";
hotkeyTextBox.KeyDown += CaptureKeyDown;
hotkeyTextBox.KeyUp += CaptureKeyUp;
cancelButton.Click += (_, _) => { DialogResult = DialogResult.Cancel; Close(); };
var panel = new FlowLayoutPanel
{
Dock = DockStyle.Fill,
FlowDirection = FlowDirection.TopDown,
Padding = new Padding(12),
AutoSize = true,
};
panel.Controls.Add(instructionLabel);
panel.Controls.Add(hotkeyTextBox);
panel.Controls.Add(cancelButton);
Controls.Add(panel);
Shown += (_, _) => hotkeyTextBox.Focus();
}
public HotkeyInfo? CapturedHotkey { get; private set; }
/// <summary>True if the user pressed a modifier key (Ctrl / Shift / Alt) at any point
/// during this capture session. Used in conjunction with <see cref="SawAnyNonModifier"/>
/// to detect "user tried to bind a combo but the non-modifier key was swallowed by a
/// low-level keyboard hook" — see <see cref="SawAnyNonModifier"/>.</summary>
public bool SawAnyModifier { get; private set; }
/// <summary>True if the user pressed any non-Escape, non-modifier key during this
/// capture session. If <see cref="SawAnyModifier"/> is true but this is false when the
/// dialog closes without an OK result, we observed the modifier keys but never the
/// final key the user was trying to bind — strong indicator that another app
/// (NVDA / NVDA Remote / AutoHotkey / etc.) is intercepting the combination at a
/// low-level keyboard hook before our window sees it. The caller can use that to
/// show a clear "your combo is being hooked elsewhere" message.</summary>
public bool SawAnyNonModifier { get; private set; }
protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
{
if (msg.Msg is 0x0100 or 0x0104) { CaptureKeyData(keyData); return true; }
if (msg.Msg is 0x0101 or 0x0105) { HandleKeyRelease(); return true; }
return base.ProcessCmdKey(ref msg, keyData);
}
private void CaptureKeyDown(object? sender, KeyEventArgs e)
{
CaptureKeyData(e.KeyData);
e.SuppressKeyPress = true;
e.Handled = true;
}
private void CaptureKeyUp(object? sender, KeyEventArgs e)
{
HandleKeyRelease();
e.SuppressKeyPress = true;
e.Handled = true;
}
private void CaptureKeyData(Keys keyData)
{
var key = keyData & Keys.KeyCode;
var control = keyData.HasFlag(Keys.Control);
var shift = keyData.HasFlag(Keys.Shift);
var alt = keyData.HasFlag(Keys.Alt);
if (key == Keys.Escape) { DialogResult = DialogResult.Cancel; Close(); return; }
if (IsModifier(key))
{
SawAnyModifier = true;
capturingCombination = true;
hotkeyTextBox.Text = BuildModifierPrompt(control, alt, shift);
return;
}
// Anything that passed the Escape + IsModifier filters is a "real" non-modifier key.
// Tracking this lets the caller distinguish "user pressed Esc immediately" from
// "user held modifiers but the non-modifier key was eaten by a low-level hook".
SawAnyNonModifier = true;
var proposed = new HotkeyInfo(key, control, shift, alt);
if (!proposed.IsValid)
{
hotkeyTextBox.Text = "Hotkey must include a modifier and a non-modifier key.";
return;
}
capturingCombination = true;
pendingHotkey = proposed;
hotkeyTextBox.Text = proposed.ToString();
}
private void HandleKeyRelease()
{
if (!capturingCombination || pendingHotkey is null) return;
CapturedHotkey = pendingHotkey;
pendingHotkey = null;
capturingCombination = false;
BeginInvoke(() => { DialogResult = DialogResult.OK; Close(); });
}
private static bool IsModifier(Keys key) =>
key is Keys.ControlKey or Keys.ShiftKey or Keys.Menu
or Keys.LControlKey or Keys.RControlKey
or Keys.LShiftKey or Keys.RShiftKey
or Keys.LMenu or Keys.RMenu;
private static string BuildModifierPrompt(bool control, bool alt, bool shift)
{
var parts = new List<string>(3);
if (control) parts.Add("Control");
if (alt) parts.Add("Alt");
if (shift) parts.Add("Shift");
return parts.Count == 0 ? "Press a full key combination." : string.Join("+", parts) + "+...";
}
}
+30
View File
@@ -0,0 +1,30 @@
using System.Windows.Forms;
namespace RemSound.Core;
public sealed record HotkeyInfo(Keys Key, bool Control, bool Shift, bool Alt)
{
public static HotkeyInfo Default { get; } = new(Keys.M, true, true, true);
/// <summary>Sentinel for "no hotkey assigned". Used by features that want a global hotkey
/// to be opt-in rather than always-on (volume up/down, etc.). The hotkey controller skips
/// registration silently when a hotkey is unset.</summary>
public static HotkeyInfo Unset { get; } = new(Keys.None, false, false, false);
public bool IsUnset => Key == Keys.None && !Control && !Shift && !Alt;
public bool IsValid =>
(Control || Shift || Alt) &&
Key is not Keys.None and not Keys.ControlKey and not Keys.ShiftKey and not Keys.Menu;
public override string ToString()
{
if (IsUnset) return "(not set)";
var parts = new List<string>(4);
if (Control) parts.Add("Control");
if (Shift) parts.Add("Shift");
if (Alt) parts.Add("Alt");
parts.Add(Key.ToString());
return string.Join("+", parts);
}
}
+51
View File
@@ -0,0 +1,51 @@
namespace RemSound.Core;
/// <summary>
/// Float32 ↔ packed signed 24-bit little-endian conversions. The 24-bit format is what we put on the wire
/// (3 bytes per sample, no padding) — same quality as float32 in the audible range, 25% less bandwidth.
/// </summary>
public static class PcmPack
{
/// <summary>
/// Pack a span of float samples (range 1..+1) into signed 24-bit little-endian PCM.
/// Destination must be at least <c>source.Length * 3</c> bytes.
/// </summary>
public static void FloatToInt24LE(ReadOnlySpan<float> source, Span<byte> destination)
{
if (destination.Length < source.Length * 3)
{
throw new ArgumentException("Destination too small", nameof(destination));
}
for (int i = 0, j = 0; i < source.Length; i++, j += 3)
{
var clamped = Math.Clamp(source[i], -1f, 1f);
// Signed-symmetric: scale by 2^23 - 1 then truncate. Round-to-nearest avoided here on purpose
// because the audio path is already band-limited; the extra ULP is inaudible and the cost matters.
var sample = (int)(clamped * 8388607f);
destination[j] = (byte)(sample & 0xFF);
destination[j + 1] = (byte)((sample >> 8) & 0xFF);
destination[j + 2] = (byte)((sample >> 16) & 0xFF);
}
}
/// <summary>
/// Unpack signed 24-bit little-endian PCM into floats in [1, +1].
/// </summary>
public static void Int24LEToFloat(ReadOnlySpan<byte> source, Span<float> destination)
{
var sampleCount = source.Length / 3;
if (destination.Length < sampleCount)
{
throw new ArgumentException("Destination too small", nameof(destination));
}
for (int i = 0, j = 0; i < sampleCount; i++, j += 3)
{
// Sign-extend by shifting left to bit 31 then arithmetic right back.
int packed = (source[j]) | (source[j + 1] << 8) | (source[j + 2] << 16);
int signed = (packed << 8) >> 8;
destination[i] = signed / 8388607f;
}
}
}
+15
View File
@@ -0,0 +1,15 @@
using System.Net;
namespace RemSound.Core;
public sealed record PeerAnnouncement(
Guid InstanceId,
string Name,
int AudioPort,
bool CanSend,
bool CanReceive,
DateTime LastSeenUtc,
IPAddress Address)
{
public string DisplayName => $"{Name} at {Address}";
}
+257
View File
@@ -0,0 +1,257 @@
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
namespace RemSound.Core;
/// <summary>
/// UDP peer discovery. Each running instance announces itself on
/// <see cref="DefaultDiscoveryPort"/> every 1.5 s. Peers expire after 8 s of silence.
///
/// Announcements go out two ways:
/// • Broadcast on every connected LAN subnet (for same-network discovery — instant on
/// home/office wifi).
/// • Unicast to a configurable list of "known" IPs (for VPN/Tailscale/WAN discovery —
/// broadcast doesn't traverse VPN tunnels, so we explicitly send announcements to
/// remembered/manual peer addresses). The App keeps this list in sync via
/// <see cref="SetUnicastPeerAddresses"/>.
/// </summary>
public sealed class PeerDiscoveryService : IDisposable
{
public const int DefaultDiscoveryPort = 47821;
private readonly Guid instanceId = Guid.NewGuid();
private readonly object gate = new();
private readonly Dictionary<Guid, PeerAnnouncement> peers = [];
private CancellationTokenSource? cts;
private UdpClient? listener;
private UdpClient? announcer;
private Task? listenTask;
private Task? announceTask;
private int audioPort = RemPacket.DefaultPort;
private bool canSend;
private bool canReceive;
private bool announceEnabled = true;
// Snapshot of "send announcements directly to these IPs each tick" — typically the user's
// remembered + manually-typed peer IPs. Replaced atomically; the announce loop reads the
// reference once per tick. Volatile-write semantics via the assignment under the gate are
// sufficient because we only ever swap the reference, never mutate in place.
private IReadOnlyList<IPAddress> unicastTargets = [];
public event Action? PeersChanged;
public IReadOnlyList<PeerAnnouncement> Peers
{
get
{
lock (gate)
{
PruneExpiredPeers();
return peers.Values.OrderBy(p => p.Name).ThenBy(p => p.Address.ToString()).ToList();
}
}
}
public void Start(int selectedAudioPort, bool sendEnabled, bool receiveEnabled)
{
Stop();
audioPort = selectedAudioPort;
canSend = sendEnabled;
canReceive = receiveEnabled;
announceEnabled = true;
cts = new CancellationTokenSource();
listener = new UdpClient(AddressFamily.InterNetwork);
listener.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
listener.EnableBroadcast = true;
listener.Client.Bind(new IPEndPoint(IPAddress.Any, DefaultDiscoveryPort));
announcer = new UdpClient(AddressFamily.InterNetwork) { EnableBroadcast = true };
listenTask = Task.Run(() => ListenLoop(cts.Token));
announceTask = Task.Run(() => AnnounceLoop(cts.Token));
}
public void UpdateCapabilities(int selectedAudioPort, bool sendEnabled, bool receiveEnabled)
{
audioPort = selectedAudioPort;
canSend = sendEnabled;
canReceive = receiveEnabled;
SendAnnouncement();
}
public void SetAnnounceEnabled(bool enabled)
{
announceEnabled = enabled;
if (enabled) SendAnnouncement();
}
/// <summary>
/// Updates the list of IP addresses that announcements should be unicast to in addition to
/// LAN broadcast. The App calls this whenever its remembered+manual-peers set changes; the
/// loop reads the latest snapshot on each tick.
///
/// Why unicast at all: broadcast doesn't traverse VPNs (Tailscale, WireGuard, ZeroTier).
/// To be discoverable over a VPN we have to explicitly announce to each known IP. Sending
/// to a remembered peer that happens to be offline is harmless — UDP is fire-and-forget.
/// </summary>
public void SetUnicastPeerAddresses(IEnumerable<IPAddress> addresses)
{
unicastTargets = addresses.Distinct().ToList();
SendAnnouncement();
}
public void Stop()
{
cts?.Cancel();
listener?.Dispose();
announcer?.Dispose();
listener = null;
announcer = null;
cts?.Dispose();
cts = null;
}
public void Dispose() => Stop();
private async Task ListenLoop(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
try
{
var result = await listener!.ReceiveAsync(token).ConfigureAwait(false);
var json = Encoding.UTF8.GetString(result.Buffer);
var message = JsonSerializer.Deserialize<DiscoveryMessage>(json);
if (message is null || message.InstanceId == instanceId) continue;
var peer = new PeerAnnouncement(
message.InstanceId,
string.IsNullOrWhiteSpace(message.Name) ? result.RemoteEndPoint.Address.ToString() : message.Name.Trim(),
message.AudioPort,
message.CanSend,
message.CanReceive,
DateTime.UtcNow,
result.RemoteEndPoint.Address);
// Auto-add the source IP to our unicast targets so subsequent announcements go
// back the way they came. This is what makes discovery bidirectional over a
// VPN: A unicasts to B (because A had B remembered/manually-added) → B receives
// it → B adds A to its own unicast list → B's announcements now reach A too,
// even though A was never in B's remembered list. Without this, only the side
// that had typed the other's IP would see the other.
AddUnicastTarget(result.RemoteEndPoint.Address);
bool changed;
lock (gate)
{
changed = !peers.TryGetValue(peer.InstanceId, out var existing)
|| existing.Name != peer.Name
|| existing.AudioPort != peer.AudioPort
|| existing.CanSend != peer.CanSend
|| existing.CanReceive != peer.CanReceive
|| !Equals(existing.Address, peer.Address);
peers[peer.InstanceId] = peer;
PruneExpiredPeers();
}
if (changed) PeersChanged?.Invoke();
}
catch (OperationCanceledException) { break; }
catch (ObjectDisposedException) { break; }
catch
{
try { await Task.Delay(500, token).ConfigureAwait(false); } catch { break; }
}
}
}
private void AddUnicastTarget(IPAddress address)
{
// Idempotent — only swap the snapshot if this IP isn't already there. Avoids churning
// the list on every received announcement (which is every 1.5 s per peer).
var current = unicastTargets;
if (current.Any(a => a.Equals(address))) return;
var updated = current.ToList();
updated.Add(address);
unicastTargets = updated;
}
private async Task AnnounceLoop(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
SendAnnouncement();
try { await Task.Delay(1500, token).ConfigureAwait(false); }
catch (OperationCanceledException) { break; }
}
}
private void SendAnnouncement()
{
var currentAnnouncer = announcer;
if (currentAnnouncer is null || !announceEnabled) return;
var message = new DiscoveryMessage(instanceId, Environment.MachineName, audioPort, canSend, canReceive);
var bytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(message));
// Broadcast to LAN — instant discovery on the same physical/wifi network. Each connected
// NIC gets its own subnet broadcast (e.g. 192.168.1.255).
foreach (var broadcastAddress in GetBroadcastAddresses())
{
try
{
currentAnnouncer.Send(bytes, bytes.Length, new IPEndPoint(broadcastAddress, DefaultDiscoveryPort));
}
catch
{
// Discovery is convenience. Audio still works without it.
}
}
// Unicast to known peer IPs — covers Tailscale / VPN / WAN where broadcast doesn't
// traverse the tunnel. Sending to an offline peer is silent fire-and-forget.
foreach (var unicast in unicastTargets)
{
try
{
currentAnnouncer.Send(bytes, bytes.Length, new IPEndPoint(unicast, DefaultDiscoveryPort));
}
catch
{
// Same — discovery is best-effort.
}
}
}
private static IEnumerable<IPAddress> GetBroadcastAddresses()
{
var addresses = new HashSet<IPAddress> { IPAddress.Broadcast };
foreach (var ni in NetworkInterface.GetAllNetworkInterfaces())
{
if (ni.OperationalStatus != OperationalStatus.Up || ni.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue;
foreach (var unicast in ni.GetIPProperties().UnicastAddresses)
{
if (unicast.Address.AddressFamily != AddressFamily.InterNetwork || unicast.IPv4Mask is null) continue;
var addr = unicast.Address.GetAddressBytes();
var mask = unicast.IPv4Mask.GetAddressBytes();
var bcast = new byte[4];
for (var i = 0; i < 4; i++) bcast[i] = (byte)(addr[i] | ~mask[i]);
addresses.Add(new IPAddress(bcast));
}
}
return addresses;
}
private void PruneExpiredPeers()
{
var cutoff = DateTime.UtcNow.AddSeconds(-8);
foreach (var peer in peers.Values.Where(p => p.LastSeenUtc < cutoff).ToList())
{
peers.Remove(peer.InstanceId);
}
}
private sealed record DiscoveryMessage(Guid InstanceId, string Name, int AudioPort, bool CanSend, bool CanReceive);
}
+174
View File
@@ -0,0 +1,174 @@
using System.Text.Json.Serialization;
using System.Windows.Forms;
namespace RemSound.Core;
/// <summary>
/// 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
/// <c>&lt;exe&gt;\profiles\&lt;machine name&gt;\&lt;title&gt;.json</c> 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.
/// </summary>
public sealed class Profile
{
/// <summary>Display title and filename stem (sanitised). Required.</summary>
public string Title { 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 ===
/// <summary>The ASIO driver this profile uses. <c>null</c> 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 <c>"AudioModeRaw"</c> or <c>"BothModeWarningSuppressed"</c> simply
/// have those keys ignored on deserialisation.</summary>
public string? AsioDriverName { get; set; }
// === Selected devices (raw device IDs, not display names) ===
public List<string> SelectedWasapiReceiveOutputs { get; set; } = [];
public List<string> SelectedAsioReceiveOutputs { get; set; } = [];
public List<string> SelectedWasapiSendOutputs { get; set; } = []; // loopback (system audio)
public List<string> SelectedWasapiSendInputs { get; set; } = []; // microphones / line-ins
public List<string> SelectedAsioSendInputs { get; set; } = [];
// === Connectivity & transport ===
public int AudioPort { get; set; } = 47830;
public int CodecRaw { get; set; } = (int)AudioTransportCodec.Pcm;
public int OpusFrameMilliseconds { get; set; } = 10;
public int SendRateRaw { get; set; } = (int)SendRate.Standard;
public bool TightLatencyMode { get; set; }
/// <summary>True suppresses the connect/disconnect sound cues. Off by default.
/// 2026-05-06.</summary>
public bool MuteConnectionCues { 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;
/// <summary>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.</summary>
public int MaxLatencyMsAsio { get; set; } = 10;
/// <summary>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.</summary>
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.
/// <summary>Receiver-side concealment artifact, stored as raw int for JSON-stability
/// across enum-reorderings. Defaults to <see cref="ConcealmentArtifact.NoiseBurst"/>
/// (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).</summary>
public int ConcealmentArtifactRaw { get; set; } = (int)ConcealmentArtifact.NoiseBurst;
[JsonIgnore]
public ConcealmentArtifact ConcealmentArtifact
{
get => (ConcealmentArtifact)ConcealmentArtifactRaw;
set => ConcealmentArtifactRaw = (int)value;
}
// === Peers ===
public List<string> RememberedPeers { get; set; } = [];
/// <summary>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.</summary>
public List<string> 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; }
/// <summary>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.</summary>
public HotkeyRecord? RemoteVolumeUpHotkey { get; set; }
/// <summary>Mirror of RemoteVolumeUpHotkey for "lower volume" commands.</summary>
public HotkeyRecord? RemoteVolumeDownHotkey { get; set; }
/// <summary>Hotkey that sends a "toggle receive mute" command to every connected peer.</summary>
public HotkeyRecord? RemoteMuteToggleHotkey { get; set; }
/// <summary>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.</summary>
public HotkeyRecord? SystemVolumeUpHotkey { get; set; }
/// <summary>Mirror of SystemVolumeUpHotkey for the down direction.</summary>
public HotkeyRecord? SystemVolumeDownHotkey { get; set; }
/// <summary>Hotkey that sends a "toggle Windows default-output-device mute" command to
/// every connected peer.</summary>
public HotkeyRecord? SystemMuteToggleHotkey { get; set; }
/// <summary>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).</summary>
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;
}
/// <summary>Returns a defaults-only profile — same shape as the "blank template"
/// the user picks at startup. Title is empty (caller assigns when saving).</summary>
public static Profile NewBlank() => new();
}
/// <summary>JSON-serialisable hotkey representation. Mirrors <see cref="HotkeyInfo"/>
/// but stores Key as a string to keep the JSON robust to enum reorganisations.</summary>
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<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;
}
}
+191
View File
@@ -0,0 +1,191 @@
using System.Text.Json;
namespace RemSound.Core;
/// <summary>
/// File-backed store for <see cref="Profile"/> instances. One profile = one JSON file
/// under <c>&lt;exe&gt;\profiles\&lt;machine name&gt;\&lt;title&gt;.json</c>.
///
/// Profile names are user-supplied "plain English" strings; the store sanitises them
/// for the filesystem (replaces invalid chars with underscores) but keeps the original
/// string as the in-file Title. Two profiles whose sanitised filenames collide will
/// overwrite each other — fine in practice; very rare.
///
/// Per-machine subfolder: profiles\&lt;machine&gt;\ — keeps each machine's profiles
/// separate by default. To share a profile between machines, copy the .json file from
/// one machine's folder into the other machine's folder. The profile content is fully
/// portable; device IDs that don't exist on the loading machine are silently dropped
/// at apply time.
/// </summary>
public sealed class ProfileStore
{
private readonly string baseDir;
public ProfileStore()
{
var machineFolder = SanitiseFsName(Environment.MachineName);
baseDir = Path.Combine(AppContext.BaseDirectory, "profiles", machineFolder);
try { Directory.CreateDirectory(baseDir); }
catch { /* permissions; List/Save will surface this when actually used */ }
}
/// <summary>Construct a profile store pointing at an explicit directory. Used when the
/// user has picked a custom profiles folder via the "Browse for profile folder" button
/// — typically a Dropbox / OneDrive / shared-drive path, or a per-project folder.
/// No per-machine subfolder is appended; the supplied path IS the profiles folder, so
/// the same path on multiple machines shares profiles. Throws if the path is null or
/// empty (caller should validate before constructing).</summary>
public ProfileStore(string customDirectory)
{
if (string.IsNullOrWhiteSpace(customDirectory))
throw new ArgumentException("Custom profile directory cannot be null or empty", nameof(customDirectory));
baseDir = customDirectory;
try { Directory.CreateDirectory(baseDir); }
catch { /* permissions; List/Save will surface this when actually used */ }
}
/// <summary>Folder this store reads from and writes into.</summary>
public string BaseDirectory => baseDir;
/// <summary>Returns the user-facing titles of every profile in the folder, sorted
/// alphabetically (case-insensitive). Excludes the synthetic blank-template; the
/// caller decides whether to surface that.</summary>
public IReadOnlyList<string> ListProfileTitles()
{
if (!Directory.Exists(baseDir)) return [];
try
{
return Directory.GetFiles(baseDir, "*.json")
.Select(p => TryReadTitle(p) ?? Path.GetFileNameWithoutExtension(p))
.Where(static t => !string.IsNullOrWhiteSpace(t))
.OrderBy(t => t, StringComparer.OrdinalIgnoreCase)
.ToList();
}
catch
{
return [];
}
}
/// <summary>Loads a profile by title. Returns null if the file is missing or
/// unreadable. Malformed JSON is treated as "not found" rather than throwing —
/// the caller can surface a diagnostic and fall back to a blank template.</summary>
public Profile? Load(string title)
{
if (string.IsNullOrWhiteSpace(title)) return null;
var path = PathFor(title);
if (!File.Exists(path)) return null;
try
{
var json = File.ReadAllText(path);
var profile = JsonSerializer.Deserialize<Profile>(json);
// Force the in-file Title to whatever was on disk; defends against the user
// renaming the .json filename without editing the JSON.
if (profile is not null && string.IsNullOrWhiteSpace(profile.Title))
{
profile.Title = title;
}
return profile;
}
catch
{
return null;
}
}
/// <summary>Writes a profile to disk. Title must be non-empty; caller is responsible
/// for prompting the user or generating one. Throws if filesystem write fails.</summary>
public void Save(Profile profile)
{
if (profile is null) throw new ArgumentNullException(nameof(profile));
if (string.IsNullOrWhiteSpace(profile.Title))
throw new ArgumentException("Profile title cannot be empty", nameof(profile));
Directory.CreateDirectory(baseDir);
var path = PathFor(profile.Title);
var json = JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(path, json);
}
/// <summary>Deletes the profile by title. Returns true if a file was removed,
/// false if it didn't exist or couldn't be deleted.</summary>
public bool Delete(string title)
{
if (string.IsNullOrWhiteSpace(title)) return false;
var path = PathFor(title);
if (!File.Exists(path)) return false;
try
{
File.Delete(path);
return true;
}
catch
{
return false;
}
}
/// <summary>True if a profile with the given title (sanitised filename) already exists.</summary>
public bool Exists(string title) => !string.IsNullOrWhiteSpace(title) && File.Exists(PathFor(title));
/// <summary>Rename a profile on disk. Loads the JSON, updates the in-file Title field,
/// writes it under the new sanitised filename, then deletes the old file. Returns true
/// on success. Fails (returns false, no changes made) if the source doesn't exist, the
/// new title is empty/identical, or a file already exists at the destination filename.
/// 2026-05-06.</summary>
public bool Rename(string oldTitle, string newTitle)
{
if (string.IsNullOrWhiteSpace(oldTitle) || string.IsNullOrWhiteSpace(newTitle)) return false;
if (string.Equals(oldTitle, newTitle, StringComparison.Ordinal)) return false;
var oldPath = PathFor(oldTitle);
var newPath = PathFor(newTitle);
if (!File.Exists(oldPath)) return false;
if (File.Exists(newPath) && !string.Equals(oldPath, newPath, StringComparison.OrdinalIgnoreCase)) return false;
try
{
var profile = Load(oldTitle);
if (profile is null) return false;
profile.Title = newTitle;
var json = JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(newPath, json);
if (!string.Equals(oldPath, newPath, StringComparison.OrdinalIgnoreCase))
{
File.Delete(oldPath);
}
return true;
}
catch
{
return false;
}
}
/// <summary>The on-disk path for a profile of the given title in this store's base
/// directory. Sanitises filesystem-invalid characters in the title before joining.
/// Public so callers (e.g. MainForm at startup) can record where a loaded profile
/// lives, which matters once Save As lets the user write outside <see cref="BaseDirectory"/>.</summary>
public string PathFor(string title) => Path.Combine(baseDir, SanitiseFsName(title) + ".json");
/// <summary>Read just the Title field of a profile JSON to surface the user-supplied
/// name even if it differs from the sanitised filename. Cheap; the file is small.</summary>
private static string? TryReadTitle(string path)
{
try
{
using var doc = JsonDocument.Parse(File.ReadAllText(path));
if (doc.RootElement.TryGetProperty(nameof(Profile.Title), out var titleProp)
&& titleProp.ValueKind == JsonValueKind.String)
{
return titleProp.GetString();
}
}
catch { /* fall through */ }
return null;
}
private static string SanitiseFsName(string name)
{
if (string.IsNullOrWhiteSpace(name)) return "untitled";
foreach (var c in Path.GetInvalidFileNameChars()) name = name.Replace(c, '_');
return name.Trim();
}
}
+358
View File
@@ -0,0 +1,358 @@
using System.Buffers.Binary;
namespace RemSound.Core;
public enum RemPacketType : byte
{
Format = 1,
Audio = 2,
KeepAlive = 3,
Heartbeat = 4,
/// <summary>
/// Remote-control message from one connected peer to another. Currently used to let a
/// peer adjust the receiver-side volume slider on a peer it's connected to (so a user
/// who's NVDA-Remote'd into another machine can still nudge the listening volume on
/// the machine they're physically at). Wire format: 1 byte <see cref="RemoteControlKind"/>
/// + 1 byte signed delta (interpreted as signed sbyte; range -128..127, percent points).
/// Old peers see "unknown packet type" and silently drop, so adding this is wire-safe.
/// </summary>
Control = 5,
}
public enum HeartbeatKind : byte
{
Ping = 0,
Pong = 1,
}
/// <summary>
/// What a Control packet is asking the receiver to do.
///
/// Two families of commands:
/// * <see cref="VolumeUp"/> / <see cref="VolumeDown"/> / <see cref="MuteToggle"/> — adjust
/// the receiver's RemSound app volume slider (in-app, only affects RemSound's own audio).
/// Delta byte carries a percent-point step (typically ±5).
/// * <see cref="SystemVolumeUp"/> / <see cref="SystemVolumeDown"/> / <see cref="SystemMuteToggle"/>
/// — adjust the receiver's Windows default-output-device master volume (system-wide,
/// affects every app on the receiving machine, including its screen reader). Each call
/// issues exactly one Windows native volume-step (typically ~2%), matching what the
/// keyboard volume keys do. Delta byte ignored.
///
/// Kept narrow on purpose: remote control is a small audio convenience, not a generic
/// "do anything to that peer" channel. Adding new commands later means adding enum values;
/// old receivers see them as invalid and ignore the packet.
/// </summary>
public enum RemoteControlKind : byte
{
VolumeUp = 0,
VolumeDown = 1,
MuteToggle = 2,
SystemVolumeUp = 3,
SystemVolumeDown = 4,
SystemMuteToggle = 5,
}
[Flags]
public enum KeepAliveCapabilities : byte
{
None = 0,
CanSend = 1,
CanReceive = 2,
}
public enum KeepAliveKind : byte
{
Heartbeat = 1,
Ack = 2,
}
public readonly record struct KeepAliveInfo(
Guid SessionId,
KeepAliveKind Kind,
KeepAliveCapabilities Capabilities,
AudioTransportCodec Codec,
long UnixTimeMilliseconds);
/// <summary>
/// Wire format for RemSound packets. Header is 12 bytes; body length is implied by the UDP datagram.
/// Header layout (little-endian):
/// uint32 magic 'RMND'
/// uint8 version 1
/// uint8 type RemPacketType
/// uint16 streamId
/// uint32 sequence
/// </summary>
public static class RemPacket
{
public const int HeaderSize = 12;
/// <summary>Minimum format payload size. Builds older than 2026-05-11 only emit this
/// many bytes; readers must accept this length as a valid (but unextended) format
/// packet and default any post-32-byte fields. See <see cref="FormatPayloadExtendedSize"/>.</summary>
public const int FormatPayloadSize = 32;
/// <summary>Extended format payload size (32 base + 4 extension). The extension carries
/// the <see cref="AudioFormatInfo.Lane"/> byte at offset 32 plus 3 reserved-zero bytes for
/// future growth. Receivers must check <c>payload.Length &gt;= FormatPayloadExtendedSize</c>
/// before reading the Lane field; payloads shorter than that default Lane to
/// <see cref="RenderRoute.Mixed"/>. Senders newer than 2026-05-11 always write this size.</summary>
public const int FormatPayloadExtendedSize = 36;
public const int KeepAlivePayloadSize = 28;
/// <summary>
/// Heartbeat payload: 1 byte <see cref="HeartbeatKind"/> + 8 bytes originator-monotonic
/// timestamp (Stopwatch.ElapsedMilliseconds at the time the originating Ping was sent).
/// Pongs copy the originator's timestamp verbatim — sender computes RTT against its own
/// clock, so no clock sync is needed between peers (RFC 3550 RTCP DLSR pattern, simplified).
/// </summary>
public const int HeartbeatPayloadSize = 9;
/// <summary>
/// Control payload: 1 byte <see cref="RemoteControlKind"/> + 1 signed byte delta. Total
/// 2 bytes, plus the 12-byte header = 14 bytes on the wire. See <see cref="RemPacketType.Control"/>
/// for the rationale.
/// </summary>
public const int ControlPayloadSize = 2;
/// <summary>
/// Single canonical port for everything: receiver bind, LAN peer-to-peer dials, and the
/// public RemSound relay. Was 47820 (audio receiver) + 47830 (relay) in the old design;
/// unified to 47830 on 2026-05-05 so users never have to type `:port` after a hostname or
/// IP. Any peer the user adds — Tailscale IP, LAN IP, or relay hostname — defaults to
/// this port. The +1 (discovery) and +2 (heartbeat) derived ports follow accordingly.
/// </summary>
public const int DefaultPort = 47830;
/// <summary>
/// Kept as an alias for the single canonical port so existing call sites that distinguish
/// "the local bind" from "the dial default" still compile. They point at the same value
/// now — there is no longer a separate dial port.
/// </summary>
public const int DefaultPeerDialPort = DefaultPort;
public const int Magic = 0x444E4D52; // 'RMND' little-endian
public const byte Version = 1;
/// <summary>
/// Maximum payload bytes guaranteed to fit a typical Ethernet path without IP fragmentation
/// (1500 - 20 IP - 8 UDP - 12 RemPacket header - 6 PCM-multipart sub-header).
/// </summary>
public const int MaxAudioPayloadBytes = 1454;
public static int WriteHeader(Span<byte> destination, RemPacketType type, ushort streamId, uint sequence)
{
if (destination.Length < HeaderSize)
{
throw new ArgumentException("Header destination too small", nameof(destination));
}
BinaryPrimitives.WriteInt32LittleEndian(destination, Magic);
destination[4] = Version;
destination[5] = (byte)type;
BinaryPrimitives.WriteUInt16LittleEndian(destination[6..], streamId == 0 ? (ushort)1 : streamId);
BinaryPrimitives.WriteUInt32LittleEndian(destination[8..], sequence);
return HeaderSize;
}
/// <summary>
/// Writes the format payload. Always emits <see cref="FormatPayloadExtendedSize"/> bytes
/// (36): the 32 legacy fields followed by a Lane byte and 3 reserved-zero bytes. Old
/// receivers that only read 32 bytes will still parse the legacy block correctly and
/// ignore the trailing 4 — see the <see cref="FormatPayloadSize"/> doc comment for the
/// compatibility contract.
/// </summary>
public static int WriteFormatPayload(Span<byte> destination, AudioFormatInfo format)
{
if (destination.Length < FormatPayloadExtendedSize)
{
throw new ArgumentException("Format payload destination too small", nameof(destination));
}
BinaryPrimitives.WriteInt32LittleEndian(destination, format.SampleRate);
BinaryPrimitives.WriteInt32LittleEndian(destination[4..], format.Channels);
BinaryPrimitives.WriteInt32LittleEndian(destination[8..], format.BitsPerSample);
BinaryPrimitives.WriteInt32LittleEndian(destination[12..], format.Encoding);
BinaryPrimitives.WriteInt32LittleEndian(destination[16..], format.BlockAlign);
BinaryPrimitives.WriteInt32LittleEndian(destination[20..], format.AverageBytesPerSecond);
BinaryPrimitives.WriteInt32LittleEndian(destination[24..], format.Codec);
BinaryPrimitives.WriteInt32LittleEndian(destination[28..], format.FrameDurationMilliseconds);
// Extension: 1 byte Lane + 3 reserved-zero bytes. Zero-fill the reserved slot so a
// future receiver doesn't accidentally read stale stack data if WriteFormatPayload
// is called on an uninitialised buffer.
destination[32] = (byte)format.Lane;
destination[33] = 0;
destination[34] = 0;
destination[35] = 0;
return FormatPayloadExtendedSize;
}
public static int WriteKeepAlivePayload(Span<byte> destination, KeepAliveInfo info)
{
if (destination.Length < KeepAlivePayloadSize)
{
throw new ArgumentException("KeepAlive payload destination too small", nameof(destination));
}
destination[0] = (byte)info.Kind;
destination[1] = (byte)info.Codec;
destination[2] = (byte)info.Capabilities;
destination[3] = 0;
BinaryPrimitives.WriteInt64LittleEndian(destination[4..], info.UnixTimeMilliseconds);
if (!info.SessionId.TryWriteBytes(destination.Slice(12, 16)))
{
return 0;
}
return KeepAlivePayloadSize;
}
public static bool TryReadHeader(ReadOnlySpan<byte> packet, out RemPacketType type, out ushort streamId, out uint sequence)
{
type = default;
streamId = 0;
sequence = 0;
if (packet.Length < HeaderSize) return false;
if (BinaryPrimitives.ReadInt32LittleEndian(packet) != Magic) return false;
if (packet[4] != Version) return false;
type = (RemPacketType)packet[5];
streamId = BinaryPrimitives.ReadUInt16LittleEndian(packet[6..]);
if (streamId == 0) streamId = 1;
sequence = BinaryPrimitives.ReadUInt32LittleEndian(packet[8..]);
return true;
}
/// <summary>
/// Reads the format payload. Accepts both the legacy 32-byte and the extended 36-byte
/// layouts: the legacy layout defaults <see cref="AudioFormatInfo.Lane"/> to
/// <see cref="RenderRoute.Mixed"/>, which is exactly what an old sender (pre-2026-05-11)
/// would have meant. Lane values outside the defined enum range are clamped to Mixed
/// rather than rejected — better to play the audio in the default route than drop a
/// stream because a future sender sent an unknown value.
/// </summary>
public static bool TryReadFormat(ReadOnlySpan<byte> payload, out AudioFormatInfo format)
{
format = new AudioFormatInfo(48000, 2, 32, 3, 8, 384000);
if (payload.Length < FormatPayloadSize) return false;
var lane = RenderRoute.Mixed;
if (payload.Length >= FormatPayloadExtendedSize)
{
var laneRaw = payload[32];
lane = laneRaw switch
{
(byte)RenderRoute.Mixed => RenderRoute.Mixed,
(byte)RenderRoute.WasapiLane => RenderRoute.WasapiLane,
(byte)RenderRoute.AsioLane => RenderRoute.AsioLane,
_ => RenderRoute.Mixed, // forward-compat: unknown lane → safe default
};
}
format = new AudioFormatInfo(
BinaryPrimitives.ReadInt32LittleEndian(payload),
BinaryPrimitives.ReadInt32LittleEndian(payload[4..]),
BinaryPrimitives.ReadInt32LittleEndian(payload[8..]),
BinaryPrimitives.ReadInt32LittleEndian(payload[12..]),
BinaryPrimitives.ReadInt32LittleEndian(payload[16..]),
BinaryPrimitives.ReadInt32LittleEndian(payload[20..]),
BinaryPrimitives.ReadInt32LittleEndian(payload[24..]),
BinaryPrimitives.ReadInt32LittleEndian(payload[28..]),
lane);
return true;
}
public static int WriteHeartbeatPayload(Span<byte> destination, HeartbeatKind kind, long originatorTickMs)
{
if (destination.Length < HeartbeatPayloadSize)
{
throw new ArgumentException("Heartbeat payload destination too small", nameof(destination));
}
destination[0] = (byte)kind;
BinaryPrimitives.WriteInt64LittleEndian(destination[1..], originatorTickMs);
return HeartbeatPayloadSize;
}
public static bool TryReadHeartbeat(ReadOnlySpan<byte> payload, out HeartbeatKind kind, out long originatorTickMs)
{
kind = HeartbeatKind.Ping;
originatorTickMs = 0;
if (payload.Length < HeartbeatPayloadSize) return false;
var raw = payload[0];
if (raw != (byte)HeartbeatKind.Ping && raw != (byte)HeartbeatKind.Pong) return false;
kind = (HeartbeatKind)raw;
originatorTickMs = BinaryPrimitives.ReadInt64LittleEndian(payload[1..]);
return true;
}
public static int WriteControlPayload(Span<byte> destination, RemoteControlKind kind, sbyte delta)
{
if (destination.Length < ControlPayloadSize)
{
throw new ArgumentException("Control payload destination too small", nameof(destination));
}
destination[0] = (byte)kind;
destination[1] = (byte)delta;
return ControlPayloadSize;
}
public static bool TryReadControl(ReadOnlySpan<byte> payload, out RemoteControlKind kind, out sbyte delta)
{
kind = RemoteControlKind.VolumeUp;
delta = 0;
if (payload.Length < ControlPayloadSize) return false;
var raw = payload[0];
// Reject unknown kinds rather than coercing — keeps the door open to future kinds
// without an old receiver guessing wrong on an unfamiliar value.
if (raw != (byte)RemoteControlKind.VolumeUp
&& raw != (byte)RemoteControlKind.VolumeDown
&& raw != (byte)RemoteControlKind.MuteToggle
&& raw != (byte)RemoteControlKind.SystemVolumeUp
&& raw != (byte)RemoteControlKind.SystemVolumeDown
&& raw != (byte)RemoteControlKind.SystemMuteToggle) return false;
kind = (RemoteControlKind)raw;
delta = (sbyte)payload[1];
return true;
}
public static bool TryReadKeepAlive(ReadOnlySpan<byte> payload, out KeepAliveInfo info)
{
info = default;
if (payload.Length < KeepAlivePayloadSize) return false;
if (!Enum.IsDefined((KeepAliveKind)payload[0])) return false;
info = new KeepAliveInfo(
new Guid(payload.Slice(12, 16)),
(KeepAliveKind)payload[0],
(KeepAliveCapabilities)payload[2],
Enum.IsDefined((AudioTransportCodec)payload[1]) ? (AudioTransportCodec)payload[1] : AudioTransportCodec.Pcm,
BinaryPrimitives.ReadInt64LittleEndian(payload[4..]));
return true;
}
}
/// <summary>
/// PCM transport sub-header. PCM frames are larger than a UDP datagram (10 ms × 48 kHz × 2 ch × 3 byte = 2880 B)
/// so they're split into multi-part chunks. The receiver assembles parts back into a complete frame
/// before queueing for playout. Sub-header (6 bytes) is prepended to the audio bytes:
/// uint32 frameId
/// uint8 partIndex
/// uint8 totalParts
/// </summary>
public static class RemPcmFrame
{
public const int SubHeaderSize = 6;
public static int WriteSubHeader(Span<byte> destination, uint frameId, byte partIndex, byte totalParts)
{
if (destination.Length < SubHeaderSize)
{
throw new ArgumentException("PCM sub-header destination too small", nameof(destination));
}
BinaryPrimitives.WriteUInt32LittleEndian(destination, frameId);
destination[4] = partIndex;
destination[5] = totalParts;
return SubHeaderSize;
}
public static bool TryReadSubHeader(ReadOnlySpan<byte> source, out uint frameId, out byte partIndex, out byte totalParts)
{
frameId = 0;
partIndex = 0;
totalParts = 0;
if (source.Length < SubHeaderSize) return false;
frameId = BinaryPrimitives.ReadUInt32LittleEndian(source);
partIndex = source[4];
totalParts = source[5];
return totalParts > 0 && partIndex < totalParts;
}
}
+11
View File
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWindowsForms>true</UseWindowsForms>
<RootNamespace>RemSound.Core</RootNamespace>
<AssemblyName>RemSound.Core</AssemblyName>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>
+523
View File
@@ -0,0 +1,523 @@
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 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);
}
public int LoadOpusFrameMilliseconds(int defaultValue = 10) =>
Try(() => Load()?.OpusFrameMilliseconds is int v && (v == 10 || v == 20) ? v : (int?)null) ?? defaultValue;
public void SaveOpusFrameMilliseconds(int value)
{
var s = Load() ?? new Settings();
s.OpusFrameMilliseconds = value == 20 ? 20 : 10;
Save(s);
}
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>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);
}
/// <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),
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,
OpusFrameMilliseconds = profile.OpusFrameMilliseconds,
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,
Smoothness = profile.Smoothness,
ConcealmentArtifact = (ConcealmentArtifact)profile.ConcealmentArtifactRaw,
MuteConnectionCues = profile.MuteConnectionCues,
};
}
/// <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.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.OpusFrameMilliseconds is int op) profile.OpusFrameMilliseconds = op;
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.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;
}
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? 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; }
public int? OpusFrameMilliseconds { get; set; }
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 int? Smoothness { get; set; }
public ConcealmentArtifact? ConcealmentArtifact { get; set; }
public bool? MuteConnectionCues { get; set; }
}
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;
}
}
}
+39
View File
@@ -0,0 +1,39 @@
namespace RemSound.Core;
/// <summary>
/// Tag carried in the per-stream <see cref="AudioFormatInfo.Lane"/> wire field, telling the
/// receiver which render backend a particular stream's audio belongs to. In the three classic
/// audio modes (WasapiOnly, AsioOnly, Both) every stream from a sender carries
/// <see cref="Mixed"/> and the receiver's <c>PlayoutEngine</c> mixes them all into one bus
/// that is fanned out to every configured render backend — identical to the pre-2026-05-11
/// behaviour.
///
/// The BothIndependent mode (added 2026-05-11) is the reason this exists: in that mode the
/// sender emits *two* streams in parallel — a WASAPI lane at WASAPI's native latency and an
/// ASIO lane at ASIO's native latency. The sender tags each lane with <see cref="WasapiLane"/>
/// or <see cref="AsioLane"/>; the receiver routes each lane's audio to a separate
/// <c>SessionPlayout</c> group, and each render backend reads only the group it owns. No
/// cross-clock resampler, no tee — each lane stays at its own native latency end-to-end.
///
/// Wire format: stored as a single byte at offset 32 of the format payload. Receivers that
/// don't understand the field (pre-2026-05-11 builds) parse only the first 32 bytes and
/// behave exactly as before — the new field is purely additive. Receivers that do understand
/// it but receive a 32-byte payload (because the sender is old) default to <see cref="Mixed"/>,
/// also matching the pre-2026-05-11 behaviour.
/// </summary>
public enum RenderRoute : byte
{
/// <summary>Legacy / classic behaviour: stream is mixed with every other stream and sent
/// to all render backends. Used by every classic-mode sender lane and is the default
/// when the format-packet Lane field is missing or zero.</summary>
Mixed = 0,
/// <summary>Stream belongs to the WASAPI render lane and should only reach WASAPI output
/// devices, bypassing the cross-backend mix. Only emitted by senders in BothIndependent
/// mode.</summary>
WasapiLane = 1,
/// <summary>Stream belongs to the ASIO render lane and should only reach ASIO outputs,
/// bypassing the cross-backend mix. Only emitted by senders in BothIndependent mode.</summary>
AsioLane = 2,
}
+22
View File
@@ -0,0 +1,22 @@
namespace RemSound.Core;
/// <summary>
/// How often the sender cuts the audio stream into a packet for transmission. Smaller frames
/// = more packets per second = lower send-side latency, but more network/CPU overhead per
/// second.
///
/// Mapping per codec:
/// * PCM: Standard = 5 ms (240 samples), Tight = 2.5 ms (120 samples).
/// * Opus 20 ms: Standard = 20 ms, Tight = 10 ms.
/// * Opus 10 ms: Standard = 10 ms, Tight = 5 ms.
///
/// "Tight" is documented as LAN-only because the smaller frame size means less time for the
/// network to absorb jitter before the next packet arrives. On a stable LAN it cuts ~2.5 ms
/// off the send-side accumulator latency without audible cost; over WAN with typical jitter
/// it'll glitch.
/// </summary>
public enum SendRate
{
Standard = 0,
Tight = 1,
}
@@ -0,0 +1,65 @@
using System.Runtime.InteropServices;
namespace RemSound.Core;
/// <summary>
/// Boosts the calling thread to MMCSS Pro Audio class plus ThreadPriority.Highest.
/// Dispose on the same thread that constructed it. Designed for capture/render/network audio threads.
/// </summary>
public sealed class WindowsAudioThreadBoost : IDisposable
{
private readonly IntPtr avrtHandle;
private readonly ThreadPriority previousPriority;
private readonly int ownerThreadId;
public WindowsAudioThreadBoost(string taskName)
{
ownerThreadId = Environment.CurrentManagedThreadId;
previousPriority = Thread.CurrentThread.Priority;
Thread.CurrentThread.Priority = ThreadPriority.Highest;
Mode = "ThreadPriority.Highest";
if (!OperatingSystem.IsWindows()) return;
avrtHandle = AvSetMmThreadCharacteristics(taskName, out _);
if (avrtHandle == IntPtr.Zero && !string.Equals(taskName, "Audio", StringComparison.OrdinalIgnoreCase))
{
avrtHandle = AvSetMmThreadCharacteristics("Audio", out _);
if (avrtHandle != IntPtr.Zero) taskName = "Audio";
}
if (avrtHandle != IntPtr.Zero)
{
AvSetMmThreadPriority(avrtHandle, AvrtPriority.High);
Mode = $"MMCSS {taskName}";
}
}
public string Mode { get; }
public void Dispose()
{
if (Environment.CurrentManagedThreadId != ownerThreadId) return;
if (avrtHandle != IntPtr.Zero) AvRevertMmThreadCharacteristics(avrtHandle);
Thread.CurrentThread.Priority = previousPriority;
}
[DllImport("avrt.dll", CharSet = CharSet.Unicode, SetLastError = true, EntryPoint = "AvSetMmThreadCharacteristicsW")]
private static extern IntPtr AvSetMmThreadCharacteristics(string taskName, out uint taskIndex);
[DllImport("avrt.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool AvSetMmThreadPriority(IntPtr avrtHandle, AvrtPriority priority);
[DllImport("avrt.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool AvRevertMmThreadCharacteristics(IntPtr avrtHandle);
private enum AvrtPriority
{
Low = -1,
Normal = 0,
High = 1,
Critical = 2,
}
}