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 - - - - - - - - -