Shared homes + legacy sweep (review Phase 4)

Duplication that had to agree by luck now has ONE home each:
- PeerAddress (Core): the host[:port] split + resolve-preferring-IPv4 that lived as
  byte-identical private copies in MainForm and ServiceSendHost (plus an extra resolve
  copy). Both now delegate. (The unicast-hints path deliberately keeps its own loop -
  it collects ALL IPv4 records, a different behaviour, not a duplicate.)
- RemSoundCrypto.ForPlainPassword: key + fingerprint always derived together - the
  divergence that once got the service's audio silently rejected can't recur.
- AudioTransportRules.EffectiveOpusFrameSamples moved to Core; the headless service no
  longer reaches into the WinForms MainForm for a frame-size rule.
- ServiceAudioDefaults (Core): the service's fixed live-jamming transport numbers,
  referenced by BOTH the dialog that writes the profile and the host that re-forces
  them at runtime - they can no longer disagree.

Legacy sweep:
- HandleTypeProbe + its per-minute tick retired (the 2026-06 handle-leak investigation
  it was built for is closed; git history has it).
- RemSound.Harness project removed from the tree + solution (superseded by --selftest).
- Fixed the stale AppConfig claim that per-profile hotkeys are 'no longer read' (the
  one-time v4.4 import reads them once); Profile.AudioPort now carries an explicit
  DEAD FIELD / trap warning backed by the persistence tripwire test.

Gate 59/59.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-23 16:17:41 +01:00
co-authored by Claude Fable 5
parent 9f13ef3aae
commit 517a066923
12 changed files with 134 additions and 552 deletions
-1
View File
@@ -3,7 +3,6 @@
<Project Path="src/RemSound.Core/RemSound.Core.csproj" /> <Project Path="src/RemSound.Core/RemSound.Core.csproj" />
<Project Path="src/RemSound.Sender/RemSound.Sender.csproj" /> <Project Path="src/RemSound.Sender/RemSound.Sender.csproj" />
<Project Path="src/RemSound.Receiver/RemSound.Receiver.csproj" /> <Project Path="src/RemSound.Receiver/RemSound.Receiver.csproj" />
<Project Path="src/RemSound.Harness/RemSound.Harness.csproj" />
<Project Path="src/RemSound.App/RemSound.App.csproj" /> <Project Path="src/RemSound.App/RemSound.App.csproj" />
</Folder> </Folder>
</Solution> </Solution>
-230
View File
@@ -1,230 +0,0 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text;
namespace RemSound.App;
/// <summary>
/// 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 <c>handles=</c> 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).
/// </summary>
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<ushort, string> TypeNameByIndex = new();
private static readonly int OwnPid = Process.GetCurrentProcess().Id;
/// <summary>
/// Returns "Event=120345 Section=15234 File=210 … total=N" — the <paramref name="topN"/> 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.
/// </summary>
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}";
}
/// <summary>
/// Primary path: query only THIS process's handle table. Returns the formatted summary, or null
/// on any failure (with <paramref name="status"/> set to the NTSTATUS for diagnostics).
/// </summary>
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<ushort, int>();
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);
}
}
/// <summary>
/// Fallback path: walk the whole system handle table and filter to our PID. Returns null on any
/// failure (with <paramref name="status"/> 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 <see cref="TryOwnProcess"/> is tried first.
/// </summary>
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<ushort, int>();
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<ushort, int> counts, int topN)
{
var ordered = new List<KeyValuePair<ushort, int>>(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);
}
+13 -78
View File
@@ -591,9 +591,6 @@ public sealed class MainForm : Form
// snapshot tick (~1 Hz) and triggers a forced gen2 + finalizer flush every 300 ticks // 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. // (~5 minutes). See the inline comment in SnapshotLogIfDue for the full rationale.
private int nativeReaperTickCount; 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 // 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; // the receiver-side cumulative counter snapshot at the previous SnapshotLogIfDue tick;
@@ -7295,45 +7292,11 @@ public sealed class MainForm : Form
.ToArray(); .ToArray();
} }
private async Task<IPAddress?> ResolvePeerAddressAsync(string text) // 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.
// Strip any host:port suffix before resolving; the port is parsed separately by the private static Task<IPAddress?> ResolvePeerAddressAsync(string text) => PeerAddress.ResolveHostAsync(text);
// 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;
}
}
/// <summary> internal static (string host, int? port) TrySplitHostPort(string text) => PeerAddress.Split(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).
/// </summary>
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);
}
private PeerAnnouncement CreateManualPeer(string entry, IPAddress address) 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. // no-ops when logFile.Enabled is false, so we don't need to wrap individual writes.
if (!DiagnosticsGate.Enabled) return; if (!DiagnosticsGate.Enabled) return;
// Handle-TYPE breakdown — names which kind of handle is leaking (Event / Section / Key / // (The per-minute HandleTypeProbe walk that lived here — added 2026-06-07 to name the leaking
// Thread / …), the piece the plain handles= count can't give us. It walks the whole // handle type in the Realtek/WASAPI investigation — was retired in the 2026-07-19 legacy sweep
// system handle table, so it's far heavier than the per-tick meter: run it about once a // with that investigation closed. Git history has it if a handle hunt ever needs it back.)
// 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 */ }
});
}
// SNAP latency columns: in classic modes the legacy MaxLatencyMs / TargetLatencyMs // 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 // 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 (currentProfilePassword != lastDerivedPassword)
{ {
if (string.IsNullOrEmpty(currentProfilePassword)) // Key + fingerprint always together, through the one shared rule (same as the service).
{ (currentAudioKey, currentAudioFingerprint) = RemSoundCrypto.ForPlainPassword(currentProfilePassword);
currentAudioKey = null;
currentAudioFingerprint = null;
}
else
{
currentAudioKey = RemSoundCrypto.DeriveKey(currentProfilePassword);
currentAudioFingerprint = RemSoundCrypto.Fingerprint(currentProfilePassword);
}
lastDerivedPassword = currentProfilePassword; lastDerivedPassword = currentProfilePassword;
} }
sender.AudioKey = currentAudioKey; 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 /// 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. /// floored). Floor is 120 samples = 2.5 ms = standard libopus's RESTRICTED_LOWDELAY minimum.
/// </summary> /// </summary>
// internal so the send-only service host reuses the exact same frame-size rule as the main app. // The rule itself lives in Core (AudioTransportRules) — shared with the service, which must never
internal static int EffectiveOpusFrameSamples(AudioTransportCodec codec, int opusFrameSamples, SendRate rate) // depend on this Form. Thin wrapper kept for the existing call sites.
{ internal static int EffectiveOpusFrameSamples(AudioTransportCodec codec, int opusFrameSamples, SendRate rate) =>
if (codec != AudioTransportCodec.Opus) return opusFrameSamples; AudioTransportRules.EffectiveOpusFrameSamples(codec, opusFrameSamples, rate);
return rate == SendRate.Tight ? Math.Max(120, opusFrameSamples / 2) : opusFrameSamples;
}
/// <summary> /// <summary>
/// Short codec label for the per-peer line in the connectivity dialog. e.g. "PCM", /// Short codec label for the per-peer line in the connectivity dialog. e.g. "PCM",
+6 -7
View File
@@ -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). // specific apps) only — capturing a mic from an unattended, logged-out box isn't a use case (Ed).
private MnemonicLabel? sendModeLabel, outputsLabel, appsLabel; private MnemonicLabel? sendModeLabel, outputsLabel, appsLabel;
// No "Audio profile" tab: the service always sends Opus at the live-jamming frame (2.5 ms), Small // No "Audio profile" tab: the service always sends the fixed live-jamming transport (see
// packets, and locked to the audio clock — the settings Ed found sound good. They're forced in // ServiceAudioDefaults in Core — ONE set of numbers shared with ServiceSendHost, which re-forces
// SaveToProfile below and again at runtime in ServiceSendHost, so there's nothing to misconfigure // them at runtime, so the saved profile and the running stream can never disagree).
// (Ed, 2026-07-17: removed the tab; nobody should be touching these). private const AudioTransportCodec ServiceCodec = ServiceAudioDefaults.Codec;
private const AudioTransportCodec ServiceCodec = AudioTransportCodec.Opus; private const int ServiceOpusFrameSamples = ServiceAudioDefaults.OpusFrameSamplesPerChannel;
private const int ServiceOpusFrameSamples = 120; // 2.5 ms at 48 kHz — live latency private const SendRate ServiceSendRate = ServiceAudioDefaults.Rate;
private const SendRate ServiceSendRate = SendRate.Tight; // "Small" packets
// --- Button row --- // --- Button row ---
private readonly Button saveButton = new() { Text = "&Save and Close", AutoSize = true, DialogResult = DialogResult.OK }; private readonly Button saveButton = new() { Text = "&Save and Close", AutoSize = true, DialogResult = DialogResult.OK };
+11 -36
View File
@@ -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 (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; } 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 // Encryption: key + fingerprint always together, through the ONE shared rule (the peer
// MainForm.RecomputeAudioCrypto. The peer verifies the fingerprint before accepting a stream // verifies the fingerprint before accepting a stream; a key alone gets silently rejected).
// sending the key without it would get the service's audio rejected at the far end.
var plainPassword = string.IsNullOrEmpty(profile.Password) ? "" : RemSoundCrypto.Deobfuscate(profile.Password); var plainPassword = string.IsNullOrEmpty(profile.Password) ? "" : RemSoundCrypto.Deobfuscate(profile.Password);
sender.AudioKey = string.IsNullOrEmpty(plainPassword) ? null : RemSoundCrypto.DeriveKey(plainPassword); (sender.AudioKey, sender.AudioFingerprint) = RemSoundCrypto.ForPlainPassword(plainPassword);
sender.AudioFingerprint = string.IsNullOrEmpty(plainPassword) ? null : RemSoundCrypto.Fingerprint(plainPassword);
// The service's audio transport is FIXED to the known-good live-jamming config, regardless of // 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 // what the profile carries (the config dialog no longer exposes these — Ed, 2026-07-17).
// PCM sounded hideous over the service; Opus at the 2.5 ms live frame with Small packets and // The numbers live in ServiceAudioDefaults, shared with the dialog that writes the profile.
// lock-to-clock is what sounds right. Forcing it here means a stale or hand-edited profile can sender.ConfigureCodec(ServiceAudioDefaults.Codec, AudioTransportRules.EffectiveOpusFrameSamples(
// never put the service back on a bad codec. ServiceAudioDefaults.Codec, ServiceAudioDefaults.OpusFrameSamplesPerChannel, ServiceAudioDefaults.Rate));
const AudioTransportCodec serviceCodec = AudioTransportCodec.Opus; sender.SetSendRate(ServiceAudioDefaults.Rate);
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);
sender.SetTightLatency(true); // lock to audio clock — always on 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 // 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. // 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<string>(); var seen = new HashSet<string>();
foreach (var entry in entries.Where(e => !string.IsNullOrWhiteSpace(e)).Distinct()) foreach (var entry in entries.Where(e => !string.IsNullOrWhiteSpace(e)).Distinct())
{ {
var (host, port) = SplitHostPort(entry); // Shared split + resolve (PeerAddress) — the app's peer paths use the same rules.
IPAddress? addr; var (_, port) = PeerAddress.Split(entry);
if (!IPAddress.TryParse(host, out addr)) var addr = PeerAddress.ResolveHost(entry);
{
try
{
var found = Dns.GetHostAddresses(host);
addr = found.FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork) ?? found.FirstOrDefault();
}
catch { addr = null; }
}
if (addr is null) continue; if (addr is null) continue;
// Send to the peer's audio port: an explicit "host:port" wins, else the standard peer port — // 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). // 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; 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() public void Dispose()
{ {
lock (gate) lock (gate)
+2 -1
View File
@@ -202,7 +202,8 @@ public sealed class AppConfig
/// They now live here — one set shared by every profile, loaded/saved via /// They now live here — one set shared by every profile, loaded/saved via
/// <see cref="RemSoundSettingsStore"/>'s Load*/Save* hotkey methods. Null = use the built-in default /// <see cref="RemSoundSettingsStore"/>'s Load*/Save* hotkey methods. Null = use the built-in default
/// for that action. The old per-profile <see cref="HotkeyRecord"/> fields on <see cref="Profile"/> /// for that action. The old per-profile <see cref="HotkeyRecord"/> fields on <see cref="Profile"/>
/// are kept only so old profile JSONs still deserialise; they are no longer read or written.</summary> /// 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.</summary>
public HotkeyRecord? ReceiveMuteHotkey { get; set; } public HotkeyRecord? ReceiveMuteHotkey { get; set; }
public HotkeyRecord? SendMuteHotkey { get; set; } public HotkeyRecord? SendMuteHotkey { get; set; }
public HotkeyRecord? TrayHotkey { get; set; } public HotkeyRecord? TrayHotkey { get; set; }
+33
View File
@@ -0,0 +1,33 @@
namespace RemSound.Core;
/// <summary>
/// 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.
/// </summary>
public static class AudioTransportRules
{
/// <summary>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.</summary>
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;
}
}
/// <summary>
/// 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.
/// </summary>
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
}
+56
View File
@@ -0,0 +1,56 @@
using System.Net;
using System.Net.Sockets;
namespace RemSound.Core;
/// <summary>
/// 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.
/// </summary>
public static class PeerAddress
{
/// <summary>Parse "host:port" or just "host". Returns (host, null) when no valid port is present.
/// Ports outside 165535 are not ports.</summary>
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);
}
/// <summary>The address-family preference every resolve path shares: IPv4 first (the wire format and
/// discovery are IPv4 today), otherwise whatever came back.</summary>
public static IPAddress? PreferIPv4(IPAddress[]? addresses) =>
addresses is null || addresses.Length == 0
? null
: addresses.FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork) ?? addresses[0];
/// <summary>Resolve a peer entry's HOST part to an address (literal IPs short-circuit DNS).
/// Null on any failure — callers skip unresolvable peers.</summary>
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; }
}
/// <summary>Async twin of <see cref="ResolveHost"/> for UI callers.</summary>
public static async Task<IPAddress?> 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; }
}
}
+5
View File
@@ -95,6 +95,11 @@ public sealed class Profile
// the ACTIVE subset (SelectedSendApplications above) is per-profile. // the ACTIVE subset (SelectedSendApplications above) is per-profile.
// === Connectivity & transport === // === Connectivity & transport ===
/// <summary>DEAD FIELD — nothing reads it. The live port is the fixed <c>RemPacket.DefaultPort</c>
/// 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.</summary>
public int AudioPort { get; set; } = 47830; public int AudioPort { get; set; } = 47830;
public int CodecRaw { get; set; } = (int)AudioTransportCodec.Pcm; public int CodecRaw { get; set; } = (int)AudioTransportCodec.Pcm;
/// <summary>Opus frame size in samples-per-channel at 48 kHz. 120 = 2.5 ms, 240 = 5 ms, /// <summary>Opus frame size in samples-per-channel at 48 kHz. 120 = 2.5 ms, 240 = 5 ms,
+8
View File
@@ -66,6 +66,14 @@ public static class RemSoundCrypto
private static readonly byte[] ObfuscationKey = private static readonly byte[] ObfuscationKey =
Encoding.UTF8.GetBytes("RemSound-profile-password-scramble-v1"); Encoding.UTF8.GetBytes("RemSound-profile-password-scramble-v1");
/// <summary>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.</summary>
public static (byte[]? Key, byte[]? Fingerprint) ForPlainPassword(string? plainPassword) =>
string.IsNullOrEmpty(plainPassword) ? (null, null) : (DeriveKey(plainPassword), Fingerprint(plainPassword));
/// <summary>Derive the 256-bit AES key for a password. Cache the result; never call per packet.</summary> /// <summary>Derive the 256-bit AES key for a password. Cache the result; never call per packet.</summary>
public static byte[] DeriveKey(string? password) => public static byte[] DeriveKey(string? password) =>
Rfc2898DeriveBytes.Pbkdf2( Rfc2898DeriveBytes.Pbkdf2(
-180
View File
@@ -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 <ip:port> [--opus] [--device <id>]");
Console.WriteLine(" Capture the default (or selected) render device and send to the receiver.");
Console.WriteLine();
Console.WriteLine(" RemSound.Harness recv [--port N] [--device <id>] [--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;
}
@@ -1,19 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWindowsForms>true</UseWindowsForms>
<RootNamespace>RemSound.Harness</RootNamespace>
<AssemblyName>RemSound.Harness</AssemblyName>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\RemSound.Core\RemSound.Core.csproj" />
<ProjectReference Include="..\RemSound.Sender\RemSound.Sender.csproj" />
<ProjectReference Include="..\RemSound.Receiver\RemSound.Receiver.csproj" />
<PackageReference Include="NAudio" Version="2.3.0" />
</ItemGroup>
</Project>