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:
@@ -3,7 +3,6 @@
|
||||
<Project Path="src/RemSound.Core/RemSound.Core.csproj" />
|
||||
<Project Path="src/RemSound.Sender/RemSound.Sender.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" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<IPAddress?> 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<IPAddress?> ResolvePeerAddressAsync(string text) => PeerAddress.ResolveHostAsync(text);
|
||||
|
||||
/// <summary>
|
||||
/// 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);
|
||||
}
|
||||
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.
|
||||
/// </summary>
|
||||
// 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);
|
||||
|
||||
/// <summary>
|
||||
/// Short codec label for the per-peer line in the connectivity dialog. e.g. "PCM",
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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<string>();
|
||||
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)
|
||||
|
||||
@@ -202,7 +202,8 @@ public sealed class AppConfig
|
||||
/// 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
|
||||
/// 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? SendMuteHotkey { get; set; }
|
||||
public HotkeyRecord? TrayHotkey { get; set; }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 1–65535 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; }
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,11 @@ public sealed class Profile
|
||||
// the ACTIVE subset (SelectedSendApplications above) is per-profile.
|
||||
|
||||
// === 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 CodecRaw { get; set; } = (int)AudioTransportCodec.Pcm;
|
||||
/// <summary>Opus frame size in samples-per-channel at 48 kHz. 120 = 2.5 ms, 240 = 5 ms,
|
||||
|
||||
@@ -66,6 +66,14 @@ public static class RemSoundCrypto
|
||||
private static readonly byte[] ObfuscationKey =
|
||||
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>
|
||||
public static byte[] DeriveKey(string? password) =>
|
||||
Rfc2898DeriveBytes.Pbkdf2(
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user