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