diff --git a/RemSound.slnx b/RemSound.slnx
index 86deb09..ed19994 100644
--- a/RemSound.slnx
+++ b/RemSound.slnx
@@ -3,7 +3,6 @@
-
diff --git a/src/RemSound.App/HandleTypeProbe.cs b/src/RemSound.App/HandleTypeProbe.cs
deleted file mode 100644
index 2eeacc8..0000000
--- a/src/RemSound.App/HandleTypeProbe.cs
+++ /dev/null
@@ -1,230 +0,0 @@
-using System.Collections.Generic;
-using System.Diagnostics;
-using System.Runtime.InteropServices;
-using System.Text;
-
-namespace RemSound.App;
-
-///
-/// Counts THIS process's open OS handles grouped by type (Event, Section, File, Key, Thread, …).
-/// Added 2026-06-07 to pin down the receiver handle leak: the diag line's handles= column
-/// proved the leak is OS handles, but not WHICH kind — and the kind names the culprit (Event ⇒
-/// a waitable-object leak, Section ⇒ a WASAPI buffer/audio-client leak, Key ⇒ a CNG/AesGcm leak,
-/// Thread ⇒ thread-handle leak, etc.).
-///
-/// Primary mechanism: NtQueryInformationProcess(ProcessHandleInformation) returns ONLY the calling
-/// process's handle table — small, fast, and it never touches the system-wide handle table. The
-/// original system-wide walk via NtQuerySystemInformation(SystemExtendedHandleInformation) returned
-/// STATUS_ACCESS_VIOLATION (0xC0000005) on Andre's ASUS-Realtek machine, so it is now only a
-/// fallback for any box where the per-process class is unavailable. Type names are resolved per
-/// distinct ObjectTypeIndex ONCE via NtQueryObject(ObjectTypeInformation) on a sample handle —
-/// class 2 is safe on our own handles (the known NtQueryObject hang only affects ObjectName-
-/// Information, class 1, on synchronous pipes, which we never request). Everything is wrapped in
-/// try/catch and unmanaged buffers are always freed, so a failure degrades to a probe-error string
-/// rather than disturbing the very memory we're measuring. x64 only (the shipped runtime).
-///
-internal static class HandleTypeProbe
-{
- private const int ProcessHandleInformation = 51; // PROCESSINFOCLASS
- private const int SystemExtendedHandleInformation = 0x40; // SYSTEM_INFORMATION_CLASS
- private const int ObjectTypeInformation = 2;
- private const uint STATUS_INFO_LENGTH_MISMATCH = 0xC0000004;
-
- // PROCESS_HANDLE_SNAPSHOT_INFORMATION (x64): NumberOfHandles (ULONG_PTR) +0, Reserved +8,
- // then PROCESS_HANDLE_TABLE_ENTRY_INFO[] at +16. Each entry is 40 bytes:
- // HandleValue +0, HandleCount +8, PointerCount +16, GrantedAccess +24,
- // ObjectTypeIndex (ULONG) +28, HandleAttributes +32, Reserved +36.
- private const int PhHeaderSize = 16;
- private const int PhEntrySize = 40;
- private const int PhOffHandleValue = 0;
- private const int PhOffObjectTypeIndex = 28;
-
- // 64-bit SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX is 40 bytes; UniqueProcessId at +8, HandleValue at
- // +16, ObjectTypeIndex (USHORT) at +30. Header is 16 bytes (NumberOfHandles + Reserved).
- private const int SysHeaderSize = 16;
- private const int SysEntrySize = 40;
- private const int SysOffUniqueProcessId = 8;
- private const int SysOffHandleValue = 16;
- private const int SysOffObjectTypeIndex = 30;
-
- // Cap any snapshot so a pathological system can't make us allocate without bound (we're
- // hunting a leak — don't become one). 128 MB covers well over a million handles.
- private const int MaxBufferBytes = 128 * 1024 * 1024;
-
- private static readonly nint CurrentProcessPseudoHandle = (nint)(-1);
- private static readonly Dictionary TypeNameByIndex = new();
- private static readonly int OwnPid = Process.GetCurrentProcess().Id;
-
- ///
- /// Returns "Event=120345 Section=15234 File=210 … total=N" — the most
- /// common handle types owned by this process. Tries the per-process query first, falls back to
- /// the system-wide walk, and returns a probe-error string (never throws) if both fail.
- /// Heavier than the per-tick meter, so call it on a slow cadence, not every diag line.
- ///
- public static string Summarize(int topN = 10)
- {
- var own = TryOwnProcess(topN, out var ownStatus);
- if (own != null) return own;
- var sys = TrySystemWide(topN, out var sysStatus);
- if (sys != null) return sys;
- return $"probe-error proc-status=0x{ownStatus:X8} sys-status=0x{sysStatus:X8}";
- }
-
- ///
- /// Primary path: query only THIS process's handle table. Returns the formatted summary, or null
- /// on any failure (with set to the NTSTATUS for diagnostics).
- ///
- private static string? TryOwnProcess(int topN, out uint status)
- {
- nint buffer = 0;
- status = 0;
- try
- {
- var size = 1 << 18; // 256 KB — our own table is small even when leaking.
- while (true)
- {
- buffer = buffer == 0 ? Marshal.AllocHGlobal(size) : Marshal.ReAllocHGlobal(buffer, (nint)size);
- status = NtQueryInformationProcess(CurrentProcessPseudoHandle, ProcessHandleInformation, buffer, (uint)size, out var needed);
- if (status != STATUS_INFO_LENGTH_MISMATCH) break;
- size = (int)System.Math.Min((long)System.Math.Max(needed, (uint)size) * 2, MaxBufferBytes);
- if (size >= MaxBufferBytes) { status = NtQueryInformationProcess(CurrentProcessPseudoHandle, ProcessHandleInformation, buffer, (uint)size, out _); break; }
- }
- if (status != 0) return null;
-
- var count = Marshal.ReadInt64(buffer); // NumberOfHandles
- var counts = new Dictionary();
- var entryBase = buffer + PhHeaderSize;
- for (long i = 0; i < count; i++)
- {
- var entry = entryBase + (nint)(i * PhEntrySize);
- var typeIndex = (ushort)Marshal.ReadInt32(entry + PhOffObjectTypeIndex);
- counts.TryGetValue(typeIndex, out var c);
- counts[typeIndex] = c + 1;
- if (!TypeNameByIndex.ContainsKey(typeIndex))
- {
- var handle = Marshal.ReadIntPtr(entry + PhOffHandleValue);
- TypeNameByIndex[typeIndex] = ResolveTypeName(handle, typeIndex);
- }
- }
- return counts.Count == 0 ? null : Format(counts, topN);
- }
- catch
- {
- return null;
- }
- finally
- {
- if (buffer != 0) Marshal.FreeHGlobal(buffer);
- }
- }
-
- ///
- /// Fallback path: walk the whole system handle table and filter to our PID. Returns null on any
- /// failure (with set). Kept for machines where the per-process class
- /// is unavailable; on Andre's box this path returns STATUS_ACCESS_VIOLATION, which is exactly
- /// why is tried first.
- ///
- private static string? TrySystemWide(int topN, out uint status)
- {
- nint buffer = 0;
- status = 0;
- try
- {
- var size = 1 << 20; // 1 MB to start; grow on mismatch.
- while (true)
- {
- buffer = buffer == 0 ? Marshal.AllocHGlobal(size) : Marshal.ReAllocHGlobal(buffer, (nint)size);
- status = NtQuerySystemInformation(SystemExtendedHandleInformation, buffer, (uint)size, out var needed);
- if (status != STATUS_INFO_LENGTH_MISMATCH) break;
- size = (int)System.Math.Min((long)System.Math.Max(needed, (uint)size) * 2, MaxBufferBytes);
- if (size >= MaxBufferBytes) { status = NtQuerySystemInformation(SystemExtendedHandleInformation, buffer, (uint)size, out _); break; }
- }
- if (status != 0) return null;
-
- var count = Marshal.ReadInt64(buffer); // NumberOfHandles
- var counts = new Dictionary();
- var entryBase = buffer + SysHeaderSize;
- for (long i = 0; i < count; i++)
- {
- var entry = entryBase + (nint)(i * SysEntrySize);
- var pid = (int)Marshal.ReadInt64(entry + SysOffUniqueProcessId);
- if (pid != OwnPid) continue;
- var typeIndex = (ushort)Marshal.ReadInt16(entry + SysOffObjectTypeIndex);
- counts.TryGetValue(typeIndex, out var c);
- counts[typeIndex] = c + 1;
- if (!TypeNameByIndex.ContainsKey(typeIndex))
- {
- var handle = Marshal.ReadIntPtr(entry + SysOffHandleValue);
- TypeNameByIndex[typeIndex] = ResolveTypeName(handle, typeIndex);
- }
- }
- return counts.Count == 0 ? null : Format(counts, topN);
- }
- catch
- {
- return null;
- }
- finally
- {
- if (buffer != 0) Marshal.FreeHGlobal(buffer);
- }
- }
-
- private static string Format(Dictionary counts, int topN)
- {
- var ordered = new List>(counts);
- ordered.Sort((a, b) => b.Value.CompareTo(a.Value));
-
- var sb = new StringBuilder();
- var total = 0;
- var shown = 0;
- foreach (var kv in ordered)
- {
- total += kv.Value;
- if (shown < topN)
- {
- if (sb.Length > 0) sb.Append(' ');
- sb.Append(TypeNameByIndex.TryGetValue(kv.Key, out var n) ? n : $"Type#{kv.Key}").Append('=').Append(kv.Value);
- shown++;
- }
- }
- sb.Append(" total=").Append(total);
- return sb.ToString();
- }
-
- private static string ResolveTypeName(nint handle, ushort index)
- {
- nint info = 0;
- try
- {
- const int len = 4096;
- info = Marshal.AllocHGlobal(len);
- var status = NtQueryObject(handle, ObjectTypeInformation, info, len, out _);
- if (status != 0) return $"Type#{index}";
- // OBJECT_TYPE_INFORMATION starts with UNICODE_STRING TypeName { USHORT Length;
- // USHORT MaximumLength; PWSTR Buffer; } — Length at +0, Buffer (ptr) at +8 on x64.
- var nameLen = (ushort)Marshal.ReadInt16(info);
- var namePtr = Marshal.ReadIntPtr(info + 8);
- if (namePtr == 0 || nameLen == 0) return $"Type#{index}";
- var name = Marshal.PtrToStringUni(namePtr, nameLen / 2);
- return string.IsNullOrEmpty(name) ? $"Type#{index}" : name;
- }
- catch
- {
- return $"Type#{index}";
- }
- finally
- {
- if (info != 0) Marshal.FreeHGlobal(info);
- }
- }
-
- [DllImport("ntdll.dll")]
- private static extern uint NtQueryInformationProcess(nint processHandle, int processInformationClass, nint processInformation, uint processInformationLength, out uint returnLength);
-
- [DllImport("ntdll.dll")]
- private static extern uint NtQuerySystemInformation(int systemInformationClass, nint systemInformation, uint systemInformationLength, out uint returnLength);
-
- [DllImport("ntdll.dll")]
- private static extern uint NtQueryObject(nint handle, int objectInformationClass, nint objectInformation, int objectInformationLength, out int returnLength);
-}
diff --git a/src/RemSound.App/MainForm.cs b/src/RemSound.App/MainForm.cs
index 1932de1..c8f512f 100644
--- a/src/RemSound.App/MainForm.cs
+++ b/src/RemSound.App/MainForm.cs
@@ -591,9 +591,6 @@ public sealed class MainForm : Form
// snapshot tick (~1 Hz) and triggers a forced gen2 + finalizer flush every 300 ticks
// (~5 minutes). See the inline comment in SnapshotLogIfDue for the full rationale.
private int nativeReaperTickCount;
- // Counts status ticks (~1 Hz) so the heavier handle-TYPE probe runs on a slow cadence
- // (~once a minute) rather than every diag line. 2026-06-07, for the receiver handle leak.
- private int handleProbeTickCount;
// Previous-tick values for the per-second deltas surfaced in the diag log line. Each is
// the receiver-side cumulative counter snapshot at the previous SnapshotLogIfDue tick;
@@ -7295,45 +7292,11 @@ public sealed class MainForm : Form
.ToArray();
}
- private async Task ResolvePeerAddressAsync(string text)
- {
- // Strip any host:port suffix before resolving; the port is parsed separately by the
- // caller via TrySplitHostPort.
- var (hostOnly, _) = TrySplitHostPort(text);
- if (IPAddress.TryParse(hostOnly, out var direct)) return direct;
- try
- {
- var addresses = await Dns.GetHostAddressesAsync(hostOnly);
- return addresses.FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork) ?? addresses.FirstOrDefault();
- }
- catch
- {
- return null;
- }
- }
+ // Address split + resolve moved to Core (PeerAddress) so the app and the service share one
+ // implementation — these thin wrappers keep the existing call sites readable.
+ private static Task ResolvePeerAddressAsync(string text) => PeerAddress.ResolveHostAsync(text);
- ///
- /// Parse "host:port" or just "host" / "ipv4:port" / IPv4. Returns (host, port?) where port
- /// is null when the user didn't include one. IPv6 literals are not supported in the manual
- /// peer field today; if/when they are, they'll need bracket syntax. Bare numeric strings are
- /// treated as hosts (no port).
- ///
- internal static (string host, int? port) TrySplitHostPort(string text)
- {
- if (string.IsNullOrWhiteSpace(text)) return (text ?? string.Empty, null);
- text = text.Trim();
- var colon = text.LastIndexOf(':');
- if (colon <= 0 || colon == text.Length - 1) return (text, null);
- var maybeHost = text[..colon];
- var maybePort = text[(colon + 1)..];
- // If there's another colon earlier, it's likely an IPv6 literal — leave the whole thing
- // as the host. (Manual peer entry doesn't formally support IPv6 today, but don't
- // misinterpret one as host:port and resolve garbage.)
- if (maybeHost.Contains(':')) return (text, null);
- if (!int.TryParse(maybePort, out var port)) return (text, null);
- if (port < 1 || port > 65535) return (text, null);
- return (maybeHost, port);
- }
+ internal static (string host, int? port) TrySplitHostPort(string text) => PeerAddress.Split(text);
private PeerAnnouncement CreateManualPeer(string entry, IPAddress address)
{
@@ -7623,27 +7586,9 @@ public sealed class MainForm : Form
// no-ops when logFile.Enabled is false, so we don't need to wrap individual writes.
if (!DiagnosticsGate.Enabled) return;
- // Handle-TYPE breakdown — names which kind of handle is leaking (Event / Section / Key /
- // Thread / …), the piece the plain handles= count can't give us. It walks the whole
- // system handle table, so it's far heavier than the per-tick meter: run it about once a
- // minute, only when logging is actually on (the diag gate can be open for auto-tune with
- // logging off), and off the UI thread. logFile.Event is thread-safe. 2026-06-07.
- handleProbeTickCount++;
- if (handleProbeTickCount >= 60 && logFile.Enabled)
- {
- handleProbeTickCount = 0;
- Task.Run(() =>
- {
- try
- {
- // Always log — Summarize returns a "probe-error …" string on failure rather
- // than empty, so a blank build vs a silently-failing probe can never again be
- // confused (that cost us a run on 2026-06-07).
- logFile.Event($"handle-types: {HandleTypeProbe.Summarize()}");
- }
- catch { /* probe is best-effort — never let it disturb the tick */ }
- });
- }
+ // (The per-minute HandleTypeProbe walk that lived here — added 2026-06-07 to name the leaking
+ // handle type in the Realtek/WASAPI investigation — was retired in the 2026-07-19 legacy sweep
+ // with that investigation closed. Git history has it if a handle hunt ever needs it back.)
// SNAP latency columns: in classic modes the legacy MaxLatencyMs / TargetLatencyMs
// pair holds the only route's value (Mixed). In BothIndependent we map them to the
@@ -8676,16 +8621,8 @@ public sealed class MainForm : Form
{
if (currentProfilePassword != lastDerivedPassword)
{
- if (string.IsNullOrEmpty(currentProfilePassword))
- {
- currentAudioKey = null;
- currentAudioFingerprint = null;
- }
- else
- {
- currentAudioKey = RemSoundCrypto.DeriveKey(currentProfilePassword);
- currentAudioFingerprint = RemSoundCrypto.Fingerprint(currentProfilePassword);
- }
+ // Key + fingerprint always together, through the one shared rule (same as the service).
+ (currentAudioKey, currentAudioFingerprint) = RemSoundCrypto.ForPlainPassword(currentProfilePassword);
lastDerivedPassword = currentProfilePassword;
}
sender.AudioKey = currentAudioKey;
@@ -9521,12 +9458,10 @@ public sealed class MainForm : Form
/// Standard returns the codec's natural frame; Tight halves it (Opus 960 → 480 → 240 → 120
/// floored). Floor is 120 samples = 2.5 ms = standard libopus's RESTRICTED_LOWDELAY minimum.
///
- // internal so the send-only service host reuses the exact same frame-size rule as the main app.
- internal static int EffectiveOpusFrameSamples(AudioTransportCodec codec, int opusFrameSamples, SendRate rate)
- {
- if (codec != AudioTransportCodec.Opus) return opusFrameSamples;
- return rate == SendRate.Tight ? Math.Max(120, opusFrameSamples / 2) : opusFrameSamples;
- }
+ // The rule itself lives in Core (AudioTransportRules) — shared with the service, which must never
+ // depend on this Form. Thin wrapper kept for the existing call sites.
+ internal static int EffectiveOpusFrameSamples(AudioTransportCodec codec, int opusFrameSamples, SendRate rate) =>
+ AudioTransportRules.EffectiveOpusFrameSamples(codec, opusFrameSamples, rate);
///
/// Short codec label for the per-peer line in the connectivity dialog. e.g. "PCM",
diff --git a/src/RemSound.App/ServiceProfileDialog.cs b/src/RemSound.App/ServiceProfileDialog.cs
index 06ffe0d..16bdb1f 100644
--- a/src/RemSound.App/ServiceProfileDialog.cs
+++ b/src/RemSound.App/ServiceProfileDialog.cs
@@ -34,13 +34,12 @@ internal sealed class ServiceProfileDialog : Form
// specific apps) only — capturing a mic from an unattended, logged-out box isn't a use case (Ed).
private MnemonicLabel? sendModeLabel, outputsLabel, appsLabel;
- // No "Audio profile" tab: the service always sends Opus at the live-jamming frame (2.5 ms), Small
- // packets, and locked to the audio clock — the settings Ed found sound good. They're forced in
- // SaveToProfile below and again at runtime in ServiceSendHost, so there's nothing to misconfigure
- // (Ed, 2026-07-17: removed the tab; nobody should be touching these).
- private const AudioTransportCodec ServiceCodec = AudioTransportCodec.Opus;
- private const int ServiceOpusFrameSamples = 120; // 2.5 ms at 48 kHz — live latency
- private const SendRate ServiceSendRate = SendRate.Tight; // "Small" packets
+ // No "Audio profile" tab: the service always sends the fixed live-jamming transport (see
+ // ServiceAudioDefaults in Core — ONE set of numbers shared with ServiceSendHost, which re-forces
+ // them at runtime, so the saved profile and the running stream can never disagree).
+ private const AudioTransportCodec ServiceCodec = ServiceAudioDefaults.Codec;
+ private const int ServiceOpusFrameSamples = ServiceAudioDefaults.OpusFrameSamplesPerChannel;
+ private const SendRate ServiceSendRate = ServiceAudioDefaults.Rate;
// --- Button row ---
private readonly Button saveButton = new() { Text = "&Save and Close", AutoSize = true, DialogResult = DialogResult.OK };
diff --git a/src/RemSound.App/ServiceSendHost.cs b/src/RemSound.App/ServiceSendHost.cs
index 0d986f1..0918439 100644
--- a/src/RemSound.App/ServiceSendHost.cs
+++ b/src/RemSound.App/ServiceSendHost.cs
@@ -325,22 +325,16 @@ public sealed class ServiceSendHost : IDisposable
if (specs.Count == 0) { log?.Invoke("service: profile has no WASAPI send sources — nothing to stream"); return false; }
if (endpoints.Count == 0) { log?.Invoke("service: profile has no reachable peers — nothing to stream to"); return false; }
- // Encryption: derive BOTH the key AND the fingerprint from the plain password, exactly like
- // MainForm.RecomputeAudioCrypto. The peer verifies the fingerprint before accepting a stream —
- // sending the key without it would get the service's audio rejected at the far end.
+ // Encryption: key + fingerprint always together, through the ONE shared rule (the peer
+ // verifies the fingerprint before accepting a stream; a key alone gets silently rejected).
var plainPassword = string.IsNullOrEmpty(profile.Password) ? "" : RemSoundCrypto.Deobfuscate(profile.Password);
- sender.AudioKey = string.IsNullOrEmpty(plainPassword) ? null : RemSoundCrypto.DeriveKey(plainPassword);
- sender.AudioFingerprint = string.IsNullOrEmpty(plainPassword) ? null : RemSoundCrypto.Fingerprint(plainPassword);
+ (sender.AudioKey, sender.AudioFingerprint) = RemSoundCrypto.ForPlainPassword(plainPassword);
// The service's audio transport is FIXED to the known-good live-jamming config, regardless of
- // what the profile carries (the config dialog no longer exposes these — Ed, 2026-07-17). Raw
- // PCM sounded hideous over the service; Opus at the 2.5 ms live frame with Small packets and
- // lock-to-clock is what sounds right. Forcing it here means a stale or hand-edited profile can
- // never put the service back on a bad codec.
- const AudioTransportCodec serviceCodec = AudioTransportCodec.Opus;
- const int serviceOpusFrameSamples = 120; // 2.5 ms at 48 kHz
- const SendRate serviceSendRate = SendRate.Tight; // "Small" packets
- sender.ConfigureCodec(serviceCodec, MainForm.EffectiveOpusFrameSamples(serviceCodec, serviceOpusFrameSamples, serviceSendRate));
- sender.SetSendRate(serviceSendRate);
+ // what the profile carries (the config dialog no longer exposes these — Ed, 2026-07-17).
+ // The numbers live in ServiceAudioDefaults, shared with the dialog that writes the profile.
+ sender.ConfigureCodec(ServiceAudioDefaults.Codec, AudioTransportRules.EffectiveOpusFrameSamples(
+ ServiceAudioDefaults.Codec, ServiceAudioDefaults.OpusFrameSamplesPerChannel, ServiceAudioDefaults.Rate));
+ sender.SetSendRate(ServiceAudioDefaults.Rate);
sender.SetTightLatency(true); // lock to audio clock — always on
// Arm the full set to begin with (nothing is known-dead yet); RefreshSendArming then prunes any
// peer the heartbeat can't reach and re-arms it when it recovers.
@@ -613,17 +607,9 @@ public sealed class ServiceSendHost : IDisposable
var seen = new HashSet();
foreach (var entry in entries.Where(e => !string.IsNullOrWhiteSpace(e)).Distinct())
{
- var (host, port) = SplitHostPort(entry);
- IPAddress? addr;
- if (!IPAddress.TryParse(host, out addr))
- {
- try
- {
- var found = Dns.GetHostAddresses(host);
- addr = found.FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork) ?? found.FirstOrDefault();
- }
- catch { addr = null; }
- }
+ // Shared split + resolve (PeerAddress) — the app's peer paths use the same rules.
+ var (_, port) = PeerAddress.Split(entry);
+ var addr = PeerAddress.ResolveHost(entry);
if (addr is null) continue;
// Send to the peer's audio port: an explicit "host:port" wins, else the standard peer port —
// the same default the main app's manual-peer path uses (NOT the local listen port).
@@ -633,17 +619,6 @@ public sealed class ServiceSendHost : IDisposable
return result;
}
- // Minimal "host[:port]" parser (self-contained so the host doesn't depend on the WinForms UI).
- internal static (string host, int? port) SplitHostPort(string text)
- {
- text = text.Trim();
- var colon = text.LastIndexOf(':');
- if (colon <= 0 || colon == text.Length - 1) return (text, null);
- var host = text[..colon];
- if (host.Contains(':')) return (text, null); // looks like an IPv6 literal — treat whole as host
- return int.TryParse(text[(colon + 1)..], out var port) && port is >= 1 and <= 65535 ? (host, port) : (text, null);
- }
-
public void Dispose()
{
lock (gate)
diff --git a/src/RemSound.Core/AppConfig.cs b/src/RemSound.Core/AppConfig.cs
index 012d283..314d3ab 100644
--- a/src/RemSound.Core/AppConfig.cs
+++ b/src/RemSound.Core/AppConfig.cs
@@ -202,7 +202,8 @@ public sealed class AppConfig
/// They now live here — one set shared by every profile, loaded/saved via
/// 's Load*/Save* hotkey methods. Null = use the built-in default
/// for that action. The old per-profile fields on
- /// are kept only so old profile JSONs still deserialise; they are no longer read or written.
+ /// are kept so old profile JSONs still deserialise and are READ exactly once — by MainForm's one-time
+ /// shortcut-import offer for v4.4 upgraders — but are never written or otherwise consulted.
public HotkeyRecord? ReceiveMuteHotkey { get; set; }
public HotkeyRecord? SendMuteHotkey { get; set; }
public HotkeyRecord? TrayHotkey { get; set; }
diff --git a/src/RemSound.Core/AudioTransportRules.cs b/src/RemSound.Core/AudioTransportRules.cs
new file mode 100644
index 0000000..ad921e4
--- /dev/null
+++ b/src/RemSound.Core/AudioTransportRules.cs
@@ -0,0 +1,33 @@
+namespace RemSound.Core;
+
+///
+/// Transport rules shared by the main window and the send-only service. Moved here from MainForm
+/// (review sweep): the headless service reaching into a WinForms Form for a frame-size rule was an
+/// inverted dependency, and the service's fixed audio numbers were hard-coded in two files that had
+/// to agree by luck.
+///
+public static class AudioTransportRules
+{
+ /// Maps the codec + configured Opus frame + send-rate to the frame the encoder actually
+ /// uses, in samples-per-channel at 48 kHz. Standard returns the codec's natural frame; Tight halves
+ /// it, floored at 120 samples = 2.5 ms (libopus RESTRICTED_LOWDELAY minimum). PCM frame size is set
+ /// separately in AudioSender.SetSendRate.
+ public static int EffectiveOpusFrameSamples(AudioTransportCodec codec, int opusFrameSamples, SendRate rate)
+ {
+ if (codec != AudioTransportCodec.Opus) return opusFrameSamples;
+ return rate == SendRate.Tight ? Math.Max(120, opusFrameSamples / 2) : opusFrameSamples;
+ }
+}
+
+///
+/// The service's FIXED audio transport — the known-good live-jamming config (raw PCM sounded hideous
+/// over the service; Opus at the 2.5 ms frame with Small packets and lock-to-clock is what sounds
+/// right — Ed, 2026-07-17). The config dialog writes these into the saved service profile AND the
+/// service host re-forces them at runtime; both reference THIS so they can never disagree.
+///
+public static class ServiceAudioDefaults
+{
+ public const AudioTransportCodec Codec = AudioTransportCodec.Opus;
+ public const int OpusFrameSamplesPerChannel = 120; // 2.5 ms at 48 kHz
+ public const SendRate Rate = SendRate.Tight; // "Small" packets
+}
diff --git a/src/RemSound.Core/PeerAddress.cs b/src/RemSound.Core/PeerAddress.cs
new file mode 100644
index 0000000..a076475
--- /dev/null
+++ b/src/RemSound.Core/PeerAddress.cs
@@ -0,0 +1,56 @@
+using System.Net;
+using System.Net.Sockets;
+
+namespace RemSound.Core;
+
+///
+/// ONE home for peer-address handling. The "host[:port]" split and the resolve-preferring-IPv4 rule
+/// used to live as byte-identical private copies in the main window AND the service host (plus two more
+/// resolve copies in the peer paths) — exactly the kind of duplication that silently drifts. Both sides
+/// now call this. Bare numeric strings are hosts (no port); a second colon means an IPv6 literal, which
+/// the manual peer field doesn't formally support yet, so the whole text is treated as the host rather
+/// than mis-parsed as host:port.
+///
+public static class PeerAddress
+{
+ /// Parse "host:port" or just "host". Returns (host, null) when no valid port is present.
+ /// Ports outside 1–65535 are not ports.
+ public static (string Host, int? Port) Split(string? text)
+ {
+ if (string.IsNullOrWhiteSpace(text)) return (text ?? string.Empty, null);
+ text = text.Trim();
+ var colon = text.LastIndexOf(':');
+ if (colon <= 0 || colon == text.Length - 1) return (text, null);
+ var host = text[..colon];
+ if (host.Contains(':')) return (text, null); // IPv6 literal — the whole thing is the host
+ return int.TryParse(text[(colon + 1)..], out var port) && port is >= 1 and <= 65535
+ ? (host, port)
+ : (text, null);
+ }
+
+ /// The address-family preference every resolve path shares: IPv4 first (the wire format and
+ /// discovery are IPv4 today), otherwise whatever came back.
+ public static IPAddress? PreferIPv4(IPAddress[]? addresses) =>
+ addresses is null || addresses.Length == 0
+ ? null
+ : addresses.FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork) ?? addresses[0];
+
+ /// Resolve a peer entry's HOST part to an address (literal IPs short-circuit DNS).
+ /// Null on any failure — callers skip unresolvable peers.
+ public static IPAddress? ResolveHost(string entry)
+ {
+ var (host, _) = Split(entry);
+ if (IPAddress.TryParse(host, out var direct)) return direct;
+ try { return PreferIPv4(Dns.GetHostAddresses(host)); }
+ catch { return null; }
+ }
+
+ /// Async twin of for UI callers.
+ public static async Task ResolveHostAsync(string entry)
+ {
+ var (host, _) = Split(entry);
+ if (IPAddress.TryParse(host, out var direct)) return direct;
+ try { return PreferIPv4(await Dns.GetHostAddressesAsync(host).ConfigureAwait(false)); }
+ catch { return null; }
+ }
+}
diff --git a/src/RemSound.Core/Profile.cs b/src/RemSound.Core/Profile.cs
index a1f9c9d..4f191ff 100644
--- a/src/RemSound.Core/Profile.cs
+++ b/src/RemSound.Core/Profile.cs
@@ -95,6 +95,11 @@ public sealed class Profile
// the ACTIVE subset (SelectedSendApplications above) is per-profile.
// === Connectivity & transport ===
+ /// DEAD FIELD — nothing reads it. The live port is the fixed RemPacket.DefaultPort
+ /// constant, and every save writes this back at its default (it isn't wired through the settings
+ /// cache or any control). TRAP: wiring a future "custom port" UI to this field without adding real
+ /// persistence would silently reset the user's choice to 47830 on every save — the persistence
+ /// tripwire self-test documents this and will flag any change in its behaviour.
public int AudioPort { get; set; } = 47830;
public int CodecRaw { get; set; } = (int)AudioTransportCodec.Pcm;
/// Opus frame size in samples-per-channel at 48 kHz. 120 = 2.5 ms, 240 = 5 ms,
diff --git a/src/RemSound.Core/RemSoundCrypto.cs b/src/RemSound.Core/RemSoundCrypto.cs
index 715a880..1dd6cb8 100644
--- a/src/RemSound.Core/RemSoundCrypto.cs
+++ b/src/RemSound.Core/RemSoundCrypto.cs
@@ -66,6 +66,14 @@ public static class RemSoundCrypto
private static readonly byte[] ObfuscationKey =
Encoding.UTF8.GetBytes("RemSound-profile-password-scramble-v1");
+ /// The one rule for turning a PLAIN password into the audio credentials: null/empty →
+ /// (null, null) → no audio flows (encryption is mandatory); otherwise the key AND the fingerprint,
+ /// always together — the peer verifies the fingerprint before accepting a stream, so a key without
+ /// its fingerprint gets the audio silently rejected at the far end (a divergence that already bit
+ /// the service once). The app and the service both derive through THIS.
+ public static (byte[]? Key, byte[]? Fingerprint) ForPlainPassword(string? plainPassword) =>
+ string.IsNullOrEmpty(plainPassword) ? (null, null) : (DeriveKey(plainPassword), Fingerprint(plainPassword));
+
/// Derive the 256-bit AES key for a password. Cache the result; never call per packet.
public static byte[] DeriveKey(string? password) =>
Rfc2898DeriveBytes.Pbkdf2(
diff --git a/src/RemSound.Harness/Program.cs b/src/RemSound.Harness/Program.cs
deleted file mode 100644
index 5d8e064..0000000
--- a/src/RemSound.Harness/Program.cs
+++ /dev/null
@@ -1,180 +0,0 @@
-using System.Net;
-using NAudio.CoreAudioApi;
-using RemSound.Core;
-using RemSound.Receiver;
-using RemSound.Sender;
-
-if (args.Length == 0 || args[0] is "-h" or "--help" or "help")
-{
- PrintUsage();
- return 0;
-}
-
-return args[0].ToLowerInvariant() switch
-{
- "send" => RunSend(args),
- "recv" or "receive" => RunReceive(args),
- "devices" => ListDevices(),
- "loopback" => RunLoopback(args),
- _ => Unknown(args[0]),
-};
-
-static int Unknown(string verb)
-{
- Console.Error.WriteLine($"Unknown verb '{verb}'.");
- PrintUsage();
- return 1;
-}
-
-static void PrintUsage()
-{
- Console.WriteLine("RemSound.Harness — minimal command-line test for the new audio engine.");
- Console.WriteLine();
- Console.WriteLine("Usage:");
- Console.WriteLine(" RemSound.Harness devices");
- Console.WriteLine(" List Windows render devices and their IDs.");
- Console.WriteLine();
- Console.WriteLine(" RemSound.Harness send [--opus] [--device ]");
- Console.WriteLine(" Capture the default (or selected) render device and send to the receiver.");
- Console.WriteLine();
- Console.WriteLine(" RemSound.Harness recv [--port N] [--device ] [--max-latency N]");
- Console.WriteLine(" Listen for RemSound packets and play them through the default (or selected) device.");
- Console.WriteLine();
- Console.WriteLine(" RemSound.Harness loopback [--opus] [--max-latency N]");
- Console.WriteLine(" Run sender + receiver on localhost. Useful for sanity checks; will feed the");
- Console.WriteLine(" default output back into the system, so use headphones and a different render device for the receiver.");
- Console.WriteLine();
- Console.WriteLine("Press Ctrl+C to stop in any mode.");
-}
-
-static int ListDevices()
-{
- var enumerator = new MMDeviceEnumerator();
- var defaultRender = enumerator.GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia);
- var devices = enumerator.EnumerateAudioEndPoints(DataFlow.Render, DeviceState.Active);
- Console.WriteLine($"{"State",-8}{"Default",-9}{"Name"}");
- foreach (var device in devices)
- {
- var marker = device.ID == defaultRender.ID ? "yes" : "";
- Console.WriteLine($"{device.State,-8}{marker,-9}{device.FriendlyName}");
- Console.WriteLine($" ID: {device.ID}");
- device.Dispose();
- }
- defaultRender.Dispose();
- return 0;
-}
-
-static int RunSend(string[] args)
-{
- if (args.Length < 2)
- {
- Console.Error.WriteLine("Missing destination. Example: RemSound.Harness send 192.168.1.42:47830");
- return 1;
- }
- if (!TryParseEndpoint(args[1], out var target))
- {
- Console.Error.WriteLine($"Could not parse '{args[1]}' as ip:port.");
- return 1;
- }
-
- var codec = args.Contains("--opus") ? AudioTransportCodec.Opus : AudioTransportCodec.Pcm;
- var deviceId = ParseOption(args, "--device");
-
- using var sender = new AudioSender();
- if (deviceId is not null)
- {
- sender.Configure(new[] { new CaptureSourceSpec(deviceId, CaptureKind.Loopback, deviceId) });
- }
- sender.ConfigureCodec(codec);
- sender.SetReceivers(new[] { target });
- sender.Start();
-
- Console.WriteLine($"Sending {codec} from \"{sender.CaptureDeviceName}\" → {target}. Press Ctrl+C to stop.");
- using var quit = new ManualResetEventSlim(false);
- Console.CancelKeyPress += (_, e) => { e.Cancel = true; quit.Set(); };
- var lastPackets = 0L;
- while (!quit.Wait(1000))
- {
- var p = sender.PacketsSent;
- var rate = p - lastPackets;
- lastPackets = p;
- Console.WriteLine($"[send] packets={p} /sec={rate} bytes={sender.BytesSent} uptime={sender.Uptime:hh\\:mm\\:ss}");
- }
- sender.Stop();
- return 0;
-}
-
-static int RunReceive(string[] args)
-{
- var port = int.TryParse(ParseOption(args, "--port"), out var p) ? p : RemPacket.DefaultPort;
- var deviceId = ParseOption(args, "--device");
- var maxLatency = int.TryParse(ParseOption(args, "--max-latency"), out var ml) ? ml : 80;
-
- using var receiver = new AudioReceiver();
- if (deviceId is not null) receiver.SetOutputDevices(new[] { deviceId });
- receiver.MaxLatencyMs = maxLatency;
- receiver.Start(port);
-
- Console.WriteLine($"Listening on UDP :{port}, output \"{receiver.OutputDeviceName}\", max latency {maxLatency} ms. Press Ctrl+C to stop.");
- using var quit = new ManualResetEventSlim(false);
- Console.CancelKeyPress += (_, e) => { e.Cancel = true; quit.Set(); };
- var lastPackets = 0L;
- while (!quit.Wait(1000))
- {
- var pk = receiver.PacketsReceived;
- var rate = pk - lastPackets;
- lastPackets = pk;
- Console.WriteLine($"[recv] packets={pk} /sec={rate} buffer={receiver.CurrentBufferMs}ms underruns={receiver.Underruns} drops={receiver.Drops}");
- }
- receiver.Stop();
- return 0;
-}
-
-static int RunLoopback(string[] args)
-{
- var codec = args.Contains("--opus") ? AudioTransportCodec.Opus : AudioTransportCodec.Pcm;
- var maxLatency = int.TryParse(ParseOption(args, "--max-latency"), out var ml) ? ml : 80;
-
- using var receiver = new AudioReceiver();
- receiver.MaxLatencyMs = maxLatency;
- receiver.Start();
-
- using var sender = new AudioSender();
- sender.ConfigureCodec(codec);
- sender.SetReceivers(new[] { new IPEndPoint(IPAddress.Loopback, RemPacket.DefaultPort) });
- sender.Start();
-
- Console.WriteLine($"Loopback running, codec={codec}, max latency={maxLatency} ms. Ctrl+C to stop.");
- using var quit = new ManualResetEventSlim(false);
- Console.CancelKeyPress += (_, e) => { e.Cancel = true; quit.Set(); };
- while (!quit.Wait(1000))
- {
- Console.WriteLine($"send pkt={sender.PacketsSent} recv pkt={receiver.PacketsReceived} buf={receiver.CurrentBufferMs}ms under={receiver.Underruns} drop={receiver.Drops}");
- }
- sender.Stop();
- receiver.Stop();
- return 0;
-}
-
-static bool TryParseEndpoint(string text, out IPEndPoint endpoint)
-{
- endpoint = new IPEndPoint(IPAddress.Loopback, 0);
- var split = text.Split(':');
- if (split.Length != 2) return false;
- if (!IPAddress.TryParse(split[0], out var ip)) return false;
- if (!int.TryParse(split[1], out var port) || port is <= 0 or > 65535) return false;
- endpoint = new IPEndPoint(ip, port);
- return true;
-}
-
-static string? ParseOption(string[] args, string optionName)
-{
- for (var i = 0; i < args.Length - 1; i++)
- {
- if (string.Equals(args[i], optionName, StringComparison.OrdinalIgnoreCase))
- {
- return args[i + 1];
- }
- }
- return null;
-}
diff --git a/src/RemSound.Harness/RemSound.Harness.csproj b/src/RemSound.Harness/RemSound.Harness.csproj
deleted file mode 100644
index f9b4096..0000000
--- a/src/RemSound.Harness/RemSound.Harness.csproj
+++ /dev/null
@@ -1,19 +0,0 @@
-
-
- Exe
- net10.0-windows
- enable
- enable
- true
- RemSound.Harness
- RemSound.Harness
- true
-
-
-
-
-
-
-
-
-